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

+149 -1
View File
@@ -3,6 +3,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import { z } from "zod";
import {
AutomationService,
CreateBucketRequest,
IntegrationsService,
OpenAPI,
@@ -574,7 +575,20 @@ server.registerTool(
const webhookRef = z.string().describe("Webhook id (see primora_list_webhooks)");
const webhookEvents = z
.array(z.enum(["issue.created", "deploy.marker", "webhook.test"]))
.array(
z.enum([
"issue.created",
"deploy.marker",
"webhook.test",
"job.run",
"document.created",
"document.updated",
"document.deleted",
"object.created",
"object.updated",
"object.deleted",
]),
)
.describe("Event filter — empty means every event");
server.registerTool(
@@ -732,4 +746,138 @@ server.registerTool(
},
);
/* ---------------------------------------------------------- */
/* automation — scheduled jobs */
/* ---------------------------------------------------------- */
const jobRef = z.string().describe("Scheduled job id (see primora_list_jobs)");
server.registerTool(
"primora_list_jobs",
{
description: "List scheduled jobs for a project — cron schedule, target URL, last/next run",
inputSchema: { projectId: z.string().optional() },
},
async ({ projectId: p }) => {
try {
return ok(await AutomationService.listScheduledJobs({ projectId: projectId(p) }));
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"primora_create_job",
{
description:
"Create a scheduled job — cron expression or @descriptor, signed POST to url. Omit secret to generate one (returned once)",
inputSchema: {
name: z.string(),
schedule: z
.string()
.describe('Cron (5-field, e.g. "*/15 * * * *") or descriptor: @hourly, @daily, @every 30m'),
url: z.string().describe("HTTPS endpoint (http allowed only for private/self-hosted targets)"),
secret: z.string().optional(),
payload: z.record(z.unknown()).optional().describe("Static JSON payload merged into every delivery"),
enabled: z.boolean().optional(),
projectId: z.string().optional(),
},
},
async ({ name, schedule, url, secret, payload, enabled, projectId: p }) => {
try {
return ok(
await AutomationService.createScheduledJob({
projectId: projectId(p),
requestBody: { name, schedule, url, secret, payload, enabled },
}),
);
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"primora_update_job",
{
description: "Update a scheduled job — name, schedule, url, payload, enabled flag, or rotate the secret",
inputSchema: {
jobId: jobRef,
name: z.string().optional(),
schedule: z.string().optional(),
url: z.string().optional(),
secret: z.string().optional(),
payload: z.record(z.unknown()).optional(),
enabled: z.boolean().optional(),
projectId: z.string().optional(),
},
},
async ({ jobId, name, schedule, url, secret, payload, enabled, projectId: p }) => {
try {
return ok(
await AutomationService.updateScheduledJob({
projectId: projectId(p),
jobId,
requestBody: { name, schedule, url, secret, payload, enabled },
}),
);
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"primora_delete_job",
{
description: "Delete a scheduled job and stop future runs",
inputSchema: { jobId: jobRef, projectId: z.string().optional() },
},
async ({ jobId, projectId: p }) => {
try {
await AutomationService.deleteScheduledJob({ projectId: projectId(p), jobId });
return ok({ deleted: jobId });
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"primora_list_job_runs",
{
description: "Run history for a scheduled job — status, HTTP code, duration, errors",
inputSchema: {
jobId: jobRef,
limit: z.number().int().optional(),
projectId: z.string().optional(),
},
},
async ({ jobId, limit, projectId: p }) => {
try {
return ok(
await AutomationService.listScheduledJobRuns({ projectId: projectId(p), jobId, limit }),
);
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"primora_run_job",
{
description: "Trigger a manual run of a scheduled job now",
inputSchema: { jobId: jobRef, projectId: z.string().optional() },
},
async ({ jobId, projectId: p }) => {
try {
return ok(await AutomationService.runScheduledJob({ projectId: projectId(p), jobId }));
} catch (e) {
return fail(e);
}
},
);
await server.connect(new StdioServerTransport());