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.
- package/README.md +3 -3
- package/package.json +2 -2
- package/src/config.mjs +2 -2
- package/src/update.mjs +10 -1
- package/template/README.md +2 -2
- package/template/console/README.md +2 -2
- package/template/console/analytics/AnalyticsWorkspace.tsx +49 -36
- package/template/console/analytics/controller.ts +10 -2
- package/template/console/analytics/types.ts +7 -0
- package/template/console/appeals/AppealFollowup.tsx +171 -0
- package/template/console/appeals/AppealWorkspace.tsx +33 -4
- package/template/console/appeals/RecordAppealDialog.tsx +42 -0
- package/template/console/appeals/appeal-corrections.css +29 -0
- package/template/console/appeals/controller.ts +40 -14
- package/template/console/appeals/events.ts +1 -0
- package/template/console/appeals/types.ts +6 -0
- package/template/console/appeals/useAppealDraftGuard.ts +12 -0
- package/template/console/assistant/AssistantWorkspace.tsx +28 -5
- package/template/console/command/CommandCentre.tsx +101 -64
- package/template/console/command/controller.ts +80 -70
- package/template/console/command/events.ts +1 -2
- package/template/console/command/presentation.ts +29 -0
- package/template/console/command/store.ts +4 -4
- package/template/console/command/types.ts +26 -21
- package/template/console/configuration/ConfigurationBuilders.tsx +101 -0
- package/template/console/configuration/ConfigurationDialog.tsx +28 -13
- package/template/console/configuration/ConfigurationWorkspace.tsx +21 -8
- package/template/console/configuration/types.ts +1 -1
- package/template/console/lib/priority.ts +13 -0
- package/template/console/operations/CaseEscalations.tsx +142 -0
- package/template/console/operations/OperationsWorkspace.tsx +2 -0
- package/template/console/operations/case-escalations.css +20 -0
- package/template/console/people/PeopleWorkspace.tsx +8 -4
- package/template/console/people/PersonAccessDialog.tsx +40 -0
- package/template/console/people/types.ts +1 -0
- package/template/console/profile/ProfileWorkspace.tsx +1 -1
- package/template/console/registry/IntegrationSetup.tsx +41 -0
- package/template/console/registry/IntegrationVerification.tsx +54 -0
- package/template/console/registry/RegistryDetailsDialog.tsx +2 -2
- package/template/console/registry/RegistryDialog.tsx +56 -27
- package/template/console/registry/RegistryWorkspace.tsx +10 -5
- package/template/console/registry/integration-verification.css +25 -0
- package/template/console/reports/CaseActions.tsx +81 -0
- package/template/console/reports/CaseContinuity.tsx +27 -0
- package/template/console/reports/CaseNotices.tsx +48 -0
- package/template/console/reports/CaseOwnership.tsx +78 -0
- package/template/console/reports/CaseReviewTasks.tsx +148 -0
- package/template/console/reports/EnrichmentFindings.tsx +148 -0
- package/template/console/reports/InvestigationBrief.tsx +29 -0
- package/template/console/reports/ReportDrawer.tsx +122 -67
- package/template/console/reports/ReportWorkspace.tsx +112 -23
- package/template/console/reports/controller.ts +125 -60
- package/template/console/reports/events.ts +9 -2
- package/template/console/reports/load-inbox.ts +17 -0
- package/template/console/reports/presence-presentation.ts +52 -0
- package/template/console/reports/private-drafts.ts +32 -0
- package/template/console/reports/store.ts +2 -2
- package/template/console/reports/types.ts +50 -2
- package/template/console/reports/use-editing-access.ts +13 -0
- package/template/console/settings/SettingsWorkspace.tsx +1 -1
- package/template/console/shell/WorkspaceShell.tsx +55 -29
- package/template/console/shell/WorkspaceUpdateNotice.tsx +48 -0
- package/template/console/shell/controller.ts +23 -16
- package/template/console/shell/navigation.ts +30 -39
- package/template/console/workflows/CreatePlaybookDialog.tsx +73 -0
- package/template/console/workflows/PlaybookExamples.tsx +160 -0
- package/template/console/workflows/PlaybookFields.tsx +109 -0
- package/template/console/workflows/PlaybookGuideEditor.tsx +125 -0
- package/template/console/workflows/PlaybookHistory.tsx +106 -0
- package/template/console/workflows/WorkflowDialogs.tsx +2 -1
- package/template/console/workflows/WorkflowStudio.tsx +108 -39
- package/template/console/workflows/WorkflowWorkspace.tsx +9 -7
- package/template/console/workflows/templates.ts +3 -3
- package/template/console/workflows/types.ts +11 -0
- package/template/migrations/0001_safest_resolve_baseline.sql +0 -1
- package/template/migrations/0002_playbook_publication_evidence.sql +36 -0
- package/template/migrations/0003_action_confirmation.sql +24 -0
- package/template/migrations/0004_notice_recovery.sql +22 -0
- package/template/migrations/0005_appeal_continuity.sql +17 -0
- package/template/migrations/0006_case_ownership.sql +23 -0
- package/template/migrations/0007_integration_verification.sql +21 -0
- package/template/migrations/0008_case_escalations.sql +135 -0
- package/template/migrations/0009_participant_continuation.sql +61 -0
- package/template/migrations/0010_appeal_corrections.sql +41 -0
- package/template/npm-shrinkwrap.json +8036 -0
- package/template/package.json +31 -22
- package/template/public/_headers +16 -1
- package/template/public/console/auth-shell.js +22151 -14847
- package/template/public/console/release.json +1 -0
- package/template/public/console/workspace-features.css +2 -0
- package/template/public/embed/embed.css +3 -0
- package/template/public/embed/embed.js +19 -1
- package/template/public/embed/index.html +2 -0
- package/template/public/index.html +9 -6
- package/template/public/participant.css +2 -0
- package/template/public/participant.html +13 -0
- package/template/public/participant.js +111 -0
- package/template/public/public-report.js +21 -1
- package/template/public/report/index.html +1 -1
- package/template/public/styles.css +599 -83
- package/template/public/workspace.css +124 -0
- package/template/scripts/reports-plan.mjs +1 -1
- package/template/shared/case-escalations.ts +31 -0
- package/template/shared/integration-progress.ts +27 -0
- package/template/shared/notice-inputs.ts +3 -0
- package/template/shared/playbook-resources.ts +14 -0
- package/template/shared/playbooks.ts +249 -0
- package/template/src/agent-executor.ts +25 -8
- package/template/src/ai-observability.ts +14 -13
- package/template/src/ai-registry-validation.ts +1 -1
- package/template/src/appeal-continuity.ts +75 -0
- package/template/src/appeal-correction-state.ts +46 -0
- package/template/src/appeal-corrections.ts +112 -0
- package/template/src/appeal-decisions.ts +85 -0
- package/template/src/assistant.ts +3 -4
- package/template/src/case-escalations.ts +223 -0
- package/template/src/case-investigator.ts +179 -0
- package/template/src/component-executor.ts +5 -4
- package/template/src/configuration-registry.ts +4 -1
- package/template/src/connection-oauth.ts +12 -3
- package/template/src/database-readiness.ts +14 -0
- package/template/src/human-tasks.ts +152 -55
- package/template/src/index.ts +191 -29
- package/template/src/installation-admin.ts +2 -1
- package/template/src/integration-progress.ts +45 -0
- package/template/src/integration-verification.ts +249 -0
- package/template/src/investigation-evidence.ts +95 -0
- package/template/src/investigator-fixture.ts +30 -0
- package/template/src/notice-delivery.ts +19 -0
- package/template/src/notice-recovery.ts +81 -0
- package/template/src/operator-scope.ts +31 -0
- package/template/src/participant-access.ts +163 -0
- package/template/src/participant-case.ts +87 -0
- package/template/src/participant-customer-access.ts +93 -0
- package/template/src/participant-recovery.ts +43 -0
- package/template/src/participant-routes.ts +67 -0
- package/template/src/platform-registry-validation.ts +25 -2
- package/template/src/platform-registry.ts +10 -7
- package/template/src/playbook-resources.ts +58 -0
- package/template/src/report-admin.ts +9 -2
- package/template/src/report-auth.ts +1 -28
- package/template/src/report-better-auth.ts +18 -6
- package/template/src/report-delivery.ts +149 -148
- package/template/src/report-email-inbound.ts +33 -22
- package/template/src/report-email-template.ts +5 -2
- package/template/src/report-email.ts +7 -30
- package/template/src/report-governance.ts +42 -45
- package/template/src/report-http.ts +37 -2
- package/template/src/report-operations.ts +30 -67
- package/template/src/report-operator-accounts.ts +7 -6
- package/template/src/report-ownership.ts +64 -0
- package/template/src/report-presence.ts +15 -9
- package/template/src/report-repository.ts +16 -7
- package/template/src/report-turnstile.ts +7 -3
- package/template/src/report-types.ts +1 -0
- package/template/src/report-workflow.ts +183 -249
- package/template/src/workflow-actions.ts +248 -156
- package/template/src/workflow-compiler.ts +63 -12
- package/template/src/workflow-dynamic-runtime.ts +20 -9
- package/template/src/workflow-effects.ts +117 -39
- package/template/src/workflow-event-transport.ts +16 -0
- package/template/src/workflow-expressions.ts +30 -0
- package/template/src/workflow-loader-code.ts +21 -0
- package/template/src/workflow-platform-types.ts +3 -0
- package/template/src/workflow-platform-validation.ts +23 -1
- package/template/src/workflow-repository.ts +131 -28
- package/template/src/workflow-runs.ts +61 -13
- package/template/src/workflow-simulator.ts +7 -4
- package/template/src/workspace-governance.ts +6 -4
- package/template/tsconfig.json +1 -0
- package/template/vite.console.config.ts +30 -2
- 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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
146
|
-
`).bind(
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
178
|
-
`).bind(
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
|
222
|
-
UPDATE
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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,
|
|
239
|
-
|
|
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<{
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
)
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
.
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
)
|
|
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.
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
}
|