feat: phase 7 automation & realtime — scheduled jobs, domain events, SSE

Scheduled jobs (migration 00009): cron expressions + @every descriptors,
in-process scheduler (15s scan, missed-run collapse), signed HTTP POST
delivery (X-Primora-Signature), AES-256-GCM sealed secrets returned once,
run history with status/duration/http-code, manual execution, retention
pruning. Viewer reads, admin/developer writes.

Domain events: publishEvent fans document/object/issue/deploy/job
mutations to realtime subscribers and matching webhooks through one
path. Webhook event filter extended to job.run, document.*, object.*.

Realtime: GET /projects/:id/realtime/stream SSE with 25s keepalives and
?token=/?api_key= auth for EventSource clients.

Automation dashboard page: Schedules tab (CRUD, run history, manual run,
secret-once banner) + Live events tab (live badge, pause/clear). Demo
mode parity. primora jobs:* CLI group, six primora_*_job* MCP tools,
AutomationService in the generated client.

Fixes folded in: gzip middleware buffered SSE flushes (broke EventSource
on both streams — */stream now bypasses compression); event payloads
double-marshaled json.RawMessage as base64; compose hardcoded localhost
trusted origins so non-default ports broke sign-in; frontend auth base
URL baked an absolute localhost instead of resolving /auth on origin.

Verified live: stack up on :8085, signed delivery HMAC-checked at sink,
@every 30s schedule firing, run history rendering, document.created
landing in the live events feed.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-18 15:15:01 +02:00
1 parent 4b7fdf35ad
commit 9e9a3d5f98
49 files changed
+3555 -32

No files matched your search

@@ -34,8 +34,10 @@ type PlatformService struct {
publicURL string
dbx *dbx.Client
hub *EventHub
realtime *EventHub
enc *secrets.Encryptor
dispatcher *WebhookDispatcher
scheduler *JobScheduler
}
type BootstrapInput struct {
@@ -222,19 +224,50 @@ type InvitationSummary struct {
}
func NewPlatformService(repo *repositories.CoreRepository, store *storage.LocalStore, mailer *Mailer, publicURL string, dbxClient *dbx.Client, enc *secrets.Encryptor, logger *slog.Logger) *PlatformService {
s := &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL, dbx: dbxClient, hub: NewEventHub(), enc: enc}
s := &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL, dbx: dbxClient, hub: NewEventHub(), realtime: NewEventHub(), enc: enc}
if enc != nil {
s.dispatcher = NewWebhookDispatcher(repo.Queries(), enc, logger)
s.dispatcher.Start(context.Background())
s.scheduler = NewJobScheduler(repo.Queries(), enc, logger, s.publishEvent)
s.scheduler.Start(context.Background())
}
return s
}
// Close stops the webhook dispatcher. Idempotent.
// Close stops the webhook dispatcher and job scheduler. Idempotent.
func (s *PlatformService) Close() {
if s.dispatcher != nil {
s.dispatcher.Stop()
}
if s.scheduler != nil {
s.scheduler.Stop()
}
}
// publishEvent fans a domain event out to realtime SSE subscribers and to
// matching webhooks. Failures are logged by the callees — events never block
// the request that produced them.
func (s *PlatformService) publishEvent(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any) {
if s.realtime != nil {
s.realtime.Broadcast(projectID.String(), map[string]any{
"type": eventType,
"occurred_at": time.Now().UTC().Format(time.RFC3339),
"data": data,
})
}
s.dispatchEvent(ctx, projectID, eventType, data)
}
// SubscribeRealtime registers an SSE consumer for project domain events.
func (s *PlatformService) SubscribeRealtime(ctx context.Context, actor *models.Actor, projectID uuid.UUID) (chan []byte, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
return nil, err
}
return s.realtime.Subscribe(projectID.String()), nil
}
func (s *PlatformService) UnsubscribeRealtime(projectID uuid.UUID, ch chan []byte) {
s.realtime.Unsubscribe(projectID.String(), ch)
}
func (s *PlatformService) Me(ctx context.Context, actor *models.Actor) (PlatformSummary, error) {
@@ -1242,6 +1275,12 @@ func (s *PlatformService) UploadObject(ctx context.Context, actor *models.Actor,
"bucketId": bucket.ID.String(),
"key": objectKey,
}))
s.publishEvent(ctx, bucket.ProjectID, WebhookEventObjectCreated, map[string]any{
"bucket_id": bucket.ID.String(),
"object_key": objectKey,
"content_type": contentType,
"size_bytes": stored.SizeBytes,
})
return row, nil
}
@@ -1363,6 +1402,12 @@ func (s *PlatformService) UpdateObject(ctx context.Context, actor *models.Actor,
"oldObjectKey": oldObject.ObjectKey,
"newObjectKey": updated.ObjectKey,
}))
s.publishEvent(ctx, sourceBucket.ProjectID, WebhookEventObjectUpdated, map[string]any{
"bucket_id": destinationBucket.ID.String(),
"object_key": updated.ObjectKey,
"old_bucket_id": sourceBucket.ID.String(),
"old_object_key": oldObject.ObjectKey,
})
return updated, nil
}
@@ -1444,6 +1489,14 @@ func (s *PlatformService) CopyObject(ctx context.Context, actor *models.Actor, b
"sourceObjectKey": sourceObjectKey,
"newObjectKey": created.ObjectKey,
}))
s.publishEvent(ctx, sourceBucket.ProjectID, WebhookEventObjectCreated, map[string]any{
"bucket_id": destinationBucket.ID.String(),
"object_key": created.ObjectKey,
"content_type": created.ContentType,
"size_bytes": created.SizeBytes,
"source_bucket_id": sourceBucket.ID.String(),
"source_object_key": sourceObjectKey,
})
return created, nil
}
@@ -1469,6 +1522,10 @@ func (s *PlatformService) DeleteObject(ctx context.Context, actor *models.Actor,
"bucketId": bucketID.String(),
"key": objectKey,
}))
s.publishEvent(ctx, bucket.ProjectID, WebhookEventObjectDeleted, map[string]any{
"bucket_id": bucketID.String(),
"object_key": objectKey,
})
return nil
}
@@ -1692,6 +1749,11 @@ func (s *PlatformService) CreateDocument(ctx context.Context, actor *models.Acto
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, collection.ProjectID, actor, requestID, "document.created", "document", row.ID.String(), map[string]any{
"collectionId": collectionID.String(),
}))
s.publishEvent(ctx, collection.ProjectID, WebhookEventDocumentCreated, map[string]any{
"collection_id": collectionID.String(),
"document_id": row.ID.String(),
"document": json.RawMessage(row.Data),
})
return row, nil
}
@@ -1716,6 +1778,11 @@ func (s *PlatformService) UpdateDocument(ctx context.Context, actor *models.Acto
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, collection.ProjectID, actor, requestID, "document.updated", "document", documentID.String(), map[string]any{
"collectionId": collectionID.String(),
}))
s.publishEvent(ctx, collection.ProjectID, WebhookEventDocumentUpdated, map[string]any{
"collection_id": collectionID.String(),
"document_id": documentID.String(),
"document": json.RawMessage(row.Data),
})
return row, nil
}
@@ -1737,6 +1804,10 @@ func (s *PlatformService) DeleteDocument(ctx context.Context, actor *models.Acto
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, collection.ProjectID, actor, requestID, "document.deleted", "document", documentID.String(), map[string]any{
"collectionId": collectionID.String(),
}))
s.publishEvent(ctx, collection.ProjectID, WebhookEventDocumentDeleted, map[string]any{
"collection_id": collectionID.String(),
"document_id": documentID.String(),
})
return nil
}