create-safest-tools 0.6.0 → 0.7.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 (172) hide show
  1. package/README.md +3 -3
  2. package/package.json +2 -2
  3. package/src/config.mjs +2 -2
  4. package/src/update.mjs +10 -1
  5. package/template/README.md +2 -2
  6. package/template/console/README.md +2 -2
  7. package/template/console/analytics/AnalyticsWorkspace.tsx +49 -36
  8. package/template/console/analytics/controller.ts +10 -2
  9. package/template/console/analytics/types.ts +7 -0
  10. package/template/console/appeals/AppealFollowup.tsx +171 -0
  11. package/template/console/appeals/AppealWorkspace.tsx +33 -4
  12. package/template/console/appeals/RecordAppealDialog.tsx +42 -0
  13. package/template/console/appeals/appeal-corrections.css +29 -0
  14. package/template/console/appeals/controller.ts +40 -14
  15. package/template/console/appeals/events.ts +1 -0
  16. package/template/console/appeals/types.ts +6 -0
  17. package/template/console/appeals/useAppealDraftGuard.ts +12 -0
  18. package/template/console/assistant/AssistantWorkspace.tsx +28 -5
  19. package/template/console/command/CommandCentre.tsx +101 -64
  20. package/template/console/command/controller.ts +80 -70
  21. package/template/console/command/events.ts +1 -2
  22. package/template/console/command/presentation.ts +29 -0
  23. package/template/console/command/store.ts +4 -4
  24. package/template/console/command/types.ts +26 -21
  25. package/template/console/configuration/ConfigurationBuilders.tsx +101 -0
  26. package/template/console/configuration/ConfigurationDialog.tsx +28 -13
  27. package/template/console/configuration/ConfigurationWorkspace.tsx +21 -8
  28. package/template/console/configuration/types.ts +1 -1
  29. package/template/console/lib/priority.ts +13 -0
  30. package/template/console/operations/CaseEscalations.tsx +142 -0
  31. package/template/console/operations/OperationsWorkspace.tsx +2 -0
  32. package/template/console/operations/case-escalations.css +20 -0
  33. package/template/console/people/PeopleWorkspace.tsx +8 -4
  34. package/template/console/people/PersonAccessDialog.tsx +40 -0
  35. package/template/console/people/types.ts +1 -0
  36. package/template/console/profile/ProfileWorkspace.tsx +1 -1
  37. package/template/console/registry/IntegrationSetup.tsx +41 -0
  38. package/template/console/registry/IntegrationVerification.tsx +54 -0
  39. package/template/console/registry/RegistryDetailsDialog.tsx +2 -2
  40. package/template/console/registry/RegistryDialog.tsx +56 -27
  41. package/template/console/registry/RegistryWorkspace.tsx +10 -5
  42. package/template/console/registry/integration-verification.css +25 -0
  43. package/template/console/reports/CaseActions.tsx +81 -0
  44. package/template/console/reports/CaseContinuity.tsx +27 -0
  45. package/template/console/reports/CaseNotices.tsx +48 -0
  46. package/template/console/reports/CaseOwnership.tsx +78 -0
  47. package/template/console/reports/CaseReviewTasks.tsx +148 -0
  48. package/template/console/reports/EnrichmentFindings.tsx +148 -0
  49. package/template/console/reports/InvestigationBrief.tsx +29 -0
  50. package/template/console/reports/ReportDrawer.tsx +122 -67
  51. package/template/console/reports/ReportWorkspace.tsx +112 -23
  52. package/template/console/reports/controller.ts +125 -60
  53. package/template/console/reports/events.ts +9 -2
  54. package/template/console/reports/load-inbox.ts +17 -0
  55. package/template/console/reports/presence-presentation.ts +52 -0
  56. package/template/console/reports/private-drafts.ts +32 -0
  57. package/template/console/reports/store.ts +2 -2
  58. package/template/console/reports/types.ts +50 -2
  59. package/template/console/reports/use-editing-access.ts +13 -0
  60. package/template/console/settings/SettingsWorkspace.tsx +1 -1
  61. package/template/console/shell/WorkspaceShell.tsx +55 -29
  62. package/template/console/shell/WorkspaceUpdateNotice.tsx +48 -0
  63. package/template/console/shell/controller.ts +23 -16
  64. package/template/console/shell/navigation.ts +30 -39
  65. package/template/console/workflows/CreatePlaybookDialog.tsx +73 -0
  66. package/template/console/workflows/PlaybookExamples.tsx +160 -0
  67. package/template/console/workflows/PlaybookFields.tsx +109 -0
  68. package/template/console/workflows/PlaybookGuideEditor.tsx +125 -0
  69. package/template/console/workflows/PlaybookHistory.tsx +106 -0
  70. package/template/console/workflows/WorkflowDialogs.tsx +2 -1
  71. package/template/console/workflows/WorkflowStudio.tsx +108 -39
  72. package/template/console/workflows/WorkflowWorkspace.tsx +9 -7
  73. package/template/console/workflows/templates.ts +3 -3
  74. package/template/console/workflows/types.ts +11 -0
  75. package/template/migrations/0001_safest_resolve_baseline.sql +0 -1
  76. package/template/migrations/0002_playbook_publication_evidence.sql +36 -0
  77. package/template/migrations/0003_action_confirmation.sql +24 -0
  78. package/template/migrations/0004_notice_recovery.sql +22 -0
  79. package/template/migrations/0005_appeal_continuity.sql +17 -0
  80. package/template/migrations/0006_case_ownership.sql +23 -0
  81. package/template/migrations/0007_integration_verification.sql +21 -0
  82. package/template/migrations/0008_case_escalations.sql +135 -0
  83. package/template/migrations/0009_participant_continuation.sql +61 -0
  84. package/template/migrations/0010_appeal_corrections.sql +41 -0
  85. package/template/npm-shrinkwrap.json +8036 -0
  86. package/template/package.json +31 -22
  87. package/template/public/_headers +16 -1
  88. package/template/public/console/auth-shell.js +22151 -14847
  89. package/template/public/console/release.json +1 -0
  90. package/template/public/console/workspace-features.css +2 -0
  91. package/template/public/embed/embed.css +3 -0
  92. package/template/public/embed/embed.js +19 -1
  93. package/template/public/embed/index.html +2 -0
  94. package/template/public/index.html +9 -6
  95. package/template/public/participant.css +2 -0
  96. package/template/public/participant.html +13 -0
  97. package/template/public/participant.js +111 -0
  98. package/template/public/public-report.js +21 -1
  99. package/template/public/report/index.html +1 -1
  100. package/template/public/styles.css +599 -83
  101. package/template/public/workspace.css +124 -0
  102. package/template/scripts/reports-plan.mjs +1 -1
  103. package/template/shared/case-escalations.ts +31 -0
  104. package/template/shared/integration-progress.ts +27 -0
  105. package/template/shared/notice-inputs.ts +3 -0
  106. package/template/shared/playbook-resources.ts +14 -0
  107. package/template/shared/playbooks.ts +249 -0
  108. package/template/src/agent-executor.ts +25 -8
  109. package/template/src/ai-observability.ts +14 -13
  110. package/template/src/ai-registry-validation.ts +1 -1
  111. package/template/src/appeal-continuity.ts +75 -0
  112. package/template/src/appeal-correction-state.ts +46 -0
  113. package/template/src/appeal-corrections.ts +112 -0
  114. package/template/src/appeal-decisions.ts +85 -0
  115. package/template/src/assistant.ts +3 -4
  116. package/template/src/case-escalations.ts +223 -0
  117. package/template/src/case-investigator.ts +179 -0
  118. package/template/src/component-executor.ts +5 -4
  119. package/template/src/configuration-registry.ts +4 -1
  120. package/template/src/connection-oauth.ts +12 -3
  121. package/template/src/database-readiness.ts +14 -0
  122. package/template/src/human-tasks.ts +152 -55
  123. package/template/src/index.ts +191 -29
  124. package/template/src/installation-admin.ts +2 -1
  125. package/template/src/integration-progress.ts +45 -0
  126. package/template/src/integration-verification.ts +249 -0
  127. package/template/src/investigation-evidence.ts +95 -0
  128. package/template/src/investigator-fixture.ts +30 -0
  129. package/template/src/notice-delivery.ts +19 -0
  130. package/template/src/notice-recovery.ts +81 -0
  131. package/template/src/operator-scope.ts +31 -0
  132. package/template/src/participant-access.ts +163 -0
  133. package/template/src/participant-case.ts +87 -0
  134. package/template/src/participant-customer-access.ts +93 -0
  135. package/template/src/participant-recovery.ts +43 -0
  136. package/template/src/participant-routes.ts +67 -0
  137. package/template/src/platform-registry-validation.ts +25 -2
  138. package/template/src/platform-registry.ts +10 -7
  139. package/template/src/playbook-resources.ts +58 -0
  140. package/template/src/report-admin.ts +9 -2
  141. package/template/src/report-auth.ts +1 -28
  142. package/template/src/report-better-auth.ts +18 -6
  143. package/template/src/report-delivery.ts +149 -148
  144. package/template/src/report-email-inbound.ts +33 -22
  145. package/template/src/report-email-template.ts +5 -2
  146. package/template/src/report-email.ts +7 -30
  147. package/template/src/report-governance.ts +42 -45
  148. package/template/src/report-http.ts +37 -2
  149. package/template/src/report-operations.ts +30 -67
  150. package/template/src/report-operator-accounts.ts +7 -6
  151. package/template/src/report-ownership.ts +64 -0
  152. package/template/src/report-presence.ts +15 -9
  153. package/template/src/report-repository.ts +16 -7
  154. package/template/src/report-turnstile.ts +7 -3
  155. package/template/src/report-types.ts +1 -0
  156. package/template/src/report-workflow.ts +183 -249
  157. package/template/src/workflow-actions.ts +248 -156
  158. package/template/src/workflow-compiler.ts +63 -12
  159. package/template/src/workflow-dynamic-runtime.ts +20 -9
  160. package/template/src/workflow-effects.ts +117 -39
  161. package/template/src/workflow-event-transport.ts +16 -0
  162. package/template/src/workflow-expressions.ts +30 -0
  163. package/template/src/workflow-loader-code.ts +21 -0
  164. package/template/src/workflow-platform-types.ts +3 -0
  165. package/template/src/workflow-platform-validation.ts +23 -1
  166. package/template/src/workflow-repository.ts +131 -28
  167. package/template/src/workflow-runs.ts +61 -13
  168. package/template/src/workflow-simulator.ts +7 -4
  169. package/template/src/workspace-governance.ts +6 -4
  170. package/template/tsconfig.json +1 -0
  171. package/template/vite.console.config.ts +30 -2
  172. package/template/worker-configuration.d.ts +3 -2
@@ -1,5 +1,6 @@
1
1
  import { canonicalJson } from "./audit";
2
2
  import { ApiError } from "./report-http";
3
+ import { compilePlaybook, parsePlaybook, playbookAuthority, PlaybookInputError } from "../shared/playbooks";
3
4
  import type {
4
5
  JsonObject,
5
6
  JsonValue,
@@ -162,7 +163,7 @@ export function parseWorkflowGraph(value: unknown): WorkflowGraphV1 {
162
163
  const input = object(value, "workflow");
163
164
  knownFields(input, [
164
165
  "schema_version", "purpose", "entry_node_id", "nodes", "edges", "input_schema",
165
- "terminal_codes", "default_policies",
166
+ "terminal_codes", "default_policies", "playbook",
166
167
  ], "workflow");
167
168
  if (input.schema_version !== "1") throw new ApiError(400, "unsupported_workflow_schema", "workflow.schema_version must be 1.", { path: "workflow.schema_version" });
168
169
  if (typeof input.purpose !== "string" || !purposes.has(input.purpose as WorkflowGraphV1["purpose"])) {
@@ -181,7 +182,16 @@ export function parseWorkflowGraph(value: unknown): WorkflowGraphV1 {
181
182
  if (new Set(terminalCodes).size !== terminalCodes.length) throw new ApiError(400, "invalid_workflow", "workflow.terminal_codes contains duplicates.");
182
183
  const defaults = object(input.default_policies ?? {}, "workflow.default_policies");
183
184
  knownFields(defaults, ["timeout", "retry", "data_retention_class"], "workflow.default_policies");
185
+ let playbook: WorkflowGraphV1["playbook"];
186
+ if (input.playbook !== undefined) {
187
+ try { playbook = parsePlaybook(input.playbook); }
188
+ catch (error) {
189
+ if (error instanceof PlaybookInputError) throw new ApiError(400, "invalid_playbook", error.message, { path: error.path });
190
+ throw error;
191
+ }
192
+ }
184
193
  return {
194
+ ...(playbook ? { playbook } : {}),
185
195
  schemaVersion: "1",
186
196
  purpose: input.purpose as WorkflowGraphV1["purpose"],
187
197
  entryNodeId: identifier(input.entry_node_id, "workflow.entry_node_id"),
@@ -276,6 +286,17 @@ function capabilityManifest(graph: WorkflowGraphV1, estimatedMaximumSteps: numbe
276
286
  export function validateWorkflowGraph(graph: WorkflowGraphV1, authorityMode: WorkflowAuthorityMode = "assist"): WorkflowValidationResult {
277
287
  const errors: WorkflowValidationIssue[] = [];
278
288
  const warnings: WorkflowValidationIssue[] = [];
289
+ if (graph.playbook) {
290
+ const guide = graph.playbook;
291
+ if (guide.editing_mode === "guided") {
292
+ const structure = (candidate: WorkflowGraphV1): string => canonicalWorkflowGraph({ ...candidate, nodes: candidate.nodes.map(({ metadata: _metadata, ...node }) => node) });
293
+ if (structure(graph) !== structure(parseWorkflowGraph(compilePlaybook(guide)))) errors.push(issue("playbook_graph_mismatch", "The workflow differs from its guide. Save the matching guide or explicitly switch to advanced workflow editing."));
294
+ if (playbookAuthority(guide) !== authorityMode) errors.push(issue("playbook_authority_mismatch", "Publication authority must match the playbook's selected rollout mode."));
295
+ if (!guide.summary || !guide.policy_text || !guide.policy_code || !guide.outcomes.decision_codes.length) errors.push(issue("playbook_rules_incomplete", "Describe the situation, applicable policy and permitted outcomes before publishing."));
296
+ if (guide.rollout === "automate" && (!guide.investigation.enabled || !guide.outcomes.delegated_decision_codes.length)) errors.push(issue("playbook_automation_incomplete", "Automation requires an investigator and explicitly delegated outcomes."));
297
+ if (guide.rollout !== "automate" && guide.outcomes.actions.some(action => action.approval !== "human")) errors.push(issue("playbook_action_approval_required", "Actions in Assist require human approval."));
298
+ } else warnings.push(issue("playbook_advanced_workflow", "This playbook uses advanced workflow editing. Its guide is reference material; inspect the workflow for executable behaviour."));
299
+ }
279
300
  const nodeById = new Map<string, WorkflowNodeV1>();
280
301
  const edgeIds = new Set<string>();
281
302
  const outgoing = new Map<string, WorkflowEdgeV1[]>();
@@ -500,6 +521,7 @@ export function parseWorkflowPublish(value: unknown): WorkflowPublishInput {
500
521
 
501
522
  export function graphForStorage(graph: WorkflowGraphV1): Record<string, unknown> {
502
523
  return {
524
+ ...(graph.playbook ? { playbook: graph.playbook } : {}),
503
525
  schema_version: graph.schemaVersion,
504
526
  purpose: graph.purpose,
505
527
  entry_node_id: graph.entryNodeId,
@@ -1,3 +1,4 @@
1
+ import { noticeTemplateVariables } from "../shared/notice-inputs";
1
2
  import { canonicalJson } from "./audit";
2
3
  import { sha256 } from "./report-crypto";
3
4
  import { ApiError } from "./report-http";
@@ -20,6 +21,7 @@ import { simulateWorkflow, simulationMatchesExpected } from "./workflow-simulato
20
21
  import type { JsonObject } from "./workflow-platform-types";
21
22
 
22
23
  interface WorkflowListRow {
24
+ graphJson: string | null;
23
25
  id: string;
24
26
  key: string;
25
27
  name: string;
@@ -72,6 +74,7 @@ function parsedJson<T>(value: string | null, fallback: T): T {
72
74
 
73
75
  function workflowFromRow(row: WorkflowListRow): Record<string, unknown> {
74
76
  return {
77
+ playbook: parsedJson<{ playbook?: unknown }>(row.graphJson, {}).playbook ?? null,
75
78
  id: row.id,
76
79
  key: row.key,
77
80
  name: row.name,
@@ -144,6 +147,7 @@ export async function listWorkflowDefinitions(
144
147
  SELECT w.id, w.workflow_key AS key, w.name, w.description, w.purpose, w.status,
145
148
  w.active_version_id AS activeVersionId, w.created_by AS createdBy,
146
149
  w.created_at AS createdAt, w.updated_at AS updatedAt,
150
+ COALESCE(d.graph_json, v.graph_json) AS graphJson,
147
151
  d.id AS draftId, d.revision AS draftRevision, d.validation_status AS validationStatus,
148
152
  d.validation_json AS validationJson, v.version, v.authority_mode AS authorityMode,
149
153
  v.published_at AS publishedAt,
@@ -166,6 +170,7 @@ export async function loadWorkflowDefinition(
166
170
  SELECT w.id, w.workflow_key AS key, w.name, w.description, w.purpose, w.status,
167
171
  w.active_version_id AS activeVersionId, w.created_by AS createdBy,
168
172
  w.created_at AS createdAt, w.updated_at AS updatedAt,
173
+ COALESCE(d.graph_json, v.graph_json) AS graphJson,
169
174
  d.id AS draftId, d.revision AS draftRevision, d.validation_status AS validationStatus,
170
175
  d.validation_json AS validationJson, v.version, v.authority_mode AS authorityMode,
171
176
  v.published_at AS publishedAt,
@@ -194,7 +199,7 @@ export async function loadWorkflowDefinition(
194
199
  WHERE v.workflow_id = ?1 ORDER BY v.version DESC, v.id DESC
195
200
  `).bind(workflowId).all<Record<string, unknown>>(),
196
201
  db.prepare(`
197
- SELECT id, name, input_json AS inputJson, expected_json AS expectedJson,
202
+ SELECT id, name, revision, input_json AS inputJson, expected_json AS expectedJson,
198
203
  failure_injection_json AS failureInjectionJson, updated_at AS updatedAt
199
204
  FROM workflow_fixtures WHERE workflow_id = ?1 ORDER BY name, id
200
205
  `).bind(workflowId).all<Record<string, unknown>>(),
@@ -337,6 +342,13 @@ export async function validateWorkflowDraft(
337
342
  if (!draft) throw new ApiError(404, "workflow_not_found", "The workflow does not exist.");
338
343
  const graph = parseWorkflowGraph(parsedJson(draft.graph_json, null));
339
344
  const validation = validateWorkflowGraph(graph, authorityMode);
345
+ for (const dependency of validation.dependencies) {
346
+ if (!await dependencyAvailable(db, dependency)) {
347
+ validation.errors.push({ code: "workflow_dependency_unavailable", message: `Select a published ${dependency.type} for ${dependency.key}. Version “${dependency.versionId}” is unavailable.`, path: dependency.key });
348
+ }
349
+ }
350
+ validation.errors.push(...await workflowNoticeInputIssues(db, graph));
351
+ validation.valid = validation.errors.length === 0;
340
352
  const validationJson = canonicalJson(validation);
341
353
  await db.prepare(`
342
354
  UPDATE workflow_drafts SET validation_json = ?2, validation_digest = ?3,
@@ -350,7 +362,7 @@ export async function validateWorkflowDraft(
350
362
 
351
363
  export async function listWorkflowFixtures(db: D1Database, workflowId: string): Promise<Record<string, unknown>[]> {
352
364
  const rows = await db.prepare(`
353
- SELECT id, name, input_json AS inputJson, component_results_json AS componentResultsJson,
365
+ SELECT id, name, revision, input_json AS inputJson, component_results_json AS componentResultsJson,
354
366
  human_responses_json AS humanResponsesJson, expected_json AS expectedJson,
355
367
  failure_injection_json AS failureInjectionJson, created_by AS createdBy,
356
368
  created_at AS createdAt, updated_at AS updatedAt
@@ -371,6 +383,12 @@ export async function listWorkflowFixtures(db: D1Database, workflowId: string):
371
383
  }));
372
384
  }
373
385
 
386
+ function normalizedFixtureName(value: string): string {
387
+ const name = value.trim();
388
+ if (name.length < 2 || name.length > 120 || /[\u0000-\u001f\u007f]/u.test(name)) throw new ApiError(400, "workflow_fixture_name_invalid", "Use an example name between 2 and 120 characters, without control characters.");
389
+ return name;
390
+ }
391
+
374
392
  export async function createWorkflowFixture(
375
393
  db: D1Database,
376
394
  workflowId: string,
@@ -390,7 +408,7 @@ export async function createWorkflowFixture(
390
408
  if (!await db.prepare(`SELECT id FROM workflow_definitions WHERE id = ?1 AND status != 'archived'`).bind(workflowId).first()) {
391
409
  throw new ApiError(404, "workflow_not_found", "The workflow does not exist.");
392
410
  }
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.");
411
+ input = { ...input, name: normalizedFixtureName(input.name) };
394
412
  if (await db.prepare(`SELECT id FROM workflow_fixtures WHERE workflow_id = ?1 AND name = ?2`).bind(workflowId, input.name).first()) {
395
413
  throw new ApiError(409, "workflow_fixture_exists", "A fixture with this name already exists.");
396
414
  }
@@ -432,6 +450,7 @@ export async function updateWorkflowFixture(
432
450
  workflowId: string,
433
451
  fixtureId: string,
434
452
  input: {
453
+ expectedRevision: number;
435
454
  name: string;
436
455
  input: JsonObject;
437
456
  componentResults: JsonObject;
@@ -444,31 +463,36 @@ export async function updateWorkflowFixture(
444
463
  ): Promise<{ fixtureId: string; idempotentReplay: boolean }> {
445
464
  const replay = await mutationReplay(db, idempotencyKey, "workflow.fixture.update", session.actor.id);
446
465
  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();
466
+ input = { ...input, name: normalizedFixtureName(input.name) };
467
+ const fixture = await db.prepare(`SELECT id, revision FROM workflow_fixtures WHERE id = ?1 AND workflow_id = ?2 LIMIT 1`)
468
+ .bind(fixtureId, workflowId).first<{ id: string; revision: number }>();
450
469
  if (!fixture) throw new ApiError(404, "workflow_fixture_not_found", "The workflow fixture does not exist.");
470
+ if (fixture.revision !== input.expectedRevision) throw new ApiError(409, "workflow_fixture_revision_conflict", "This example changed after you opened it. Reload the saved example before editing again.");
471
+ if (await db.prepare(`SELECT id FROM workflow_fixtures WHERE workflow_id = ?1 AND name = ?2 AND id != ?3`).bind(workflowId, input.name, fixtureId).first()) throw new ApiError(409, "workflow_fixture_exists", "An example with this name already exists.");
451
472
  const timestamp = now();
452
473
  const response = { fixtureId };
453
- await db.batch([
474
+ try { await db.batch([
454
475
  db.prepare(`
455
476
  UPDATE workflow_fixtures SET name = ?3, input_json = ?4,
456
477
  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
478
+ expected_json = ?7, failure_injection_json = ?8, updated_at = ?9, revision = revision + 1
479
+ WHERE id = ?1 AND workflow_id = ?2 AND revision = ?10
459
480
  `).bind(
460
481
  fixtureId, workflowId, input.name, canonicalJson(input.input),
461
482
  canonicalJson(input.componentResults), canonicalJson(input.humanResponses),
462
- canonicalJson(input.expected), canonicalJson(input.failureInjection), timestamp,
483
+ canonicalJson(input.expected), canonicalJson(input.failureInjection), timestamp, input.expectedRevision,
463
484
  ),
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),
485
+ db.prepare(`INSERT INTO configuration_mutations (idempotency_key, action, actor_id, target_id, response_json, created_at) VALUES (?1, 'workflow.fixture.update', ?2, (SELECT id FROM workflow_fixtures WHERE id = ?3 AND revision = ?6 AND changes() = 1), ?4, ?5)`)
486
+ .bind(idempotencyKey, session.actor.id, fixtureId, canonicalJson(response), timestamp, input.expectedRevision + 1),
466
487
  auditStatement(db, {
467
488
  idempotencyKey: `workflow-fixture-update:${idempotencyKey}`,
468
489
  action: "workflow.fixture_updated", session, targetType: "workflow_fixture",
469
490
  targetId: fixtureId, details: { workflowId, name: input.name }, createdAt: timestamp,
470
491
  }),
471
- ]);
492
+ ]); } catch (error) {
493
+ if (String(error).includes("configuration_mutations.target_id")) throw new ApiError(409, "workflow_fixture_revision_conflict", "This example changed while it was being saved. Reload the saved example before editing again.");
494
+ throw error;
495
+ }
472
496
  return { fixtureId, idempotentReplay: false };
473
497
  }
474
498
 
@@ -509,12 +533,12 @@ export async function runWorkflowFixtureTest(
509
533
  db.prepare(`SELECT graph_json AS graphJson, graph_digest AS graphDigest FROM workflow_drafts WHERE workflow_id = ?1 LIMIT 1`)
510
534
  .bind(workflowId).first<{ graphJson: string; graphDigest: string }>(),
511
535
  db.prepare(`
512
- SELECT input_json AS inputJson, component_results_json AS componentResultsJson,
536
+ SELECT revision, name, input_json AS inputJson, component_results_json AS componentResultsJson,
513
537
  human_responses_json AS humanResponsesJson, expected_json AS expectedJson,
514
538
  failure_injection_json AS failureInjectionJson
515
539
  FROM workflow_fixtures WHERE id = ?1 AND workflow_id = ?2 LIMIT 1
516
540
  `).bind(fixtureId, workflowId).first<{
517
- inputJson: string; componentResultsJson: string; humanResponsesJson: string;
541
+ revision: number; name: string; inputJson: string; componentResultsJson: string; humanResponsesJson: string;
518
542
  expectedJson: string; failureInjectionJson: string;
519
543
  }>(),
520
544
  ]);
@@ -528,7 +552,9 @@ export async function runWorkflowFixtureTest(
528
552
  failureInjection: parsedJson(fixture.failureInjectionJson, {}),
529
553
  });
530
554
  const expected = parsedJson<JsonObject>(fixture.expectedJson, {});
531
- const passed = simulation.status === "passed" && simulationMatchesExpected(simulation, expected);
555
+ const hasExpectation = !graph.playbook || (typeof expected.terminal_code === "string" && graph.nodes.some(node => node.kind === "end" && node.config.terminal_code === expected.terminal_code));
556
+ const simulationError = simulation.error ?? (!hasExpectation ? { code: "example_expected_outcome_required" } : null);
557
+ const passed = hasExpectation && simulation.status === "passed" && simulationMatchesExpected(simulation, expected);
532
558
  const status = passed ? "passed" : "failed";
533
559
  const testRunId = crypto.randomUUID();
534
560
  const timestamp = now();
@@ -536,22 +562,45 @@ export async function runWorkflowFixtureTest(
536
562
  status,
537
563
  terminalCode: simulation.terminalCode,
538
564
  steps: simulation.steps,
539
- expectedMatched: passed,
565
+ expectedMatched: hasExpectation && simulationMatchesExpected(simulation, expected),
566
+ expected,
567
+ fixtureSnapshot: {
568
+ name: fixture.name, revision: fixture.revision,
569
+ input: parsedJson(fixture.inputJson, {}),
570
+ component_results: parsedJson(fixture.componentResultsJson, {}),
571
+ human_responses: parsedJson(fixture.humanResponsesJson, {}),
572
+ expected,
573
+ failure_injection: parsedJson(fixture.failureInjectionJson, {}),
574
+ },
575
+ error: simulationError,
540
576
  resultDigest: `sha256:${await sha256(simulation.canonicalResult)}`,
541
577
  };
542
578
  await db.prepare(`
543
579
  INSERT INTO workflow_test_runs (
544
580
  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)
581
+ result_json, error_json, started_by, started_at, finished_at, fixture_revision
582
+ ) VALUES (?1, ?2, ?3, ?4, 'simulation', ?5, ?6, ?7, ?8, ?9, ?9, ?10)
547
583
  `).bind(
548
584
  testRunId, workflowId, fixtureId, draft.graphDigest, status,
549
- canonicalJson(result), simulation.error ? canonicalJson(simulation.error) : null,
550
- session.actor.id, timestamp,
585
+ canonicalJson(result), simulationError ? canonicalJson(simulationError) : null,
586
+ session.actor.id, timestamp, fixture.revision,
551
587
  ).run();
552
588
  return { testRunId, status, graphDigest: draft.graphDigest, result };
553
589
  }
554
590
 
591
+ export async function listWorkflowTestRuns(db: D1Database, workflowId: string): Promise<Record<string, unknown>[]> {
592
+ const rows = await db.prepare(`
593
+ SELECT t.id AS testRunId, t.fixture_id AS fixtureId, t.fixture_revision AS fixtureRevision,
594
+ t.graph_digest AS graphDigest, t.status, t.finished_at AS finishedAt, t.result_json AS resultJson
595
+ FROM workflow_test_runs t WHERE t.workflow_id = ?1
596
+ AND t.id = (SELECT latest.id FROM workflow_test_runs latest
597
+ WHERE latest.workflow_id = ?1 AND latest.fixture_id = t.fixture_id
598
+ ORDER BY latest.rowid DESC LIMIT 1)
599
+ ORDER BY t.finished_at DESC, t.id DESC
600
+ `).bind(workflowId).all<Record<string, unknown>>();
601
+ return rows.results.map(({ resultJson, ...row }) => ({ ...row, result: parsedJson(String(resultJson ?? ""), {}) }));
602
+ }
603
+
555
604
  export async function loadWorkflowTestRun(
556
605
  db: D1Database,
557
606
  workflowId: string,
@@ -559,7 +608,7 @@ export async function loadWorkflowTestRun(
559
608
  ): Promise<Record<string, unknown> | null> {
560
609
  const row = await db.prepare(`
561
610
  SELECT id, workflow_id AS workflowId, workflow_version_id AS workflowVersionId,
562
- fixture_id AS fixtureId, graph_digest AS graphDigest, mode, status,
611
+ fixture_id AS fixtureId, fixture_revision AS fixtureRevision, graph_digest AS graphDigest, mode, status,
563
612
  result_json AS resultJson, error_json AS errorJson, started_by AS startedBy,
564
613
  started_at AS startedAt, finished_at AS finishedAt
565
614
  FROM workflow_test_runs WHERE id = ?1 AND workflow_id = ?2 LIMIT 1
@@ -624,6 +673,7 @@ export async function diffWorkflowVersion(
624
673
  againstId = prior?.id;
625
674
  }
626
675
  const against = againstId ? await loadWorkflowVersion(db, workflowId, againstId) : null;
676
+ if (againstId && !against) throw new ApiError(404, "workflow_version_not_found", "The comparison version does not belong to this playbook.");
627
677
  const targetGraph = target.graph as WorkflowGraphV1;
628
678
  const againstGraph = against?.graph as WorkflowGraphV1 | undefined;
629
679
  const targetNodes = new Map((targetGraph?.nodes ?? []).map((node) => [node.id, canonicalJson(node)]));
@@ -637,6 +687,8 @@ export async function diffWorkflowVersion(
637
687
  againstVersionId: against?.id ?? null,
638
688
  graphDigestChanged: target.graphDigest !== against?.graphDigest,
639
689
  nodes: { added, removed, changed },
690
+ guide: Object.keys(targetGraph?.playbook ?? {}).filter(key => canonicalJson((targetGraph.playbook as unknown as Record<string, unknown>)[key]) !== canonicalJson((againstGraph?.playbook as unknown as Record<string, unknown> | undefined)?.[key] ?? null)),
691
+ edgesChanged: canonicalJson(targetGraph.edges) !== canonicalJson(againstGraph?.edges ?? []),
640
692
  authorityMode: { from: against?.authorityMode ?? null, to: target.authorityMode },
641
693
  dependencies: { from: against?.dependencyManifest ?? [], to: target.dependencyManifest ?? [] },
642
694
  };
@@ -705,6 +757,23 @@ async function requireDependencies(db: D1Database, dependencies: WorkflowDepende
705
757
  if (missing.length) throw new ApiError(409, "workflow_dependency_unavailable", "The workflow references unpublished, paused, retired, missing, or incompatible dependencies.", { dependencies: missing });
706
758
  }
707
759
 
760
+ export async function workflowNoticeInputIssues(db: D1Database, graph: WorkflowGraphV1) {
761
+ const issues: { code: string; message: string; path: string }[] = [];
762
+ for (const node of graph.nodes.filter(node => node.kind === "send_message" && node.config.audience !== "internal")) {
763
+ const template = await db.prepare(`SELECT body_template AS body, audience FROM message_template_versions WHERE id = ?1 AND published_at IS NOT NULL`)
764
+ .bind(String(node.config.template_version_id ?? "")).first<{ body: string; audience: string }>();
765
+ if (!template || template.audience !== node.config.audience) {
766
+ issues.push({ code: "notice_template_audience_mismatch", message: `${node.name}: choose a published template for this audience.`, path: node.id });
767
+ continue;
768
+ }
769
+ const names = noticeTemplateVariables(template.body);
770
+ const missing = names.filter(name => node.inputMapping[name] === undefined || node.inputMapping[name] === "" || (typeof node.inputMapping[name] === "object" && node.inputMapping[name] !== null && !Array.isArray(node.inputMapping[name]) && (node.inputMapping[name] as JsonObject).$literal === ""));
771
+ const extra = Object.keys(node.inputMapping).filter(name => name !== "body" && !names.includes(name));
772
+ if (missing.length || extra.length) issues.push({ code: "notice_inputs_required", message: `${node.name}: ${missing.length ? `provide notice content for ${missing.join(", ")}.` : `remove unused notice inputs ${extra.join(", ")}.`}`, path: node.id });
773
+ }
774
+ return issues;
775
+ }
776
+
708
777
  async function persistArtifact(
709
778
  db: D1Database,
710
779
  bucket: R2Bucket,
@@ -783,9 +852,33 @@ export async function publishWorkflowDraft(
783
852
  if (!testRun || testRun.status !== "passed" || testRun.graphDigest !== graphDigest) {
784
853
  throw new ApiError(409, "workflow_tests_required", "A passing server-recorded fixture test for this exact draft is required before publication.");
785
854
  }
855
+ const suiteEvidence: string[] = [];
856
+ const exampleRecords: { test_run_id: string; name: string; revision: number }[] = [];
857
+ if (graph.playbook) {
858
+ const suite = await db.prepare(`
859
+ SELECT f.id, f.name, f.revision, t.id AS testRunId, t.status
860
+ FROM workflow_fixtures f LEFT JOIN workflow_test_runs t ON t.id = (
861
+ SELECT latest.id FROM workflow_test_runs latest
862
+ WHERE latest.fixture_id = f.id AND latest.workflow_id = ?1
863
+ AND latest.graph_digest = ?2 AND latest.fixture_revision = f.revision
864
+ ORDER BY latest.rowid DESC LIMIT 1
865
+ ) WHERE f.workflow_id = ?1 ORDER BY f.name
866
+ `).bind(workflowId, graphDigest).all<{ id: string; name: string; revision: number; testRunId: string | null; status: string | null }>();
867
+ const missing = suite.results.filter(example => example.status !== "passed" || !example.testRunId);
868
+ if (!suite.results.length || missing.length) throw new ApiError(409, "playbook_examples_required", "Every saved example must pass against this exact playbook draft before publication.", { examples: missing.map(example => ({ id: example.id, name: example.name })) });
869
+ suiteEvidence.push(...suite.results.map(example => example.testRunId!));
870
+ exampleRecords.push(...suite.results.map(example => ({ test_run_id: example.testRunId!, name: example.name, revision: example.revision })));
871
+ }
786
872
  const validation = validateWorkflowGraph(graph, input.authorityMode);
787
873
  if (!validation.valid) throw new ApiError(400, "workflow_validation_failed", "The workflow draft has publication errors.", { errors: validation.errors, warnings: validation.warnings });
788
874
  await requireDependencies(db, validation.dependencies);
875
+ const noticeIssues = await workflowNoticeInputIssues(db, graph);
876
+ if (noticeIssues.length) throw new ApiError(409, "notice_inputs_required", "Review participant notice content before publishing.", { errors: noticeIssues });
877
+ for(const node of graph.nodes.filter(candidate=>candidate.config.investigator===true)) {
878
+ const approved=await db.prepare(`SELECT v.id FROM agent_versions v JOIN model_alias_versions m ON m.id=v.model_alias_version_id JOIN model_aliases a ON a.id=m.alias_id JOIN prompt_versions p ON p.id=v.prompt_version_id
879
+ WHERE v.id=?1 AND m.provider='workers_ai' AND m.model LIKE '@cf/%' AND m.retired_at IS NULL AND a.status='active' AND p.status='published'`).bind(String(node.config.agent_version_id)).first();
880
+ if(!approved)throw new ApiError(409,"investigation_model_adapter_unavailable","Choose an active investigator with a published prompt and an approved Workers AI model. Other providers need an explicit investigator adapter.");
881
+ }
789
882
  const compiled = await compileWorkflowGraph(graph, input.authorityMode, compatibilityDate);
790
883
  const artifactId = await persistArtifact(db, bucket, compiled);
791
884
  const latest = await db.prepare(`SELECT COALESCE(MAX(version), 0) AS version FROM workflow_versions WHERE workflow_id = ?1`)
@@ -799,13 +892,13 @@ export async function publishWorkflowDraft(
799
892
  INSERT INTO workflow_versions (
800
893
  id, workflow_id, version, graph_json, graph_digest, input_schema_json,
801
894
  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)
895
+ artifact_id, compiler_version, test_evidence_json, published_by, published_at, source_draft_revision
896
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, '{"type":"object"}', ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)
804
897
  `).bind(
805
898
  versionId, workflowId, version, canonicalWorkflowGraph(graph), compiled.graphDigest,
806
899
  canonicalJson(graph.inputSchema), input.authorityMode, canonicalJson(compiled.dependencyManifest),
807
900
  canonicalJson(compiled.capabilityManifest), artifactId, compiled.compilerVersion,
808
- canonicalJson({ test_run_id: testRun.id, passed: true, graph_digest: testRun.graphDigest }), session.actor.id, publishedAt,
901
+ canonicalJson({ test_run_id: testRun.id, ...(suiteEvidence.length ? { example_test_run_ids: suiteEvidence, examples: exampleRecords } : {}), passed: true, graph_digest: testRun.graphDigest }), session.actor.id, publishedAt, workflow.revision,
809
902
  ),
810
903
  db.prepare(`UPDATE workflow_definitions SET active_version_id = ?2, updated_at = ?3 WHERE id = ?1 AND status = 'active'`)
811
904
  .bind(workflowId, versionId, publishedAt),
@@ -834,7 +927,12 @@ export async function publishWorkflowDraft(
834
927
  ) VALUES (?1, ?2, ?3, ?4)
835
928
  `).bind(versionId, dependency.type, dependency.key, dependency.versionId));
836
929
  }
837
- await db.batch(statements);
930
+ try { await db.batch(statements); } catch (error) {
931
+ if (String(error).includes("workflow_publication_conflict")) throw new ApiError(409, "workflow_revision_conflict", "The draft changed during publication. Reload it and test the latest changes.");
932
+ if (String(error).includes("playbook_publication_evidence_changed")) throw new ApiError(409, "playbook_examples_required", "The saved examples or their results changed during publication. Run the examples again.");
933
+ if (String(error).includes("workflow_versions.workflow_id, workflow_versions.version")) throw new ApiError(409, "workflow_publication_conflict", "Another author published a version at the same time. Reload the version history.");
934
+ throw error;
935
+ }
838
936
  return { ...response, idempotentReplay: false };
839
937
  }
840
938
 
@@ -859,7 +957,7 @@ export async function loadQueueWorkflowPins(db: D1Database, queueId: string): Pr
859
957
  export async function pinQueueWorkflow(
860
958
  db: D1Database,
861
959
  queueId: string,
862
- input: { workflowVersionId: string; mode: "shadow" | "primary"; rollbackOfPinId?: string },
960
+ input: { workflowVersionId: string; mode: "shadow" | "primary"; rollbackOfPinId?: string; expectedPinId?: string | null },
863
961
  session: OperatorSession,
864
962
  idempotencyKey: string,
865
963
  ): Promise<{ pinId: string; workflowVersionId: string; mode: string; idempotentReplay: boolean }> {
@@ -885,6 +983,8 @@ export async function pinQueueWorkflow(
885
983
  const current = await db.prepare(`
886
984
  SELECT id FROM queue_workflow_pins WHERE queue_id = ?1 AND mode = ?2 AND deactivated_at IS NULL LIMIT 1
887
985
  `).bind(queueId, input.mode).first<{ id: string }>();
986
+ if (input.expectedPinId !== undefined && input.expectedPinId !== (current?.id ?? null)) throw new ApiError(409, "workflow_pin_conflict", "This queue activation changed. Reload the active version before trying again.");
987
+ if (input.rollbackOfPinId && input.rollbackOfPinId !== current?.id) throw new ApiError(409, "workflow_pin_conflict", "The activation being replaced is no longer current.");
888
988
  const pinId = crypto.randomUUID();
889
989
  const activatedAt = now();
890
990
  const response = { pinId, workflowVersionId: input.workflowVersionId, mode: input.mode };
@@ -911,6 +1011,9 @@ export async function pinQueueWorkflow(
911
1011
  createdAt: activatedAt,
912
1012
  }),
913
1013
  );
914
- await db.batch(statements);
1014
+ try { await db.batch(statements); } catch (error) {
1015
+ if (String(error).includes("queue_workflow_pins.queue_id, queue_workflow_pins.mode")) throw new ApiError(409, "workflow_pin_conflict", "This queue activation changed while saving. Reload the active version before trying again.");
1016
+ throw error;
1017
+ }
915
1018
  return { ...response, idempotentReplay: false };
916
1019
  }
@@ -3,6 +3,8 @@ import { sha256 } from "./report-crypto";
3
3
  import { ApiError } from "./report-http";
4
4
  import type { OperatorSession, WorkflowDispatchJob } from "./report-types";
5
5
  import { startDynamicWorkflow } from "./workflow-dynamic-runtime";
6
+ import { canRequeueUnavailableWorkflowDispatch } from "./workflow-loader-code";
7
+ import { workflowRunEventType } from "./workflow-event-transport";
6
8
  import type { JsonObject } from "./workflow-platform-types";
7
9
 
8
10
  interface MutationRow {
@@ -392,7 +394,7 @@ export async function processWorkflowDispatchJob(
392
394
  if (dispatch.operation === "event") {
393
395
  if (!dispatch.instanceId || !dispatch.eventType || !dispatch.eventPayloadJson) throw new Error("workflow_event_dispatch_not_ready");
394
396
  const instance = await env.WORKFLOWS.get(dispatch.instanceId);
395
- await instance.sendEvent({ type: dispatch.eventType, payload: parsedJson(dispatch.eventPayloadJson, {}) });
397
+ await instance.sendEvent({ type: await workflowRunEventType(env.DB, dispatch.runId, dispatch.eventType), payload: parsedJson(dispatch.eventPayloadJson, {}) });
396
398
  const deliveredAt = now();
397
399
  await env.DB.batch([
398
400
  env.DB.prepare(`
@@ -428,13 +430,21 @@ export async function processWorkflowDispatchJob(
428
430
  } catch (error) {
429
431
  const delaySeconds = Math.min(900, 5 * (2 ** Math.min(dispatch.attemptCount, 7)));
430
432
  const timestamp = now();
433
+ const errorMessage = error instanceof Error ? error.message.slice(0, 200) : "workflow_dispatch_failed";
434
+ console.error(JSON.stringify({
435
+ message: "workflow dispatch attempt failed",
436
+ runId: dispatch.runId,
437
+ outboxId: dispatch.outboxId,
438
+ attemptCount: dispatch.attemptCount,
439
+ error: errorMessage,
440
+ }));
431
441
  await env.DB.prepare(`
432
442
  UPDATE workflow_dispatch_outbox SET state = CASE WHEN attempt_count >= 8 THEN 'dead_letter' ELSE 'failed' END,
433
443
  lease_expires_at = NULL, available_at = ?2, last_error_code = ?3, updated_at = ?4
434
444
  WHERE id = ?1 AND state = 'leased'
435
445
  `).bind(
436
446
  dispatch.outboxId, new Date(Date.now() + delaySeconds * 1_000).toISOString(),
437
- error instanceof Error ? error.message.slice(0, 200) : "workflow_dispatch_failed", timestamp,
447
+ errorMessage, timestamp,
438
448
  ).run();
439
449
  if (dispatch.attemptCount >= 8) {
440
450
  await env.DB.prepare(`
@@ -450,7 +460,7 @@ export async function processWorkflowDispatchJob(
450
460
 
451
461
  export async function listWorkflowRuns(
452
462
  db: D1Database,
453
- input: { reportId?: string; state?: string; allowedQueueIds?: string[] | null; limit: number },
463
+ input: { reportId?: string; workflowId?: string; state?: string; allowedQueueIds?: string[] | null; limit: number },
454
464
  ): Promise<Record<string, unknown>[]> {
455
465
  const rows = await db.prepare(`
456
466
  SELECT r.id, r.report_id AS reportId, r.appeal_id AS appealId,
@@ -458,9 +468,12 @@ export async function listWorkflowRuns(
458
468
  r.queue_id AS queueId, r.run_kind AS runKind, r.authority_mode AS authorityMode,
459
469
  r.state, r.cloudflare_instance_id AS cloudflareInstanceId,
460
470
  r.current_node_id AS currentNodeId, r.error_code AS errorCode,
471
+ (SELECT json_extract(step.value, '$.name') FROM json_each(v.graph_json, '$.nodes') step
472
+ WHERE json_extract(step.value, '$.id') = r.current_node_id LIMIT 1) AS currentNodeName,
461
473
  r.started_at AS startedAt, r.waiting_at AS waitingAt,
462
474
  r.finished_at AS finishedAt, r.created_at AS createdAt, r.updated_at AS updatedAt,
463
475
  w.workflow_key AS workflowKey, w.name AS workflowName, v.version,
476
+ (SELECT public_reference FROM reports WHERE id = r.report_id) AS publicReference,
464
477
  (SELECT COUNT(*) FROM workflow_step_runs s WHERE s.run_id = r.id) AS stepCount,
465
478
  (SELECT COUNT(*) FROM human_tasks t WHERE t.run_id = r.id AND t.state IN ('open','claimed')) AS openTaskCount
466
479
  FROM workflow_runs r
@@ -468,11 +481,12 @@ export async function listWorkflowRuns(
468
481
  JOIN workflow_definitions w ON w.id = v.workflow_id
469
482
  WHERE (?1 IS NULL OR r.report_id = ?1) AND (?2 IS NULL OR r.state = ?2)
470
483
  AND (?3 = 1 OR r.queue_id IN (SELECT value FROM json_each(?4)))
484
+ AND (?6 IS NULL OR w.id = ?6)
471
485
  ORDER BY r.created_at DESC, r.id DESC LIMIT ?5
472
486
  `).bind(
473
487
  input.reportId ?? null, input.state ?? null,
474
488
  input.allowedQueueIds === null || input.allowedQueueIds === undefined ? 1 : 0,
475
- canonicalJson(input.allowedQueueIds ?? []), Math.max(1, Math.min(input.limit, 100)),
489
+ canonicalJson(input.allowedQueueIds ?? []), Math.max(1, Math.min(input.limit, 100)), input.workflowId ?? null,
476
490
  ).all<Record<string, unknown>>();
477
491
  return rows.results;
478
492
  }
@@ -535,7 +549,7 @@ export async function sendWorkflowEvent(
535
549
  if (!run.cloudflare_instance_id) return { eventId, idempotentReplay: false, delivered: false };
536
550
  try {
537
551
  const instance = await env.WORKFLOWS.get(run.cloudflare_instance_id);
538
- await instance.sendEvent({ type: input.eventType, payload: input.payload });
552
+ await instance.sendEvent({ type: await workflowRunEventType(env.DB, runId, input.eventType), payload: input.payload });
539
553
  const deliveredAt = now();
540
554
  await env.DB.batch([
541
555
  env.DB.prepare(`UPDATE workflow_event_inbox SET delivered_at = ?2, delivery_attempts = delivery_attempts + 1 WHERE id = ?1`).bind(eventId, deliveredAt),
@@ -592,8 +606,8 @@ export async function reconcileWorkflowRun(
592
606
  idempotentReplay: true,
593
607
  };
594
608
  }
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 }>();
609
+ const run = await env.DB.prepare(`SELECT id, state, cloudflare_instance_id AS instanceId, error_code AS errorCode FROM workflow_runs WHERE id = ?1 LIMIT 1`)
610
+ .bind(runId).first<{ id: string; state: string; instanceId: string | null; errorCode: string | null }>();
597
611
  if (!run) throw new ApiError(404, "workflow_run_not_found", "The workflow run does not exist.");
598
612
  const timestamp = now();
599
613
  let state = run.state;
@@ -618,14 +632,48 @@ export async function reconcileWorkflowRun(
618
632
  }
619
633
  }
620
634
  } else {
621
- let status: InstanceStatus;
635
+ let status: InstanceStatus | null = null;
622
636
  try {
623
637
  status = await (await env.WORKFLOWS.get(run.instanceId)).status();
624
638
  } catch {
625
- throw new ApiError(503, "workflow_platform_status_unavailable", "The Workflow platform status is temporarily unavailable.");
639
+ const dispatchFailure = run.state === "repair_required"
640
+ ? await env.DB.prepare(`
641
+ SELECT o.id AS outboxId,
642
+ (SELECT COUNT(*) FROM workflow_step_runs s WHERE s.run_id = ?1) AS stepCount
643
+ FROM workflow_dispatch_outbox o
644
+ WHERE o.run_id = ?1 AND o.operation = 'start'
645
+ AND o.state IN ('failed','dead_letter','leased')
646
+ ORDER BY o.updated_at DESC LIMIT 1
647
+ `).bind(runId).first<{ outboxId: string; stepCount: number }>()
648
+ : null;
649
+ if (!dispatchFailure || !canRequeueUnavailableWorkflowDispatch({
650
+ state: run.state,
651
+ errorCode: run.errorCode,
652
+ stepCount: dispatchFailure.stepCount,
653
+ })) {
654
+ throw new ApiError(503, "workflow_platform_status_unavailable", "The Workflow platform status is temporarily unavailable.");
655
+ }
656
+ const reset = await env.DB.prepare(`
657
+ UPDATE workflow_dispatch_outbox SET state = 'pending', attempt_count = 0,
658
+ available_at = ?2, lease_expires_at = NULL, last_error_code = NULL, updated_at = ?2
659
+ WHERE id = ?1 AND state IN ('failed','dead_letter','leased')
660
+ `).bind(dispatchFailure.outboxId, timestamp).run();
661
+ if (reset.meta.changes !== 1) {
662
+ throw new ApiError(503, "workflow_platform_status_unavailable", "The Workflow platform status is temporarily unavailable.");
663
+ }
664
+ state = "dispatch_pending";
665
+ repairApplied = true;
666
+ statements.push(
667
+ env.DB.prepare(`
668
+ UPDATE workflow_runs SET state = 'dispatch_pending', cloudflare_instance_id = NULL,
669
+ error_code = NULL, error_summary = NULL, updated_at = ?2, revision = revision + 1
670
+ WHERE id = ?1 AND state = 'repair_required'
671
+ `).bind(runId, timestamp),
672
+ );
626
673
  }
627
- platformState = status.status;
628
- const mapped = status.status === "complete" ? "succeeded"
674
+ platformState = status?.status ?? null;
675
+ const mapped = !status ? null
676
+ : status.status === "complete" ? "succeeded"
629
677
  : status.status === "errored" ? "failed"
630
678
  : status.status === "terminated" ? "terminated"
631
679
  : status.status === "waiting" || status.status === "waitingForPause" ? "waiting"
@@ -642,7 +690,7 @@ export async function reconcileWorkflowRun(
642
690
  waiting_at = CASE WHEN ?2 = 'waiting' THEN COALESCE(waiting_at, ?4) ELSE NULL END,
643
691
  finished_at = CASE WHEN ?2 IN ('succeeded','failed','terminated') THEN COALESCE(finished_at, ?4) ELSE finished_at END,
644
692
  updated_at = ?4, revision = revision + 1 WHERE id = ?1
645
- `).bind(runId, mapped, status.error?.message?.slice(0, 500) ?? null, timestamp),
693
+ `).bind(runId, mapped, status?.error?.message?.slice(0, 500) ?? null, timestamp),
646
694
  );
647
695
  } else if (mapped && terminal && mapped !== run.state) {
648
696
  const incidentKey = `workflow-state-mismatch:${runId}:${run.state}:${mapped}`;
@@ -657,7 +705,7 @@ export async function reconcileWorkflowRun(
657
705
  last_seen_at = excluded.last_seen_at, metadata_json = excluded.metadata_json
658
706
  `).bind(
659
707
  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 }),
708
+ runId, timestamp, canonicalJson({ d1State: run.state, platformState: mapped, platformRawState: status?.status ?? null }),
661
709
  ));
662
710
  }
663
711
  }
@@ -1,5 +1,5 @@
1
1
  import { canonicalJson } from "./audit";
2
- import { evaluateInputMapping, evaluateWorkflowExpression } from "./workflow-expressions";
2
+ import { evaluateInputMapping, evaluateWorkflowExpression, validateJsonSchemaValue } from "./workflow-expressions";
3
3
  import type { JsonObject, JsonValue, WorkflowGraphV1, WorkflowNodeV1 } from "./workflow-platform-types";
4
4
 
5
5
  export interface WorkflowSimulationFixture {
@@ -63,7 +63,8 @@ export function simulateWorkflow(graph: WorkflowGraphV1, fixture: WorkflowSimula
63
63
  const outgoing = new Map(graph.nodes.map((node) => [node.id, graph.edges.filter((edge) => edge.from.nodeId === node.id)]));
64
64
  const steps: WorkflowSimulationStep[] = [];
65
65
  const stepOutputs: JsonObject = {};
66
- const baseContext: JsonObject = { input: fixture.input, steps: stepOutputs, findings: {} };
66
+ // Fixtures use the same named context and wait payload shape as the runtime.
67
+ const baseContext: JsonObject = { ...fixture.input, input: fixture.input, run: fixture.input.run ?? { id: "fixture-run" }, steps: stepOutputs, findings: fixture.input.findings ?? {} };
67
68
 
68
69
  const record = (node: WorkflowNodeV1, branchKey: string, state: WorkflowSimulationStep["state"], port: string, output: JsonObject) => {
69
70
  const entry = { nodeId: node.id, kind: node.kind, branchKey, state, port, output };
@@ -134,7 +135,9 @@ export function simulateWorkflow(graph: WorkflowGraphV1, fixture: WorkflowSimula
134
135
  state = "timed_out";
135
136
  last = { port: "timeout", status: "timed_out" };
136
137
  } else {
137
- last = { port: "completed", status: "received", response };
138
+ const errors = validateJsonSchemaValue(response, object(node.config.response_schema));
139
+ if (errors.length) throw new SimulationFailure("simulation_human_response_invalid", node.id);
140
+ last = { port: "completed", status: "received", event: { task_id: `fixture:${node.id}`, response_id: `fixture-response:${node.id}`, response } };
138
141
  }
139
142
  } else if (node.kind === "wait_for_event") {
140
143
  const response = fixture.humanResponses[node.id];
@@ -150,7 +153,7 @@ export function simulateWorkflow(graph: WorkflowGraphV1, fixture: WorkflowSimula
150
153
  last = { port: "sent", simulated: true, audience: node.config.audience ?? null };
151
154
  } else if (node.kind === "decision") {
152
155
  state = "simulated";
153
- last = { port: "recorded", simulated: true, decision: mapped };
156
+ last = { port: "recorded", simulated: true, decisionId: `fixture:${node.id}`, decisionCode: mapped.decision_code ?? null, decision: mapped };
154
157
  } else if (node.kind === "action") {
155
158
  state = "simulated";
156
159
  last = { port: "applied", simulated: true, action: mapped };