create-safest-tools 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (240) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +71 -0
  3. package/bin/create-safest-tools.mjs +12 -0
  4. package/package.json +34 -0
  5. package/src/cli.mjs +128 -0
  6. package/src/config.mjs +151 -0
  7. package/src/scaffold.mjs +30 -0
  8. package/template/LICENSE +201 -0
  9. package/template/README.md +35 -0
  10. package/template/console/README.md +28 -0
  11. package/template/console/analytics/AnalyticsWorkspace.tsx +55 -0
  12. package/template/console/analytics/controller.ts +133 -0
  13. package/template/console/analytics/events.ts +33 -0
  14. package/template/console/analytics/store.ts +33 -0
  15. package/template/console/analytics/types.ts +38 -0
  16. package/template/console/appeals/AppealWorkspace.tsx +73 -0
  17. package/template/console/appeals/controller.ts +125 -0
  18. package/template/console/appeals/events.ts +33 -0
  19. package/template/console/appeals/store.ts +35 -0
  20. package/template/console/appeals/types.ts +37 -0
  21. package/template/console/assistant/AssistantWorkspace.tsx +133 -0
  22. package/template/console/assistant/types.ts +34 -0
  23. package/template/console/auth/AccountJourney.tsx +69 -0
  24. package/template/console/auth/InvitationAcceptance.tsx +145 -0
  25. package/template/console/auth/OAuthButtons.tsx +78 -0
  26. package/template/console/auth/OperatorLogin.tsx +110 -0
  27. package/template/console/auth/PasswordRecovery.tsx +132 -0
  28. package/template/console/auth/account-route.ts +15 -0
  29. package/template/console/auth/browser-navigation.ts +7 -0
  30. package/template/console/auth/events.ts +15 -0
  31. package/template/console/command/CommandCentre.tsx +119 -0
  32. package/template/console/command/controller.ts +267 -0
  33. package/template/console/command/events.ts +19 -0
  34. package/template/console/command/store.ts +36 -0
  35. package/template/console/command/types.ts +61 -0
  36. package/template/console/components/AnalystIdentity.tsx +25 -0
  37. package/template/console/components/PageHeader.tsx +27 -0
  38. package/template/console/configuration/ConfigurationDialog.tsx +200 -0
  39. package/template/console/configuration/ConfigurationWorkspace.tsx +66 -0
  40. package/template/console/configuration/FormBuilder.tsx +143 -0
  41. package/template/console/configuration/api.ts +40 -0
  42. package/template/console/configuration/events.ts +14 -0
  43. package/template/console/configuration/types.ts +79 -0
  44. package/template/console/lib/format.ts +49 -0
  45. package/template/console/lib/http.ts +96 -0
  46. package/template/console/main.tsx +225 -0
  47. package/template/console/operations/OperationsWorkspace.tsx +126 -0
  48. package/template/console/operations/controller.ts +237 -0
  49. package/template/console/operations/events.ts +37 -0
  50. package/template/console/operations/store.ts +32 -0
  51. package/template/console/operations/types.ts +107 -0
  52. package/template/console/people/PeopleWorkspace.tsx +81 -0
  53. package/template/console/people/types.ts +25 -0
  54. package/template/console/profile/ProfileWorkspace.tsx +136 -0
  55. package/template/console/profile/events.ts +9 -0
  56. package/template/console/profile/types.ts +6 -0
  57. package/template/console/quality/QualityWorkspace.tsx +87 -0
  58. package/template/console/quality/controller.ts +154 -0
  59. package/template/console/quality/events.ts +34 -0
  60. package/template/console/quality/store.ts +36 -0
  61. package/template/console/quality/types.ts +70 -0
  62. package/template/console/queues/QueueEditor.tsx +173 -0
  63. package/template/console/queues/QueueWorkspace.tsx +109 -0
  64. package/template/console/queues/controller.ts +194 -0
  65. package/template/console/queues/events.ts +34 -0
  66. package/template/console/queues/store.ts +37 -0
  67. package/template/console/queues/types.ts +102 -0
  68. package/template/console/registry/RegistryDialog.tsx +198 -0
  69. package/template/console/registry/RegistryWorkspace.tsx +108 -0
  70. package/template/console/registry/events.ts +14 -0
  71. package/template/console/registry/types.ts +52 -0
  72. package/template/console/reports/ReportDrawer.tsx +159 -0
  73. package/template/console/reports/ReportWorkspace.tsx +90 -0
  74. package/template/console/reports/controller.ts +517 -0
  75. package/template/console/reports/events.ts +42 -0
  76. package/template/console/reports/store.ts +39 -0
  77. package/template/console/reports/types.ts +230 -0
  78. package/template/console/settings/BrandEditor.tsx +126 -0
  79. package/template/console/settings/ChannelDialog.tsx +241 -0
  80. package/template/console/settings/SettingsWorkspace.tsx +155 -0
  81. package/template/console/settings/events.ts +19 -0
  82. package/template/console/settings/handoff.ts +22 -0
  83. package/template/console/settings/types.ts +93 -0
  84. package/template/console/shell/WorkspaceShell.tsx +160 -0
  85. package/template/console/shell/controller.ts +182 -0
  86. package/template/console/shell/events.ts +25 -0
  87. package/template/console/shell/navigation.ts +61 -0
  88. package/template/console/shell/store.ts +56 -0
  89. package/template/console/shell/types.ts +86 -0
  90. package/template/console/workflows/CreateWorkflowDialog.tsx +87 -0
  91. package/template/console/workflows/WorkflowCanvas.tsx +42 -0
  92. package/template/console/workflows/WorkflowDialogs.tsx +6 -0
  93. package/template/console/workflows/WorkflowStudio.tsx +331 -0
  94. package/template/console/workflows/WorkflowWorkspace.tsx +82 -0
  95. package/template/console/workflows/events.ts +17 -0
  96. package/template/console/workflows/graph.ts +156 -0
  97. package/template/console/workflows/templates.ts +70 -0
  98. package/template/console/workflows/types.ts +201 -0
  99. package/template/gitignore.template +18 -0
  100. package/template/migrations/0001_reports_foundation.sql +444 -0
  101. package/template/migrations/0002_human_report_loop.sql +65 -0
  102. package/template/migrations/0003_delivery_reliability.sql +18 -0
  103. package/template/migrations/0004_public_intake.sql +14 -0
  104. package/template/migrations/0005_operations_visibility.sql +24 -0
  105. package/template/migrations/0006_ai_governance.sql +198 -0
  106. package/template/migrations/0007_ai_release_gates.sql +6 -0
  107. package/template/migrations/0008_retention_analytics_exports.sql +52 -0
  108. package/template/migrations/0009_retention_derived_copies.sql +14 -0
  109. package/template/migrations/0010_ai_quality_controls.sql +26 -0
  110. package/template/migrations/0011_analyst_presence.sql +28 -0
  111. package/template/migrations/0012_queue_policies.sql +87 -0
  112. package/template/migrations/0013_routing_agents.sql +63 -0
  113. package/template/migrations/0014_webhook_enrichments.sql +122 -0
  114. package/template/migrations/0015_queue_owned_ai.sql +55 -0
  115. package/template/migrations/0016_operator_accounts.sql +67 -0
  116. package/template/migrations/0017_operator_profiles_and_recovery.sql +33 -0
  117. package/template/migrations/0018_platform_configuration.sql +382 -0
  118. package/template/migrations/0019_workflow_authoring_runtime.sql +372 -0
  119. package/template/migrations/0020_tasks_findings_assistant_budgets.sql +427 -0
  120. package/template/migrations/0021_abuse_evidence_operations.sql +324 -0
  121. package/template/migrations/0022_workflow_dispatch_operations.sql +42 -0
  122. package/template/migrations/0023_component_connection_execution.sql +75 -0
  123. package/template/migrations/0024_access_runtime_integrity.sql +72 -0
  124. package/template/migrations/0025_installation_timezone.sql +11 -0
  125. package/template/migrations/0026_ai_and_egress_execution_controls.sql +49 -0
  126. package/template/migrations/0027_prompt_and_ai_registry.sql +37 -0
  127. package/template/migrations/0028_step_attempt_ai_provenance.sql +13 -0
  128. package/template/migrations/0029_evidence_fetch_transport.sql +5 -0
  129. package/template/migrations/0030_evidence_dlq_incidents.sql +45 -0
  130. package/template/migrations/0031_shadow_quality_integrity.sql +7 -0
  131. package/template/migrations/0032_action_delivery_outbox.sql +55 -0
  132. package/template/migrations/0033_configuration_and_assistant_drafts.sql +43 -0
  133. package/template/migrations/0034_installation_integrations.sql +31 -0
  134. package/template/migrations/0035_workspace_governance.sql +21 -0
  135. package/template/migrations/0036_published_routing_baseline.sql +15 -0
  136. package/template/migrations/0037_builtin_phishing_specialist.sql +71 -0
  137. package/template/migrations/0038_remove_deprecated_enrichment_runtime.sql +228 -0
  138. package/template/migrations/0039_secure_reporting_channels.sql +45 -0
  139. package/template/migrations/0040_notification_only_reporting.sql +24 -0
  140. package/template/migrations/0041_better_auth_credentials.sql +17 -0
  141. package/template/package.json +47 -0
  142. package/template/public/_headers +27 -0
  143. package/template/public/app-icon-192.png +0 -0
  144. package/template/public/app-icon-512.png +0 -0
  145. package/template/public/brand-icon.svg +7 -0
  146. package/template/public/brand-tokens.css +80 -0
  147. package/template/public/console/auth-shell.js +19340 -0
  148. package/template/public/customer-brand.js +58 -0
  149. package/template/public/embed/embed.css +139 -0
  150. package/template/public/embed/embed.js +408 -0
  151. package/template/public/embed/index.html +91 -0
  152. package/template/public/favicon.svg +7 -0
  153. package/template/public/fonts/Manrope-Variable.ttf +0 -0
  154. package/template/public/fonts/Newsreader-Italic-Variable.ttf +0 -0
  155. package/template/public/fonts/Newsreader-Variable.ttf +0 -0
  156. package/template/public/index.html +123 -0
  157. package/template/public/logo-primary.svg +7 -0
  158. package/template/public/logo-reversed.svg +7 -0
  159. package/template/public/manifest.webmanifest +21 -0
  160. package/template/public/public-report.js +184 -0
  161. package/template/public/report/index.html +39 -0
  162. package/template/public/report/public-report.css +20 -0
  163. package/template/public/social-card.png +0 -0
  164. package/template/public/styles.css +1521 -0
  165. package/template/public/widget.css +80 -0
  166. package/template/public/widget.js +220 -0
  167. package/template/reports.config.example.json +38 -0
  168. package/template/reports.schema.json +89 -0
  169. package/template/scripts/reports-auth-onboarding.mjs +180 -0
  170. package/template/scripts/reports-backup.mjs +276 -0
  171. package/template/scripts/reports-cloudflare-preflight.mjs +152 -0
  172. package/template/scripts/reports-deploy.mjs +173 -0
  173. package/template/scripts/reports-plan.mjs +226 -0
  174. package/template/scripts/reports-restore.mjs +180 -0
  175. package/template/scripts/reports-secrets.mjs +73 -0
  176. package/template/scripts/reports-uninstall-plan.mjs +32 -0
  177. package/template/src/agent-executor.ts +330 -0
  178. package/template/src/ai-observability.ts +163 -0
  179. package/template/src/ai-registry-validation.ts +244 -0
  180. package/template/src/ai-registry.ts +292 -0
  181. package/template/src/api-cursor.ts +84 -0
  182. package/template/src/assistant.ts +554 -0
  183. package/template/src/audit.ts +39 -0
  184. package/template/src/backup-service.ts +269 -0
  185. package/template/src/budget-control.ts +127 -0
  186. package/template/src/component-executor.ts +845 -0
  187. package/template/src/configuration-registry.ts +550 -0
  188. package/template/src/connection-egress.ts +430 -0
  189. package/template/src/connection-oauth.ts +368 -0
  190. package/template/src/evidence-service.ts +335 -0
  191. package/template/src/human-tasks.ts +316 -0
  192. package/template/src/index.ts +4114 -0
  193. package/template/src/installation-admin.ts +301 -0
  194. package/template/src/intake-abuse.ts +156 -0
  195. package/template/src/platform-registry-validation.ts +367 -0
  196. package/template/src/platform-registry.ts +753 -0
  197. package/template/src/report-admin.ts +342 -0
  198. package/template/src/report-ai-quality.ts +390 -0
  199. package/template/src/report-ai-validation.ts +80 -0
  200. package/template/src/report-ai.ts +857 -0
  201. package/template/src/report-auth.ts +292 -0
  202. package/template/src/report-better-auth.ts +583 -0
  203. package/template/src/report-context-schema.ts +132 -0
  204. package/template/src/report-context.ts +126 -0
  205. package/template/src/report-crypto.ts +142 -0
  206. package/template/src/report-delivery.ts +579 -0
  207. package/template/src/report-form-validation.ts +51 -0
  208. package/template/src/report-governance.ts +510 -0
  209. package/template/src/report-http.ts +90 -0
  210. package/template/src/report-operations.ts +685 -0
  211. package/template/src/report-operator-accounts.ts +810 -0
  212. package/template/src/report-presence.ts +398 -0
  213. package/template/src/report-queue-validation.ts +309 -0
  214. package/template/src/report-queues.ts +478 -0
  215. package/template/src/report-repository.ts +972 -0
  216. package/template/src/report-router-agent.ts +328 -0
  217. package/template/src/report-router-validation.ts +81 -0
  218. package/template/src/report-routing.ts +178 -0
  219. package/template/src/report-turnstile.ts +142 -0
  220. package/template/src/report-types.ts +224 -0
  221. package/template/src/report-validation.ts +279 -0
  222. package/template/src/report-workflow-validation.ts +119 -0
  223. package/template/src/report-workflow.ts +886 -0
  224. package/template/src/shadow-quality.ts +278 -0
  225. package/template/src/workflow-actions.ts +782 -0
  226. package/template/src/workflow-compiler.ts +230 -0
  227. package/template/src/workflow-dynamic-runtime.ts +613 -0
  228. package/template/src/workflow-effects.ts +316 -0
  229. package/template/src/workflow-expressions.ts +191 -0
  230. package/template/src/workflow-platform-types.ts +121 -0
  231. package/template/src/workflow-platform-validation.ts +540 -0
  232. package/template/src/workflow-repository.ts +916 -0
  233. package/template/src/workflow-runs.ts +686 -0
  234. package/template/src/workflow-simulator.ts +211 -0
  235. package/template/src/workspace-branding.ts +128 -0
  236. package/template/src/workspace-governance.ts +279 -0
  237. package/template/tsconfig.console.json +23 -0
  238. package/template/tsconfig.json +22 -0
  239. package/template/vite.console.config.ts +20 -0
  240. package/template/worker-configuration.d.ts +65 -0
@@ -0,0 +1,686 @@
1
+ import { canonicalJson } from "./audit";
2
+ import { sha256 } from "./report-crypto";
3
+ import { ApiError } from "./report-http";
4
+ import type { OperatorSession, WorkflowDispatchJob } from "./report-types";
5
+ import { startDynamicWorkflow } from "./workflow-dynamic-runtime";
6
+ import type { JsonObject } from "./workflow-platform-types";
7
+
8
+ interface MutationRow {
9
+ action: string;
10
+ actor_id: string;
11
+ response_json: string;
12
+ }
13
+
14
+ interface DispatchRow {
15
+ outboxId: string;
16
+ runId: string;
17
+ operationKey: string;
18
+ attemptCount: number;
19
+ workflowVersionId: string;
20
+ artifactId: string;
21
+ bundleDigest: string;
22
+ instanceId: string | null;
23
+ runState: string;
24
+ operation: "start" | "event";
25
+ eventType: string | null;
26
+ eventPayloadJson: string | null;
27
+ }
28
+
29
+ export class RetryableWorkflowDispatchError extends Error {
30
+ constructor(readonly delaySeconds: number) {
31
+ super("Workflow dispatch should be retried.");
32
+ this.name = "RetryableWorkflowDispatchError";
33
+ }
34
+ }
35
+
36
+ export async function preparePinnedWorkflowRunsForRouting(
37
+ db: D1Database,
38
+ input: {
39
+ reportId: string;
40
+ installationId: string;
41
+ queueId: string;
42
+ queueVersionId: string | null;
43
+ createdAt: string;
44
+ },
45
+ ): Promise<{ statements: D1PreparedStatement[]; jobs: WorkflowDispatchJob[] }> {
46
+ const pins = await db.prepare(`
47
+ SELECT p.id AS pinId, p.mode, p.workflow_version_id AS workflowVersionId,
48
+ v.authority_mode AS authorityMode, v.graph_digest AS graphDigest,
49
+ a.bundle_digest AS bundleDigest
50
+ FROM queue_workflow_pins p JOIN workflow_versions v ON v.id = p.workflow_version_id
51
+ JOIN workflow_artifacts a ON a.id = v.artifact_id
52
+ WHERE p.queue_id = ?1 AND p.deactivated_at IS NULL
53
+ AND v.retired_at IS NULL AND a.status = 'ready'
54
+ ORDER BY CASE p.mode WHEN 'primary' THEN 0 ELSE 1 END, p.activated_at, p.id
55
+ `).bind(input.queueId).all<{
56
+ pinId: string; mode: "primary" | "shadow"; workflowVersionId: string;
57
+ authorityMode: "off" | "shadow" | "assist" | "bounded_auto";
58
+ graphDigest: string; bundleDigest: string;
59
+ }>();
60
+ const statements: D1PreparedStatement[] = [];
61
+ const jobs: WorkflowDispatchJob[] = [];
62
+ for (const pin of pins.results) {
63
+ if (pin.mode === "primary" && pin.authorityMode === "shadow") continue;
64
+ if (pin.mode === "shadow" && pin.authorityMode !== "shadow") continue;
65
+ const existing = await db.prepare(`SELECT id FROM workflow_runs WHERE report_id = ?1 AND pin_id = ?2 LIMIT 1`)
66
+ .bind(input.reportId, pin.pinId).first();
67
+ if (existing) continue;
68
+ const runId = crypto.randomUUID();
69
+ const outboxId = crypto.randomUUID();
70
+ const instanceId = `run-${runId}`;
71
+ statements.push(
72
+ db.prepare(`
73
+ INSERT INTO workflow_runs (
74
+ id, installation_id, report_id, workflow_version_id, queue_id, queue_version_id,
75
+ pin_id, run_kind, authority_mode, state, cloudflare_instance_id,
76
+ graph_digest, artifact_digest, input_json, created_at, updated_at
77
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 'dispatch_pending', ?10, ?11, ?12, ?13, ?14, ?14)
78
+ `).bind(
79
+ runId, input.installationId, input.reportId, pin.workflowVersionId,
80
+ input.queueId, input.queueVersionId, pin.pinId,
81
+ pin.mode === "primary" ? "primary" : "shadow", pin.authorityMode,
82
+ instanceId, pin.graphDigest, pin.bundleDigest,
83
+ canonicalJson({ schemaVersion: 1, reportId: input.reportId, queueId: input.queueId, routingTriggered: true }),
84
+ input.createdAt,
85
+ ),
86
+ db.prepare(`
87
+ INSERT INTO workflow_dispatch_outbox (
88
+ id, run_id, operation, operation_key, state, available_at, created_at, updated_at
89
+ ) VALUES (?1, ?2, 'start', ?3, 'pending', ?4, ?4, ?4)
90
+ `).bind(outboxId, runId, `workflow-start:${runId}`, input.createdAt),
91
+ );
92
+ jobs.push({ version: 1, type: "dispatch_workflow", jobId: `workflow-dispatch:${outboxId}`, runId, outboxId });
93
+ }
94
+ return { statements, jobs };
95
+ }
96
+
97
+ function now(): string {
98
+ return new Date().toISOString();
99
+ }
100
+
101
+ function parsedJson<T>(value: string | null, fallback: T): T {
102
+ if (!value) return fallback;
103
+ try {
104
+ return JSON.parse(value) as T;
105
+ } catch {
106
+ return fallback;
107
+ }
108
+ }
109
+
110
+ async function mutationReplay(
111
+ db: D1Database,
112
+ key: string,
113
+ action: string,
114
+ actorId: string,
115
+ ): Promise<Record<string, unknown> | null> {
116
+ const row = await db.prepare(`
117
+ SELECT action, actor_id, response_json FROM configuration_mutations WHERE idempotency_key = ?1 LIMIT 1
118
+ `).bind(key).first<MutationRow>();
119
+ if (!row) return null;
120
+ if (row.action !== action || row.actor_id !== actorId) throw new ApiError(409, "idempotency_key_reused", "This Idempotency-Key was already used for another workflow operation.");
121
+ return parsedJson(row.response_json, {});
122
+ }
123
+
124
+ function auditStatement(
125
+ db: D1Database,
126
+ input: {
127
+ key: string;
128
+ action: string;
129
+ session: OperatorSession;
130
+ targetId: string;
131
+ details: unknown;
132
+ createdAt: string;
133
+ },
134
+ ): D1PreparedStatement {
135
+ return db.prepare(`
136
+ INSERT OR IGNORE INTO audit_events (
137
+ id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at
138
+ ) VALUES (?1, ?2, ?3, ?4, ?5, 'workflow_run', ?6, ?7, ?8)
139
+ `).bind(
140
+ crypto.randomUUID(), input.key, input.action, input.session.actor.type,
141
+ input.session.actor.id, input.targetId, canonicalJson(input.details), input.createdAt,
142
+ );
143
+ }
144
+
145
+ export async function createPinnedWorkflowRun(
146
+ db: D1Database,
147
+ reportId: string,
148
+ mode: "shadow" | "primary",
149
+ session: OperatorSession,
150
+ idempotencyKey: string,
151
+ ): Promise<{
152
+ runId: string;
153
+ outboxId: string;
154
+ job: WorkflowDispatchJob;
155
+ workflowVersionId: string;
156
+ idempotentReplay: boolean;
157
+ }> {
158
+ const replay = await mutationReplay(db, idempotencyKey, "workflow.run.start", session.actor.id);
159
+ if (replay) return {
160
+ runId: String(replay.runId), outboxId: String(replay.outboxId),
161
+ workflowVersionId: String(replay.workflowVersionId),
162
+ job: {
163
+ version: 1,
164
+ type: "dispatch_workflow",
165
+ jobId: `workflow-dispatch:${String(replay.outboxId)}`,
166
+ runId: String(replay.runId),
167
+ outboxId: String(replay.outboxId),
168
+ },
169
+ idempotentReplay: true,
170
+ };
171
+ const selected = await db.prepare(`
172
+ SELECT r.id AS report_id, r.installation_id, r.queue_id, r.queue_version_id,
173
+ p.id AS pin_id, p.workflow_version_id, v.authority_mode, v.graph_digest,
174
+ a.bundle_digest, a.status AS artifact_status
175
+ FROM reports r
176
+ JOIN queue_workflow_pins p ON p.queue_id = r.queue_id AND p.mode = ?2 AND p.deactivated_at IS NULL
177
+ JOIN workflow_versions v ON v.id = p.workflow_version_id AND v.retired_at IS NULL
178
+ JOIN workflow_artifacts a ON a.id = v.artifact_id
179
+ WHERE r.id = ?1 LIMIT 1
180
+ `).bind(reportId, mode).first<{
181
+ report_id: string;
182
+ installation_id: string;
183
+ queue_id: string;
184
+ queue_version_id: string | null;
185
+ pin_id: string;
186
+ workflow_version_id: string;
187
+ authority_mode: "off" | "shadow" | "assist" | "bounded_auto";
188
+ graph_digest: string;
189
+ bundle_digest: string;
190
+ artifact_status: string;
191
+ }>();
192
+ if (!selected) throw new ApiError(409, "workflow_pin_unavailable", `The report's queue has no active ${mode} workflow pin.`);
193
+ if (selected.artifact_status !== "ready") throw new ApiError(409, "workflow_artifact_not_ready", "The pinned workflow artifact is not verified and ready.");
194
+ if (mode === "primary" && selected.authority_mode === "shadow") throw new ApiError(409, "shadow_workflow_not_primary", "A shadow-authority workflow cannot create a primary run.");
195
+ if (mode === "shadow" && selected.authority_mode !== "shadow") throw new ApiError(409, "shadow_run_authority_required", "A shadow run requires a workflow version compiled with shadow authority.");
196
+ const runId = crypto.randomUUID();
197
+ const outboxId = crypto.randomUUID();
198
+ const createdAt = now();
199
+ const instanceId = `run-${runId}`;
200
+ const inputJson = canonicalJson({ schemaVersion: 1, reportId, queueId: selected.queue_id });
201
+ const response = { runId, outboxId, workflowVersionId: selected.workflow_version_id };
202
+ await db.batch([
203
+ db.prepare(`
204
+ INSERT INTO workflow_runs (
205
+ id, installation_id, report_id, workflow_version_id, queue_id, queue_version_id,
206
+ pin_id, run_kind, authority_mode, state, cloudflare_instance_id,
207
+ graph_digest, artifact_digest, input_json, created_at, updated_at
208
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 'dispatch_pending', ?10, ?11, ?12, ?13, ?14, ?14)
209
+ `).bind(
210
+ runId, selected.installation_id, reportId, selected.workflow_version_id,
211
+ selected.queue_id, selected.queue_version_id, selected.pin_id,
212
+ mode === "primary" ? "primary" : "shadow", selected.authority_mode,
213
+ instanceId, selected.graph_digest, selected.bundle_digest, inputJson, createdAt,
214
+ ),
215
+ db.prepare(`
216
+ INSERT INTO workflow_dispatch_outbox (
217
+ id, run_id, operation, operation_key, state, available_at, created_at, updated_at
218
+ ) VALUES (?1, ?2, 'start', ?3, 'pending', ?4, ?4, ?4)
219
+ `).bind(outboxId, runId, `workflow-start:${runId}`, createdAt),
220
+ db.prepare(`
221
+ INSERT INTO configuration_mutations (idempotency_key, action, actor_id, target_id, response_json, created_at)
222
+ VALUES (?1, 'workflow.run.start', ?2, ?3, ?4, ?5)
223
+ `).bind(idempotencyKey, session.actor.id, runId, canonicalJson(response), createdAt),
224
+ auditStatement(db, {
225
+ key: `workflow-run-start:${idempotencyKey}`, action: "workflow.run_requested", session,
226
+ targetId: runId,
227
+ details: { reportId, mode, workflowVersionId: selected.workflow_version_id, pinId: selected.pin_id },
228
+ createdAt,
229
+ }),
230
+ ]);
231
+ return {
232
+ ...response,
233
+ job: { version: 1, type: "dispatch_workflow", jobId: `workflow-dispatch:${outboxId}`, runId, outboxId },
234
+ idempotentReplay: false,
235
+ };
236
+ }
237
+
238
+ export async function pendingWorkflowDispatchJobs(db: D1Database, limit: number): Promise<WorkflowDispatchJob[]> {
239
+ const rows = await db.prepare(`
240
+ SELECT id, run_id FROM workflow_dispatch_outbox
241
+ WHERE operation IN ('start','event') AND state IN ('pending','failed') AND available_at <= ?1
242
+ AND (lease_expires_at IS NULL OR lease_expires_at <= ?1)
243
+ ORDER BY available_at, id LIMIT ?2
244
+ `).bind(now(), Math.max(1, Math.min(limit, 100))).all<{ id: string; run_id: string }>();
245
+ return rows.results.map((row) => ({
246
+ version: 1,
247
+ type: "dispatch_workflow",
248
+ jobId: `workflow-dispatch:${row.id}`,
249
+ runId: row.run_id,
250
+ outboxId: row.id,
251
+ }));
252
+ }
253
+
254
+ export async function createLinkedWorkflowRun(
255
+ env: Env,
256
+ input: {
257
+ parentRunId: string;
258
+ nodeId: string;
259
+ workflowVersionId: string;
260
+ payload: JsonObject;
261
+ },
262
+ ): Promise<{ runId: string; workflowVersionId: string; idempotentReplay: boolean }> {
263
+ const existing = await env.DB.prepare(`
264
+ SELECT l.child_run_id AS childRunId FROM workflow_run_links l
265
+ WHERE l.parent_run_id = ?1 AND l.node_id = ?2 LIMIT 1
266
+ `).bind(input.parentRunId, input.nodeId).first<{ childRunId: string }>();
267
+ if (existing) return { runId: existing.childRunId, workflowVersionId: input.workflowVersionId, idempotentReplay: true };
268
+ const depth = await env.DB.prepare(`
269
+ WITH RECURSIVE ancestors(id, depth) AS (
270
+ SELECT id, 1 FROM workflow_runs WHERE id = ?1
271
+ UNION ALL
272
+ SELECT r.parent_run_id, ancestors.depth + 1 FROM workflow_runs r
273
+ JOIN ancestors ON r.id = ancestors.id WHERE r.parent_run_id IS NOT NULL AND ancestors.depth < 10
274
+ ) SELECT MAX(depth) AS depth FROM ancestors
275
+ `).bind(input.parentRunId).first<{ depth: number }>();
276
+ if (Number(depth?.depth ?? 0) >= 5) throw new ApiError(409, "linked_workflow_depth_exceeded", "Linked workflow depth is limited to five runs.");
277
+ const target = await env.DB.prepare(`
278
+ SELECT p.installation_id AS installationId, p.report_id AS reportId,
279
+ p.queue_id AS queueId, p.queue_version_id AS queueVersionId,
280
+ v.id AS workflowVersionId, v.authority_mode AS authorityMode,
281
+ v.graph_digest AS graphDigest, w.purpose, a.bundle_digest AS bundleDigest,
282
+ a.status AS artifactStatus
283
+ FROM workflow_runs p CROSS JOIN workflow_versions v
284
+ JOIN workflow_definitions w ON w.id = v.workflow_id
285
+ JOIN workflow_artifacts a ON a.id = v.artifact_id
286
+ WHERE p.id = ?1 AND v.id = ?2 AND v.retired_at IS NULL AND w.status = 'active'
287
+ LIMIT 1
288
+ `).bind(input.parentRunId, input.workflowVersionId).first<{
289
+ installationId: string; reportId: string | null; queueId: string | null;
290
+ queueVersionId: string | null; workflowVersionId: string; authorityMode: "off" | "shadow" | "assist" | "bounded_auto";
291
+ graphDigest: string; purpose: "report" | "appeal" | "quality" | "operations";
292
+ bundleDigest: string; artifactStatus: string;
293
+ }>();
294
+ if (!target) throw new ApiError(409, "linked_workflow_unavailable", "The linked workflow version is unavailable.");
295
+ if (target.purpose === "report") throw new ApiError(409, "linked_primary_workflow_prohibited", "A linked workflow cannot create a second primary report run.");
296
+ if (target.artifactStatus !== "ready") throw new ApiError(409, "linked_workflow_artifact_not_ready", "The linked workflow artifact is not ready.");
297
+ const runId = crypto.randomUUID();
298
+ const outboxId = crypto.randomUUID();
299
+ const timestamp = now();
300
+ const instanceId = `run-${runId}`;
301
+ const runKind = target.purpose === "appeal" ? "appeal" : target.purpose === "quality" ? "quality" : "linked";
302
+ const runInput = canonicalJson({
303
+ schemaVersion: 1,
304
+ parentRunId: input.parentRunId,
305
+ parentNodeId: input.nodeId,
306
+ reportId: target.reportId,
307
+ payload: input.payload,
308
+ });
309
+ await env.DB.batch([
310
+ env.DB.prepare(`
311
+ INSERT INTO workflow_runs (
312
+ id, installation_id, report_id, parent_run_id, workflow_version_id,
313
+ queue_id, queue_version_id, run_kind, authority_mode, state,
314
+ cloudflare_instance_id, graph_digest, artifact_digest, input_json,
315
+ created_at, updated_at
316
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 'dispatch_pending', ?10, ?11, ?12, ?13, ?14, ?14)
317
+ `).bind(
318
+ runId, target.installationId, target.reportId, input.parentRunId,
319
+ target.workflowVersionId, target.queueId, target.queueVersionId,
320
+ runKind, target.authorityMode, instanceId, target.graphDigest,
321
+ target.bundleDigest, runInput, timestamp,
322
+ ),
323
+ env.DB.prepare(`
324
+ INSERT INTO workflow_run_links (parent_run_id, child_run_id, link_type, node_id, created_at)
325
+ VALUES (?1, ?2, 'subworkflow', ?3, ?4)
326
+ `).bind(input.parentRunId, runId, input.nodeId, timestamp),
327
+ env.DB.prepare(`
328
+ INSERT INTO workflow_dispatch_outbox (
329
+ id, run_id, operation, operation_key, state, available_at, created_at, updated_at
330
+ ) VALUES (?1, ?2, 'start', ?3, 'pending', ?4, ?4, ?4)
331
+ `).bind(outboxId, runId, `workflow-start:${runId}`, timestamp),
332
+ ]);
333
+ const job: WorkflowDispatchJob = {
334
+ version: 1,
335
+ type: "dispatch_workflow",
336
+ jobId: `workflow-dispatch:${outboxId}`,
337
+ runId,
338
+ outboxId,
339
+ };
340
+ await env.CONTROL_JOBS.send(job, { contentType: "json" }).catch(() => {});
341
+ return { runId, workflowVersionId: target.workflowVersionId, idempotentReplay: false };
342
+ }
343
+
344
+ async function leaseDispatch(db: D1Database, job: WorkflowDispatchJob): Promise<DispatchRow | null> {
345
+ const timestamp = now();
346
+ const leaseExpiresAt = new Date(Date.now() + 60_000).toISOString();
347
+ const leased = await db.prepare(`
348
+ UPDATE workflow_dispatch_outbox
349
+ SET state = 'leased', attempt_count = attempt_count + 1,
350
+ lease_expires_at = ?3, updated_at = ?2
351
+ WHERE id = ?1 AND run_id = ?4
352
+ AND state IN ('pending','failed') AND available_at <= ?2
353
+ AND (lease_expires_at IS NULL OR lease_expires_at <= ?2)
354
+ `).bind(job.outboxId, timestamp, leaseExpiresAt, job.runId).run();
355
+ if (leased.meta.changes !== 1) {
356
+ const delivered = await db.prepare(`SELECT state FROM workflow_dispatch_outbox WHERE id = ?1 AND run_id = ?2`)
357
+ .bind(job.outboxId, job.runId).first<{ state: string }>();
358
+ if (delivered?.state === "delivered") return null;
359
+ throw new RetryableWorkflowDispatchError(5);
360
+ }
361
+ return db.prepare(`
362
+ SELECT o.id AS outboxId, o.run_id AS runId, o.operation_key AS operationKey,
363
+ o.attempt_count AS attemptCount, o.operation, r.workflow_version_id AS workflowVersionId,
364
+ v.artifact_id AS artifactId, a.bundle_digest AS bundleDigest,
365
+ r.cloudflare_instance_id AS instanceId, r.state AS runState,
366
+ e.event_type AS eventType, e.payload_json AS eventPayloadJson
367
+ FROM workflow_dispatch_outbox o
368
+ JOIN workflow_runs r ON r.id = o.run_id
369
+ JOIN workflow_versions v ON v.id = r.workflow_version_id
370
+ JOIN workflow_artifacts a ON a.id = v.artifact_id
371
+ LEFT JOIN workflow_event_inbox e ON e.id = o.event_inbox_id
372
+ WHERE o.id = ?1 AND o.run_id = ?2 LIMIT 1
373
+ `).bind(job.outboxId, job.runId).first<DispatchRow>();
374
+ }
375
+
376
+ export async function processWorkflowDispatchJob(
377
+ env: Env,
378
+ ctx: ExecutionContext,
379
+ job: WorkflowDispatchJob,
380
+ ): Promise<void> {
381
+ const dispatch = await leaseDispatch(env.DB, job);
382
+ if (!dispatch) return;
383
+ if (["succeeded", "failed", "terminated", "superseded"].includes(dispatch.runState)) {
384
+ await env.DB.prepare(`
385
+ UPDATE workflow_dispatch_outbox SET state = 'dead_letter', lease_expires_at = NULL,
386
+ last_error_code = 'run_terminal_before_dispatch', updated_at = ?2 WHERE id = ?1
387
+ `).bind(dispatch.outboxId, now()).run();
388
+ return;
389
+ }
390
+ const instanceId = dispatch.instanceId ?? `run-${dispatch.runId}`;
391
+ try {
392
+ if (dispatch.operation === "event") {
393
+ if (!dispatch.instanceId || !dispatch.eventType || !dispatch.eventPayloadJson) throw new Error("workflow_event_dispatch_not_ready");
394
+ const instance = await env.WORKFLOWS.get(dispatch.instanceId);
395
+ await instance.sendEvent({ type: dispatch.eventType, payload: parsedJson(dispatch.eventPayloadJson, {}) });
396
+ const deliveredAt = now();
397
+ await env.DB.batch([
398
+ env.DB.prepare(`
399
+ UPDATE workflow_event_inbox SET delivered_at = ?2,
400
+ delivery_attempts = delivery_attempts + 1, last_error_code = NULL
401
+ WHERE id = (SELECT event_inbox_id FROM workflow_dispatch_outbox WHERE id = ?1)
402
+ `).bind(dispatch.outboxId, deliveredAt),
403
+ env.DB.prepare(`
404
+ UPDATE workflow_dispatch_outbox SET state = 'delivered', lease_expires_at = NULL,
405
+ last_error_code = NULL, updated_at = ?2 WHERE id = ?1 AND state = 'leased'
406
+ `).bind(dispatch.outboxId, deliveredAt),
407
+ ]);
408
+ return;
409
+ }
410
+ const platformInstanceId = await startDynamicWorkflow(env, ctx, {
411
+ artifactId: dispatch.artifactId,
412
+ workflowVersionId: dispatch.workflowVersionId,
413
+ instanceId,
414
+ runId: dispatch.runId,
415
+ });
416
+ const timestamp = now();
417
+ await env.DB.batch([
418
+ env.DB.prepare(`
419
+ UPDATE workflow_runs SET state = 'queued', cloudflare_instance_id = ?2,
420
+ updated_at = ?3, revision = revision + 1
421
+ WHERE id = ?1 AND state = 'dispatch_pending'
422
+ `).bind(dispatch.runId, platformInstanceId, timestamp),
423
+ env.DB.prepare(`
424
+ UPDATE workflow_dispatch_outbox SET state = 'delivered', lease_expires_at = NULL,
425
+ last_error_code = NULL, updated_at = ?2 WHERE id = ?1 AND state = 'leased'
426
+ `).bind(dispatch.outboxId, timestamp),
427
+ ]);
428
+ } catch (error) {
429
+ const delaySeconds = Math.min(900, 5 * (2 ** Math.min(dispatch.attemptCount, 7)));
430
+ const timestamp = now();
431
+ await env.DB.prepare(`
432
+ UPDATE workflow_dispatch_outbox SET state = CASE WHEN attempt_count >= 8 THEN 'dead_letter' ELSE 'failed' END,
433
+ lease_expires_at = NULL, available_at = ?2, last_error_code = ?3, updated_at = ?4
434
+ WHERE id = ?1 AND state = 'leased'
435
+ `).bind(
436
+ dispatch.outboxId, new Date(Date.now() + delaySeconds * 1_000).toISOString(),
437
+ error instanceof Error ? error.message.slice(0, 200) : "workflow_dispatch_failed", timestamp,
438
+ ).run();
439
+ if (dispatch.attemptCount >= 8) {
440
+ await env.DB.prepare(`
441
+ UPDATE workflow_runs SET state = 'repair_required', error_code = 'workflow_dispatch_exhausted',
442
+ error_summary = 'Workflow dispatch exhausted automatic attempts.', updated_at = ?2,
443
+ revision = revision + 1 WHERE id = ?1 AND state = 'dispatch_pending'
444
+ `).bind(dispatch.runId, timestamp).run();
445
+ return;
446
+ }
447
+ throw new RetryableWorkflowDispatchError(delaySeconds);
448
+ }
449
+ }
450
+
451
+ export async function listWorkflowRuns(
452
+ db: D1Database,
453
+ input: { reportId?: string; state?: string; allowedQueueIds?: string[] | null; limit: number },
454
+ ): Promise<Record<string, unknown>[]> {
455
+ const rows = await db.prepare(`
456
+ SELECT r.id, r.report_id AS reportId, r.appeal_id AS appealId,
457
+ r.parent_run_id AS parentRunId, r.workflow_version_id AS workflowVersionId,
458
+ r.queue_id AS queueId, r.run_kind AS runKind, r.authority_mode AS authorityMode,
459
+ r.state, r.cloudflare_instance_id AS cloudflareInstanceId,
460
+ r.current_node_id AS currentNodeId, r.error_code AS errorCode,
461
+ r.started_at AS startedAt, r.waiting_at AS waitingAt,
462
+ r.finished_at AS finishedAt, r.created_at AS createdAt, r.updated_at AS updatedAt,
463
+ w.workflow_key AS workflowKey, w.name AS workflowName, v.version,
464
+ (SELECT COUNT(*) FROM workflow_step_runs s WHERE s.run_id = r.id) AS stepCount,
465
+ (SELECT COUNT(*) FROM human_tasks t WHERE t.run_id = r.id AND t.state IN ('open','claimed')) AS openTaskCount
466
+ FROM workflow_runs r
467
+ JOIN workflow_versions v ON v.id = r.workflow_version_id
468
+ JOIN workflow_definitions w ON w.id = v.workflow_id
469
+ WHERE (?1 IS NULL OR r.report_id = ?1) AND (?2 IS NULL OR r.state = ?2)
470
+ AND (?3 = 1 OR r.queue_id IN (SELECT value FROM json_each(?4)))
471
+ ORDER BY r.created_at DESC, r.id DESC LIMIT ?5
472
+ `).bind(
473
+ input.reportId ?? null, input.state ?? null,
474
+ input.allowedQueueIds === null || input.allowedQueueIds === undefined ? 1 : 0,
475
+ canonicalJson(input.allowedQueueIds ?? []), Math.max(1, Math.min(input.limit, 100)),
476
+ ).all<Record<string, unknown>>();
477
+ return rows.results;
478
+ }
479
+
480
+ export async function loadWorkflowRun(db: D1Database, runId: string): Promise<Record<string, unknown> | null> {
481
+ const run = await db.prepare(`
482
+ SELECT r.*, w.workflow_key, w.name AS workflow_name, v.version,
483
+ v.graph_digest AS version_graph_digest, a.bundle_digest, a.status AS artifact_status
484
+ FROM workflow_runs r JOIN workflow_versions v ON v.id = r.workflow_version_id
485
+ JOIN workflow_definitions w ON w.id = v.workflow_id
486
+ JOIN workflow_artifacts a ON a.id = v.artifact_id
487
+ WHERE r.id = ?1 LIMIT 1
488
+ `).bind(runId).first<Record<string, unknown>>();
489
+ if (!run) return null;
490
+ const [steps, waits, events, tasks] = await Promise.all([
491
+ db.prepare(`SELECT * FROM workflow_step_runs WHERE run_id = ?1 ORDER BY started_at, node_id, occurrence`).bind(runId).all(),
492
+ db.prepare(`SELECT * FROM workflow_waits WHERE run_id = ?1 ORDER BY created_at, id`).bind(runId).all(),
493
+ db.prepare(`SELECT * FROM workflow_event_inbox WHERE run_id = ?1 ORDER BY received_at, id`).bind(runId).all(),
494
+ db.prepare(`SELECT * FROM human_tasks WHERE run_id = ?1 ORDER BY created_at, id`).bind(runId).all(),
495
+ ]);
496
+ return { run, steps: steps.results, waits: waits.results, events: events.results, tasks: tasks.results };
497
+ }
498
+
499
+ export async function sendWorkflowEvent(
500
+ env: Env,
501
+ runId: string,
502
+ input: { eventType: string; eventKey: string; sourceType: string; sourceId?: string; payload: unknown },
503
+ ): Promise<{ eventId: string; idempotentReplay: boolean; delivered: boolean }> {
504
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$/u.test(input.eventType)) throw new ApiError(400, "workflow_event_type_invalid", "event_type is invalid.");
505
+ if (input.eventKey.length < 1 || input.eventKey.length > 200) throw new ApiError(400, "workflow_event_key_invalid", "event_key is invalid.");
506
+ const run = await env.DB.prepare(`
507
+ SELECT id, cloudflare_instance_id, state FROM workflow_runs WHERE id = ?1 LIMIT 1
508
+ `).bind(runId).first<{ id: string; cloudflare_instance_id: string | null; state: string }>();
509
+ if (!run) throw new ApiError(404, "workflow_run_not_found", "The workflow run does not exist.");
510
+ const existing = await env.DB.prepare(`
511
+ SELECT id, delivered_at FROM workflow_event_inbox
512
+ WHERE run_id = ?1 AND event_type = ?2 AND event_key = ?3 LIMIT 1
513
+ `).bind(runId, input.eventType, input.eventKey).first<{ id: string; delivered_at: string | null }>();
514
+ if (existing) return { eventId: existing.id, idempotentReplay: true, delivered: Boolean(existing.delivered_at) };
515
+ const eventId = crypto.randomUUID();
516
+ const payloadJson = canonicalJson(input.payload);
517
+ const timestamp = now();
518
+ await env.DB.batch([
519
+ env.DB.prepare(`
520
+ INSERT INTO workflow_event_inbox (
521
+ id, run_id, event_type, event_key, source_type, source_id,
522
+ payload_json, payload_digest, received_at
523
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
524
+ `).bind(
525
+ eventId, runId, input.eventType, input.eventKey, input.sourceType,
526
+ input.sourceId ?? null, payloadJson, `sha256:${await sha256(payloadJson)}`, timestamp,
527
+ ),
528
+ env.DB.prepare(`
529
+ INSERT INTO workflow_dispatch_outbox (
530
+ id, run_id, operation, event_inbox_id, operation_key,
531
+ state, available_at, created_at, updated_at
532
+ ) VALUES (?1, ?2, 'event', ?1, ?3, 'pending', ?4, ?4, ?4)
533
+ `).bind(eventId, runId, `workflow-event:${runId}:${input.eventType}:${input.eventKey}`, timestamp),
534
+ ]);
535
+ if (!run.cloudflare_instance_id) return { eventId, idempotentReplay: false, delivered: false };
536
+ try {
537
+ const instance = await env.WORKFLOWS.get(run.cloudflare_instance_id);
538
+ await instance.sendEvent({ type: input.eventType, payload: input.payload });
539
+ const deliveredAt = now();
540
+ await env.DB.batch([
541
+ env.DB.prepare(`UPDATE workflow_event_inbox SET delivered_at = ?2, delivery_attempts = delivery_attempts + 1 WHERE id = ?1`).bind(eventId, deliveredAt),
542
+ env.DB.prepare(`UPDATE workflow_dispatch_outbox SET state = 'delivered', updated_at = ?2 WHERE id = ?1`).bind(eventId, deliveredAt),
543
+ ]);
544
+ return { eventId, idempotentReplay: false, delivered: true };
545
+ } catch {
546
+ await env.DB.batch([
547
+ env.DB.prepare(`UPDATE workflow_event_inbox SET delivery_attempts = delivery_attempts + 1, last_error_code = 'platform_event_send_failed' WHERE id = ?1`).bind(eventId),
548
+ env.DB.prepare(`UPDATE workflow_dispatch_outbox SET state = 'failed', last_error_code = 'platform_event_send_failed', updated_at = ?2 WHERE id = ?1`).bind(eventId, now()),
549
+ ]);
550
+ return { eventId, idempotentReplay: false, delivered: false };
551
+ }
552
+ }
553
+
554
+ export async function controlWorkflowRun(
555
+ env: Env,
556
+ runId: string,
557
+ operation: "pause" | "resume" | "terminate",
558
+ ): Promise<void> {
559
+ const run = await env.DB.prepare(`SELECT cloudflare_instance_id, state FROM workflow_runs WHERE id = ?1 LIMIT 1`)
560
+ .bind(runId).first<{ cloudflare_instance_id: string | null; state: string }>();
561
+ if (!run) throw new ApiError(404, "workflow_run_not_found", "The workflow run does not exist.");
562
+ if (!run.cloudflare_instance_id) throw new ApiError(409, "workflow_instance_unavailable", "The workflow run has not been dispatched.");
563
+ const instance = await env.WORKFLOWS.get(run.cloudflare_instance_id);
564
+ if (operation === "pause") await instance.pause();
565
+ else if (operation === "resume") await instance.resume();
566
+ else await instance.terminate();
567
+ const timestamp = now();
568
+ await env.DB.prepare(`
569
+ UPDATE workflow_runs SET state = ?2, finished_at = CASE WHEN ?2 = 'terminated' THEN ?3 ELSE finished_at END,
570
+ waiting_at = CASE WHEN ?2 = 'paused' THEN waiting_at ELSE NULL END,
571
+ updated_at = ?3, revision = revision + 1 WHERE id = ?1
572
+ `).bind(runId, operation === "pause" ? "paused" : operation === "resume" ? "running" : "terminated", timestamp).run();
573
+ }
574
+
575
+ export async function reconcileWorkflowRun(
576
+ env: Env,
577
+ runId: string,
578
+ session: OperatorSession,
579
+ idempotencyKey: string,
580
+ ): Promise<{ runId: string; state: string; platformState: string | null; repairApplied: boolean; idempotentReplay: boolean }> {
581
+ const auditKey = `workflow-run-reconcile:${idempotencyKey}`;
582
+ const replay = await env.DB.prepare(`SELECT details_json AS detailsJson, target_id AS targetId FROM audit_events WHERE idempotency_key = ?1 AND action = 'workflow.run_reconciled' LIMIT 1`)
583
+ .bind(auditKey).first<{ detailsJson: string; targetId: string }>();
584
+ if (replay) {
585
+ if (replay.targetId !== runId) throw new ApiError(409, "idempotency_key_reused", "This Idempotency-Key was already used for another workflow run.");
586
+ const details = parsedJson<Record<string, unknown>>(replay.detailsJson, {});
587
+ return {
588
+ runId,
589
+ state: String(details.state ?? "unknown"),
590
+ platformState: typeof details.platformState === "string" ? details.platformState : null,
591
+ repairApplied: details.repairApplied === true,
592
+ idempotentReplay: true,
593
+ };
594
+ }
595
+ const run = await env.DB.prepare(`SELECT id, state, cloudflare_instance_id AS instanceId FROM workflow_runs WHERE id = ?1 LIMIT 1`)
596
+ .bind(runId).first<{ id: string; state: string; instanceId: string | null }>();
597
+ if (!run) throw new ApiError(404, "workflow_run_not_found", "The workflow run does not exist.");
598
+ const timestamp = now();
599
+ let state = run.state;
600
+ let platformState: string | null = null;
601
+ let repairApplied = false;
602
+ const terminal = ["succeeded", "failed", "terminated", "superseded"].includes(run.state);
603
+ const statements: D1PreparedStatement[] = [];
604
+ if (!run.instanceId) {
605
+ if (!terminal) {
606
+ const reset = await env.DB.prepare(`
607
+ UPDATE workflow_dispatch_outbox SET state = 'pending', attempt_count = 0,
608
+ available_at = ?2, lease_expires_at = NULL, last_error_code = NULL, updated_at = ?2
609
+ WHERE run_id = ?1 AND operation = 'start' AND state IN ('failed','dead_letter','leased')
610
+ `).bind(runId, timestamp).run();
611
+ repairApplied = reset.meta.changes > 0;
612
+ if (repairApplied) {
613
+ state = "dispatch_pending";
614
+ statements.push(
615
+ env.DB.prepare(`UPDATE workflow_runs SET state = 'dispatch_pending', error_code = NULL, error_summary = NULL, updated_at = ?2, revision = revision + 1 WHERE id = ?1 AND state NOT IN ('succeeded','failed','terminated','superseded')`)
616
+ .bind(runId, timestamp),
617
+ );
618
+ }
619
+ }
620
+ } else {
621
+ let status: InstanceStatus;
622
+ try {
623
+ status = await (await env.WORKFLOWS.get(run.instanceId)).status();
624
+ } catch {
625
+ throw new ApiError(503, "workflow_platform_status_unavailable", "The Workflow platform status is temporarily unavailable.");
626
+ }
627
+ platformState = status.status;
628
+ const mapped = status.status === "complete" ? "succeeded"
629
+ : status.status === "errored" ? "failed"
630
+ : status.status === "terminated" ? "terminated"
631
+ : status.status === "waiting" || status.status === "waitingForPause" ? "waiting"
632
+ : status.status === "queued" || status.status === "running" || status.status === "paused" ? status.status
633
+ : null;
634
+ if (mapped && !terminal && mapped !== run.state) {
635
+ state = mapped;
636
+ repairApplied = true;
637
+ statements.push(
638
+ env.DB.prepare(`
639
+ UPDATE workflow_runs SET state = ?2,
640
+ error_code = CASE WHEN ?2 = 'failed' THEN 'platform_instance_errored' ELSE error_code END,
641
+ error_summary = CASE WHEN ?2 = 'failed' THEN ?3 ELSE error_summary END,
642
+ waiting_at = CASE WHEN ?2 = 'waiting' THEN COALESCE(waiting_at, ?4) ELSE NULL END,
643
+ finished_at = CASE WHEN ?2 IN ('succeeded','failed','terminated') THEN COALESCE(finished_at, ?4) ELSE finished_at END,
644
+ updated_at = ?4, revision = revision + 1 WHERE id = ?1
645
+ `).bind(runId, mapped, status.error?.message?.slice(0, 500) ?? null, timestamp),
646
+ );
647
+ } else if (mapped && terminal && mapped !== run.state) {
648
+ const incidentKey = `workflow-state-mismatch:${runId}:${run.state}:${mapped}`;
649
+ statements.push(env.DB.prepare(`
650
+ INSERT INTO operations_incidents (
651
+ id, installation_id, incident_key, severity, category, state, title, summary,
652
+ entity_type, entity_id, occurrence_count, first_seen_at, last_seen_at, metadata_json
653
+ ) VALUES (?1, 'default', ?2, 'high', 'workflow_state_mismatch', 'open',
654
+ 'Workflow state differs from platform', ?3, 'workflow_run', ?4, 1, ?5, ?5, ?6)
655
+ ON CONFLICT(installation_id, incident_key) DO UPDATE SET
656
+ state = 'open', occurrence_count = operations_incidents.occurrence_count + 1,
657
+ last_seen_at = excluded.last_seen_at, metadata_json = excluded.metadata_json
658
+ `).bind(
659
+ crypto.randomUUID(), incidentKey, "A terminal D1 workflow state differs from the Cloudflare Workflow instance state.",
660
+ runId, timestamp, canonicalJson({ d1State: run.state, platformState: mapped, platformRawState: status.status }),
661
+ ));
662
+ }
663
+ }
664
+ if (repairApplied) {
665
+ statements.push(env.DB.prepare(`
666
+ INSERT OR IGNORE INTO repair_actions (
667
+ id, action_type, entity_type, entity_id, operation_key, state,
668
+ before_json, after_json, planned_by, planned_at, applied_at
669
+ ) VALUES (?1, 'reconcile_workflow_run', 'workflow_run', ?2, ?3, 'applied', ?4, ?5, ?6, ?7, ?7)
670
+ `).bind(
671
+ crypto.randomUUID(), runId, `workflow-run-reconcile:${runId}:${idempotencyKey}`,
672
+ canonicalJson({ state: run.state }), canonicalJson({ state, platformState }), session.actor.id, timestamp,
673
+ ));
674
+ }
675
+ const details = { state, platformState, repairApplied };
676
+ statements.push(auditStatement(env.DB, {
677
+ key: auditKey,
678
+ action: "workflow.run_reconciled",
679
+ session,
680
+ targetId: runId,
681
+ details,
682
+ createdAt: timestamp,
683
+ }));
684
+ await env.DB.batch(statements);
685
+ return { runId, ...details, idempotentReplay: false };
686
+ }