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
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { reconcileAppealCompletion } from "./appeal-continuity";
|
|
2
|
+
import { correctionReviewerEligible } from "./appeal-correction-state";
|
|
3
|
+
import { workflowRunEventType } from "./workflow-event-transport";
|
|
1
4
|
import { canonicalJson } from "./audit";
|
|
2
5
|
import { BudgetControlError, reserveBudgets, settleBudgets } from "./budget-control";
|
|
3
6
|
import { ConnectionExecutionError, executeConnectionJson } from "./connection-egress";
|
|
@@ -35,6 +38,8 @@ interface ActionComponentRow {
|
|
|
35
38
|
interface ProposalRow {
|
|
36
39
|
id: string;
|
|
37
40
|
installationId: string;
|
|
41
|
+
integrationId: string;
|
|
42
|
+
appealId: string | null;
|
|
38
43
|
reportId: string;
|
|
39
44
|
runId: string;
|
|
40
45
|
stepRunId: string;
|
|
@@ -54,9 +59,15 @@ interface ProposalRow {
|
|
|
54
59
|
deliveryAttemptCount: number;
|
|
55
60
|
deliveryTerminal: number;
|
|
56
61
|
lastErrorCode: string | null;
|
|
62
|
+
acceptedAt: string | null;
|
|
63
|
+
confirmedAt: string | null;
|
|
64
|
+
confirmationDeadlineAt: string | null;
|
|
65
|
+
deliveryCycle: number;
|
|
66
|
+
cycleStartAttempt: number;
|
|
57
67
|
}
|
|
58
68
|
|
|
59
69
|
const ACTION_MAX_ATTEMPTS = 8;
|
|
70
|
+
const ACTION_CONFIRMATION_TIMEOUT_MS = 15 * 60_000;
|
|
60
71
|
|
|
61
72
|
export class RetryableActionProposalError extends Error {
|
|
62
73
|
constructor(readonly delaySeconds: number) {
|
|
@@ -147,6 +158,7 @@ export async function prepareWorkflowAction(
|
|
|
147
158
|
branchKey: string;
|
|
148
159
|
stepRunId: string;
|
|
149
160
|
authorityMode: WorkflowAuthorityMode;
|
|
161
|
+
awaitRecovery?: boolean;
|
|
150
162
|
},
|
|
151
163
|
): Promise<Record<string, unknown>> {
|
|
152
164
|
if (input.authorityMode === "shadow" || input.authorityMode === "off") throw new WorkflowEffectError("action_authority_insufficient");
|
|
@@ -170,14 +182,25 @@ export async function prepareWorkflowAction(
|
|
|
170
182
|
const proposalId = `${input.stepRunId}:proposal`;
|
|
171
183
|
const operationId = `action:${input.runId}:${input.node.id}:${input.branchKey || "main"}:${mapped.idempotency_key}`;
|
|
172
184
|
const existing = await env.DB.prepare(`
|
|
173
|
-
SELECT id, state, payload_digest AS payloadDigest,
|
|
185
|
+
SELECT id, state, payload_digest AS payloadDigest, preconditions_json AS preconditionsJson,
|
|
174
186
|
approval_policy_json AS approvalPolicyJson,
|
|
175
187
|
delivery_terminal AS deliveryTerminal, last_error_code AS lastErrorCode
|
|
176
188
|
FROM action_proposals WHERE operation_id = ?1 LIMIT 1
|
|
177
189
|
`).bind(operationId).first<{
|
|
178
|
-
id: string; state: string; payloadDigest: string; approvalPolicyJson: string;
|
|
190
|
+
id: string; state: string; payloadDigest: string; preconditionsJson: string; approvalPolicyJson: string;
|
|
179
191
|
deliveryTerminal: number; lastErrorCode: string | null;
|
|
180
192
|
}>();
|
|
193
|
+
if (typeof preconditions.decision_code === "string" && preconditions.latest_decision_id === undefined) {
|
|
194
|
+
if (existing) {
|
|
195
|
+
const pinnedDecision = parsedObject(existing.preconditionsJson).latest_decision_id;
|
|
196
|
+
if (pinnedDecision !== undefined) preconditions.latest_decision_id = pinnedDecision;
|
|
197
|
+
} else {
|
|
198
|
+
const decision = await env.DB.prepare(`SELECT id, decision_code AS code FROM decisions WHERE report_id = ?1 ORDER BY created_at DESC, rowid DESC LIMIT 1`)
|
|
199
|
+
.bind(context.reportId).first<{ id: string; code: string }>();
|
|
200
|
+
if (!decision || decision.code !== preconditions.decision_code) throw new WorkflowEffectError("action_precondition_changed");
|
|
201
|
+
preconditions.latest_decision_id = decision.id;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
181
204
|
const policy = approvalMode(input.node, component, input.authorityMode);
|
|
182
205
|
const expiresSeconds = Number(input.node.config.expiry_seconds ?? 86_400);
|
|
183
206
|
if (!Number.isInteger(expiresSeconds) || expiresSeconds < 60 || expiresSeconds > 2_678_400) throw new WorkflowEffectError("action_expiry_invalid");
|
|
@@ -202,6 +225,8 @@ export async function prepareWorkflowAction(
|
|
|
202
225
|
if (existing.state === "rejected") return { port: "rejected", proposalId: existing.id, payloadDigest, idempotentReplay: true };
|
|
203
226
|
if (existing.state === "cancelled") return { port: "timeout", proposalId: existing.id, payloadDigest, idempotentReplay: true };
|
|
204
227
|
if (existing.state === "failed" && existing.deliveryTerminal === 1) {
|
|
228
|
+
const expiry = parsedObject(existing.approvalPolicyJson).expires_at;
|
|
229
|
+
if (input.awaitRecovery && typeof expiry === "string" && expiry > now()) return { ...deliveryPending(existing.id, payloadDigest, expiry), recoveryRequired: true };
|
|
205
230
|
return { port: "error", proposalId: existing.id, payloadDigest, errorCode: existing.lastErrorCode ?? "action_delivery_failed", idempotentReplay: true };
|
|
206
231
|
}
|
|
207
232
|
if (existing.state === "approval_required") {
|
|
@@ -226,13 +251,14 @@ export async function prepareWorkflowAction(
|
|
|
226
251
|
id, installation_id, report_id, run_id, step_run_id, action_code,
|
|
227
252
|
target_json, payload_json, preconditions_json, payload_digest,
|
|
228
253
|
component_version_id, authority_mode, approval_policy_json, state,
|
|
229
|
-
operation_id, created_by_type, created_by_id, next_attempt_at, created_at, updated_at
|
|
230
|
-
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, 'system', ?4, ?17, ?16, ?16)
|
|
254
|
+
operation_id, created_by_type, created_by_id, next_attempt_at, created_at, updated_at, decision_id
|
|
255
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, 'system', ?4, ?17, ?16, ?16, (SELECT id FROM decisions WHERE id = ?18 AND report_id = ?3))
|
|
231
256
|
`).bind(
|
|
232
257
|
proposalId, context.installationId, context.reportId, input.runId, input.stepRunId,
|
|
233
258
|
actionCode, canonicalJson(target), canonicalJson(payload), canonicalJson(preconditions),
|
|
234
259
|
payloadDigest, componentVersionId, input.authorityMode, canonicalJson(approvalPolicy),
|
|
235
260
|
initialState, operationId, timestamp, policy === "human" ? null : timestamp,
|
|
261
|
+
typeof preconditions.latest_decision_id === "string" ? preconditions.latest_decision_id : null,
|
|
236
262
|
)];
|
|
237
263
|
if (policy === "human") {
|
|
238
264
|
const taskId = `${input.stepRunId}:action-approval-task`;
|
|
@@ -263,7 +289,7 @@ export async function prepareWorkflowAction(
|
|
|
263
289
|
|
|
264
290
|
async function loadProposal(db: D1Database, proposalId: string): Promise<ProposalRow> {
|
|
265
291
|
const proposal = await db.prepare(`
|
|
266
|
-
SELECT p.id, p.installation_id AS installationId, p.report_id AS reportId,
|
|
292
|
+
SELECT p.id, p.installation_id AS installationId, p.report_id AS reportId, p.appeal_id AS appealId,
|
|
267
293
|
p.run_id AS runId, p.step_run_id AS stepRunId, p.action_code AS actionCode,
|
|
268
294
|
p.target_json AS targetJson, p.payload_json AS payloadJson,
|
|
269
295
|
p.preconditions_json AS preconditionsJson, p.payload_digest AS payloadDigest,
|
|
@@ -271,7 +297,10 @@ async function loadProposal(db: D1Database, proposalId: string): Promise<Proposa
|
|
|
271
297
|
p.approval_policy_json AS approvalPolicyJson, p.state, p.operation_id AS operationId,
|
|
272
298
|
r.workflow_version_id AS workflowVersionId, rp.policy_version_id AS policyVersionId,
|
|
273
299
|
rp.queue_id AS queueId, p.delivery_attempt_count AS deliveryAttemptCount,
|
|
274
|
-
p.delivery_terminal AS deliveryTerminal, p.last_error_code AS lastErrorCode
|
|
300
|
+
p.delivery_terminal AS deliveryTerminal, p.last_error_code AS lastErrorCode,
|
|
301
|
+
rp.integration_id AS integrationId, p.accepted_at AS acceptedAt, p.confirmed_at AS confirmedAt,
|
|
302
|
+
p.confirmation_deadline_at AS confirmationDeadlineAt, p.delivery_cycle AS deliveryCycle,
|
|
303
|
+
p.cycle_start_attempt AS cycleStartAttempt
|
|
275
304
|
FROM action_proposals p JOIN workflow_runs r ON r.id = p.run_id
|
|
276
305
|
JOIN reports rp ON rp.id = p.report_id WHERE p.id = ?1 LIMIT 1
|
|
277
306
|
`).bind(proposalId).first<ProposalRow>();
|
|
@@ -283,24 +312,37 @@ async function validatePreconditions(db: D1Database, proposal: ProposalRow): Pro
|
|
|
283
312
|
const preconditions = parsedObject(proposal.preconditionsJson);
|
|
284
313
|
const report = await db.prepare(`
|
|
285
314
|
SELECT state, queue_id AS queueId,
|
|
286
|
-
(SELECT id FROM decisions WHERE report_id = reports.id ORDER BY created_at DESC,
|
|
315
|
+
(SELECT id FROM decisions WHERE report_id = reports.id ORDER BY created_at DESC, rowid DESC LIMIT 1) AS latestDecisionId,
|
|
316
|
+
(SELECT decision_code FROM decisions WHERE report_id = reports.id ORDER BY created_at DESC, rowid DESC LIMIT 1) AS latestDecisionCode,
|
|
287
317
|
(SELECT target_reference FROM report_targets WHERE report_id = reports.id ORDER BY created_at, id LIMIT 1) AS targetReference
|
|
288
318
|
FROM reports WHERE id = ?1 LIMIT 1
|
|
289
|
-
`).bind(proposal.reportId).first<{ state: string; queueId: string | null; latestDecisionId: string | null; targetReference: string | null }>();
|
|
319
|
+
`).bind(proposal.reportId).first<{ state: string; queueId: string | null; latestDecisionId: string | null; latestDecisionCode: string | null; targetReference: string | null }>();
|
|
290
320
|
if (!report) throw new WorkflowEffectError("action_report_missing");
|
|
321
|
+
if (proposal.appealId) {
|
|
322
|
+
const eligible=await db.prepare(`SELECT c.id FROM appeal_corrections c JOIN appeal_decisions ad ON ad.id=c.appeal_decision_id JOIN appeals a ON a.id=ad.appeal_id JOIN decisions d ON d.id=a.decision_id JOIN reports r ON r.id=c.report_id JOIN action_proposals original ON original.id=c.original_proposal_id JOIN operator_users u ON u.id=c.approved_by
|
|
323
|
+
WHERE c.proposal_id=?1 AND c.appeal_id=?2 AND c.mode='automated' AND c.integration_id=?3 AND r.integration_id=c.integration_id AND r.state='appealed' AND ad.completion_state='waiting' AND ad.correction_confirmed_at IS NULL AND ad.outcome IN ('modified','reversed') AND ad.rationale_redacted_at IS NULL
|
|
324
|
+
AND d.maker_id!=c.approved_by AND ${correctionReviewerEligible}
|
|
325
|
+
AND original.state='applied' AND original.confirmed_at IS NOT NULL AND original.operation_id=c.original_operation_id AND original.target_json=c.original_target_json AND original.target_json=?4
|
|
326
|
+
AND original.payload_digest=?5 AND ad.id=?6 AND a.decision_id=?7
|
|
327
|
+
AND NOT EXISTS(SELECT 1 FROM appeal_decisions newer JOIN appeals na ON na.id=newer.appeal_id WHERE na.report_id=r.id AND newer.created_at>ad.created_at AND newer.outcome='upheld')`)
|
|
328
|
+
.bind(proposal.id,proposal.appealId,proposal.integrationId,proposal.targetJson,preconditions.original_payload_digest??null,preconditions.appeal_decision_id??null,preconditions.latest_decision_id??null).first();
|
|
329
|
+
if(!eligible)throw new WorkflowEffectError('appeal_correction_precondition_changed');
|
|
330
|
+
} else if (["appealed", "closed"].includes(report.state)) throw new WorkflowEffectError("action_case_no_longer_eligible");
|
|
291
331
|
const checks: [JsonValue | undefined, unknown][] = [
|
|
292
332
|
[preconditions.report_state, report.state],
|
|
293
333
|
[preconditions.queue_id, report.queueId],
|
|
294
334
|
[preconditions.latest_decision_id, report.latestDecisionId],
|
|
335
|
+
[preconditions.decision_code, report.latestDecisionCode],
|
|
295
336
|
[preconditions.target_reference, report.targetReference],
|
|
296
337
|
];
|
|
297
338
|
if (checks.some(([expected, actual]) => expected !== undefined && expected !== actual)) throw new WorkflowEffectError("action_precondition_changed");
|
|
298
339
|
}
|
|
299
340
|
|
|
300
341
|
async function emitActionResolution(env: Env, proposal: ProposalRow): Promise<void> {
|
|
301
|
-
|
|
342
|
+
if(proposal.appealId){await reconcileAppealCompletion(env.DB,proposal.reportId);return;}
|
|
343
|
+
const eventId = `${proposal.id}:delivery-resolution${proposal.deliveryCycle > 1 ? `:${proposal.deliveryCycle}` : ""}`;
|
|
302
344
|
const eventType = `action.${proposal.id}.delivery-resolved`;
|
|
303
|
-
const eventKey = `action-delivery:${proposal.id}`;
|
|
345
|
+
const eventKey = `action-delivery:${proposal.id}:${proposal.deliveryCycle}`;
|
|
304
346
|
const payload = {
|
|
305
347
|
proposal_id: proposal.id,
|
|
306
348
|
state: proposal.state,
|
|
@@ -327,7 +369,7 @@ async function emitActionResolution(env: Env, proposal: ProposalRow): Promise<vo
|
|
|
327
369
|
.bind(proposal.runId).first<{ instanceId: string | null }>();
|
|
328
370
|
if (!run?.instanceId) return;
|
|
329
371
|
try {
|
|
330
|
-
await (await env.WORKFLOWS.get(run.instanceId)).sendEvent({ type: eventType, payload });
|
|
372
|
+
await (await env.WORKFLOWS.get(run.instanceId)).sendEvent({ type: await workflowRunEventType(env.DB, proposal.runId, eventType), payload });
|
|
331
373
|
await env.DB.batch([
|
|
332
374
|
env.DB.prepare(`UPDATE workflow_event_inbox SET delivered_at = ?2, delivery_attempts = delivery_attempts + 1, last_error_code = NULL WHERE id = ?1`).bind(eventId, now()),
|
|
333
375
|
env.DB.prepare(`UPDATE workflow_dispatch_outbox SET state = 'delivered', last_error_code = NULL, updated_at = ?2 WHERE id = ?1`).bind(eventId, now()),
|
|
@@ -343,8 +385,8 @@ async function emitActionResolution(env: Env, proposal: ProposalRow): Promise<vo
|
|
|
343
385
|
export async function pendingActionProposalJobs(db: D1Database, limit = 50): Promise<ActionProposalJob[]> {
|
|
344
386
|
const rows = await db.prepare(`
|
|
345
387
|
SELECT id, run_id AS runId FROM action_proposals
|
|
346
|
-
WHERE delivery_terminal = 0
|
|
347
|
-
AND ((state IN ('queued','failed') AND next_attempt_at <= ?1)
|
|
388
|
+
WHERE delivery_terminal = 0
|
|
389
|
+
AND ((state IN ('queued','failed') AND next_attempt_at <= ?1 AND (delivery_attempt_count - cycle_start_attempt < ?2 OR accepted_at IS NOT NULL))
|
|
348
390
|
OR (state = 'applying' AND lease_expires_at <= ?1))
|
|
349
391
|
ORDER BY next_attempt_at, id LIMIT ?3
|
|
350
392
|
`).bind(now(), ACTION_MAX_ATTEMPTS, Math.max(1, Math.min(100, limit))).all<{ id: string; runId: string }>();
|
|
@@ -354,120 +396,164 @@ export async function pendingActionProposalJobs(db: D1Database, limit = 50): Pro
|
|
|
354
396
|
export async function reconcileActionProposalEvents(env: Env, limit = 50): Promise<number> {
|
|
355
397
|
const rows = await env.DB.prepare(`
|
|
356
398
|
SELECT p.id FROM action_proposals p
|
|
357
|
-
WHERE (p.state = 'applied' OR (p.state = 'failed' AND p.delivery_terminal = 1))
|
|
358
|
-
AND NOT EXISTS (SELECT 1 FROM workflow_event_inbox e WHERE e.id = p.id || ':delivery-resolution')
|
|
399
|
+
WHERE p.appeal_id IS NULL AND (p.state = 'applied' OR (p.state = 'failed' AND p.delivery_terminal = 1))
|
|
400
|
+
AND NOT EXISTS (SELECT 1 FROM workflow_event_inbox e WHERE e.id = p.id || ':delivery-resolution' || CASE WHEN p.delivery_cycle > 1 THEN ':' || p.delivery_cycle ELSE '' END)
|
|
359
401
|
ORDER BY p.updated_at, p.id LIMIT ?1
|
|
360
402
|
`).bind(Math.max(1, Math.min(100, limit))).all<{ id: string }>();
|
|
361
403
|
for (const row of rows.results) await emitActionResolution(env, await loadProposal(env.DB, row.id));
|
|
362
404
|
return rows.results.length;
|
|
363
405
|
}
|
|
364
406
|
|
|
407
|
+
interface ActionReceipt {
|
|
408
|
+
action_id: string;
|
|
409
|
+
operation_id: string;
|
|
410
|
+
payload_digest: string;
|
|
411
|
+
status: "accepted" | "applied" | "failed";
|
|
412
|
+
terminal: boolean;
|
|
413
|
+
error_code: string | null;
|
|
414
|
+
retry_after_seconds: number;
|
|
415
|
+
provider_request_id: string | null;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function actionReceipt(value: Record<string, unknown>, proposal: ProposalRow): ActionReceipt {
|
|
419
|
+
if (value.action_id !== proposal.id || value.operation_id !== proposal.operationId || value.payload_digest !== proposal.payloadDigest) {
|
|
420
|
+
throw new WorkflowEffectError("action_result_identity_mismatch");
|
|
421
|
+
}
|
|
422
|
+
if (!["accepted", "applied", "failed"].includes(String(value.status))) throw new WorkflowEffectError("action_confirmation_required");
|
|
423
|
+
if (value.terminal !== undefined && typeof value.terminal !== "boolean") throw new WorkflowEffectError("action_result_invalid");
|
|
424
|
+
if (value.retry_after_seconds !== undefined && (!Number.isInteger(value.retry_after_seconds) || Number(value.retry_after_seconds) < 1 || Number(value.retry_after_seconds) > 21_600)) {
|
|
425
|
+
throw new WorkflowEffectError("action_result_invalid");
|
|
426
|
+
}
|
|
427
|
+
if (value.error_code !== undefined && (typeof value.error_code !== "string" || !/^[a-zA-Z0-9_.:-]{1,200}$/u.test(value.error_code))) throw new WorkflowEffectError("action_result_invalid");
|
|
428
|
+
if (value.provider_request_id !== undefined && (typeof value.provider_request_id !== "string" || value.provider_request_id.length > 500)) throw new WorkflowEffectError("action_result_invalid");
|
|
429
|
+
const status = value.status as ActionReceipt["status"];
|
|
430
|
+
return {
|
|
431
|
+
action_id: proposal.id, operation_id: proposal.operationId, payload_digest: proposal.payloadDigest, status,
|
|
432
|
+
terminal: status === "applied" || (status === "failed" && value.terminal !== false),
|
|
433
|
+
error_code: status === "failed" ? String(value.error_code ?? "action_provider_failed") : null,
|
|
434
|
+
retry_after_seconds: Number(value.retry_after_seconds ?? 30),
|
|
435
|
+
provider_request_id: typeof value.provider_request_id === "string" ? value.provider_request_id : null,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async function saveActionReceipt(
|
|
440
|
+
env: Env,
|
|
441
|
+
proposal: ProposalRow,
|
|
442
|
+
receipt: ActionReceipt,
|
|
443
|
+
actor: { type: "system" | "service_identity"; id: string },
|
|
444
|
+
attempt?: { number: number; leaseExpiresAt: string; connectionAttemptId: string; responseDigest: string },
|
|
445
|
+
): Promise<{ proposal: ProposalRow; changed: boolean }> {
|
|
446
|
+
const timestamp = now();
|
|
447
|
+
const expiry = parsedObject(proposal.approvalPolicyJson).expires_at;
|
|
448
|
+
if (typeof expiry !== "string") throw new WorkflowEffectError("action_approval_expiry_missing");
|
|
449
|
+
const deadline = proposal.confirmationDeadlineAt ?? new Date(Math.min(Date.parse(expiry), Date.now() + ACTION_CONFIRMATION_TIMEOUT_MS)).toISOString();
|
|
450
|
+
const terminal = receipt.terminal || (receipt.status === "failed" && proposal.deliveryAttemptCount - proposal.cycleStartAttempt >= ACTION_MAX_ATTEMPTS);
|
|
451
|
+
const state = receipt.status === "accepted" ? "queued" : receipt.status;
|
|
452
|
+
const nextAttemptAt = receipt.status === "accepted" ? deadline : new Date(Date.now() + receipt.retry_after_seconds * 1_000).toISOString();
|
|
453
|
+
const receiptJson = canonicalJson(receipt);
|
|
454
|
+
const auditKey = `action-result:${proposal.operationId}:${proposal.deliveryCycle}:${await sha256(receiptJson)}`;
|
|
455
|
+
const result = await env.DB.batch([
|
|
456
|
+
env.DB.prepare(`UPDATE action_proposals SET state = ?2, delivery_terminal = ?3,
|
|
457
|
+
accepted_at = CASE WHEN ?2 = 'queued' THEN COALESCE(accepted_at, ?4) WHEN ?2 = 'failed' AND ?3 = 0 THEN NULL ELSE accepted_at END,
|
|
458
|
+
confirmed_at = CASE WHEN ?2 = 'applied' THEN ?4 ELSE confirmed_at END,
|
|
459
|
+
confirmation_deadline_at = CASE WHEN ?2 = 'queued' THEN ?5 WHEN ?2 = 'failed' AND ?3 = 0 THEN NULL ELSE confirmation_deadline_at END,
|
|
460
|
+
last_receipt_json = ?6, next_attempt_at = ?7, lease_expires_at = NULL, last_error_code = ?8, updated_at = ?4
|
|
461
|
+
WHERE id = ?1 AND delivery_terminal = 0 AND state IN ('queued','applying','failed')
|
|
462
|
+
AND delivery_attempt_count > cycle_start_attempt AND delivery_cycle = ?9
|
|
463
|
+
AND (?10 IS NULL OR (state = 'applying' AND delivery_attempt_count = ?10 AND lease_expires_at = ?11))
|
|
464
|
+
AND EXISTS (SELECT 1 FROM reports WHERE id = action_proposals.report_id AND integration_id = ?12)
|
|
465
|
+
AND (appeal_id IS NULL OR (json_extract(approval_policy_json,'$.expires_at') > MAX(?4,strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
466
|
+
AND EXISTS(SELECT 1 FROM appeal_corrections c JOIN appeal_decisions ad ON ad.id=c.appeal_decision_id JOIN appeals a ON a.id=ad.appeal_id JOIN reports r ON r.id=c.report_id JOIN action_proposals original ON original.id=c.original_proposal_id
|
|
467
|
+
WHERE c.proposal_id=action_proposals.id AND c.mode='automated' AND c.integration_id=?12 AND c.original_target_json=action_proposals.target_json AND c.appeal_id=action_proposals.appeal_id AND ad.completion_state='waiting' AND ad.correction_confirmed_at IS NULL AND r.state='appealed'
|
|
468
|
+
AND original.operation_id=c.original_operation_id AND original.target_json=c.original_target_json AND original.state='applied' AND original.confirmed_at IS NOT NULL
|
|
469
|
+
AND a.decision_id=(SELECT id FROM decisions WHERE report_id=r.id ORDER BY created_at DESC,rowid DESC LIMIT 1)
|
|
470
|
+
AND ad.id=json_extract(action_proposals.preconditions_json,'$.appeal_decision_id')
|
|
471
|
+
AND NOT EXISTS(SELECT 1 FROM appeal_decisions newer JOIN appeals na ON na.id=newer.appeal_id WHERE na.report_id=r.id AND newer.created_at>ad.created_at AND newer.outcome='upheld'))))` )
|
|
472
|
+
.bind(proposal.id, state, terminal ? 1 : 0, timestamp, deadline, receiptJson, nextAttemptAt, receipt.error_code,
|
|
473
|
+
proposal.deliveryCycle, attempt?.number ?? null, attempt?.leaseExpiresAt ?? null, proposal.integrationId),
|
|
474
|
+
env.DB.prepare(`INSERT OR IGNORE INTO audit_events (id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at)
|
|
475
|
+
SELECT ?1, ?2, ?3, ?4, ?5, 'action_proposal', ?6, ?7, ?8 WHERE changes() = 1`)
|
|
476
|
+
.bind(crypto.randomUUID(), auditKey, receipt.status === "applied" ? "workflow.action_applied" : "workflow.action_result_received",
|
|
477
|
+
actor.type, actor.id, proposal.id, canonicalJson({ ...receipt, terminal, deliveryCycle: proposal.deliveryCycle,
|
|
478
|
+
...(attempt ? { connectionAttemptId: attempt.connectionAttemptId, responseDigest: attempt.responseDigest } : {}) }), timestamp),
|
|
479
|
+
]);
|
|
480
|
+
return { proposal: await loadProposal(env.DB, proposal.id), changed: result[0]?.meta.changes === 1 };
|
|
481
|
+
}
|
|
482
|
+
|
|
365
483
|
export async function processActionProposalJob(env: Env, job: ActionProposalJob): Promise<void> {
|
|
366
484
|
const timestamp = now();
|
|
367
|
-
|
|
485
|
+
// A connection may run for up to 120 seconds. The action lease outlives it.
|
|
486
|
+
const leaseExpiresAt = new Date(Date.now() + 150_000).toISOString();
|
|
368
487
|
const claimed = await env.DB.prepare(`
|
|
369
488
|
UPDATE action_proposals SET state = 'applying',
|
|
370
|
-
delivery_attempt_count = delivery_attempt_count + 1,
|
|
489
|
+
delivery_attempt_count = delivery_attempt_count + CASE WHEN accepted_at IS NULL AND delivery_attempt_count - cycle_start_attempt < ?5 THEN 1 ELSE 0 END,
|
|
490
|
+
last_error_code = CASE WHEN accepted_at IS NULL AND delivery_attempt_count - cycle_start_attempt >= ?5 THEN 'action_delivery_attempts_exhausted' ELSE last_error_code END,
|
|
371
491
|
lease_expires_at = ?3, updated_at = ?2
|
|
372
492
|
WHERE id = ?1 AND run_id = ?4 AND delivery_terminal = 0
|
|
373
|
-
AND ((state IN ('queued','failed') AND next_attempt_at <= ?2)
|
|
493
|
+
AND ((state IN ('queued','failed') AND next_attempt_at <= ?2 AND (delivery_attempt_count - cycle_start_attempt < ?5 OR accepted_at IS NOT NULL))
|
|
374
494
|
OR (state = 'applying' AND lease_expires_at <= ?2))
|
|
375
495
|
RETURNING id
|
|
376
|
-
`).bind(job.proposalId, timestamp, leaseExpiresAt, job.runId).first();
|
|
496
|
+
`).bind(job.proposalId, timestamp, leaseExpiresAt, job.runId, ACTION_MAX_ATTEMPTS).first();
|
|
497
|
+
// Duplicate or premature queue deliveries are acknowledged. D1 reconciliation
|
|
498
|
+
// owns the next due attempt; repeatedly retrying here can exhaust the queue.
|
|
499
|
+
if (!claimed) return;
|
|
377
500
|
let proposal = await loadProposal(env.DB, job.proposalId);
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
throw new RetryableActionProposalError(5);
|
|
381
|
-
}
|
|
501
|
+
const claimedAttempt = proposal.deliveryAttemptCount;
|
|
502
|
+
const claimedCycle = proposal.deliveryCycle;
|
|
382
503
|
const approvalPolicy = parsedObject(proposal.approvalPolicyJson);
|
|
383
504
|
let reservations: Awaited<ReturnType<typeof reserveBudgets>> = [];
|
|
505
|
+
let requestSent = false;
|
|
384
506
|
try {
|
|
385
|
-
if (
|
|
386
|
-
|
|
387
|
-
|
|
507
|
+
if (proposal.acceptedAt) throw new WorkflowEffectError("action_confirmation_timed_out");
|
|
508
|
+
if (proposal.lastErrorCode === "action_delivery_attempts_exhausted") throw new WorkflowEffectError("action_delivery_attempts_exhausted");
|
|
509
|
+
if (typeof approvalPolicy.expires_at !== "string" || approvalPolicy.expires_at <= timestamp) throw new WorkflowEffectError("action_approval_expired");
|
|
388
510
|
await validatePreconditions(env.DB, proposal);
|
|
511
|
+
if (approvalPolicy.mode === "human") {
|
|
512
|
+
const approval = await env.DB.prepare(`SELECT id FROM action_approvals WHERE proposal_id = ?1 AND decision = 'approved' AND payload_digest = ?2 LIMIT 1`)
|
|
513
|
+
.bind(proposal.id, proposal.payloadDigest).first();
|
|
514
|
+
if (!approval) throw new WorkflowEffectError("action_approval_missing");
|
|
515
|
+
}
|
|
389
516
|
const component = await actionComponent(env.DB, proposal.componentVersionId);
|
|
390
|
-
const reference = /^(?:(
|
|
517
|
+
const reference = /^(?:(POST|PUT|PATCH|DELETE)\s+)?(\/[^\s]*)$/u.exec(component.implementationReference.trim());
|
|
391
518
|
if (!reference || !component.connectionVersionId) throw new WorkflowEffectError("action_component_operation_invalid");
|
|
392
519
|
reservations = await reserveBudgets(env.DB, {
|
|
393
520
|
installationId: proposal.installationId,
|
|
394
|
-
scopes: [
|
|
395
|
-
{ type: "
|
|
396
|
-
|
|
397
|
-
...(proposal.queueId ? [{ type: "queue", id: proposal.queueId }] : []),
|
|
398
|
-
],
|
|
399
|
-
metric: "actions", amount: 1, operationType: "action_proposal",
|
|
400
|
-
operationId: proposal.operationId, attemptId: String(proposal.deliveryAttemptCount),
|
|
521
|
+
scopes: [{ type: "workflow", id: proposal.workflowVersionId }, { type: "component", id: proposal.componentVersionId },
|
|
522
|
+
...(proposal.queueId ? [{ type: "queue", id: proposal.queueId }] : [])],
|
|
523
|
+
metric: "actions", amount: 1, operationType: "action_proposal", operationId: proposal.operationId, attemptId: String(claimedAttempt),
|
|
401
524
|
});
|
|
525
|
+
requestSent = true;
|
|
402
526
|
const result = await executeConnectionJson(env, {
|
|
403
|
-
connectionVersionId: component.connectionVersionId,
|
|
404
|
-
|
|
405
|
-
operationId: proposal.operationId,
|
|
406
|
-
attemptNumber: proposal.deliveryAttemptCount,
|
|
407
|
-
method: reference[1] ?? "POST",
|
|
408
|
-
path: reference[2]!,
|
|
527
|
+
connectionVersionId: component.connectionVersionId, actionProposalId: proposal.id,
|
|
528
|
+
operationId: proposal.operationId, attemptNumber: claimedAttempt, method: reference[1] ?? "POST", path: reference[2]!,
|
|
409
529
|
body: {
|
|
410
|
-
schema_version: "1",
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
action_code: proposal.actionCode,
|
|
415
|
-
target: parsedObject(proposal.targetJson),
|
|
416
|
-
payload: parsedObject(proposal.payloadJson),
|
|
417
|
-
preconditions: parsedObject(proposal.preconditionsJson),
|
|
418
|
-
workflow_version_id: proposal.workflowVersionId,
|
|
419
|
-
policy_version_id: proposal.policyVersionId,
|
|
530
|
+
schema_version: "1", action_id: proposal.id, operation_id: proposal.operationId, payload_digest: proposal.payloadDigest,
|
|
531
|
+
action_code: proposal.actionCode, target: parsedObject(proposal.targetJson), payload: parsedObject(proposal.payloadJson),
|
|
532
|
+
preconditions: parsedObject(proposal.preconditionsJson), workflow_version_id: proposal.workflowVersionId,
|
|
533
|
+
policy_version_id: proposal.policyVersionId, expires_at: approvalPolicy.expires_at,
|
|
420
534
|
},
|
|
421
535
|
});
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
env.DB.prepare(`
|
|
428
|
-
INSERT OR IGNORE INTO audit_events (
|
|
429
|
-
id, idempotency_key, action, actor_type, actor_id,
|
|
430
|
-
target_type, target_id, details_json, created_at
|
|
431
|
-
) VALUES (?1, ?2, 'workflow.action_applied', 'system', ?3,
|
|
432
|
-
'action_proposal', ?4, ?5, ?6)
|
|
433
|
-
`).bind(crypto.randomUUID(), `workflow-action-applied:${proposal.operationId}`, proposal.runId, proposal.id, canonicalJson({
|
|
434
|
-
actionCode: proposal.actionCode, payloadDigest: proposal.payloadDigest,
|
|
435
|
-
connectionAttemptId: result.connectionAttemptId, responseDigest: result.responseDigest,
|
|
436
|
-
}), finishedAt),
|
|
437
|
-
]);
|
|
438
|
-
await settleBudgets(env.DB, reservations, true).catch(() => {});
|
|
439
|
-
proposal = await loadProposal(env.DB, proposal.id);
|
|
440
|
-
await emitActionResolution(env, proposal);
|
|
536
|
+
if (validateJsonSchemaValue(result.data as JsonValue, parsedObject(component.outputSchemaJson)).length) throw new WorkflowEffectError("action_response_schema_invalid");
|
|
537
|
+
const receipt = actionReceipt(result.data, proposal);
|
|
538
|
+
proposal = (await saveActionReceipt(env, proposal, receipt, { type: "system", id: proposal.runId }, {
|
|
539
|
+
number: claimedAttempt, leaseExpiresAt, connectionAttemptId: result.connectionAttemptId, responseDigest: result.responseDigest,
|
|
540
|
+
})).proposal;
|
|
441
541
|
} catch (error) {
|
|
442
|
-
const failure = error instanceof ConnectionExecutionError
|
|
542
|
+
const failure = error instanceof ConnectionExecutionError || error instanceof BudgetControlError
|
|
443
543
|
? new WorkflowEffectError(error.code, error.retryable, error.delaySeconds)
|
|
444
|
-
: error instanceof
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
await env.DB.prepare(`
|
|
452
|
-
UPDATE action_proposals SET state = 'failed', delivery_terminal = ?2,
|
|
453
|
-
next_attempt_at = ?3, lease_expires_at = NULL, last_error_code = ?4, updated_at = ?5
|
|
454
|
-
WHERE id = ?1 AND state = 'applying'
|
|
455
|
-
`).bind(
|
|
456
|
-
proposal.id, terminal ? 1 : 0,
|
|
457
|
-
new Date(Date.now() + delaySeconds * 1_000).toISOString(), failure.code, now(),
|
|
458
|
-
).run();
|
|
459
|
-
await settleBudgets(env.DB, reservations, false);
|
|
544
|
+
: error instanceof WorkflowEffectError ? error : new WorkflowEffectError("action_delivery_failed", true, 15);
|
|
545
|
+
const terminal = !failure.retryable || claimedAttempt - proposal.cycleStartAttempt >= ACTION_MAX_ATTEMPTS;
|
|
546
|
+
const delaySeconds = Math.max(1, Math.min(21_600, failure.delaySeconds * (2 ** Math.min(claimedAttempt - proposal.cycleStartAttempt - 1, 7))));
|
|
547
|
+
await env.DB.prepare(`UPDATE action_proposals SET state = 'failed', delivery_terminal = ?2,
|
|
548
|
+
next_attempt_at = ?3, lease_expires_at = NULL, last_error_code = ?4, updated_at = ?5
|
|
549
|
+
WHERE id = ?1 AND state = 'applying' AND delivery_terminal = 0 AND delivery_attempt_count = ?6 AND delivery_cycle = ?7 AND lease_expires_at = ?8`)
|
|
550
|
+
.bind(proposal.id, terminal ? 1 : 0, new Date(Date.now() + delaySeconds * 1_000).toISOString(), failure.code, now(), claimedAttempt, claimedCycle, leaseExpiresAt).run();
|
|
460
551
|
proposal = await loadProposal(env.DB, proposal.id);
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
}
|
|
465
|
-
if (terminal) {
|
|
466
|
-
await emitActionResolution(env, proposal);
|
|
467
|
-
return;
|
|
468
|
-
}
|
|
469
|
-
throw new RetryableActionProposalError(delaySeconds);
|
|
552
|
+
} finally {
|
|
553
|
+
// A request can have reached the customer even if its response was lost.
|
|
554
|
+
await settleBudgets(env.DB, reservations, requestSent);
|
|
470
555
|
}
|
|
556
|
+
if (proposal.state === "applied" || proposal.deliveryTerminal === 1) await emitActionResolution(env, proposal);
|
|
471
557
|
}
|
|
472
558
|
|
|
473
559
|
export async function acceptActionResult(
|
|
@@ -476,69 +562,56 @@ export async function acceptActionResult(
|
|
|
476
562
|
integrationId: string,
|
|
477
563
|
): Promise<{ proposalId: string; state: string; idempotentReplay: boolean }> {
|
|
478
564
|
const proposalId = typeof value.action_id === "string" ? value.action_id : "";
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
const status = value.status;
|
|
482
|
-
if (!proposalId || proposalId.length > 500 || !operationId || operationId.length > 500
|
|
483
|
-
|| !/^sha256:[a-f0-9]{64}$/u.test(payloadDigest)
|
|
484
|
-
|| (status !== "applied" && status !== "failed")) {
|
|
485
|
-
throw new ApiError(400, "action_result_invalid", "action_id, operation_id, payload_digest, and a valid status are required.");
|
|
565
|
+
if (!proposalId || proposalId.length > 500 || !["applied", "failed"].includes(String(value.status))) {
|
|
566
|
+
throw new ApiError(400, "action_result_invalid", "An action identity and an applied or failed result are required.");
|
|
486
567
|
}
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
568
|
+
// Scope the lookup itself: another integration cannot probe a case or replay
|
|
569
|
+
// a valid receipt learned elsewhere.
|
|
570
|
+
const owned = await env.DB.prepare(`SELECT p.id FROM action_proposals p JOIN reports r ON r.id = p.report_id
|
|
571
|
+
WHERE p.id = ?1 AND r.integration_id = ?2 LIMIT 1`).bind(proposalId, integrationId).first();
|
|
572
|
+
if (!owned) throw new ApiError(404, "action_result_unavailable", "This action is not available to this integration.");
|
|
573
|
+
const proposal = await loadProposal(env.DB, proposalId);
|
|
574
|
+
let receipt: ActionReceipt;
|
|
575
|
+
try { receipt = actionReceipt(value, proposal); }
|
|
576
|
+
catch (error) {
|
|
577
|
+
throw new ApiError(error instanceof WorkflowEffectError && error.code === "action_result_identity_mismatch" ? 409 : 400,
|
|
578
|
+
error instanceof WorkflowEffectError ? error.code : "action_result_invalid", "The result must match the immutable action operation and payload.");
|
|
490
579
|
}
|
|
491
|
-
if (proposal.state ===
|
|
492
|
-
if (
|
|
493
|
-
|
|
494
|
-
throw new ApiError(409, "action_result_terminal", "The action proposal is not awaiting an asynchronous result.");
|
|
580
|
+
if (proposal.state === receipt.status && proposal.deliveryTerminal === 1) return { proposalId, state: proposal.state, idempotentReplay: true };
|
|
581
|
+
if (!["queued", "applying", "failed"].includes(proposal.state) || proposal.deliveryTerminal === 1 || proposal.deliveryAttemptCount <= proposal.cycleStartAttempt) {
|
|
582
|
+
throw new ApiError(409, "action_result_terminal", "The action is not awaiting a customer result.");
|
|
495
583
|
}
|
|
496
|
-
const
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
const nextAttemptAt = new Date(Date.now() + Math.max(1, Math.min(21_600, Number(value.retry_after_seconds) || 30)) * 1_000).toISOString();
|
|
501
|
-
await env.DB.batch([
|
|
502
|
-
env.DB.prepare(`
|
|
503
|
-
UPDATE action_proposals SET state = ?2, delivery_terminal = ?3,
|
|
504
|
-
next_attempt_at = ?4, lease_expires_at = NULL, last_error_code = ?5,
|
|
505
|
-
updated_at = ?6 WHERE id = ?1 AND delivery_terminal = 0
|
|
506
|
-
`).bind(
|
|
507
|
-
proposalId, status === "applied" ? "applied" : "failed",
|
|
508
|
-
status === "applied" || terminal ? 1 : 0, nextAttemptAt,
|
|
509
|
-
status === "applied" ? null : errorCode, timestamp,
|
|
510
|
-
),
|
|
511
|
-
env.DB.prepare(`
|
|
512
|
-
INSERT OR IGNORE INTO audit_events (
|
|
513
|
-
id, idempotency_key, action, actor_type, actor_id,
|
|
514
|
-
target_type, target_id, details_json, created_at
|
|
515
|
-
) VALUES (?1, ?2, 'workflow.action_result_received', 'service_identity', ?3,
|
|
516
|
-
'action_proposal', ?4, ?5, ?6)
|
|
517
|
-
`).bind(crypto.randomUUID(), `action-result:${operationId}:${status}`, integrationId, proposalId, canonicalJson({
|
|
518
|
-
status, payloadDigest, terminal: status === "applied" || terminal,
|
|
519
|
-
providerRequestId: typeof value.provider_request_id === "string" ? value.provider_request_id.slice(0, 500) : null,
|
|
520
|
-
errorCode: status === "failed" ? errorCode : null,
|
|
521
|
-
}), timestamp),
|
|
522
|
-
]);
|
|
523
|
-
proposal = await loadProposal(env.DB, proposalId);
|
|
524
|
-
if (proposal.state === "applied" || proposal.deliveryTerminal === 1) await emitActionResolution(env, proposal);
|
|
525
|
-
else await env.DELIVERY_JOBS.send(actionJob(proposal.id, proposal.runId), { contentType: "json" }).catch(() => {});
|
|
526
|
-
return { proposalId, state: proposal.state, idempotentReplay: false };
|
|
584
|
+
const saved = await saveActionReceipt(env, proposal, receipt, { type: "service_identity", id: integrationId });
|
|
585
|
+
if (!saved.changed && saved.proposal.state !== receipt.status) throw new ApiError(409, "action_result_conflict", "Another result or operator action was recorded first. Refresh the action before retrying.");
|
|
586
|
+
if (saved.proposal.state === "applied" || saved.proposal.deliveryTerminal === 1) await emitActionResolution(env, saved.proposal);
|
|
587
|
+
return { proposalId, state: saved.proposal.state, idempotentReplay: !saved.changed };
|
|
527
588
|
}
|
|
528
589
|
|
|
529
590
|
export async function completeWorkflowAction(
|
|
530
591
|
env: Env,
|
|
531
|
-
input: { proposalId: string; eventResult: { status: "received"; event: unknown } | { status: "timed_out" } },
|
|
592
|
+
input: { proposalId: string; awaitRecovery?: boolean; eventResult: { status: "received"; event: unknown } | { status: "timed_out" } },
|
|
532
593
|
): Promise<Record<string, unknown>> {
|
|
533
|
-
|
|
594
|
+
let proposal = await loadProposal(env.DB, input.proposalId);
|
|
534
595
|
if (proposal.state === "applied") return { port: "applied", proposalId: proposal.id, payloadDigest: proposal.payloadDigest };
|
|
535
596
|
if (proposal.state === "rejected") return { port: "rejected", proposalId: proposal.id, payloadDigest: proposal.payloadDigest };
|
|
536
597
|
if (proposal.state === "failed" && proposal.deliveryTerminal === 1) {
|
|
598
|
+
const expiry = parsedObject(proposal.approvalPolicyJson).expires_at;
|
|
599
|
+
if (input.awaitRecovery && input.eventResult.status !== "timed_out" && typeof expiry === "string" && expiry > now()) {
|
|
600
|
+
return { ...deliveryPending(proposal.id, proposal.payloadDigest, expiry), recoveryRequired: true, errorCode: proposal.lastErrorCode };
|
|
601
|
+
}
|
|
537
602
|
return { port: "error", proposalId: proposal.id, payloadDigest: proposal.payloadDigest, errorCode: proposal.lastErrorCode ?? "action_delivery_failed" };
|
|
538
603
|
}
|
|
539
604
|
if (input.eventResult.status === "timed_out") {
|
|
540
|
-
await env.DB.
|
|
541
|
-
.
|
|
605
|
+
await env.DB.batch([
|
|
606
|
+
env.DB.prepare(`UPDATE action_proposals SET state = CASE WHEN accepted_at IS NOT NULL OR state = 'applying' THEN 'failed' ELSE 'cancelled' END,
|
|
607
|
+
delivery_terminal = 1, lease_expires_at = NULL, last_error_code = 'action_confirmation_timed_out', updated_at = ?2
|
|
608
|
+
WHERE id = ?1 AND state IN ('approval_required','approved','queued','applying','failed') AND delivery_terminal = 0`)
|
|
609
|
+
.bind(proposal.id, now()),
|
|
610
|
+
env.DB.prepare(`UPDATE human_tasks SET state = 'cancelled', updated_at = ?2 WHERE action_proposal_id = ?1 AND state IN ('open','claimed')
|
|
611
|
+
AND EXISTS (SELECT 1 FROM action_proposals WHERE id = ?1 AND state IN ('cancelled','failed'))`).bind(proposal.id, now()),
|
|
612
|
+
]);
|
|
613
|
+
proposal = await loadProposal(env.DB, proposal.id);
|
|
614
|
+
if (proposal.state === "applied") return { port: "applied", proposalId: proposal.id, payloadDigest: proposal.payloadDigest };
|
|
542
615
|
return { port: "timeout", proposalId: proposal.id, payloadDigest: proposal.payloadDigest };
|
|
543
616
|
}
|
|
544
617
|
if (["queued", "applying", "failed"].includes(proposal.state)) {
|
|
@@ -571,6 +644,8 @@ export async function loadActionProposalDetail(db: D1Database, proposalId: strin
|
|
|
571
644
|
p.authority_mode AS authorityMode, p.approval_policy_json AS approvalPolicyJson,
|
|
572
645
|
p.state, p.operation_id AS operationId, p.compensates_proposal_id AS compensatesProposalId,
|
|
573
646
|
p.delivery_attempt_count AS deliveryAttemptCount, p.delivery_terminal AS deliveryTerminal,
|
|
647
|
+
p.accepted_at AS acceptedAt, p.confirmed_at AS confirmedAt, p.confirmation_deadline_at AS confirmationDeadlineAt,
|
|
648
|
+
p.delivery_cycle AS deliveryCycle, p.last_receipt_json AS lastReceiptJson,
|
|
574
649
|
p.next_attempt_at AS nextAttemptAt, p.last_error_code AS lastErrorCode,
|
|
575
650
|
p.created_by_type AS createdByType, p.created_by_id AS createdById,
|
|
576
651
|
p.created_at AS createdAt, p.updated_at AS updatedAt
|
|
@@ -586,6 +661,8 @@ export async function loadActionProposalDetail(db: D1Database, proposalId: strin
|
|
|
586
661
|
return {
|
|
587
662
|
proposal: {
|
|
588
663
|
...proposal,
|
|
664
|
+
receipt: typeof proposal.lastReceiptJson === "string" ? JSON.parse(proposal.lastReceiptJson) : null,
|
|
665
|
+
lastReceiptJson: undefined,
|
|
589
666
|
target: typeof proposal.targetJson === "string" ? JSON.parse(proposal.targetJson) : {},
|
|
590
667
|
payload: typeof proposal.payloadJson === "string" ? JSON.parse(proposal.payloadJson) : {},
|
|
591
668
|
preconditions: typeof proposal.preconditionsJson === "string" ? JSON.parse(proposal.preconditionsJson) : {},
|
|
@@ -628,7 +705,7 @@ async function emitApprovalEvent(
|
|
|
628
705
|
.bind(proposal.runId).first<{ instanceId: string | null }>();
|
|
629
706
|
if (!instance?.instanceId) return;
|
|
630
707
|
try {
|
|
631
|
-
await (await env.WORKFLOWS.get(instance.instanceId)).sendEvent({ type: eventType, payload });
|
|
708
|
+
await (await env.WORKFLOWS.get(instance.instanceId)).sendEvent({ type: await workflowRunEventType(env.DB, proposal.runId, eventType), payload });
|
|
632
709
|
const deliveredAt = now();
|
|
633
710
|
await env.DB.batch([
|
|
634
711
|
env.DB.prepare(`UPDATE workflow_event_inbox SET delivered_at = ?2, delivery_attempts = delivery_attempts + 1, last_error_code = NULL WHERE id = ?1`).bind(approvalId, deliveredAt),
|
|
@@ -663,7 +740,7 @@ export async function decideActionProposal(
|
|
|
663
740
|
}
|
|
664
741
|
const approvalId = crypto.randomUUID();
|
|
665
742
|
const timestamp = now();
|
|
666
|
-
await env.DB.batch([
|
|
743
|
+
try { await env.DB.batch([
|
|
667
744
|
env.DB.prepare(`
|
|
668
745
|
INSERT INTO action_approvals (
|
|
669
746
|
id, proposal_id, approver_id, payload_digest, decision,
|
|
@@ -687,7 +764,10 @@ export async function decideActionProposal(
|
|
|
687
764
|
session.actor.type, session.actor.id, proposalId,
|
|
688
765
|
canonicalJson({ payloadDigest: proposal.payloadDigest, reason: input.reason?.trim() ?? null }), timestamp,
|
|
689
766
|
),
|
|
690
|
-
]);
|
|
767
|
+
]); } catch (error) {
|
|
768
|
+
if (String(error).includes("action_approval_state_changed")) throw new ApiError(409, "action_approval_changed", "The proposal changed or its authorization expired. Refresh it before deciding.");
|
|
769
|
+
throw error;
|
|
770
|
+
}
|
|
691
771
|
const updated = await loadProposal(env.DB, proposalId);
|
|
692
772
|
await emitApprovalEvent(env, updated, approvalId, input.decision, session.actor.id);
|
|
693
773
|
return { proposalId, approvalId, state: input.decision, idempotentReplay: false };
|
|
@@ -701,22 +781,34 @@ export async function retryActionProposal(
|
|
|
701
781
|
idempotencyKey: string,
|
|
702
782
|
): Promise<{ proposalId: string; state: string; idempotentReplay: boolean }> {
|
|
703
783
|
const key = `action-retry:${idempotencyKey}`;
|
|
704
|
-
const prior = await env.DB.prepare(`SELECT target_id AS targetId FROM audit_events WHERE idempotency_key = ?1 AND action = 'action.retry_requested' LIMIT 1`)
|
|
705
|
-
.bind(key).first<{ targetId: string }>();
|
|
784
|
+
const prior = await env.DB.prepare(`SELECT target_id AS targetId, actor_id AS actorId FROM audit_events WHERE idempotency_key = ?1 AND action = 'action.retry_requested' LIMIT 1`)
|
|
785
|
+
.bind(key).first<{ targetId: string; actorId: string }>();
|
|
706
786
|
if (prior) {
|
|
707
|
-
if (prior.targetId !== proposalId) throw new ApiError(409, "idempotency_key_reused", "This Idempotency-Key was already used for another action retry.");
|
|
708
|
-
|
|
787
|
+
if (prior.targetId !== proposalId || prior.actorId !== session.actor.id) throw new ApiError(409, "idempotency_key_reused", "This Idempotency-Key was already used for another action retry.");
|
|
788
|
+
const saved = await loadProposal(env.DB, proposalId);
|
|
789
|
+
if (saved.payloadDigest !== payloadDigest) throw new ApiError(409, "action_retry_digest_mismatch", "Retry requires the original payload digest.");
|
|
790
|
+
return { proposalId, state: saved.state, idempotentReplay: true };
|
|
709
791
|
}
|
|
710
792
|
const proposal = await loadProposal(env.DB, proposalId);
|
|
711
793
|
if (proposal.payloadDigest !== payloadDigest) throw new ApiError(409, "action_retry_digest_mismatch", "Retry requires the exact immutable payload digest.");
|
|
712
794
|
if (proposal.state !== "failed" || proposal.deliveryTerminal !== 1) throw new ApiError(409, "action_not_retryable", "Only a terminal failed action can be retried.");
|
|
713
795
|
const timestamp = now();
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
796
|
+
const policy = parsedObject(proposal.approvalPolicyJson);
|
|
797
|
+
if (typeof policy.expires_at !== "string" || policy.expires_at <= timestamp) throw new ApiError(409, "action_approval_expired", "The action authorization expired. Review and authorize a new proposal before sending it.");
|
|
798
|
+
await validatePreconditions(env.DB, proposal);
|
|
799
|
+
const result = await env.DB.batch([
|
|
800
|
+
env.DB.prepare(`UPDATE action_proposals SET state = 'queued', delivery_terminal = 0,
|
|
801
|
+
cycle_start_attempt = delivery_attempt_count, delivery_cycle = delivery_cycle + 1,
|
|
802
|
+
accepted_at = NULL, confirmation_deadline_at = NULL, next_attempt_at = ?2, lease_expires_at = NULL, last_error_code = NULL, updated_at = ?2
|
|
803
|
+
WHERE id = ?1 AND state = 'failed' AND delivery_terminal = 1 AND delivery_cycle = ?3
|
|
804
|
+
AND json_extract(approval_policy_json, '$.expires_at') > ?2
|
|
805
|
+
AND (appeal_id IS NOT NULL OR EXISTS (SELECT 1 FROM workflow_runs WHERE id = action_proposals.run_id AND state NOT IN ('terminated','cancelled')))`)
|
|
806
|
+
.bind(proposalId, timestamp, proposal.deliveryCycle),
|
|
807
|
+
env.DB.prepare(`INSERT INTO audit_events (id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at)
|
|
808
|
+
SELECT ?1, ?2, 'action.retry_requested', ?3, ?4, 'action_proposal', ?5, ?6, ?7 WHERE changes() = 1`)
|
|
809
|
+
.bind(crypto.randomUUID(), key, session.actor.type, session.actor.id, proposalId, canonicalJson({ payloadDigest, deliveryCycle: proposal.deliveryCycle + 1 }), timestamp),
|
|
719
810
|
]);
|
|
811
|
+
if (result[0]?.meta.changes !== 1) throw new ApiError(409, "action_retry_conflict", "The action changed before the retry was saved. Refresh it and try again.");
|
|
720
812
|
await env.DELIVERY_JOBS.send(actionJob(proposalId, proposal.runId), { contentType: "json" }).catch(() => {});
|
|
721
813
|
return { proposalId, state: "queued", idempotentReplay: false };
|
|
722
814
|
}
|