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

+6 -2
View File
@@ -1,8 +1,12 @@
import { createAuthClient } from "better-auth/solid";
import { adminClient } from "better-auth/client/plugins";
// Use full URL for dev mode, relative path for production
const baseURL = import.meta.env.VITE_AUTH_BASE_URL ?? "http://localhost/auth";
// Same-origin default — nginx routes /auth to the auth service at whatever
// port or domain serves the app. better-auth requires an absolute baseURL, so
// resolve it against the runtime origin; VITE_AUTH_BASE_URL overrides for
// split-origin setups.
const baseURL =
import.meta.env.VITE_AUTH_BASE_URL ?? new URL("/auth", window.location.origin).href;
export const authClient = createAuthClient({
baseURL,
+141
View File
@@ -8,6 +8,8 @@ import {
OrganizationInvitation,
OrganizationMember,
ProjectMember,
ScheduledJob,
ScheduledJobRun,
WebhookDelivery,
} from "@primora/api-client";
import type {
@@ -32,6 +34,7 @@ import type {
TelemetryStats,
Webhook,
WebhookCreateResponse,
ScheduledJobCreateResponse,
} from "@primora/api-client";
const STORAGE_KEY = "primora_demo_mode";
@@ -485,6 +488,74 @@ const demoDeliveries: WebhookDelivery[] = [
},
];
const demoJobs: ScheduledJob[] = [
{
id: "demo-job-1",
project_id: "demo-project-1",
name: "nightly-rollup",
schedule: "0 3 * * *",
url: "https://api.example.com/jobs/rollup",
payload: { task: "rollup", window: "24h" },
enabled: true,
has_secret: true,
last_run_at: new Date(Date.now() - 7 * 3_600_000).toISOString(),
last_status: ScheduledJob.last_status.SUCCESS,
next_run_at: new Date(Date.now() + 17 * 3_600_000).toISOString(),
created_at: new Date(Date.now() - 14 * 86_400_000).toISOString(),
},
{
id: "demo-job-2",
project_id: "demo-project-1",
name: "healthcheck-ping",
schedule: "@every 15m",
url: "https://status.example.com/ping",
payload: {},
enabled: false,
has_secret: false,
last_run_at: new Date(Date.now() - 3 * 86_400_000).toISOString(),
last_status: ScheduledJob.last_status.FAILED,
created_at: new Date(Date.now() - 30 * 86_400_000).toISOString(),
},
];
const demoJobRuns: Record<string, ScheduledJobRun[]> = {
"demo-job-1": [
{
id: "demo-run-1",
job_id: "demo-job-1",
status: ScheduledJobRun.status.SUCCESS,
triggered_by: ScheduledJobRun.triggered_by.SCHEDULE,
status_code: 200,
duration_ms: 214,
started_at: new Date(Date.now() - 7 * 3_600_000).toISOString(),
finished_at: new Date(Date.now() - 7 * 3_600_000 + 214).toISOString(),
},
{
id: "demo-run-2",
job_id: "demo-job-1",
status: ScheduledJobRun.status.FAILED,
triggered_by: ScheduledJobRun.triggered_by.MANUAL,
status_code: 500,
error: "endpoint returned 500",
duration_ms: 1024,
started_at: new Date(Date.now() - 31 * 3_600_000).toISOString(),
finished_at: new Date(Date.now() - 31 * 3_600_000 + 1024).toISOString(),
},
],
"demo-job-2": [
{
id: "demo-run-3",
job_id: "demo-job-2",
status: ScheduledJobRun.status.FAILED,
triggered_by: ScheduledJobRun.triggered_by.SCHEDULE,
error: "connection refused",
duration_ms: 12,
started_at: new Date(Date.now() - 3 * 86_400_000).toISOString(),
finished_at: new Date(Date.now() - 3 * 86_400_000 + 12).toISOString(),
},
],
};
class DemoService {
private delay(ms = 250) {
return new Promise((resolve) => setTimeout(resolve, ms));
@@ -1233,6 +1304,76 @@ class DemoService {
return delivery;
}
async listScheduledJobs(): Promise<{ items: ScheduledJob[] }> {
await this.delay();
return { items: demoJobs };
}
async createScheduledJob(data: {
requestBody?: { name?: string; schedule?: string; url?: string; secret?: string; payload?: Record<string, unknown>; enabled?: boolean };
}): Promise<ScheduledJobCreateResponse> {
await this.delay();
const generated = !data.requestBody?.secret;
const job: ScheduledJob = {
id: `demo-job-${demoJobs.length + 1}`,
project_id: "demo-project-1",
name: data.requestBody?.name ?? "untitled",
schedule: data.requestBody?.schedule ?? "0 * * * *",
url: data.requestBody?.url ?? "https://example.com",
payload: (data.requestBody?.payload ?? {}) as Record<string, never>,
enabled: data.requestBody?.enabled ?? true,
has_secret: true,
last_status: null,
next_run_at: new Date(Date.now() + 3_600_000).toISOString(),
created_at: new Date().toISOString(),
};
demoJobs.push(job);
return { ...job, secret: generated ? "demo-secret-" + Math.random().toString(36).slice(2, 18) : undefined };
}
async updateScheduledJob(data: { jobId?: string; requestBody?: Partial<ScheduledJob> }): Promise<ScheduledJob> {
await this.delay();
const job = demoJobs.find((j) => j.id === data.jobId);
if (job && data.requestBody) {
Object.assign(job, data.requestBody);
}
return job as ScheduledJob;
}
async deleteScheduledJob(data: { jobId?: string }) {
await this.delay();
const idx = demoJobs.findIndex((j) => j.id === data.jobId);
if (idx >= 0) demoJobs.splice(idx, 1);
return {};
}
async listScheduledJobRuns(data: { jobId?: string; limit?: number }): Promise<{ items: ScheduledJobRun[] }> {
await this.delay();
return { items: (demoJobRuns[data.jobId ?? ""] ?? []).slice(0, data.limit ?? 50) };
}
async runScheduledJob(data: { jobId?: string }): Promise<ScheduledJobRun> {
await this.delay();
const jobID = data.jobId ?? "demo-job-1";
const run: ScheduledJobRun = {
id: `demo-run-${Date.now()}`,
job_id: jobID,
status: ScheduledJobRun.status.SUCCESS,
triggered_by: ScheduledJobRun.triggered_by.MANUAL,
status_code: 200,
duration_ms: Math.floor(Math.random() * 800) + 50,
started_at: new Date().toISOString(),
finished_at: new Date().toISOString(),
};
(demoJobRuns[jobID] ??= []).unshift(run);
const job = demoJobs.find((j) => j.id === jobID);
if (job) {
job.last_run_at = run.started_at;
job.last_status = ScheduledJob.last_status.SUCCESS;
}
return run;
}
async createDeployMarker(data: {
requestBody?: { version?: string; ref?: string; environment?: string; note?: string };
}): Promise<DeployMarker> {