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

+30 -1
View File
@@ -4,6 +4,7 @@ import { cmdLogin, cmdLogout, cmdWhoami } from "./commands/auth.js";
import { cmdAuditList } from "./commands/audit.js";
import { cmdBucketsCreate, cmdBucketsList } from "./commands/buckets.js";
import { cmdContext, cmdUse } from "./commands/context.js";
import { cmdJobsCreate, cmdJobsList, cmdJobsRemove, cmdJobsRun, cmdJobsRuns } from "./commands/jobs.js";
import { cmdKeysCreate, cmdKeysList, cmdKeysRevoke } from "./commands/keys.js";
import {
cmdObjectsDownload,
@@ -104,6 +105,34 @@ cli
.option("--yes", "Skip the confirmation prompt")
.action(cmdKeysRevoke);
cli
.command("jobs:list", "List scheduled jobs in the current project")
.option("--project <id>", "Project override")
.action(cmdJobsList);
cli
.command("jobs:create <name>", "Create a scheduled job (secret shown once if generated)")
.option("--project <id>", "Project override")
.option("--schedule <cron>", "Cron or descriptor, e.g. '*/15 * * * *' or '@every 1h'")
.option("--url <url>", "Target endpoint (HTTPS public, HTTP private)")
.option("--payload <json>", "Static JSON payload merged into every delivery")
.option("--secret <secret>", "HMAC signing secret (generated if omitted)")
.option("--disabled", "Create paused — enable via the dashboard or API")
.action(cmdJobsCreate);
cli
.command("jobs:run <job>", "Trigger a manual run now")
.option("--project <id>", "Project override")
.action(cmdJobsRun);
cli
.command("jobs:runs <job>", "List run history for a job")
.option("--project <id>", "Project override")
.option("--limit <n>", "Page size (default 50)")
.action(cmdJobsRuns);
cli
.command("jobs:rm <job>", "Delete a scheduled job")
.option("--project <id>", "Project override")
.option("--yes", "Skip the confirmation prompt")
.action(cmdJobsRemove);
cli
.command("audit:list", "List audit events for the current project")
.option("--project <id>", "Project override")
@@ -121,7 +150,7 @@ cli.version("0.4.0");
// cac only matches the first positional token, so "orgs list" cannot be a
// command name. Fold "group verb" pairs into "group:verb" before parsing —
// `primora orgs list` and `primora orgs:list` both work.
const GROUPS = new Set(["orgs", "projects", "buckets", "objects", "keys", "audit"]);
const GROUPS = new Set(["orgs", "projects", "buckets", "objects", "keys", "jobs", "audit"]);
const argv = process.argv.slice(2);
if (GROUPS.has(argv[0]) && argv[1] && !argv[1].startsWith("-")) {
argv.splice(0, 2, `${argv[0]}:${argv[1]}`);
+149
View File
@@ -0,0 +1,149 @@
import type { ScheduledJob, ScheduledJobRun } from "@primora/api-client";
import { AutomationService } from "@primora/api-client";
import { loadConfig } from "../config.js";
import { configureClient, requireAuth, requireProject } from "../http.js";
import { formatDate, isJson, printJson, printTable, success } from "../out.js";
interface JobOptions {
project?: string;
json?: boolean;
}
function jobRow(job: ScheduledJob): string[] {
return [
job.name,
job.schedule,
job.url,
job.enabled ? "on" : "off",
job.last_status ?? "—",
job.last_run_at ? formatDate(job.last_run_at) : "never",
job.enabled && job.next_run_at ? formatDate(job.next_run_at) : "—",
job.id,
];
}
export async function cmdJobsList(options: JobOptions): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
const projectId = requireProject(cfg, options.project);
const page = await AutomationService.listScheduledJobs({ projectId });
if (isJson(options)) {
printJson(page);
} else {
printTable(
["NAME", "SCHEDULE", "URL", "ENABLED", "LAST", "LAST RUN", "NEXT RUN", "ID"],
page.items.map(jobRow),
);
}
}
export async function cmdJobsCreate(
name: string,
options: JobOptions & {
schedule?: string;
url?: string;
payload?: string;
secret?: string;
disabled?: boolean;
},
): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
if (!options.schedule) throw new Error("pass --schedule, e.g. '*/15 * * * *' or '@every 1h'");
if (!options.url) throw new Error("pass --url <https endpoint>");
let payload: Record<string, unknown> | undefined;
if (options.payload) {
try {
payload = JSON.parse(options.payload) as Record<string, unknown>;
} catch {
throw new Error("--payload must be valid JSON");
}
}
const projectId = requireProject(cfg, options.project);
const job = await AutomationService.createScheduledJob({
projectId,
requestBody: {
name,
schedule: options.schedule,
url: options.url,
secret: options.secret || undefined,
payload,
enabled: !options.disabled,
},
});
if (isJson(options)) {
printJson(job);
return;
}
success(`created ${job.name} (${job.id})`);
if (job.secret) {
process.stderr.write(`signing secret (shown once): ${job.secret}\n`);
}
}
export async function cmdJobsRemove(jobId: string, options: JobOptions & { yes?: boolean }): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
const projectId = requireProject(cfg, options.project);
await AutomationService.deleteScheduledJob({ projectId, jobId });
if (isJson(options)) {
printJson({ deleted: jobId });
} else {
success(`deleted ${jobId}`);
}
}
export async function cmdJobsRun(jobId: string, options: JobOptions): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
const projectId = requireProject(cfg, options.project);
const run = await AutomationService.runScheduledJob({ projectId, jobId });
if (isJson(options)) {
printJson(run);
} else {
success(`run queued (${run.id}) — status ${run.status}`);
}
}
function runRow(run: ScheduledJobRun): string[] {
return [
formatDate(run.started_at),
run.status,
run.triggered_by,
run.status_code != null ? String(run.status_code) : "—",
run.duration_ms != null ? `${run.duration_ms}ms` : "—",
run.error || "—",
];
}
export async function cmdJobsRuns(
jobId: string,
options: JobOptions & { limit?: string },
): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
const projectId = requireProject(cfg, options.project);
const page = await AutomationService.listScheduledJobRuns({
projectId,
jobId,
limit: options.limit ? Number(options.limit) : 50,
});
if (isJson(options)) {
printJson(page);
} else {
printTable(["STARTED", "STATUS", "TRIGGER", "HTTP", "DURATION", "ERROR"], page.items.map(runRow));
}
}