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
@@ -5,7 +5,6 @@ import { assertOperatorQueueAccess } from "./report-auth";
5
5
  import type { OperatorSession } from "./report-types";
6
6
  import { validateJsonSchemaValue } from "./workflow-expressions";
7
7
  import type { JsonObject, JsonValue } from "./workflow-platform-types";
8
- import { sendWorkflowEvent } from "./workflow-runs";
9
8
 
10
9
  function now(): string {
11
10
  return new Date().toISOString();
@@ -30,7 +29,7 @@ function parsedObject(value: string): JsonObject {
30
29
 
31
30
  export async function listHumanTasks(
32
31
  db: D1Database,
33
- input: { state?: string; queueId?: string; operatorId?: string; allowedQueueIds?: string[] | null; limit: number },
32
+ input: { state?: string; queueId?: string; reportId?: string; operatorId?: string; allowedQueueIds?: string[] | null; limit: number },
34
33
  ): Promise<Record<string, unknown>[]> {
35
34
  const timestamp = now();
36
35
  await db.batch([
@@ -58,13 +57,14 @@ export async function listHumanTasks(
58
57
  ON c.task_id = t.id AND c.released_at IS NULL AND c.expires_at > ?1
59
58
  WHERE (?2 IS NULL OR t.state = ?2) AND (?3 IS NULL OR t.queue_id = ?3)
60
59
  AND (?4 IS NULL OR t.assigned_operator_id = ?4)
60
+ AND (?8 IS NULL OR t.report_id = ?8)
61
61
  AND (?5 = 1 OR t.queue_id IN (SELECT value FROM json_each(?6)))
62
62
  ORDER BY CASE WHEN t.due_at IS NULL THEN 1 ELSE 0 END, t.due_at, t.created_at, t.id
63
63
  LIMIT ?7
64
64
  `).bind(
65
65
  timestamp, input.state ?? null, input.queueId ?? null, input.operatorId ?? null,
66
66
  input.allowedQueueIds === null || input.allowedQueueIds === undefined ? 1 : 0,
67
- canonicalJson(input.allowedQueueIds ?? []), Math.max(1, Math.min(input.limit, 100)),
67
+ canonicalJson(input.allowedQueueIds ?? []), Math.max(1, Math.min(input.limit, 100)), input.reportId ?? null,
68
68
  ).all<Record<string, unknown>>();
69
69
  return rows.results.map((row) => ({
70
70
  ...row,
@@ -135,16 +135,20 @@ export async function reassignHumanTask(
135
135
  if (!queue) throw new ApiError(409, "human_task_queue_unavailable", "The destination queue is not active.");
136
136
  if (!["open", "claimed"].includes(task.state)) throw new ApiError(409, "human_task_not_reassignable", "Only an open or claimed task can be reassigned.");
137
137
  const timestamp = now();
138
- await db.batch([
139
- db.prepare(`UPDATE human_task_claims SET released_at = ?2, release_reason = 'task_reassigned' WHERE task_id = ?1 AND released_at IS NULL`)
140
- .bind(taskId, timestamp),
141
- db.prepare(`UPDATE human_tasks SET state = 'open', queue_id = ?2, assigned_operator_id = NULL, updated_at = ?3 WHERE id = ?1`)
142
- .bind(taskId, queueId, timestamp),
138
+ const auditId = crypto.randomUUID();
139
+ const written = await db.batch([
140
+ db.prepare(`UPDATE human_tasks SET state = 'open', queue_id = ?2, assigned_operator_id = NULL, updated_at = ?3
141
+ WHERE id = ?1 AND state IN ('open','claimed') AND queue_id IS ?4`)
142
+ .bind(taskId, queueId, timestamp, task.queueId),
143
143
  db.prepare(`
144
144
  INSERT INTO audit_events (id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at)
145
- VALUES (?1, ?2, 'human_task.reassigned', ?3, ?4, 'human_task', ?5, ?6, ?7)
146
- `).bind(crypto.randomUUID(), auditKey, session.actor.type, session.actor.id, taskId, canonicalJson({ previousQueueId: task.queueId, queueId, reason }), timestamp),
145
+ SELECT ?1, ?2, 'human_task.reassigned', ?3, ?4, 'human_task', ?5, ?6, ?7 WHERE changes() = 1
146
+ `).bind(auditId, auditKey, session.actor.type, session.actor.id, taskId, canonicalJson({ previousQueueId: task.queueId, queueId, reason }), timestamp),
147
+ db.prepare(`UPDATE human_task_claims SET released_at = ?2, release_reason = 'task_reassigned'
148
+ WHERE task_id = ?1 AND released_at IS NULL AND EXISTS (SELECT 1 FROM audit_events WHERE id = ?3)`)
149
+ .bind(taskId, timestamp, auditId),
147
150
  ]);
151
+ if (written[0]?.meta.changes !== 1) throw new ApiError(409, "human_task_not_reassignable", "This task changed before reassignment. Reload its current state.");
148
152
  return { taskId, queueId, state: "open", idempotentReplay: false };
149
153
  }
150
154
 
@@ -167,16 +171,20 @@ export async function cancelHumanTask(
167
171
  }
168
172
  if (!["open", "claimed"].includes(task.state)) throw new ApiError(409, "human_task_not_cancellable", "Only an open or claimed task can be cancelled.");
169
173
  const timestamp = now();
170
- await db.batch([
171
- db.prepare(`UPDATE human_task_claims SET released_at = ?2, release_reason = 'task_cancelled' WHERE task_id = ?1 AND released_at IS NULL`)
172
- .bind(taskId, timestamp),
173
- db.prepare(`UPDATE human_tasks SET state = 'cancelled', assigned_operator_id = NULL, updated_at = ?2 WHERE id = ?1`)
174
+ const auditId = crypto.randomUUID();
175
+ const written = await db.batch([
176
+ db.prepare(`UPDATE human_tasks SET state = 'cancelled', assigned_operator_id = NULL, updated_at = ?2
177
+ WHERE id = ?1 AND state IN ('open','claimed')`)
174
178
  .bind(taskId, timestamp),
175
179
  db.prepare(`
176
180
  INSERT INTO audit_events (id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at)
177
- VALUES (?1, ?2, 'human_task.cancelled', ?3, ?4, 'human_task', ?5, ?6, ?7)
178
- `).bind(crypto.randomUUID(), auditKey, session.actor.type, session.actor.id, taskId, canonicalJson({ reason, runId: task.runId }), timestamp),
181
+ SELECT ?1, ?2, 'human_task.cancelled', ?3, ?4, 'human_task', ?5, ?6, ?7 WHERE changes() = 1
182
+ `).bind(auditId, auditKey, session.actor.type, session.actor.id, taskId, canonicalJson({ reason, runId: task.runId }), timestamp),
183
+ db.prepare(`UPDATE human_task_claims SET released_at = ?2, release_reason = 'task_cancelled'
184
+ WHERE task_id = ?1 AND released_at IS NULL AND EXISTS (SELECT 1 FROM audit_events WHERE id = ?3)`)
185
+ .bind(taskId, timestamp, auditId),
179
186
  ]);
187
+ if (written[0]?.meta.changes !== 1) throw new ApiError(409, "human_task_not_cancellable", "This task changed before cancellation. Reload its current state.");
180
188
  return { taskId, runId: task.runId, state: "cancelled", idempotentReplay: false };
181
189
  }
182
190
 
@@ -203,12 +211,26 @@ export async function claimHumanTask(
203
211
  throw new ApiError(409, "human_task_already_claimed", "The task is already claimed by another operator.");
204
212
  }
205
213
  const claimId = crypto.randomUUID();
206
- await db.batch([
207
- db.prepare(`INSERT INTO human_task_claims (id, task_id, operator_id, claimed_at, expires_at) VALUES (?1, ?2, ?3, ?4, ?5)`)
208
- .bind(claimId, taskId, session.actor.id, timestamp, expiresAt),
209
- db.prepare(`UPDATE human_tasks SET state = 'claimed', assigned_operator_id = ?2, updated_at = ?3 WHERE id = ?1 AND state IN ('open','claimed')`)
210
- .bind(taskId, session.actor.id, timestamp),
211
- ]);
214
+ let written: D1Result[];
215
+ try {
216
+ written = await db.batch([
217
+ db.prepare(`
218
+ INSERT INTO human_task_claims (id, task_id, operator_id, claimed_at, expires_at)
219
+ SELECT ?1, t.id, ?3, ?4, ?5 FROM human_tasks t
220
+ WHERE t.id = ?2 AND t.state IN ('open','claimed') AND t.queue_id IS ?6
221
+ AND (t.due_at IS NULL OR t.due_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
222
+ AND EXISTS (SELECT 1 FROM workflow_runs wr WHERE wr.id = t.run_id AND wr.state NOT IN ('succeeded','failed','terminated','superseded') AND wr.authority_mode IN ('assist','bounded_auto'))
223
+ AND NOT EXISTS (SELECT 1 FROM human_task_claims c WHERE c.task_id = t.id AND c.released_at IS NULL)
224
+ `).bind(claimId, taskId, session.actor.id, timestamp, expiresAt, task.queueId),
225
+ db.prepare(`UPDATE human_tasks SET state = 'claimed', assigned_operator_id = ?2, updated_at = ?3
226
+ WHERE id = ?1 AND state IN ('open','claimed') AND EXISTS (SELECT 1 FROM human_task_claims WHERE id = ?4)`)
227
+ .bind(taskId, session.actor.id, timestamp, claimId),
228
+ ]);
229
+ } catch (error) {
230
+ if (String(error).includes("UNIQUE constraint failed: human_task_claims")) throw new ApiError(409, "human_task_already_claimed", "Another reviewer claimed this task. Reload it to see the current owner.");
231
+ throw error;
232
+ }
233
+ if (written[0]?.meta.changes !== 1) throw new ApiError(409, "human_task_not_claimable", "This task changed or reached its deadline. Reload it before claiming.");
212
234
  return { claimId, expiresAt };
213
235
  }
214
236
 
@@ -218,13 +240,16 @@ export async function releaseHumanTaskClaim(
218
240
  session: OperatorSession,
219
241
  ): Promise<void> {
220
242
  const timestamp = now();
221
- const released = await db.prepare(`
222
- UPDATE human_task_claims SET released_at = ?3, release_reason = 'operator_released'
223
- WHERE task_id = ?1 AND operator_id = ?2 AND released_at IS NULL
224
- `).bind(taskId, session.actor.id, timestamp).run();
225
- if (released.meta.changes !== 1) throw new ApiError(409, "human_task_claim_missing", "You do not hold the active claim for this task.");
226
- await db.prepare(`UPDATE human_tasks SET state = 'open', assigned_operator_id = NULL, updated_at = ?2 WHERE id = ?1 AND state = 'claimed'`)
227
- .bind(taskId, timestamp).run();
243
+ const result = await db.batch([
244
+ db.prepare(`UPDATE human_tasks SET state = 'open', assigned_operator_id = NULL, updated_at = ?3
245
+ WHERE id = ?1 AND state = 'claimed' AND assigned_operator_id = ?2
246
+ AND EXISTS (SELECT 1 FROM human_task_claims c WHERE c.task_id = ?1 AND c.operator_id = ?2 AND c.released_at IS NULL)`)
247
+ .bind(taskId, session.actor.id, timestamp),
248
+ db.prepare(`UPDATE human_task_claims SET released_at = ?3, release_reason = 'operator_released'
249
+ WHERE task_id = ?1 AND operator_id = ?2 AND released_at IS NULL`)
250
+ .bind(taskId, session.actor.id, timestamp),
251
+ ]);
252
+ if (result[1]?.meta.changes !== 1) throw new ApiError(409, "human_task_claim_missing", "You do not hold the active claim for this task.");
228
253
  }
229
254
 
230
255
  export async function respondToHumanTask(
@@ -234,17 +259,32 @@ export async function respondToHumanTask(
234
259
  session: OperatorSession,
235
260
  idempotencyKey: string,
236
261
  ): Promise<{ responseId: string; eventId: string; eventDelivered: boolean; idempotentReplay: boolean }> {
262
+ const response = jsonObject(responseValue);
263
+ const responseJson = canonicalJson(response);
264
+ const responseDigest = `sha256:${await sha256(responseJson)}`;
237
265
  const existing = await env.DB.prepare(`
238
- SELECT r.id, t.run_id AS runId, t.action_proposal_id AS actionProposalId FROM human_task_responses r
239
- JOIN human_tasks t ON t.id = r.task_id
266
+ SELECT r.id, r.task_id AS taskId, r.response_json AS responseJson,
267
+ r.response_digest AS responseDigest, t.queue_id AS queueId,
268
+ t.run_id AS runId, t.action_proposal_id AS actionProposalId
269
+ FROM human_task_responses r JOIN human_tasks t ON t.id = r.task_id
240
270
  WHERE r.operator_id = ?1 AND r.idempotency_key = ?2 LIMIT 1
241
- `).bind(session.actor.id, idempotencyKey).first<{ id: string; runId: string; actionProposalId: string | null }>();
271
+ `).bind(session.actor.id, idempotencyKey).first<{
272
+ id: string; taskId: string; responseJson: string; responseDigest: string;
273
+ queueId: string | null; runId: string; actionProposalId: string | null;
274
+ }>();
242
275
  if (existing) {
243
- const event = await sendWorkflowEvent(env, existing.runId, {
244
- eventType: existing.actionProposalId ? `action.${existing.actionProposalId}.resolved` : `task.${taskId}.completed`, eventKey: `task-response:${existing.id}`,
245
- sourceType: "operator", sourceId: session.actor.id, payload: { task_id: taskId, response_id: existing.id },
276
+ if (existing.taskId !== taskId || existing.responseDigest !== responseDigest) {
277
+ throw new ApiError(409, "idempotency_key_reused", "This submission key belongs to a different task or response. Reload the saved task before submitting changes.");
278
+ }
279
+ assertOperatorQueueAccess(session, existing.queueId);
280
+ const event = await taskCompletionEvent(env.DB, {
281
+ taskId, responseId: existing.id, response: parsedObject(existing.responseJson),
282
+ runId: existing.runId, actionProposalId: existing.actionProposalId, operatorId: session.actor.id,
246
283
  });
247
- return { responseId: existing.id, eventId: event.eventId, eventDelivered: event.delivered, idempotentReplay: true };
284
+ // Older responses may have committed before their event was recorded.
285
+ await env.DB.batch(event.statements);
286
+ const delivered = await enqueueTaskCompletion(env, event);
287
+ return { responseId: existing.id, eventId: event.id, eventDelivered: delivered, idempotentReplay: true };
248
288
  }
249
289
  const timestamp = now();
250
290
  const task = await env.DB.prepare(`
@@ -265,22 +305,29 @@ export async function respondToHumanTask(
265
305
  if (task.state !== "claimed" || task.operatorId !== session.actor.id || !task.claimId || !task.expiresAt || task.expiresAt <= timestamp) {
266
306
  throw new ApiError(409, "human_task_claim_required", "A current claim held by this operator is required.");
267
307
  }
268
- const response = jsonObject(responseValue);
308
+ if ("decision_code" in response && !session.permissions.decideReports) throw new ApiError(403, "review_decision_permission_denied", "Your role cannot submit a case decision.");
269
309
  const errors = validateJsonSchemaValue(response as JsonValue, parsedObject(task.responseSchemaJson));
270
310
  if (errors.length) throw new ApiError(422, "human_task_response_schema_invalid", "The task response does not match its published schema.", { errors });
271
- const responseJson = canonicalJson(response);
272
311
  const responseId = crypto.randomUUID();
273
312
  const statements: D1PreparedStatement[] = [
274
313
  env.DB.prepare(`
275
314
  INSERT INTO human_task_responses (
276
315
  id, task_id, claim_id, operator_id, response_json, response_digest,
277
316
  idempotency_key, submitted_at
278
- ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
279
- `).bind(responseId, taskId, task.claimId, session.actor.id, responseJson, `sha256:${await sha256(responseJson)}`, idempotencyKey, timestamp),
280
- env.DB.prepare(`UPDATE human_task_claims SET released_at = ?2, release_reason = 'task_completed' WHERE id = ?1 AND released_at IS NULL`)
281
- .bind(task.claimId, timestamp),
282
- env.DB.prepare(`UPDATE human_tasks SET state = 'completed', completed_at = ?2, updated_at = ?2 WHERE id = ?1 AND state = 'claimed'`)
283
- .bind(taskId, timestamp),
317
+ ) SELECT ?1, t.id, c.id, ?4, ?5, ?6, ?7, ?8
318
+ FROM human_tasks t JOIN human_task_claims c ON c.task_id = t.id
319
+ LEFT JOIN action_proposals p ON p.id = t.action_proposal_id
320
+ WHERE t.id = ?2 AND c.id = ?3 AND t.state = 'claimed'
321
+ AND c.operator_id = ?4 AND c.released_at IS NULL
322
+ AND c.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
323
+ AND (t.due_at IS NULL OR t.due_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
324
+ AND EXISTS (SELECT 1 FROM workflow_runs wr WHERE wr.id = t.run_id AND wr.state NOT IN ('succeeded','failed','terminated','superseded') AND wr.authority_mode IN ('assist','bounded_auto'))
325
+ AND (t.action_proposal_id IS NULL OR (p.state = 'approval_required' AND p.payload_digest = ?9))
326
+ `).bind(responseId, taskId, task.claimId, session.actor.id, responseJson, responseDigest, idempotencyKey, timestamp, task.actionPayloadDigest),
327
+ env.DB.prepare(`UPDATE human_task_claims SET released_at = ?2, release_reason = 'task_completed' WHERE id = ?1 AND released_at IS NULL AND EXISTS (SELECT 1 FROM human_task_responses WHERE id = ?3)`)
328
+ .bind(task.claimId, timestamp, responseId),
329
+ env.DB.prepare(`UPDATE human_tasks SET state = 'completed', completed_at = ?2, updated_at = ?2 WHERE id = ?1 AND state = 'claimed' AND EXISTS (SELECT 1 FROM human_task_responses WHERE id = ?3)`)
330
+ .bind(taskId, timestamp, responseId),
284
331
  ];
285
332
  if (task.actionProposalId) {
286
333
  if (!session.permissions.approveActions) throw new ApiError(403, "action_approval_permission_denied", "Your role cannot approve or reject actions.");
@@ -293,24 +340,74 @@ export async function respondToHumanTask(
293
340
  INSERT INTO action_approvals (
294
341
  id, proposal_id, task_id, approver_id, payload_digest,
295
342
  decision, reason, idempotency_key, created_at
296
- ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
343
+ ) SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9
344
+ WHERE EXISTS (SELECT 1 FROM human_task_responses WHERE id = ?10)
297
345
  `).bind(
298
346
  crypto.randomUUID(), task.actionProposalId, taskId, session.actor.id,
299
347
  task.actionPayloadDigest, decision,
300
348
  typeof response.reason === "string" ? response.reason : null,
301
- idempotencyKey, timestamp,
349
+ idempotencyKey, timestamp, responseId,
302
350
  ),
303
351
  env.DB.prepare(`
304
352
  UPDATE action_proposals SET state = ?2, updated_at = ?3
305
- WHERE id = ?1 AND state = 'approval_required'
306
- `).bind(task.actionProposalId, decision, timestamp),
353
+ WHERE id = ?1 AND state = 'approval_required' AND EXISTS (SELECT 1 FROM human_task_responses WHERE id = ?4)
354
+ `).bind(task.actionProposalId, decision, timestamp, responseId),
307
355
  );
308
356
  }
309
- await env.DB.batch(statements);
310
- const event = await sendWorkflowEvent(env, task.runId, {
311
- eventType: task.actionProposalId ? `action.${task.actionProposalId}.resolved` : `task.${taskId}.completed`, eventKey: `task-response:${responseId}`,
312
- sourceType: "operator", sourceId: session.actor.id,
313
- payload: { task_id: taskId, response_id: responseId, response },
314
- });
315
- return { responseId, eventId: event.eventId, eventDelivered: event.delivered, idempotentReplay: false };
357
+ const event = await taskCompletionEvent(env.DB, { taskId, responseId, response, runId: task.runId, actionProposalId: task.actionProposalId, operatorId: session.actor.id });
358
+ statements.push(...event.statements);
359
+ let written: D1Result[];
360
+ try { written = await env.DB.batch(statements); }
361
+ catch (error) {
362
+ if (String(error).includes("action_approval_state_changed")) throw new ApiError(409, "action_approval_changed", "The action changed or its authorization expired. Your unsent response is preserved; refresh the task before submitting again.");
363
+ if (String(error).includes("UNIQUE constraint failed: human_task_responses")) {
364
+ throw new ApiError(409, "human_task_response_conflict", "This task already has a submitted response. Reload it to see the saved result.");
365
+ }
366
+ throw error;
367
+ }
368
+ if (written[0]?.meta.changes !== 1) throw new ApiError(409, "human_task_claim_required", "The review claim or approval changed before submission. Reload the task and review its current state.");
369
+ const delivered = await enqueueTaskCompletion(env, event);
370
+ return { responseId, eventId: event.id, eventDelivered: delivered, idempotentReplay: false };
371
+ }
372
+
373
+ async function taskCompletionEvent(db: D1Database, input: {
374
+ taskId: string; responseId: string; response: JsonObject; runId: string;
375
+ actionProposalId: string | null; operatorId: string;
376
+ }): Promise<{ id: string; runId: string; statements: D1PreparedStatement[] }> {
377
+ const eventType = input.actionProposalId ? `action.${input.actionProposalId}.resolved` : `task.${input.taskId}.completed`;
378
+ const eventKey = `task-response:${input.responseId}`;
379
+ const existing = await db.prepare(`SELECT id FROM workflow_event_inbox WHERE run_id = ?1 AND event_type = ?2 AND event_key = ?3 LIMIT 1`)
380
+ .bind(input.runId, eventType, eventKey).first<{ id: string }>();
381
+ const id = existing?.id ?? eventKey;
382
+ const payload = canonicalJson({ task_id: input.taskId, response_id: input.responseId, response: input.response });
383
+ const timestamp = now();
384
+ return { id, runId: input.runId, statements: [
385
+ db.prepare(`
386
+ INSERT OR IGNORE INTO workflow_event_inbox (
387
+ id, run_id, event_type, event_key, source_type, source_id, payload_json, payload_digest, received_at
388
+ ) SELECT ?1, ?2, ?3, ?4, 'operator', ?5, ?6, ?7, ?8
389
+ WHERE EXISTS (SELECT 1 FROM human_task_responses WHERE id = ?9 AND task_id = ?10)
390
+ `).bind(id, input.runId, eventType, eventKey, input.operatorId, payload, `sha256:${await sha256(payload)}`, timestamp, input.responseId, input.taskId),
391
+ db.prepare(`
392
+ INSERT OR IGNORE INTO workflow_dispatch_outbox (
393
+ id, run_id, operation, event_inbox_id, operation_key, state, available_at, created_at, updated_at
394
+ ) SELECT id, run_id, 'event', id, ?2, 'pending', ?3, ?3, ?3
395
+ FROM workflow_event_inbox WHERE id = ?1 AND delivered_at IS NULL
396
+ `).bind(id, `workflow-event:${input.runId}:${eventType}:${eventKey}`, timestamp),
397
+ db.prepare(`
398
+ INSERT OR IGNORE INTO audit_events (
399
+ id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at
400
+ ) SELECT ?1, ?2, 'human_task.response_submitted', 'operator_user', ?3, 'human_task', ?4, ?5, ?6
401
+ WHERE EXISTS (SELECT 1 FROM human_task_responses WHERE id = ?7)
402
+ `).bind(crypto.randomUUID(), eventKey, input.operatorId, input.taskId, canonicalJson({ runId: input.runId, responseId: input.responseId }), timestamp, input.responseId),
403
+ ] };
404
+ }
405
+
406
+ async function enqueueTaskCompletion(env: Env, event: { id: string; runId: string }): Promise<boolean> {
407
+ const stored = await env.DB.prepare(`SELECT delivered_at FROM workflow_event_inbox WHERE id = ?1`).bind(event.id).first<{ delivered_at: string | null }>();
408
+ if (stored?.delivered_at) return true;
409
+ // The response, event and outbox already committed together. Reconciliation
410
+ // can recover a failed queue send even if the browser never retries.
411
+ await env.CONTROL_JOBS.send({ version: 1, type: "dispatch_workflow", jobId: `workflow-dispatch:${event.id}`, runId: event.runId, outboxId: event.id }, { contentType: "json" }).catch(() => {});
412
+ return false;
316
413
  }