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,916 @@
1
+ import { canonicalJson } from "./audit";
2
+ import { sha256 } from "./report-crypto";
3
+ import { ApiError } from "./report-http";
4
+ import type { OperatorSession } from "./report-types";
5
+ import { compileWorkflowGraph } from "./workflow-compiler";
6
+ import type {
7
+ WorkflowAuthorityMode,
8
+ WorkflowDefinitionInput,
9
+ WorkflowDependencyReference,
10
+ WorkflowGraphV1,
11
+ WorkflowPublishInput,
12
+ WorkflowValidationResult,
13
+ } from "./workflow-platform-types";
14
+ import {
15
+ canonicalWorkflowGraph,
16
+ parseWorkflowGraph,
17
+ validateWorkflowGraph,
18
+ } from "./workflow-platform-validation";
19
+ import { simulateWorkflow, simulationMatchesExpected } from "./workflow-simulator";
20
+ import type { JsonObject } from "./workflow-platform-types";
21
+
22
+ interface WorkflowListRow {
23
+ id: string;
24
+ key: string;
25
+ name: string;
26
+ description: string;
27
+ purpose: string;
28
+ status: string;
29
+ activeVersionId: string | null;
30
+ createdBy: string;
31
+ createdAt: string;
32
+ updatedAt: string;
33
+ draftId: string | null;
34
+ draftRevision: number | null;
35
+ validationStatus: string | null;
36
+ validationJson: string | null;
37
+ version: number | null;
38
+ authorityMode: string | null;
39
+ publishedAt: string | null;
40
+ activePinCount: number;
41
+ }
42
+
43
+ interface DraftRow {
44
+ id: string;
45
+ workflow_id: string;
46
+ graph_json: string;
47
+ graph_digest: string;
48
+ revision: number;
49
+ validation_json: string;
50
+ validation_status: "unvalidated" | "valid" | "invalid";
51
+ updated_at: string;
52
+ }
53
+
54
+ interface MutationRow {
55
+ action: string;
56
+ actor_id: string;
57
+ response_json: string;
58
+ }
59
+
60
+ function now(): string {
61
+ return new Date().toISOString();
62
+ }
63
+
64
+ function parsedJson<T>(value: string | null, fallback: T): T {
65
+ if (!value) return fallback;
66
+ try {
67
+ return JSON.parse(value) as T;
68
+ } catch {
69
+ return fallback;
70
+ }
71
+ }
72
+
73
+ function workflowFromRow(row: WorkflowListRow): Record<string, unknown> {
74
+ return {
75
+ id: row.id,
76
+ key: row.key,
77
+ name: row.name,
78
+ description: row.description,
79
+ purpose: row.purpose,
80
+ status: row.status,
81
+ activeVersionId: row.activeVersionId,
82
+ createdBy: row.createdBy,
83
+ createdAt: row.createdAt,
84
+ updatedAt: row.updatedAt,
85
+ draft: row.draftId ? {
86
+ id: row.draftId,
87
+ revision: row.draftRevision,
88
+ validationStatus: row.validationStatus,
89
+ validation: parsedJson(row.validationJson, { valid: false, errors: [], warnings: [] }),
90
+ } : null,
91
+ activeVersion: row.activeVersionId ? {
92
+ id: row.activeVersionId,
93
+ version: row.version,
94
+ authorityMode: row.authorityMode,
95
+ publishedAt: row.publishedAt,
96
+ } : null,
97
+ activePinCount: Number(row.activePinCount),
98
+ };
99
+ }
100
+
101
+ async function mutationReplay(
102
+ db: D1Database,
103
+ idempotencyKey: string,
104
+ action: string,
105
+ actorId: string,
106
+ ): Promise<Record<string, unknown> | null> {
107
+ const row = await db.prepare(`
108
+ SELECT action, actor_id, response_json FROM configuration_mutations WHERE idempotency_key = ?1 LIMIT 1
109
+ `).bind(idempotencyKey).first<MutationRow>();
110
+ if (!row) return null;
111
+ if (row.action !== action || row.actor_id !== actorId) {
112
+ throw new ApiError(409, "idempotency_key_reused", "This Idempotency-Key was already used for another workflow mutation.");
113
+ }
114
+ return parsedJson<Record<string, unknown>>(row.response_json, {});
115
+ }
116
+
117
+ function auditStatement(
118
+ db: D1Database,
119
+ input: {
120
+ idempotencyKey: string;
121
+ action: string;
122
+ session: OperatorSession;
123
+ targetType: string;
124
+ targetId: string;
125
+ details: unknown;
126
+ createdAt: string;
127
+ },
128
+ ): D1PreparedStatement {
129
+ return db.prepare(`
130
+ INSERT OR IGNORE INTO audit_events (
131
+ id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at
132
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
133
+ `).bind(
134
+ crypto.randomUUID(), input.idempotencyKey, input.action, input.session.actor.type,
135
+ input.session.actor.id, input.targetType, input.targetId, canonicalJson(input.details), input.createdAt,
136
+ );
137
+ }
138
+
139
+ export async function listWorkflowDefinitions(
140
+ db: D1Database,
141
+ includeArchived = false,
142
+ ): Promise<Record<string, unknown>[]> {
143
+ const rows = await db.prepare(`
144
+ SELECT w.id, w.workflow_key AS key, w.name, w.description, w.purpose, w.status,
145
+ w.active_version_id AS activeVersionId, w.created_by AS createdBy,
146
+ w.created_at AS createdAt, w.updated_at AS updatedAt,
147
+ d.id AS draftId, d.revision AS draftRevision, d.validation_status AS validationStatus,
148
+ d.validation_json AS validationJson, v.version, v.authority_mode AS authorityMode,
149
+ v.published_at AS publishedAt,
150
+ (SELECT COUNT(*) FROM queue_workflow_pins p
151
+ WHERE p.workflow_version_id = w.active_version_id AND p.deactivated_at IS NULL) AS activePinCount
152
+ FROM workflow_definitions w
153
+ LEFT JOIN workflow_drafts d ON d.workflow_id = w.id
154
+ LEFT JOIN workflow_versions v ON v.id = w.active_version_id
155
+ WHERE (?1 = 1 OR w.status != 'archived')
156
+ ORDER BY w.name, w.id
157
+ `).bind(includeArchived ? 1 : 0).all<WorkflowListRow>();
158
+ return rows.results.map(workflowFromRow);
159
+ }
160
+
161
+ export async function loadWorkflowDefinition(
162
+ db: D1Database,
163
+ workflowId: string,
164
+ ): Promise<Record<string, unknown> | null> {
165
+ const row = await db.prepare(`
166
+ SELECT w.id, w.workflow_key AS key, w.name, w.description, w.purpose, w.status,
167
+ w.active_version_id AS activeVersionId, w.created_by AS createdBy,
168
+ w.created_at AS createdAt, w.updated_at AS updatedAt,
169
+ d.id AS draftId, d.revision AS draftRevision, d.validation_status AS validationStatus,
170
+ d.validation_json AS validationJson, v.version, v.authority_mode AS authorityMode,
171
+ v.published_at AS publishedAt,
172
+ (SELECT COUNT(*) FROM queue_workflow_pins p
173
+ WHERE p.workflow_version_id = w.active_version_id AND p.deactivated_at IS NULL) AS activePinCount
174
+ FROM workflow_definitions w
175
+ LEFT JOIN workflow_drafts d ON d.workflow_id = w.id
176
+ LEFT JOIN workflow_versions v ON v.id = w.active_version_id
177
+ WHERE w.id = ?1 LIMIT 1
178
+ `).bind(workflowId).first<WorkflowListRow>();
179
+ if (!row) return null;
180
+ const [draft, versions, fixtures] = await Promise.all([
181
+ db.prepare(`
182
+ SELECT id, workflow_id, graph_json, graph_digest, revision, validation_json, validation_status, updated_at
183
+ FROM workflow_drafts WHERE workflow_id = ?1 LIMIT 1
184
+ `).bind(workflowId).first<DraftRow>(),
185
+ db.prepare(`
186
+ SELECT v.id, v.version, v.graph_digest AS graphDigest, v.authority_mode AS authorityMode,
187
+ v.dependency_manifest_json AS dependencyManifestJson,
188
+ v.capability_manifest_json AS capabilityManifestJson,
189
+ v.artifact_id AS artifactId, v.compiler_version AS compilerVersion,
190
+ v.test_evidence_json AS testEvidenceJson, v.published_by AS publishedBy,
191
+ v.published_at AS publishedAt, v.retired_at AS retiredAt,
192
+ a.bundle_digest AS bundleDigest, a.status AS artifactStatus
193
+ FROM workflow_versions v JOIN workflow_artifacts a ON a.id = v.artifact_id
194
+ WHERE v.workflow_id = ?1 ORDER BY v.version DESC, v.id DESC
195
+ `).bind(workflowId).all<Record<string, unknown>>(),
196
+ db.prepare(`
197
+ SELECT id, name, input_json AS inputJson, expected_json AS expectedJson,
198
+ failure_injection_json AS failureInjectionJson, updated_at AS updatedAt
199
+ FROM workflow_fixtures WHERE workflow_id = ?1 ORDER BY name, id
200
+ `).bind(workflowId).all<Record<string, unknown>>(),
201
+ ]);
202
+ return {
203
+ ...workflowFromRow(row),
204
+ draft: draft ? {
205
+ id: draft.id,
206
+ revision: draft.revision,
207
+ graphDigest: draft.graph_digest,
208
+ graph: parsedJson(draft.graph_json, null),
209
+ validationStatus: draft.validation_status,
210
+ validation: parsedJson(draft.validation_json, { valid: false, errors: [], warnings: [] }),
211
+ updatedAt: draft.updated_at,
212
+ } : null,
213
+ versions: versions.results.map((version) => ({
214
+ ...version,
215
+ dependencyManifest: parsedJson(String(version.dependencyManifestJson ?? ""), []),
216
+ capabilityManifest: parsedJson(String(version.capabilityManifestJson ?? ""), {}),
217
+ testEvidence: parsedJson(String(version.testEvidenceJson ?? ""), {}),
218
+ dependencyManifestJson: undefined,
219
+ capabilityManifestJson: undefined,
220
+ testEvidenceJson: undefined,
221
+ })),
222
+ fixtures: fixtures.results.map((fixture) => ({
223
+ ...fixture,
224
+ input: parsedJson(String(fixture.inputJson ?? ""), {}),
225
+ expected: parsedJson(String(fixture.expectedJson ?? ""), {}),
226
+ failureInjection: parsedJson(String(fixture.failureInjectionJson ?? ""), {}),
227
+ inputJson: undefined,
228
+ expectedJson: undefined,
229
+ failureInjectionJson: undefined,
230
+ })),
231
+ };
232
+ }
233
+
234
+ export async function createWorkflowDefinition(
235
+ db: D1Database,
236
+ input: WorkflowDefinitionInput,
237
+ graph: WorkflowGraphV1,
238
+ session: OperatorSession,
239
+ idempotencyKey: string,
240
+ ): Promise<{ workflowId: string; draftId: string; revision: number; idempotentReplay: boolean }> {
241
+ const replay = await mutationReplay(db, idempotencyKey, "workflow.create", session.actor.id);
242
+ if (replay) return {
243
+ workflowId: String(replay.workflowId), draftId: String(replay.draftId), revision: Number(replay.revision), idempotentReplay: true,
244
+ };
245
+ if (graph.purpose !== input.purpose) throw new ApiError(400, "workflow_purpose_mismatch", "The workflow definition and graph purposes must match.");
246
+ const conflict = await db.prepare(`
247
+ SELECT id FROM workflow_definitions WHERE installation_id = 'default' AND workflow_key = ?1 LIMIT 1
248
+ `).bind(input.key).first<{ id: string }>();
249
+ if (conflict) throw new ApiError(409, "workflow_key_exists", "A workflow with this key already exists.");
250
+ const workflowId = crypto.randomUUID();
251
+ const draftId = crypto.randomUUID();
252
+ const createdAt = now();
253
+ const graphJson = canonicalWorkflowGraph(graph);
254
+ const graphDigest = `sha256:${await sha256(graphJson)}`;
255
+ const validation = validateWorkflowGraph(graph);
256
+ const response = { workflowId, draftId, revision: 1 };
257
+ await db.batch([
258
+ db.prepare(`
259
+ INSERT INTO workflow_definitions (
260
+ id, installation_id, workflow_key, name, description, purpose, status,
261
+ created_by, created_at, updated_at
262
+ ) VALUES (?1, 'default', ?2, ?3, ?4, ?5, 'active', ?6, ?7, ?7)
263
+ `).bind(workflowId, input.key, input.name, input.description, input.purpose, session.actor.id, createdAt),
264
+ db.prepare(`
265
+ INSERT INTO workflow_drafts (
266
+ id, workflow_id, graph_json, graph_digest, revision, validation_json,
267
+ validation_digest, validation_status, created_by, updated_by, created_at, updated_at
268
+ ) VALUES (?1, ?2, ?3, ?4, 1, ?5, ?6, ?7, ?8, ?8, ?9, ?9)
269
+ `).bind(
270
+ draftId, workflowId, graphJson, graphDigest, canonicalJson(validation),
271
+ `sha256:${await sha256(canonicalJson(validation))}`, validation.valid ? "valid" : "invalid",
272
+ session.actor.id, createdAt,
273
+ ),
274
+ db.prepare(`
275
+ INSERT INTO configuration_mutations (idempotency_key, action, actor_id, target_id, response_json, created_at)
276
+ VALUES (?1, 'workflow.create', ?2, ?3, ?4, ?5)
277
+ `).bind(idempotencyKey, session.actor.id, workflowId, canonicalJson(response), createdAt),
278
+ auditStatement(db, {
279
+ idempotencyKey: `workflow-create:${idempotencyKey}`, action: "workflow.created", session,
280
+ targetType: "workflow", targetId: workflowId,
281
+ details: { key: input.key, purpose: input.purpose, draftId, graphDigest }, createdAt,
282
+ }),
283
+ ]);
284
+ return { ...response, idempotentReplay: false };
285
+ }
286
+
287
+ export async function replaceWorkflowDraft(
288
+ db: D1Database,
289
+ workflowId: string,
290
+ graph: WorkflowGraphV1,
291
+ expectedRevision: number,
292
+ session: OperatorSession,
293
+ ): Promise<{ draftId: string; revision: number; graphDigest: string; validation: WorkflowValidationResult }> {
294
+ const draft = await db.prepare(`
295
+ SELECT d.id, d.workflow_id, d.graph_json, d.graph_digest, d.revision,
296
+ d.validation_json, d.validation_status, d.updated_at
297
+ FROM workflow_drafts d JOIN workflow_definitions w ON w.id = d.workflow_id
298
+ WHERE d.workflow_id = ?1 AND w.status != 'archived' LIMIT 1
299
+ `).bind(workflowId).first<DraftRow>();
300
+ if (!draft) throw new ApiError(404, "workflow_not_found", "The workflow does not exist or is archived.");
301
+ if (draft.revision !== expectedRevision) throw new ApiError(409, "workflow_revision_conflict", "The workflow draft changed after it was loaded.", {
302
+ expected_revision: expectedRevision, current_revision: draft.revision,
303
+ });
304
+ const purpose = await db.prepare(`SELECT purpose FROM workflow_definitions WHERE id = ?1`).bind(workflowId).first<{ purpose: string }>();
305
+ if (!purpose || graph.purpose !== purpose.purpose) throw new ApiError(400, "workflow_purpose_mismatch", "The workflow definition and graph purposes must match.");
306
+ const graphJson = canonicalWorkflowGraph(graph);
307
+ const graphDigest = `sha256:${await sha256(graphJson)}`;
308
+ const validation = validateWorkflowGraph(graph);
309
+ const validationJson = canonicalJson(validation);
310
+ const updatedAt = now();
311
+ const revision = draft.revision + 1;
312
+ const result = await db.prepare(`
313
+ UPDATE workflow_drafts
314
+ SET graph_json = ?2, graph_digest = ?3, revision = ?4,
315
+ validation_json = ?5, validation_digest = ?6, validation_status = ?7,
316
+ updated_by = ?8, updated_at = ?9
317
+ WHERE id = ?1 AND revision = ?10
318
+ `).bind(
319
+ draft.id, graphJson, graphDigest, revision, validationJson,
320
+ `sha256:${await sha256(validationJson)}`, validation.valid ? "valid" : "invalid",
321
+ session.actor.id, updatedAt, expectedRevision,
322
+ ).run();
323
+ if (result.meta.changes !== 1) throw new ApiError(409, "workflow_revision_conflict", "The workflow draft changed while it was being saved.");
324
+ return { draftId: draft.id, revision, graphDigest, validation };
325
+ }
326
+
327
+ export async function validateWorkflowDraft(
328
+ db: D1Database,
329
+ workflowId: string,
330
+ authorityMode: WorkflowAuthorityMode,
331
+ ): Promise<{ revision: number; graphDigest: string; validation: WorkflowValidationResult }> {
332
+ const draft = await db.prepare(`
333
+ SELECT id, workflow_id, graph_json, graph_digest, revision,
334
+ validation_json, validation_status, updated_at
335
+ FROM workflow_drafts WHERE workflow_id = ?1 LIMIT 1
336
+ `).bind(workflowId).first<DraftRow>();
337
+ if (!draft) throw new ApiError(404, "workflow_not_found", "The workflow does not exist.");
338
+ const graph = parseWorkflowGraph(parsedJson(draft.graph_json, null));
339
+ const validation = validateWorkflowGraph(graph, authorityMode);
340
+ const validationJson = canonicalJson(validation);
341
+ await db.prepare(`
342
+ UPDATE workflow_drafts SET validation_json = ?2, validation_digest = ?3,
343
+ validation_status = ?4, updated_at = ?5 WHERE id = ?1 AND revision = ?6
344
+ `).bind(
345
+ draft.id, validationJson, `sha256:${await sha256(validationJson)}`,
346
+ validation.valid ? "valid" : "invalid", now(), draft.revision,
347
+ ).run();
348
+ return { revision: draft.revision, graphDigest: draft.graph_digest, validation };
349
+ }
350
+
351
+ export async function listWorkflowFixtures(db: D1Database, workflowId: string): Promise<Record<string, unknown>[]> {
352
+ const rows = await db.prepare(`
353
+ SELECT id, name, input_json AS inputJson, component_results_json AS componentResultsJson,
354
+ human_responses_json AS humanResponsesJson, expected_json AS expectedJson,
355
+ failure_injection_json AS failureInjectionJson, created_by AS createdBy,
356
+ created_at AS createdAt, updated_at AS updatedAt
357
+ FROM workflow_fixtures WHERE workflow_id = ?1 ORDER BY name, id
358
+ `).bind(workflowId).all<Record<string, unknown>>();
359
+ return rows.results.map((row) => ({
360
+ ...row,
361
+ input: parsedJson(String(row.inputJson ?? ""), {}),
362
+ componentResults: parsedJson(String(row.componentResultsJson ?? ""), {}),
363
+ humanResponses: parsedJson(String(row.humanResponsesJson ?? ""), {}),
364
+ expected: parsedJson(String(row.expectedJson ?? ""), {}),
365
+ failureInjection: parsedJson(String(row.failureInjectionJson ?? ""), {}),
366
+ inputJson: undefined,
367
+ componentResultsJson: undefined,
368
+ humanResponsesJson: undefined,
369
+ expectedJson: undefined,
370
+ failureInjectionJson: undefined,
371
+ }));
372
+ }
373
+
374
+ export async function createWorkflowFixture(
375
+ db: D1Database,
376
+ workflowId: string,
377
+ input: {
378
+ name: string;
379
+ input: JsonObject;
380
+ componentResults: JsonObject;
381
+ humanResponses: JsonObject;
382
+ expected: JsonObject;
383
+ failureInjection: JsonObject;
384
+ },
385
+ session: OperatorSession,
386
+ idempotencyKey: string,
387
+ ): Promise<{ fixtureId: string; idempotentReplay: boolean }> {
388
+ const replay = await mutationReplay(db, idempotencyKey, "workflow.fixture.create", session.actor.id);
389
+ if (replay) return { fixtureId: String(replay.fixtureId), idempotentReplay: true };
390
+ if (!await db.prepare(`SELECT id FROM workflow_definitions WHERE id = ?1 AND status != 'archived'`).bind(workflowId).first()) {
391
+ throw new ApiError(404, "workflow_not_found", "The workflow does not exist.");
392
+ }
393
+ if (!/^[A-Za-z0-9][A-Za-z0-9 _.-]{1,119}$/u.test(input.name)) throw new ApiError(400, "workflow_fixture_name_invalid", "Fixture name must contain 2 to 120 safe characters.");
394
+ if (await db.prepare(`SELECT id FROM workflow_fixtures WHERE workflow_id = ?1 AND name = ?2`).bind(workflowId, input.name).first()) {
395
+ throw new ApiError(409, "workflow_fixture_exists", "A fixture with this name already exists.");
396
+ }
397
+ const fixtureId = crypto.randomUUID();
398
+ const createdAt = now();
399
+ const response = { fixtureId };
400
+ await db.batch([
401
+ db.prepare(`
402
+ INSERT INTO workflow_fixtures (
403
+ id, workflow_id, name, input_json, component_results_json,
404
+ human_responses_json, expected_json, failure_injection_json,
405
+ created_by, created_at, updated_at
406
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?10)
407
+ `).bind(
408
+ fixtureId, workflowId, input.name, canonicalJson(input.input),
409
+ canonicalJson(input.componentResults), canonicalJson(input.humanResponses),
410
+ canonicalJson(input.expected), canonicalJson(input.failureInjection),
411
+ session.actor.id, createdAt,
412
+ ),
413
+ db.prepare(`
414
+ INSERT INTO configuration_mutations (idempotency_key, action, actor_id, target_id, response_json, created_at)
415
+ VALUES (?1, 'workflow.fixture.create', ?2, ?3, ?4, ?5)
416
+ `).bind(idempotencyKey, session.actor.id, fixtureId, canonicalJson(response), createdAt),
417
+ auditStatement(db, {
418
+ idempotencyKey: `workflow-fixture:${idempotencyKey}`,
419
+ action: "workflow.fixture_created",
420
+ session,
421
+ targetType: "workflow_fixture",
422
+ targetId: fixtureId,
423
+ details: { workflowId, name: input.name },
424
+ createdAt,
425
+ }),
426
+ ]);
427
+ return { fixtureId, idempotentReplay: false };
428
+ }
429
+
430
+ export async function updateWorkflowFixture(
431
+ db: D1Database,
432
+ workflowId: string,
433
+ fixtureId: string,
434
+ input: {
435
+ name: string;
436
+ input: JsonObject;
437
+ componentResults: JsonObject;
438
+ humanResponses: JsonObject;
439
+ expected: JsonObject;
440
+ failureInjection: JsonObject;
441
+ },
442
+ session: OperatorSession,
443
+ idempotencyKey: string,
444
+ ): Promise<{ fixtureId: string; idempotentReplay: boolean }> {
445
+ const replay = await mutationReplay(db, idempotencyKey, "workflow.fixture.update", session.actor.id);
446
+ if (replay) return { fixtureId: String(replay.fixtureId), idempotentReplay: true };
447
+ if (!/^[A-Za-z0-9][A-Za-z0-9 _.-]{1,119}$/u.test(input.name)) throw new ApiError(400, "workflow_fixture_name_invalid", "Fixture name must contain 2 to 120 safe characters.");
448
+ const fixture = await db.prepare(`SELECT id FROM workflow_fixtures WHERE id = ?1 AND workflow_id = ?2 LIMIT 1`)
449
+ .bind(fixtureId, workflowId).first();
450
+ if (!fixture) throw new ApiError(404, "workflow_fixture_not_found", "The workflow fixture does not exist.");
451
+ const timestamp = now();
452
+ const response = { fixtureId };
453
+ await db.batch([
454
+ db.prepare(`
455
+ UPDATE workflow_fixtures SET name = ?3, input_json = ?4,
456
+ component_results_json = ?5, human_responses_json = ?6,
457
+ expected_json = ?7, failure_injection_json = ?8, updated_at = ?9
458
+ WHERE id = ?1 AND workflow_id = ?2
459
+ `).bind(
460
+ fixtureId, workflowId, input.name, canonicalJson(input.input),
461
+ canonicalJson(input.componentResults), canonicalJson(input.humanResponses),
462
+ canonicalJson(input.expected), canonicalJson(input.failureInjection), timestamp,
463
+ ),
464
+ db.prepare(`INSERT INTO configuration_mutations (idempotency_key, action, actor_id, target_id, response_json, created_at) VALUES (?1, 'workflow.fixture.update', ?2, ?3, ?4, ?5)`)
465
+ .bind(idempotencyKey, session.actor.id, fixtureId, canonicalJson(response), timestamp),
466
+ auditStatement(db, {
467
+ idempotencyKey: `workflow-fixture-update:${idempotencyKey}`,
468
+ action: "workflow.fixture_updated", session, targetType: "workflow_fixture",
469
+ targetId: fixtureId, details: { workflowId, name: input.name }, createdAt: timestamp,
470
+ }),
471
+ ]);
472
+ return { fixtureId, idempotentReplay: false };
473
+ }
474
+
475
+ export async function deleteWorkflowFixture(
476
+ db: D1Database,
477
+ workflowId: string,
478
+ fixtureId: string,
479
+ session: OperatorSession,
480
+ idempotencyKey: string,
481
+ ): Promise<{ fixtureId: string; deleted: true; idempotentReplay: boolean }> {
482
+ const replay = await mutationReplay(db, idempotencyKey, "workflow.fixture.delete", session.actor.id);
483
+ if (replay) return { fixtureId: String(replay.fixtureId), deleted: true, idempotentReplay: true };
484
+ const fixture = await db.prepare(`SELECT name FROM workflow_fixtures WHERE id = ?1 AND workflow_id = ?2 LIMIT 1`)
485
+ .bind(fixtureId, workflowId).first<{ name: string }>();
486
+ if (!fixture) throw new ApiError(404, "workflow_fixture_not_found", "The workflow fixture does not exist.");
487
+ const timestamp = now();
488
+ const response = { fixtureId, deleted: true as const };
489
+ await db.batch([
490
+ db.prepare(`DELETE FROM workflow_fixtures WHERE id = ?1 AND workflow_id = ?2`).bind(fixtureId, workflowId),
491
+ db.prepare(`INSERT INTO configuration_mutations (idempotency_key, action, actor_id, target_id, response_json, created_at) VALUES (?1, 'workflow.fixture.delete', ?2, ?3, ?4, ?5)`)
492
+ .bind(idempotencyKey, session.actor.id, fixtureId, canonicalJson(response), timestamp),
493
+ auditStatement(db, {
494
+ idempotencyKey: `workflow-fixture-delete:${idempotencyKey}`,
495
+ action: "workflow.fixture_deleted", session, targetType: "workflow_fixture",
496
+ targetId: fixtureId, details: { workflowId, name: fixture.name }, createdAt: timestamp,
497
+ }),
498
+ ]);
499
+ return { ...response, idempotentReplay: false };
500
+ }
501
+
502
+ export async function runWorkflowFixtureTest(
503
+ db: D1Database,
504
+ workflowId: string,
505
+ fixtureId: string,
506
+ session: OperatorSession,
507
+ ): Promise<{ testRunId: string; status: "passed" | "failed"; graphDigest: string; result: Record<string, unknown> }> {
508
+ const [draft, fixture] = await Promise.all([
509
+ db.prepare(`SELECT graph_json AS graphJson, graph_digest AS graphDigest FROM workflow_drafts WHERE workflow_id = ?1 LIMIT 1`)
510
+ .bind(workflowId).first<{ graphJson: string; graphDigest: string }>(),
511
+ db.prepare(`
512
+ SELECT input_json AS inputJson, component_results_json AS componentResultsJson,
513
+ human_responses_json AS humanResponsesJson, expected_json AS expectedJson,
514
+ failure_injection_json AS failureInjectionJson
515
+ FROM workflow_fixtures WHERE id = ?1 AND workflow_id = ?2 LIMIT 1
516
+ `).bind(fixtureId, workflowId).first<{
517
+ inputJson: string; componentResultsJson: string; humanResponsesJson: string;
518
+ expectedJson: string; failureInjectionJson: string;
519
+ }>(),
520
+ ]);
521
+ if (!draft) throw new ApiError(404, "workflow_not_found", "The workflow draft does not exist.");
522
+ if (!fixture) throw new ApiError(404, "workflow_fixture_not_found", "The workflow fixture does not exist.");
523
+ const graph = parseWorkflowGraph(parsedJson(draft.graphJson, null));
524
+ const simulation = simulateWorkflow(graph, {
525
+ input: parsedJson(fixture.inputJson, {}),
526
+ componentResults: parsedJson(fixture.componentResultsJson, {}),
527
+ humanResponses: parsedJson(fixture.humanResponsesJson, {}),
528
+ failureInjection: parsedJson(fixture.failureInjectionJson, {}),
529
+ });
530
+ const expected = parsedJson<JsonObject>(fixture.expectedJson, {});
531
+ const passed = simulation.status === "passed" && simulationMatchesExpected(simulation, expected);
532
+ const status = passed ? "passed" : "failed";
533
+ const testRunId = crypto.randomUUID();
534
+ const timestamp = now();
535
+ const result = {
536
+ status,
537
+ terminalCode: simulation.terminalCode,
538
+ steps: simulation.steps,
539
+ expectedMatched: passed,
540
+ resultDigest: `sha256:${await sha256(simulation.canonicalResult)}`,
541
+ };
542
+ await db.prepare(`
543
+ INSERT INTO workflow_test_runs (
544
+ id, workflow_id, fixture_id, graph_digest, mode, status,
545
+ result_json, error_json, started_by, started_at, finished_at
546
+ ) VALUES (?1, ?2, ?3, ?4, 'simulation', ?5, ?6, ?7, ?8, ?9, ?9)
547
+ `).bind(
548
+ testRunId, workflowId, fixtureId, draft.graphDigest, status,
549
+ canonicalJson(result), simulation.error ? canonicalJson(simulation.error) : null,
550
+ session.actor.id, timestamp,
551
+ ).run();
552
+ return { testRunId, status, graphDigest: draft.graphDigest, result };
553
+ }
554
+
555
+ export async function loadWorkflowTestRun(
556
+ db: D1Database,
557
+ workflowId: string,
558
+ testRunId: string,
559
+ ): Promise<Record<string, unknown> | null> {
560
+ const row = await db.prepare(`
561
+ SELECT id, workflow_id AS workflowId, workflow_version_id AS workflowVersionId,
562
+ fixture_id AS fixtureId, graph_digest AS graphDigest, mode, status,
563
+ result_json AS resultJson, error_json AS errorJson, started_by AS startedBy,
564
+ started_at AS startedAt, finished_at AS finishedAt
565
+ FROM workflow_test_runs WHERE id = ?1 AND workflow_id = ?2 LIMIT 1
566
+ `).bind(testRunId, workflowId).first<Record<string, unknown>>();
567
+ if (!row) return null;
568
+ return {
569
+ ...row,
570
+ result: parsedJson(String(row.resultJson ?? ""), null),
571
+ error: parsedJson(String(row.errorJson ?? ""), null),
572
+ resultJson: undefined,
573
+ errorJson: undefined,
574
+ };
575
+ }
576
+
577
+ export async function loadWorkflowVersion(
578
+ db: D1Database,
579
+ workflowId: string,
580
+ versionId: string,
581
+ ): Promise<Record<string, unknown> | null> {
582
+ const row = await db.prepare(`
583
+ SELECT v.id, v.workflow_id AS workflowId, v.version, v.graph_json AS graphJson,
584
+ v.graph_digest AS graphDigest, v.input_schema_json AS inputSchemaJson,
585
+ v.output_schema_json AS outputSchemaJson, v.authority_mode AS authorityMode,
586
+ v.dependency_manifest_json AS dependencyManifestJson,
587
+ v.capability_manifest_json AS capabilityManifestJson,
588
+ v.artifact_id AS artifactId, v.compiler_version AS compilerVersion,
589
+ v.test_evidence_json AS testEvidenceJson, v.published_by AS publishedBy,
590
+ v.published_at AS publishedAt, v.retired_at AS retiredAt,
591
+ a.bundle_digest AS bundleDigest, a.manifest_digest AS manifestDigest,
592
+ a.status AS artifactStatus, a.compatibility_date AS compatibilityDate
593
+ FROM workflow_versions v JOIN workflow_artifacts a ON a.id = v.artifact_id
594
+ WHERE v.id = ?1 AND v.workflow_id = ?2 LIMIT 1
595
+ `).bind(versionId, workflowId).first<Record<string, unknown>>();
596
+ if (!row) return null;
597
+ return {
598
+ ...row,
599
+ graph: parsedJson(String(row.graphJson ?? ""), null),
600
+ inputSchema: parsedJson(String(row.inputSchemaJson ?? ""), {}),
601
+ outputSchema: parsedJson(String(row.outputSchemaJson ?? ""), {}),
602
+ dependencyManifest: parsedJson(String(row.dependencyManifestJson ?? ""), []),
603
+ capabilityManifest: parsedJson(String(row.capabilityManifestJson ?? ""), {}),
604
+ testEvidence: parsedJson(String(row.testEvidenceJson ?? ""), {}),
605
+ graphJson: undefined, inputSchemaJson: undefined, outputSchemaJson: undefined,
606
+ dependencyManifestJson: undefined, capabilityManifestJson: undefined, testEvidenceJson: undefined,
607
+ };
608
+ }
609
+
610
+ export async function diffWorkflowVersion(
611
+ db: D1Database,
612
+ workflowId: string,
613
+ versionId: string,
614
+ againstVersionId?: string,
615
+ ): Promise<Record<string, unknown>> {
616
+ const target = await loadWorkflowVersion(db, workflowId, versionId);
617
+ if (!target) throw new ApiError(404, "workflow_version_not_found", "The workflow version does not exist.");
618
+ let againstId = againstVersionId;
619
+ if (!againstId) {
620
+ const prior = await db.prepare(`
621
+ SELECT id FROM workflow_versions WHERE workflow_id = ?1 AND version < ?2
622
+ ORDER BY version DESC, id DESC LIMIT 1
623
+ `).bind(workflowId, Number(target.version)).first<{ id: string }>();
624
+ againstId = prior?.id;
625
+ }
626
+ const against = againstId ? await loadWorkflowVersion(db, workflowId, againstId) : null;
627
+ const targetGraph = target.graph as WorkflowGraphV1;
628
+ const againstGraph = against?.graph as WorkflowGraphV1 | undefined;
629
+ const targetNodes = new Map((targetGraph?.nodes ?? []).map((node) => [node.id, canonicalJson(node)]));
630
+ const againstNodes = new Map((againstGraph?.nodes ?? []).map((node) => [node.id, canonicalJson(node)]));
631
+ const added = [...targetNodes.keys()].filter((id) => !againstNodes.has(id));
632
+ const removed = [...againstNodes.keys()].filter((id) => !targetNodes.has(id));
633
+ const changed = [...targetNodes.keys()].filter((id) => againstNodes.has(id) && againstNodes.get(id) !== targetNodes.get(id));
634
+ return {
635
+ workflowId,
636
+ versionId,
637
+ againstVersionId: against?.id ?? null,
638
+ graphDigestChanged: target.graphDigest !== against?.graphDigest,
639
+ nodes: { added, removed, changed },
640
+ authorityMode: { from: against?.authorityMode ?? null, to: target.authorityMode },
641
+ dependencies: { from: against?.dependencyManifest ?? [], to: target.dependencyManifest ?? [] },
642
+ };
643
+ }
644
+
645
+ export async function retireWorkflowVersion(
646
+ db: D1Database,
647
+ workflowId: string,
648
+ versionId: string,
649
+ session: OperatorSession,
650
+ idempotencyKey: string,
651
+ ): Promise<{ workflowId: string; versionId: string; retiredAt: string; idempotentReplay: boolean }> {
652
+ const replay = await mutationReplay(db, idempotencyKey, "workflow.version.retire", session.actor.id);
653
+ if (replay) return {
654
+ workflowId: String(replay.workflowId), versionId: String(replay.versionId),
655
+ retiredAt: String(replay.retiredAt), idempotentReplay: true,
656
+ };
657
+ const version = await db.prepare(`
658
+ SELECT v.id, v.retired_at AS retiredAt, w.active_version_id AS activeVersionId
659
+ FROM workflow_versions v JOIN workflow_definitions w ON w.id = v.workflow_id
660
+ WHERE v.id = ?1 AND v.workflow_id = ?2 LIMIT 1
661
+ `).bind(versionId, workflowId).first<{ id: string; retiredAt: string | null; activeVersionId: string | null }>();
662
+ if (!version) throw new ApiError(404, "workflow_version_not_found", "The workflow version does not exist.");
663
+ if (version.retiredAt) return { workflowId, versionId, retiredAt: version.retiredAt, idempotentReplay: true };
664
+ if (version.activeVersionId === versionId) throw new ApiError(409, "workflow_active_version_cannot_retire", "Publish or select a replacement before retiring the active version.");
665
+ const dependent = await db.prepare(`
666
+ SELECT
667
+ EXISTS(SELECT 1 FROM queue_workflow_pins WHERE workflow_version_id = ?1 AND deactivated_at IS NULL) AS pinned,
668
+ EXISTS(SELECT 1 FROM workflow_runs WHERE workflow_version_id = ?1 AND state IN ('created','dispatch_pending','queued','running','waiting','paused','repair_required')) AS running
669
+ `).bind(versionId).first<{ pinned: number; running: number }>();
670
+ if (dependent?.pinned || dependent?.running) throw new ApiError(409, "workflow_version_in_use", "Active pins or runs still depend on this workflow version.");
671
+ const retiredAt = now();
672
+ const response = { workflowId, versionId, retiredAt };
673
+ await db.batch([
674
+ db.prepare(`UPDATE workflow_versions SET retired_at = ?2 WHERE id = ?1 AND retired_at IS NULL`).bind(versionId, retiredAt),
675
+ db.prepare(`INSERT INTO configuration_mutations (idempotency_key, action, actor_id, target_id, response_json, created_at) VALUES (?1, 'workflow.version.retire', ?2, ?3, ?4, ?5)`)
676
+ .bind(idempotencyKey, session.actor.id, versionId, canonicalJson(response), retiredAt),
677
+ auditStatement(db, {
678
+ idempotencyKey: `workflow-version-retire:${idempotencyKey}`,
679
+ action: "workflow.version_retired", session, targetType: "workflow_version",
680
+ targetId: versionId, details: { workflowId }, createdAt: retiredAt,
681
+ }),
682
+ ]);
683
+ return { ...response, idempotentReplay: false };
684
+ }
685
+
686
+ async function dependencyAvailable(db: D1Database, dependency: WorkflowDependencyReference): Promise<boolean> {
687
+ const queryByType: Record<WorkflowDependencyReference["type"], string> = {
688
+ component: `SELECT v.id FROM component_versions v JOIN component_definitions d ON d.id = v.definition_id WHERE v.id = ?1 AND v.published_at IS NOT NULL AND v.retired_at IS NULL AND d.status IN ('active','deprecated')`,
689
+ connection: `SELECT v.id FROM connection_versions v JOIN connection_definitions d ON d.id = v.connection_id WHERE v.id = ?1 AND v.published_at IS NOT NULL AND v.retired_at IS NULL AND d.status = 'active'`,
690
+ policy: `SELECT id FROM policy_versions WHERE id = ?1 AND published_at IS NOT NULL`,
691
+ template: `SELECT id FROM message_template_versions WHERE id = ?1 AND published_at IS NOT NULL`,
692
+ model_alias: `SELECT v.id FROM model_alias_versions v JOIN model_aliases a ON a.id = v.alias_id WHERE v.id = ?1 AND v.published_at IS NOT NULL AND v.retired_at IS NULL AND a.status = 'active'`,
693
+ agent: `SELECT v.id FROM agent_versions v JOIN agent_definitions a ON a.id = v.agent_id WHERE v.id = ?1 AND v.published_at IS NOT NULL AND v.retired_at IS NULL AND a.status = 'active'`,
694
+ workflow: `SELECT v.id FROM workflow_versions v JOIN workflow_artifacts a ON a.id = v.artifact_id WHERE v.id = ?1 AND v.retired_at IS NULL AND a.status = 'ready'`,
695
+ };
696
+ return Boolean(await db.prepare(queryByType[dependency.type]).bind(dependency.versionId).first());
697
+ }
698
+
699
+ async function requireDependencies(db: D1Database, dependencies: WorkflowDependencyReference[]): Promise<void> {
700
+ const availability = await Promise.all(dependencies.map(async (dependency) => ({
701
+ dependency,
702
+ available: await dependencyAvailable(db, dependency),
703
+ })));
704
+ const missing = availability.filter((entry) => !entry.available).map((entry) => entry.dependency);
705
+ if (missing.length) throw new ApiError(409, "workflow_dependency_unavailable", "The workflow references unpublished, paused, retired, missing, or incompatible dependencies.", { dependencies: missing });
706
+ }
707
+
708
+ async function persistArtifact(
709
+ db: D1Database,
710
+ bucket: R2Bucket,
711
+ compiled: Awaited<ReturnType<typeof compileWorkflowGraph>>,
712
+ ): Promise<string> {
713
+ const existing = await db.prepare(`
714
+ SELECT id, status FROM workflow_artifacts
715
+ WHERE installation_id = 'default' AND bundle_digest = ?1 LIMIT 1
716
+ `).bind(compiled.bundleDigest).first<{ id: string; status: string }>();
717
+ if (existing?.status === "ready") return existing.id;
718
+ if (existing) throw new ApiError(409, "workflow_artifact_unavailable", "An artifact with this digest exists but is not ready.");
719
+ const artifactId = crypto.randomUUID();
720
+ const objectKey = `workflow-artifacts/${compiled.bundleDigest.slice("sha256:".length, "sha256:".length + 2)}/${compiled.bundleDigest.slice("sha256:".length)}.mjs`;
721
+ await bucket.put(objectKey, compiled.source, {
722
+ httpMetadata: { contentType: "text/javascript; charset=utf-8" },
723
+ customMetadata: {
724
+ compiler: compiled.compilerVersion,
725
+ graphDigest: compiled.graphDigest,
726
+ bundleDigest: compiled.bundleDigest,
727
+ },
728
+ });
729
+ const readBack = await bucket.get(objectKey);
730
+ if (!readBack) throw new ApiError(503, "workflow_artifact_write_failed", "The compiled workflow artifact could not be read back.");
731
+ const readBackSource = await readBack.text();
732
+ if (`sha256:${await sha256(readBackSource)}` !== compiled.bundleDigest) {
733
+ throw new ApiError(503, "workflow_artifact_digest_mismatch", "The compiled workflow artifact failed digest verification.");
734
+ }
735
+ const createdAt = now();
736
+ await db.prepare(`
737
+ INSERT INTO workflow_artifacts (
738
+ id, installation_id, compiler_version, compatibility_date, graph_digest,
739
+ source_digest, bundle_digest, manifest_digest, bundle_r2_key, bundle_size_bytes,
740
+ capability_manifest_json, compiler_warnings_json, status, created_at, verified_at
741
+ ) VALUES (?1, 'default', ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, 'ready', ?12, ?12)
742
+ `).bind(
743
+ artifactId, compiled.compilerVersion, compiled.compatibilityDate, compiled.graphDigest,
744
+ compiled.sourceDigest, compiled.bundleDigest, compiled.manifestDigest, objectKey,
745
+ new TextEncoder().encode(compiled.source).byteLength,
746
+ canonicalJson(compiled.capabilityManifest), canonicalJson(compiled.warnings), createdAt,
747
+ ).run();
748
+ return artifactId;
749
+ }
750
+
751
+ export async function publishWorkflowDraft(
752
+ db: D1Database,
753
+ bucket: R2Bucket,
754
+ workflowId: string,
755
+ input: WorkflowPublishInput,
756
+ session: OperatorSession,
757
+ idempotencyKey: string,
758
+ compatibilityDate: string,
759
+ ): Promise<{ workflowId: string; versionId: string; version: number; artifactId: string; bundleDigest: string; idempotentReplay: boolean }> {
760
+ const replay = await mutationReplay(db, idempotencyKey, "workflow.publish", session.actor.id);
761
+ if (replay) return {
762
+ workflowId: String(replay.workflowId), versionId: String(replay.versionId), version: Number(replay.version),
763
+ artifactId: String(replay.artifactId), bundleDigest: String(replay.bundleDigest), idempotentReplay: true,
764
+ };
765
+ const workflow = await db.prepare(`
766
+ SELECT w.status, w.purpose, d.id AS draft_id, d.graph_json, d.revision
767
+ FROM workflow_definitions w JOIN workflow_drafts d ON d.workflow_id = w.id
768
+ WHERE w.id = ?1 LIMIT 1
769
+ `).bind(workflowId).first<{ status: string; purpose: string; draft_id: string; graph_json: string; revision: number }>();
770
+ if (!workflow) throw new ApiError(404, "workflow_not_found", "The workflow does not exist.");
771
+ if (workflow.status !== "active") throw new ApiError(409, "workflow_not_active", "Only an active workflow can be published.");
772
+ if (workflow.revision !== input.expectedRevision) throw new ApiError(409, "workflow_revision_conflict", "The workflow draft changed after validation.", {
773
+ expected_revision: input.expectedRevision, current_revision: workflow.revision,
774
+ });
775
+ const graph = parseWorkflowGraph(parsedJson(workflow.graph_json, null));
776
+ if (graph.purpose !== workflow.purpose) throw new ApiError(409, "workflow_purpose_mismatch", "The stored workflow definition and graph purposes do not match.");
777
+ const graphDigest = `sha256:${await sha256(canonicalWorkflowGraph(graph))}`;
778
+ const testRunId = typeof input.testEvidence.test_run_id === "string" ? input.testEvidence.test_run_id : null;
779
+ const testRun = testRunId ? await db.prepare(`
780
+ SELECT id, graph_digest AS graphDigest, status FROM workflow_test_runs
781
+ WHERE id = ?1 AND workflow_id = ?2 AND mode = 'simulation' LIMIT 1
782
+ `).bind(testRunId, workflowId).first<{ id: string; graphDigest: string; status: string }>() : null;
783
+ if (!testRun || testRun.status !== "passed" || testRun.graphDigest !== graphDigest) {
784
+ throw new ApiError(409, "workflow_tests_required", "A passing server-recorded fixture test for this exact draft is required before publication.");
785
+ }
786
+ const validation = validateWorkflowGraph(graph, input.authorityMode);
787
+ if (!validation.valid) throw new ApiError(400, "workflow_validation_failed", "The workflow draft has publication errors.", { errors: validation.errors, warnings: validation.warnings });
788
+ await requireDependencies(db, validation.dependencies);
789
+ const compiled = await compileWorkflowGraph(graph, input.authorityMode, compatibilityDate);
790
+ const artifactId = await persistArtifact(db, bucket, compiled);
791
+ const latest = await db.prepare(`SELECT COALESCE(MAX(version), 0) AS version FROM workflow_versions WHERE workflow_id = ?1`)
792
+ .bind(workflowId).first<{ version: number }>();
793
+ const version = Number(latest?.version ?? 0) + 1;
794
+ const versionId = crypto.randomUUID();
795
+ const publishedAt = now();
796
+ const response = { workflowId, versionId, version, artifactId, bundleDigest: compiled.bundleDigest };
797
+ const statements: D1PreparedStatement[] = [
798
+ db.prepare(`
799
+ INSERT INTO workflow_versions (
800
+ id, workflow_id, version, graph_json, graph_digest, input_schema_json,
801
+ output_schema_json, authority_mode, dependency_manifest_json, capability_manifest_json,
802
+ artifact_id, compiler_version, test_evidence_json, published_by, published_at
803
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, '{"type":"object"}', ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
804
+ `).bind(
805
+ versionId, workflowId, version, canonicalWorkflowGraph(graph), compiled.graphDigest,
806
+ canonicalJson(graph.inputSchema), input.authorityMode, canonicalJson(compiled.dependencyManifest),
807
+ canonicalJson(compiled.capabilityManifest), artifactId, compiled.compilerVersion,
808
+ canonicalJson({ test_run_id: testRun.id, passed: true, graph_digest: testRun.graphDigest }), session.actor.id, publishedAt,
809
+ ),
810
+ db.prepare(`UPDATE workflow_definitions SET active_version_id = ?2, updated_at = ?3 WHERE id = ?1 AND status = 'active'`)
811
+ .bind(workflowId, versionId, publishedAt),
812
+ db.prepare(`
813
+ UPDATE workflow_drafts SET validation_json = ?2, validation_digest = ?3,
814
+ validation_status = 'valid', updated_at = ?4 WHERE id = ?1 AND revision = ?5
815
+ `).bind(
816
+ workflow.draft_id, canonicalJson(validation), `sha256:${await sha256(canonicalJson(validation))}`,
817
+ publishedAt, workflow.revision,
818
+ ),
819
+ db.prepare(`
820
+ INSERT INTO configuration_mutations (idempotency_key, action, actor_id, target_id, response_json, created_at)
821
+ VALUES (?1, 'workflow.publish', ?2, ?3, ?4, ?5)
822
+ `).bind(idempotencyKey, session.actor.id, versionId, canonicalJson(response), publishedAt),
823
+ auditStatement(db, {
824
+ idempotencyKey: `workflow-publish:${idempotencyKey}`, action: "workflow.version_published", session,
825
+ targetType: "workflow", targetId: workflowId,
826
+ details: { versionId, version, artifactId, bundleDigest: compiled.bundleDigest, authorityMode: input.authorityMode },
827
+ createdAt: publishedAt,
828
+ }),
829
+ ];
830
+ for (const dependency of compiled.dependencyManifest) {
831
+ statements.push(db.prepare(`
832
+ INSERT INTO workflow_dependencies (
833
+ workflow_version_id, dependency_type, dependency_key, dependency_version_id
834
+ ) VALUES (?1, ?2, ?3, ?4)
835
+ `).bind(versionId, dependency.type, dependency.key, dependency.versionId));
836
+ }
837
+ await db.batch(statements);
838
+ return { ...response, idempotentReplay: false };
839
+ }
840
+
841
+ export async function loadQueueWorkflowPins(db: D1Database, queueId: string): Promise<Record<string, unknown>[]> {
842
+ const rows = await db.prepare(`
843
+ SELECT p.id, p.queue_id AS queueId, p.queue_version_id AS queueVersionId,
844
+ p.workflow_version_id AS workflowVersionId, p.mode, p.rollback_of_pin_id AS rollbackOfPinId,
845
+ p.activated_by AS activatedBy, p.activated_at AS activatedAt,
846
+ p.deactivated_at AS deactivatedAt, w.workflow_key AS workflowKey,
847
+ w.name AS workflowName, v.version, v.authority_mode AS authorityMode,
848
+ a.bundle_digest AS bundleDigest, a.status AS artifactStatus
849
+ FROM queue_workflow_pins p
850
+ JOIN workflow_versions v ON v.id = p.workflow_version_id
851
+ JOIN workflow_definitions w ON w.id = v.workflow_id
852
+ JOIN workflow_artifacts a ON a.id = v.artifact_id
853
+ WHERE p.queue_id = ?1
854
+ ORDER BY (p.deactivated_at IS NULL) DESC, p.activated_at DESC, p.id DESC
855
+ `).bind(queueId).all<Record<string, unknown>>();
856
+ return rows.results;
857
+ }
858
+
859
+ export async function pinQueueWorkflow(
860
+ db: D1Database,
861
+ queueId: string,
862
+ input: { workflowVersionId: string; mode: "shadow" | "primary"; rollbackOfPinId?: string },
863
+ session: OperatorSession,
864
+ idempotencyKey: string,
865
+ ): Promise<{ pinId: string; workflowVersionId: string; mode: string; idempotentReplay: boolean }> {
866
+ const replay = await mutationReplay(db, idempotencyKey, "queue.workflow_pin", session.actor.id);
867
+ if (replay) return {
868
+ pinId: String(replay.pinId), workflowVersionId: String(replay.workflowVersionId), mode: String(replay.mode), idempotentReplay: true,
869
+ };
870
+ const target = await db.prepare(`
871
+ SELECT q.id AS queue_id, q.active_version_id AS queue_version_id,
872
+ v.id AS workflow_version_id, v.authority_mode, a.status AS artifact_status
873
+ FROM queue_definitions q CROSS JOIN workflow_versions v
874
+ JOIN workflow_artifacts a ON a.id = v.artifact_id
875
+ WHERE q.id = ?1 AND q.status = 'active' AND v.id = ?2 AND v.retired_at IS NULL
876
+ LIMIT 1
877
+ `).bind(queueId, input.workflowVersionId).first<{
878
+ queue_id: string; queue_version_id: string | null; workflow_version_id: string;
879
+ authority_mode: WorkflowAuthorityMode; artifact_status: string;
880
+ }>();
881
+ if (!target) throw new ApiError(404, "queue_or_workflow_not_found", "The active queue or workflow version does not exist.");
882
+ if (target.artifact_status !== "ready") throw new ApiError(409, "workflow_artifact_not_ready", "The workflow artifact is not verified and ready.");
883
+ if (input.mode === "primary" && target.authority_mode === "shadow") throw new ApiError(409, "shadow_workflow_not_primary", "A shadow-authority workflow cannot be pinned as primary.");
884
+ if (input.mode === "shadow" && target.authority_mode !== "shadow") throw new ApiError(409, "shadow_pin_authority_required", "A shadow pin must reference a version compiled with shadow authority.");
885
+ const current = await db.prepare(`
886
+ SELECT id FROM queue_workflow_pins WHERE queue_id = ?1 AND mode = ?2 AND deactivated_at IS NULL LIMIT 1
887
+ `).bind(queueId, input.mode).first<{ id: string }>();
888
+ const pinId = crypto.randomUUID();
889
+ const activatedAt = now();
890
+ const response = { pinId, workflowVersionId: input.workflowVersionId, mode: input.mode };
891
+ const statements: D1PreparedStatement[] = [];
892
+ if (current) statements.push(db.prepare(`UPDATE queue_workflow_pins SET deactivated_at = ?2 WHERE id = ?1 AND deactivated_at IS NULL`).bind(current.id, activatedAt));
893
+ statements.push(
894
+ db.prepare(`
895
+ INSERT INTO queue_workflow_pins (
896
+ id, queue_id, queue_version_id, workflow_version_id, mode,
897
+ rollback_of_pin_id, activated_by, activated_at
898
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
899
+ `).bind(
900
+ pinId, queueId, target.queue_version_id, input.workflowVersionId, input.mode,
901
+ input.rollbackOfPinId ?? current?.id ?? null, session.actor.id, activatedAt,
902
+ ),
903
+ db.prepare(`
904
+ INSERT INTO configuration_mutations (idempotency_key, action, actor_id, target_id, response_json, created_at)
905
+ VALUES (?1, 'queue.workflow_pin', ?2, ?3, ?4, ?5)
906
+ `).bind(idempotencyKey, session.actor.id, pinId, canonicalJson(response), activatedAt),
907
+ auditStatement(db, {
908
+ idempotencyKey: `queue-workflow-pin:${idempotencyKey}`, action: "queue.workflow_pinned", session,
909
+ targetType: "queue", targetId: queueId,
910
+ details: { pinId, workflowVersionId: input.workflowVersionId, mode: input.mode, priorPinId: current?.id },
911
+ createdAt: activatedAt,
912
+ }),
913
+ );
914
+ await db.batch(statements);
915
+ return { ...response, idempotentReplay: false };
916
+ }