create-safest-tools 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +71 -0
- package/bin/create-safest-tools.mjs +12 -0
- package/package.json +34 -0
- package/src/cli.mjs +128 -0
- package/src/config.mjs +151 -0
- package/src/scaffold.mjs +30 -0
- package/template/LICENSE +201 -0
- package/template/README.md +35 -0
- package/template/console/README.md +28 -0
- package/template/console/analytics/AnalyticsWorkspace.tsx +55 -0
- package/template/console/analytics/controller.ts +133 -0
- package/template/console/analytics/events.ts +33 -0
- package/template/console/analytics/store.ts +33 -0
- package/template/console/analytics/types.ts +38 -0
- package/template/console/appeals/AppealWorkspace.tsx +73 -0
- package/template/console/appeals/controller.ts +125 -0
- package/template/console/appeals/events.ts +33 -0
- package/template/console/appeals/store.ts +35 -0
- package/template/console/appeals/types.ts +37 -0
- package/template/console/assistant/AssistantWorkspace.tsx +133 -0
- package/template/console/assistant/types.ts +34 -0
- package/template/console/auth/AccountJourney.tsx +69 -0
- package/template/console/auth/InvitationAcceptance.tsx +145 -0
- package/template/console/auth/OAuthButtons.tsx +78 -0
- package/template/console/auth/OperatorLogin.tsx +110 -0
- package/template/console/auth/PasswordRecovery.tsx +132 -0
- package/template/console/auth/account-route.ts +15 -0
- package/template/console/auth/browser-navigation.ts +7 -0
- package/template/console/auth/events.ts +15 -0
- package/template/console/command/CommandCentre.tsx +119 -0
- package/template/console/command/controller.ts +267 -0
- package/template/console/command/events.ts +19 -0
- package/template/console/command/store.ts +36 -0
- package/template/console/command/types.ts +61 -0
- package/template/console/components/AnalystIdentity.tsx +25 -0
- package/template/console/components/PageHeader.tsx +27 -0
- package/template/console/configuration/ConfigurationDialog.tsx +200 -0
- package/template/console/configuration/ConfigurationWorkspace.tsx +66 -0
- package/template/console/configuration/FormBuilder.tsx +143 -0
- package/template/console/configuration/api.ts +40 -0
- package/template/console/configuration/events.ts +14 -0
- package/template/console/configuration/types.ts +79 -0
- package/template/console/lib/format.ts +49 -0
- package/template/console/lib/http.ts +96 -0
- package/template/console/main.tsx +225 -0
- package/template/console/operations/OperationsWorkspace.tsx +126 -0
- package/template/console/operations/controller.ts +237 -0
- package/template/console/operations/events.ts +37 -0
- package/template/console/operations/store.ts +32 -0
- package/template/console/operations/types.ts +107 -0
- package/template/console/people/PeopleWorkspace.tsx +81 -0
- package/template/console/people/types.ts +25 -0
- package/template/console/profile/ProfileWorkspace.tsx +136 -0
- package/template/console/profile/events.ts +9 -0
- package/template/console/profile/types.ts +6 -0
- package/template/console/quality/QualityWorkspace.tsx +87 -0
- package/template/console/quality/controller.ts +154 -0
- package/template/console/quality/events.ts +34 -0
- package/template/console/quality/store.ts +36 -0
- package/template/console/quality/types.ts +70 -0
- package/template/console/queues/QueueEditor.tsx +173 -0
- package/template/console/queues/QueueWorkspace.tsx +109 -0
- package/template/console/queues/controller.ts +194 -0
- package/template/console/queues/events.ts +34 -0
- package/template/console/queues/store.ts +37 -0
- package/template/console/queues/types.ts +102 -0
- package/template/console/registry/RegistryDialog.tsx +198 -0
- package/template/console/registry/RegistryWorkspace.tsx +108 -0
- package/template/console/registry/events.ts +14 -0
- package/template/console/registry/types.ts +52 -0
- package/template/console/reports/ReportDrawer.tsx +159 -0
- package/template/console/reports/ReportWorkspace.tsx +90 -0
- package/template/console/reports/controller.ts +517 -0
- package/template/console/reports/events.ts +42 -0
- package/template/console/reports/store.ts +39 -0
- package/template/console/reports/types.ts +230 -0
- package/template/console/settings/BrandEditor.tsx +126 -0
- package/template/console/settings/ChannelDialog.tsx +241 -0
- package/template/console/settings/SettingsWorkspace.tsx +155 -0
- package/template/console/settings/events.ts +19 -0
- package/template/console/settings/handoff.ts +22 -0
- package/template/console/settings/types.ts +93 -0
- package/template/console/shell/WorkspaceShell.tsx +160 -0
- package/template/console/shell/controller.ts +182 -0
- package/template/console/shell/events.ts +25 -0
- package/template/console/shell/navigation.ts +61 -0
- package/template/console/shell/store.ts +56 -0
- package/template/console/shell/types.ts +86 -0
- package/template/console/workflows/CreateWorkflowDialog.tsx +87 -0
- package/template/console/workflows/WorkflowCanvas.tsx +42 -0
- package/template/console/workflows/WorkflowDialogs.tsx +6 -0
- package/template/console/workflows/WorkflowStudio.tsx +331 -0
- package/template/console/workflows/WorkflowWorkspace.tsx +82 -0
- package/template/console/workflows/events.ts +17 -0
- package/template/console/workflows/graph.ts +156 -0
- package/template/console/workflows/templates.ts +70 -0
- package/template/console/workflows/types.ts +201 -0
- package/template/gitignore.template +18 -0
- package/template/migrations/0001_reports_foundation.sql +444 -0
- package/template/migrations/0002_human_report_loop.sql +65 -0
- package/template/migrations/0003_delivery_reliability.sql +18 -0
- package/template/migrations/0004_public_intake.sql +14 -0
- package/template/migrations/0005_operations_visibility.sql +24 -0
- package/template/migrations/0006_ai_governance.sql +198 -0
- package/template/migrations/0007_ai_release_gates.sql +6 -0
- package/template/migrations/0008_retention_analytics_exports.sql +52 -0
- package/template/migrations/0009_retention_derived_copies.sql +14 -0
- package/template/migrations/0010_ai_quality_controls.sql +26 -0
- package/template/migrations/0011_analyst_presence.sql +28 -0
- package/template/migrations/0012_queue_policies.sql +87 -0
- package/template/migrations/0013_routing_agents.sql +63 -0
- package/template/migrations/0014_webhook_enrichments.sql +122 -0
- package/template/migrations/0015_queue_owned_ai.sql +55 -0
- package/template/migrations/0016_operator_accounts.sql +67 -0
- package/template/migrations/0017_operator_profiles_and_recovery.sql +33 -0
- package/template/migrations/0018_platform_configuration.sql +382 -0
- package/template/migrations/0019_workflow_authoring_runtime.sql +372 -0
- package/template/migrations/0020_tasks_findings_assistant_budgets.sql +427 -0
- package/template/migrations/0021_abuse_evidence_operations.sql +324 -0
- package/template/migrations/0022_workflow_dispatch_operations.sql +42 -0
- package/template/migrations/0023_component_connection_execution.sql +75 -0
- package/template/migrations/0024_access_runtime_integrity.sql +72 -0
- package/template/migrations/0025_installation_timezone.sql +11 -0
- package/template/migrations/0026_ai_and_egress_execution_controls.sql +49 -0
- package/template/migrations/0027_prompt_and_ai_registry.sql +37 -0
- package/template/migrations/0028_step_attempt_ai_provenance.sql +13 -0
- package/template/migrations/0029_evidence_fetch_transport.sql +5 -0
- package/template/migrations/0030_evidence_dlq_incidents.sql +45 -0
- package/template/migrations/0031_shadow_quality_integrity.sql +7 -0
- package/template/migrations/0032_action_delivery_outbox.sql +55 -0
- package/template/migrations/0033_configuration_and_assistant_drafts.sql +43 -0
- package/template/migrations/0034_installation_integrations.sql +31 -0
- package/template/migrations/0035_workspace_governance.sql +21 -0
- package/template/migrations/0036_published_routing_baseline.sql +15 -0
- package/template/migrations/0037_builtin_phishing_specialist.sql +71 -0
- package/template/migrations/0038_remove_deprecated_enrichment_runtime.sql +228 -0
- package/template/migrations/0039_secure_reporting_channels.sql +45 -0
- package/template/migrations/0040_notification_only_reporting.sql +24 -0
- package/template/migrations/0041_better_auth_credentials.sql +17 -0
- package/template/package.json +47 -0
- package/template/public/_headers +27 -0
- package/template/public/app-icon-192.png +0 -0
- package/template/public/app-icon-512.png +0 -0
- package/template/public/brand-icon.svg +7 -0
- package/template/public/brand-tokens.css +80 -0
- package/template/public/console/auth-shell.js +19340 -0
- package/template/public/customer-brand.js +58 -0
- package/template/public/embed/embed.css +139 -0
- package/template/public/embed/embed.js +408 -0
- package/template/public/embed/index.html +91 -0
- package/template/public/favicon.svg +7 -0
- package/template/public/fonts/Manrope-Variable.ttf +0 -0
- package/template/public/fonts/Newsreader-Italic-Variable.ttf +0 -0
- package/template/public/fonts/Newsreader-Variable.ttf +0 -0
- package/template/public/index.html +123 -0
- package/template/public/logo-primary.svg +7 -0
- package/template/public/logo-reversed.svg +7 -0
- package/template/public/manifest.webmanifest +21 -0
- package/template/public/public-report.js +184 -0
- package/template/public/report/index.html +39 -0
- package/template/public/report/public-report.css +20 -0
- package/template/public/social-card.png +0 -0
- package/template/public/styles.css +1521 -0
- package/template/public/widget.css +80 -0
- package/template/public/widget.js +220 -0
- package/template/reports.config.example.json +38 -0
- package/template/reports.schema.json +89 -0
- package/template/scripts/reports-auth-onboarding.mjs +180 -0
- package/template/scripts/reports-backup.mjs +276 -0
- package/template/scripts/reports-cloudflare-preflight.mjs +152 -0
- package/template/scripts/reports-deploy.mjs +173 -0
- package/template/scripts/reports-plan.mjs +226 -0
- package/template/scripts/reports-restore.mjs +180 -0
- package/template/scripts/reports-secrets.mjs +73 -0
- package/template/scripts/reports-uninstall-plan.mjs +32 -0
- package/template/src/agent-executor.ts +330 -0
- package/template/src/ai-observability.ts +163 -0
- package/template/src/ai-registry-validation.ts +244 -0
- package/template/src/ai-registry.ts +292 -0
- package/template/src/api-cursor.ts +84 -0
- package/template/src/assistant.ts +554 -0
- package/template/src/audit.ts +39 -0
- package/template/src/backup-service.ts +269 -0
- package/template/src/budget-control.ts +127 -0
- package/template/src/component-executor.ts +845 -0
- package/template/src/configuration-registry.ts +550 -0
- package/template/src/connection-egress.ts +430 -0
- package/template/src/connection-oauth.ts +368 -0
- package/template/src/evidence-service.ts +335 -0
- package/template/src/human-tasks.ts +316 -0
- package/template/src/index.ts +4114 -0
- package/template/src/installation-admin.ts +301 -0
- package/template/src/intake-abuse.ts +156 -0
- package/template/src/platform-registry-validation.ts +367 -0
- package/template/src/platform-registry.ts +753 -0
- package/template/src/report-admin.ts +342 -0
- package/template/src/report-ai-quality.ts +390 -0
- package/template/src/report-ai-validation.ts +80 -0
- package/template/src/report-ai.ts +857 -0
- package/template/src/report-auth.ts +292 -0
- package/template/src/report-better-auth.ts +583 -0
- package/template/src/report-context-schema.ts +132 -0
- package/template/src/report-context.ts +126 -0
- package/template/src/report-crypto.ts +142 -0
- package/template/src/report-delivery.ts +579 -0
- package/template/src/report-form-validation.ts +51 -0
- package/template/src/report-governance.ts +510 -0
- package/template/src/report-http.ts +90 -0
- package/template/src/report-operations.ts +685 -0
- package/template/src/report-operator-accounts.ts +810 -0
- package/template/src/report-presence.ts +398 -0
- package/template/src/report-queue-validation.ts +309 -0
- package/template/src/report-queues.ts +478 -0
- package/template/src/report-repository.ts +972 -0
- package/template/src/report-router-agent.ts +328 -0
- package/template/src/report-router-validation.ts +81 -0
- package/template/src/report-routing.ts +178 -0
- package/template/src/report-turnstile.ts +142 -0
- package/template/src/report-types.ts +224 -0
- package/template/src/report-validation.ts +279 -0
- package/template/src/report-workflow-validation.ts +119 -0
- package/template/src/report-workflow.ts +886 -0
- package/template/src/shadow-quality.ts +278 -0
- package/template/src/workflow-actions.ts +782 -0
- package/template/src/workflow-compiler.ts +230 -0
- package/template/src/workflow-dynamic-runtime.ts +613 -0
- package/template/src/workflow-effects.ts +316 -0
- package/template/src/workflow-expressions.ts +191 -0
- package/template/src/workflow-platform-types.ts +121 -0
- package/template/src/workflow-platform-validation.ts +540 -0
- package/template/src/workflow-repository.ts +916 -0
- package/template/src/workflow-runs.ts +686 -0
- package/template/src/workflow-simulator.ts +211 -0
- package/template/src/workspace-branding.ts +128 -0
- package/template/src/workspace-governance.ts +279 -0
- package/template/tsconfig.console.json +23 -0
- package/template/tsconfig.json +22 -0
- package/template/vite.console.config.ts +20 -0
- package/template/worker-configuration.d.ts +65 -0
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
import { canonicalJson } from "./audit";
|
|
2
|
+
import { BudgetControlError, reserveBudgets, settleBudgets } from "./budget-control";
|
|
3
|
+
import { ConnectionExecutionError, executeConnectionJson } from "./connection-egress";
|
|
4
|
+
import { loadWorkflowExecutionContext } from "./component-executor";
|
|
5
|
+
import { sha256 } from "./report-crypto";
|
|
6
|
+
import { ApiError } from "./report-http";
|
|
7
|
+
import { evaluateInputMapping, validateJsonSchemaValue } from "./workflow-expressions";
|
|
8
|
+
import { WorkflowEffectError } from "./workflow-effects";
|
|
9
|
+
import type { ActionProposalJob, OperatorSession } from "./report-types";
|
|
10
|
+
import type { JsonObject, JsonValue, WorkflowAuthorityMode, WorkflowNodeV1 } from "./workflow-platform-types";
|
|
11
|
+
|
|
12
|
+
interface ActionContextRow {
|
|
13
|
+
installationId: string;
|
|
14
|
+
reportId: string;
|
|
15
|
+
workflowVersionId: string;
|
|
16
|
+
policyVersionId: string | null;
|
|
17
|
+
queueId: string | null;
|
|
18
|
+
reportState: string;
|
|
19
|
+
runCreatedAt: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ActionComponentRow {
|
|
23
|
+
id: string;
|
|
24
|
+
definitionStatus: string;
|
|
25
|
+
effectClass: string;
|
|
26
|
+
implementationKind: string;
|
|
27
|
+
implementationReference: string;
|
|
28
|
+
connectionVersionId: string | null;
|
|
29
|
+
inputSchemaJson: string;
|
|
30
|
+
outputSchemaJson: string;
|
|
31
|
+
approvalPolicyJson: string;
|
|
32
|
+
retiredAt: string | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface ProposalRow {
|
|
36
|
+
id: string;
|
|
37
|
+
installationId: string;
|
|
38
|
+
reportId: string;
|
|
39
|
+
runId: string;
|
|
40
|
+
stepRunId: string;
|
|
41
|
+
actionCode: string;
|
|
42
|
+
targetJson: string;
|
|
43
|
+
payloadJson: string;
|
|
44
|
+
preconditionsJson: string;
|
|
45
|
+
payloadDigest: string;
|
|
46
|
+
componentVersionId: string;
|
|
47
|
+
authorityMode: "assist" | "bounded_auto";
|
|
48
|
+
approvalPolicyJson: string;
|
|
49
|
+
state: string;
|
|
50
|
+
operationId: string;
|
|
51
|
+
workflowVersionId: string;
|
|
52
|
+
policyVersionId: string | null;
|
|
53
|
+
queueId: string | null;
|
|
54
|
+
deliveryAttemptCount: number;
|
|
55
|
+
deliveryTerminal: number;
|
|
56
|
+
lastErrorCode: string | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const ACTION_MAX_ATTEMPTS = 8;
|
|
60
|
+
|
|
61
|
+
export class RetryableActionProposalError extends Error {
|
|
62
|
+
constructor(readonly delaySeconds: number) {
|
|
63
|
+
super("The action proposal delivery is scheduled for retry.");
|
|
64
|
+
this.name = "RetryableActionProposalError";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function now(): string {
|
|
69
|
+
return new Date().toISOString();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function object(value: JsonValue | undefined, code: string): JsonObject {
|
|
73
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new WorkflowEffectError(code);
|
|
74
|
+
return value;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parsedObject(value: string): JsonObject {
|
|
78
|
+
try {
|
|
79
|
+
const parsed = JSON.parse(value) as JsonValue;
|
|
80
|
+
return object(parsed, "action_stored_json_invalid");
|
|
81
|
+
} catch {
|
|
82
|
+
return {};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function deliveryPending(proposalId: string, payloadDigest: string, expiresAt: string): Record<string, unknown> {
|
|
87
|
+
return {
|
|
88
|
+
status: "delivery_pending",
|
|
89
|
+
proposalId,
|
|
90
|
+
payloadDigest,
|
|
91
|
+
eventType: `action.${proposalId}.delivery-resolved`,
|
|
92
|
+
timeoutMs: Math.max(1_000, Date.parse(expiresAt) - Date.now()),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function actionJob(proposalId: string, runId: string): ActionProposalJob {
|
|
97
|
+
return { version: 1, type: "apply_action_proposal", jobId: `action-proposal:${proposalId}`, proposalId, runId };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function actionContext(db: D1Database, runId: string): Promise<ActionContextRow> {
|
|
101
|
+
const row = await db.prepare(`
|
|
102
|
+
SELECT wr.installation_id AS installationId, wr.report_id AS reportId,
|
|
103
|
+
wr.workflow_version_id AS workflowVersionId, r.policy_version_id AS policyVersionId,
|
|
104
|
+
r.queue_id AS queueId, r.state AS reportState, wr.created_at AS runCreatedAt
|
|
105
|
+
FROM workflow_runs wr JOIN reports r ON r.id = wr.report_id
|
|
106
|
+
WHERE wr.id = ?1 LIMIT 1
|
|
107
|
+
`).bind(runId).first<ActionContextRow>();
|
|
108
|
+
if (!row) throw new WorkflowEffectError("action_context_missing");
|
|
109
|
+
return row;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function actionComponent(db: D1Database, id: string): Promise<ActionComponentRow> {
|
|
113
|
+
const row = await db.prepare(`
|
|
114
|
+
SELECT v.id, d.status AS definitionStatus, d.effect_class AS effectClass,
|
|
115
|
+
v.implementation_kind AS implementationKind,
|
|
116
|
+
v.implementation_reference AS implementationReference,
|
|
117
|
+
v.connection_version_id AS connectionVersionId,
|
|
118
|
+
v.input_schema_json AS inputSchemaJson, v.output_schema_json AS outputSchemaJson,
|
|
119
|
+
v.approval_policy_json AS approvalPolicyJson, v.retired_at AS retiredAt
|
|
120
|
+
FROM component_versions v JOIN component_definitions d ON d.id = v.definition_id
|
|
121
|
+
WHERE v.id = ?1 LIMIT 1
|
|
122
|
+
`).bind(id).first<ActionComponentRow>();
|
|
123
|
+
if (!row || row.retiredAt || row.definitionStatus !== "active" || row.effectClass !== "side_effecting") {
|
|
124
|
+
throw new WorkflowEffectError("action_component_unavailable");
|
|
125
|
+
}
|
|
126
|
+
if (!["webhook", "external_api"].includes(row.implementationKind) || !row.connectionVersionId) {
|
|
127
|
+
throw new WorkflowEffectError("action_component_implementation_invalid");
|
|
128
|
+
}
|
|
129
|
+
return row;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function approvalMode(node: WorkflowNodeV1, component: ActionComponentRow, authorityMode: WorkflowAuthorityMode): "human" | "bounded_auto" {
|
|
133
|
+
const nodePolicy = object(node.config.approval_policy, "action_approval_policy_invalid");
|
|
134
|
+
const componentPolicy = parsedObject(component.approvalPolicyJson);
|
|
135
|
+
const requested = nodePolicy.mode;
|
|
136
|
+
if (requested !== "human" && requested !== "bounded_auto") throw new WorkflowEffectError("action_approval_policy_invalid");
|
|
137
|
+
if (componentPolicy.mode !== requested) throw new WorkflowEffectError("action_approval_policy_mismatch");
|
|
138
|
+
if (requested === "bounded_auto" && authorityMode !== "bounded_auto") throw new WorkflowEffectError("action_authority_insufficient");
|
|
139
|
+
return requested;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function prepareWorkflowAction(
|
|
143
|
+
env: Env,
|
|
144
|
+
input: {
|
|
145
|
+
runId: string;
|
|
146
|
+
node: WorkflowNodeV1;
|
|
147
|
+
branchKey: string;
|
|
148
|
+
stepRunId: string;
|
|
149
|
+
authorityMode: WorkflowAuthorityMode;
|
|
150
|
+
},
|
|
151
|
+
): Promise<Record<string, unknown>> {
|
|
152
|
+
if (input.authorityMode === "shadow" || input.authorityMode === "off") throw new WorkflowEffectError("action_authority_insufficient");
|
|
153
|
+
const componentVersionId = typeof input.node.config.component_version_id === "string" ? input.node.config.component_version_id : null;
|
|
154
|
+
const actionCode = typeof input.node.config.action_code === "string" ? input.node.config.action_code : null;
|
|
155
|
+
if (!componentVersionId || !actionCode) throw new WorkflowEffectError("action_configuration_invalid");
|
|
156
|
+
const [context, component, executionContext] = await Promise.all([
|
|
157
|
+
actionContext(env.DB, input.runId),
|
|
158
|
+
actionComponent(env.DB, componentVersionId),
|
|
159
|
+
loadWorkflowExecutionContext(env, input.runId, input.branchKey),
|
|
160
|
+
]);
|
|
161
|
+
const mapped = evaluateInputMapping(input.node.inputMapping, executionContext.context);
|
|
162
|
+
if (typeof mapped.idempotency_key !== "string" || mapped.idempotency_key.length < 8 || mapped.idempotency_key.length > 200) {
|
|
163
|
+
throw new WorkflowEffectError("action_idempotency_key_invalid");
|
|
164
|
+
}
|
|
165
|
+
const target = object(mapped.target, "action_target_invalid");
|
|
166
|
+
const payload = object(mapped.payload, "action_payload_invalid");
|
|
167
|
+
const preconditions = object(mapped.preconditions ?? {}, "action_preconditions_invalid");
|
|
168
|
+
const inputErrors = validateJsonSchemaValue(payload, parsedObject(component.inputSchemaJson));
|
|
169
|
+
if (inputErrors.length) throw new WorkflowEffectError("action_payload_schema_invalid");
|
|
170
|
+
const proposalId = `${input.stepRunId}:proposal`;
|
|
171
|
+
const operationId = `action:${input.runId}:${input.node.id}:${input.branchKey || "main"}:${mapped.idempotency_key}`;
|
|
172
|
+
const existing = await env.DB.prepare(`
|
|
173
|
+
SELECT id, state, payload_digest AS payloadDigest,
|
|
174
|
+
approval_policy_json AS approvalPolicyJson,
|
|
175
|
+
delivery_terminal AS deliveryTerminal, last_error_code AS lastErrorCode
|
|
176
|
+
FROM action_proposals WHERE operation_id = ?1 LIMIT 1
|
|
177
|
+
`).bind(operationId).first<{
|
|
178
|
+
id: string; state: string; payloadDigest: string; approvalPolicyJson: string;
|
|
179
|
+
deliveryTerminal: number; lastErrorCode: string | null;
|
|
180
|
+
}>();
|
|
181
|
+
const policy = approvalMode(input.node, component, input.authorityMode);
|
|
182
|
+
const expiresSeconds = Number(input.node.config.expiry_seconds ?? 86_400);
|
|
183
|
+
if (!Number.isInteger(expiresSeconds) || expiresSeconds < 60 || expiresSeconds > 2_678_400) throw new WorkflowEffectError("action_expiry_invalid");
|
|
184
|
+
const expiresAt = new Date(Date.parse(context.runCreatedAt) + expiresSeconds * 1_000).toISOString();
|
|
185
|
+
const digestMaterial = {
|
|
186
|
+
schema_version: "1",
|
|
187
|
+
action_id: proposalId,
|
|
188
|
+
action_code: actionCode,
|
|
189
|
+
target,
|
|
190
|
+
payload,
|
|
191
|
+
preconditions,
|
|
192
|
+
component_version_id: componentVersionId,
|
|
193
|
+
workflow_version_id: context.workflowVersionId,
|
|
194
|
+
policy_version_id: context.policyVersionId,
|
|
195
|
+
expires_at: expiresAt,
|
|
196
|
+
installation_id: context.installationId,
|
|
197
|
+
};
|
|
198
|
+
const payloadDigest = `sha256:${await sha256(canonicalJson(digestMaterial))}`;
|
|
199
|
+
if (existing) {
|
|
200
|
+
if (existing.payloadDigest !== payloadDigest) throw new WorkflowEffectError("action_operation_payload_changed");
|
|
201
|
+
if (existing.state === "applied") return { port: "applied", proposalId: existing.id, payloadDigest, idempotentReplay: true };
|
|
202
|
+
if (existing.state === "rejected") return { port: "rejected", proposalId: existing.id, payloadDigest, idempotentReplay: true };
|
|
203
|
+
if (existing.state === "cancelled") return { port: "timeout", proposalId: existing.id, payloadDigest, idempotentReplay: true };
|
|
204
|
+
if (existing.state === "failed" && existing.deliveryTerminal === 1) {
|
|
205
|
+
return { port: "error", proposalId: existing.id, payloadDigest, errorCode: existing.lastErrorCode ?? "action_delivery_failed", idempotentReplay: true };
|
|
206
|
+
}
|
|
207
|
+
if (existing.state === "approval_required") {
|
|
208
|
+
const task = await env.DB.prepare(`SELECT id, due_at AS dueAt FROM human_tasks WHERE action_proposal_id = ?1 LIMIT 1`).bind(existing.id).first<{ id: string; dueAt: string }>();
|
|
209
|
+
if (!task) throw new WorkflowEffectError("action_approval_task_missing");
|
|
210
|
+
return { status: "approval_required", proposalId: existing.id, payloadDigest, eventType: `action.${existing.id}.resolved`, timeoutMs: Math.max(1_000, Date.parse(task.dueAt) - Date.now()), taskId: task.id };
|
|
211
|
+
}
|
|
212
|
+
const storedPolicy = parsedObject(existing.approvalPolicyJson);
|
|
213
|
+
const storedExpiry = typeof storedPolicy.expires_at === "string" ? storedPolicy.expires_at : expiresAt;
|
|
214
|
+
if (existing.state === "approved") {
|
|
215
|
+
await env.DB.prepare(`UPDATE action_proposals SET state = 'queued', next_attempt_at = ?2, lease_expires_at = NULL, last_error_code = NULL, delivery_terminal = 0, updated_at = ?2 WHERE id = ?1 AND state = 'approved'`)
|
|
216
|
+
.bind(existing.id, now()).run();
|
|
217
|
+
}
|
|
218
|
+
await env.DELIVERY_JOBS.send(actionJob(existing.id, input.runId), { contentType: "json" }).catch(() => {});
|
|
219
|
+
return deliveryPending(existing.id, payloadDigest, storedExpiry);
|
|
220
|
+
}
|
|
221
|
+
const timestamp = now();
|
|
222
|
+
const approvalPolicy = { ...object(input.node.config.approval_policy, "action_approval_policy_invalid"), expires_at: expiresAt };
|
|
223
|
+
const initialState = policy === "human" ? "approval_required" : "queued";
|
|
224
|
+
const statements: D1PreparedStatement[] = [env.DB.prepare(`
|
|
225
|
+
INSERT INTO action_proposals (
|
|
226
|
+
id, installation_id, report_id, run_id, step_run_id, action_code,
|
|
227
|
+
target_json, payload_json, preconditions_json, payload_digest,
|
|
228
|
+
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)
|
|
231
|
+
`).bind(
|
|
232
|
+
proposalId, context.installationId, context.reportId, input.runId, input.stepRunId,
|
|
233
|
+
actionCode, canonicalJson(target), canonicalJson(payload), canonicalJson(preconditions),
|
|
234
|
+
payloadDigest, componentVersionId, input.authorityMode, canonicalJson(approvalPolicy),
|
|
235
|
+
initialState, operationId, timestamp, policy === "human" ? null : timestamp,
|
|
236
|
+
)];
|
|
237
|
+
if (policy === "human") {
|
|
238
|
+
const taskId = `${input.stepRunId}:action-approval-task`;
|
|
239
|
+
statements.push(env.DB.prepare(`
|
|
240
|
+
INSERT INTO human_tasks (
|
|
241
|
+
id, installation_id, report_id, run_id, step_run_id, task_type, state,
|
|
242
|
+
queue_id, context_json, context_digest, response_schema_json,
|
|
243
|
+
action_proposal_id, due_at, created_at, updated_at
|
|
244
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, 'approval', 'open', ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?12)
|
|
245
|
+
`).bind(
|
|
246
|
+
taskId, context.installationId, context.reportId, input.runId, input.stepRunId,
|
|
247
|
+
context.queueId, canonicalJson({ action_code: actionCode, target, payload, preconditions, payload_digest: payloadDigest }),
|
|
248
|
+
`sha256:${await sha256(canonicalJson({ action_code: actionCode, target, payload, preconditions, payload_digest: payloadDigest }))}`,
|
|
249
|
+
canonicalJson({
|
|
250
|
+
type: "object",
|
|
251
|
+
properties: { decision: { type: "string", enum: ["approved", "rejected"] }, reason: { type: "string", maxLength: 1_000 } },
|
|
252
|
+
required: ["decision"],
|
|
253
|
+
additionalProperties: false,
|
|
254
|
+
}), proposalId, expiresAt, timestamp,
|
|
255
|
+
));
|
|
256
|
+
await env.DB.batch(statements);
|
|
257
|
+
return { status: "approval_required", proposalId, payloadDigest, eventType: `action.${proposalId}.resolved`, timeoutMs: expiresSeconds * 1_000, taskId };
|
|
258
|
+
}
|
|
259
|
+
await env.DB.batch(statements);
|
|
260
|
+
await env.DELIVERY_JOBS.send(actionJob(proposalId, input.runId), { contentType: "json" }).catch(() => {});
|
|
261
|
+
return deliveryPending(proposalId, payloadDigest, expiresAt);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function loadProposal(db: D1Database, proposalId: string): Promise<ProposalRow> {
|
|
265
|
+
const proposal = await db.prepare(`
|
|
266
|
+
SELECT p.id, p.installation_id AS installationId, p.report_id AS reportId,
|
|
267
|
+
p.run_id AS runId, p.step_run_id AS stepRunId, p.action_code AS actionCode,
|
|
268
|
+
p.target_json AS targetJson, p.payload_json AS payloadJson,
|
|
269
|
+
p.preconditions_json AS preconditionsJson, p.payload_digest AS payloadDigest,
|
|
270
|
+
p.component_version_id AS componentVersionId, p.authority_mode AS authorityMode,
|
|
271
|
+
p.approval_policy_json AS approvalPolicyJson, p.state, p.operation_id AS operationId,
|
|
272
|
+
r.workflow_version_id AS workflowVersionId, rp.policy_version_id AS policyVersionId,
|
|
273
|
+
rp.queue_id AS queueId, p.delivery_attempt_count AS deliveryAttemptCount,
|
|
274
|
+
p.delivery_terminal AS deliveryTerminal, p.last_error_code AS lastErrorCode
|
|
275
|
+
FROM action_proposals p JOIN workflow_runs r ON r.id = p.run_id
|
|
276
|
+
JOIN reports rp ON rp.id = p.report_id WHERE p.id = ?1 LIMIT 1
|
|
277
|
+
`).bind(proposalId).first<ProposalRow>();
|
|
278
|
+
if (!proposal) throw new WorkflowEffectError("action_proposal_missing");
|
|
279
|
+
return proposal;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function validatePreconditions(db: D1Database, proposal: ProposalRow): Promise<void> {
|
|
283
|
+
const preconditions = parsedObject(proposal.preconditionsJson);
|
|
284
|
+
const report = await db.prepare(`
|
|
285
|
+
SELECT state, queue_id AS queueId,
|
|
286
|
+
(SELECT id FROM decisions WHERE report_id = reports.id ORDER BY created_at DESC, id DESC LIMIT 1) AS latestDecisionId,
|
|
287
|
+
(SELECT target_reference FROM report_targets WHERE report_id = reports.id ORDER BY created_at, id LIMIT 1) AS targetReference
|
|
288
|
+
FROM reports WHERE id = ?1 LIMIT 1
|
|
289
|
+
`).bind(proposal.reportId).first<{ state: string; queueId: string | null; latestDecisionId: string | null; targetReference: string | null }>();
|
|
290
|
+
if (!report) throw new WorkflowEffectError("action_report_missing");
|
|
291
|
+
const checks: [JsonValue | undefined, unknown][] = [
|
|
292
|
+
[preconditions.report_state, report.state],
|
|
293
|
+
[preconditions.queue_id, report.queueId],
|
|
294
|
+
[preconditions.latest_decision_id, report.latestDecisionId],
|
|
295
|
+
[preconditions.target_reference, report.targetReference],
|
|
296
|
+
];
|
|
297
|
+
if (checks.some(([expected, actual]) => expected !== undefined && expected !== actual)) throw new WorkflowEffectError("action_precondition_changed");
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function emitActionResolution(env: Env, proposal: ProposalRow): Promise<void> {
|
|
301
|
+
const eventId = `${proposal.id}:delivery-resolution`;
|
|
302
|
+
const eventType = `action.${proposal.id}.delivery-resolved`;
|
|
303
|
+
const eventKey = `action-delivery:${proposal.id}`;
|
|
304
|
+
const payload = {
|
|
305
|
+
proposal_id: proposal.id,
|
|
306
|
+
state: proposal.state,
|
|
307
|
+
payload_digest: proposal.payloadDigest,
|
|
308
|
+
error_code: proposal.lastErrorCode,
|
|
309
|
+
};
|
|
310
|
+
const payloadJson = canonicalJson(payload);
|
|
311
|
+
const timestamp = now();
|
|
312
|
+
await env.DB.batch([
|
|
313
|
+
env.DB.prepare(`
|
|
314
|
+
INSERT OR IGNORE INTO workflow_event_inbox (
|
|
315
|
+
id, run_id, event_type, event_key, source_type, source_id,
|
|
316
|
+
payload_json, payload_digest, received_at
|
|
317
|
+
) VALUES (?1, ?2, ?3, ?4, 'system', 'action-delivery-worker', ?5, ?6, ?7)
|
|
318
|
+
`).bind(eventId, proposal.runId, eventType, eventKey, payloadJson, `sha256:${await sha256(payloadJson)}`, timestamp),
|
|
319
|
+
env.DB.prepare(`
|
|
320
|
+
INSERT OR IGNORE INTO workflow_dispatch_outbox (
|
|
321
|
+
id, run_id, operation, event_inbox_id, operation_key,
|
|
322
|
+
state, available_at, created_at, updated_at
|
|
323
|
+
) VALUES (?1, ?2, 'event', ?1, ?3, 'pending', ?4, ?4, ?4)
|
|
324
|
+
`).bind(eventId, proposal.runId, `workflow-event:${proposal.runId}:${eventType}:${eventKey}`, timestamp),
|
|
325
|
+
]);
|
|
326
|
+
const run = await env.DB.prepare(`SELECT cloudflare_instance_id AS instanceId FROM workflow_runs WHERE id = ?1 LIMIT 1`)
|
|
327
|
+
.bind(proposal.runId).first<{ instanceId: string | null }>();
|
|
328
|
+
if (!run?.instanceId) return;
|
|
329
|
+
try {
|
|
330
|
+
await (await env.WORKFLOWS.get(run.instanceId)).sendEvent({ type: eventType, payload });
|
|
331
|
+
await env.DB.batch([
|
|
332
|
+
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
|
+
env.DB.prepare(`UPDATE workflow_dispatch_outbox SET state = 'delivered', last_error_code = NULL, updated_at = ?2 WHERE id = ?1`).bind(eventId, now()),
|
|
334
|
+
]);
|
|
335
|
+
} catch {
|
|
336
|
+
await env.DB.batch([
|
|
337
|
+
env.DB.prepare(`UPDATE workflow_event_inbox SET delivery_attempts = delivery_attempts + 1, last_error_code = 'platform_event_send_failed' WHERE id = ?1`).bind(eventId),
|
|
338
|
+
env.DB.prepare(`UPDATE workflow_dispatch_outbox SET state = 'failed', last_error_code = 'platform_event_send_failed', updated_at = ?2 WHERE id = ?1`).bind(eventId, now()),
|
|
339
|
+
]);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export async function pendingActionProposalJobs(db: D1Database, limit = 50): Promise<ActionProposalJob[]> {
|
|
344
|
+
const rows = await db.prepare(`
|
|
345
|
+
SELECT id, run_id AS runId FROM action_proposals
|
|
346
|
+
WHERE delivery_terminal = 0 AND delivery_attempt_count < ?2
|
|
347
|
+
AND ((state IN ('queued','failed') AND next_attempt_at <= ?1)
|
|
348
|
+
OR (state = 'applying' AND lease_expires_at <= ?1))
|
|
349
|
+
ORDER BY next_attempt_at, id LIMIT ?3
|
|
350
|
+
`).bind(now(), ACTION_MAX_ATTEMPTS, Math.max(1, Math.min(100, limit))).all<{ id: string; runId: string }>();
|
|
351
|
+
return rows.results.map((row) => actionJob(row.id, row.runId));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export async function reconcileActionProposalEvents(env: Env, limit = 50): Promise<number> {
|
|
355
|
+
const rows = await env.DB.prepare(`
|
|
356
|
+
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')
|
|
359
|
+
ORDER BY p.updated_at, p.id LIMIT ?1
|
|
360
|
+
`).bind(Math.max(1, Math.min(100, limit))).all<{ id: string }>();
|
|
361
|
+
for (const row of rows.results) await emitActionResolution(env, await loadProposal(env.DB, row.id));
|
|
362
|
+
return rows.results.length;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export async function processActionProposalJob(env: Env, job: ActionProposalJob): Promise<void> {
|
|
366
|
+
const timestamp = now();
|
|
367
|
+
const leaseExpiresAt = new Date(Date.now() + 60_000).toISOString();
|
|
368
|
+
const claimed = await env.DB.prepare(`
|
|
369
|
+
UPDATE action_proposals SET state = 'applying',
|
|
370
|
+
delivery_attempt_count = delivery_attempt_count + 1,
|
|
371
|
+
lease_expires_at = ?3, updated_at = ?2
|
|
372
|
+
WHERE id = ?1 AND run_id = ?4 AND delivery_terminal = 0
|
|
373
|
+
AND ((state IN ('queued','failed') AND next_attempt_at <= ?2)
|
|
374
|
+
OR (state = 'applying' AND lease_expires_at <= ?2))
|
|
375
|
+
RETURNING id
|
|
376
|
+
`).bind(job.proposalId, timestamp, leaseExpiresAt, job.runId).first();
|
|
377
|
+
let proposal = await loadProposal(env.DB, job.proposalId);
|
|
378
|
+
if (!claimed) {
|
|
379
|
+
if (proposal.state === "applied" || (proposal.state === "failed" && proposal.deliveryTerminal === 1)) return;
|
|
380
|
+
throw new RetryableActionProposalError(5);
|
|
381
|
+
}
|
|
382
|
+
const approvalPolicy = parsedObject(proposal.approvalPolicyJson);
|
|
383
|
+
let reservations: Awaited<ReturnType<typeof reserveBudgets>> = [];
|
|
384
|
+
try {
|
|
385
|
+
if (typeof approvalPolicy.expires_at !== "string" || approvalPolicy.expires_at <= timestamp) {
|
|
386
|
+
throw new WorkflowEffectError("action_approval_expired");
|
|
387
|
+
}
|
|
388
|
+
await validatePreconditions(env.DB, proposal);
|
|
389
|
+
const component = await actionComponent(env.DB, proposal.componentVersionId);
|
|
390
|
+
const reference = /^(?:(GET|POST|PUT|PATCH|DELETE|HEAD)\s+)?(\/[^\s]*)$/u.exec(component.implementationReference.trim());
|
|
391
|
+
if (!reference || !component.connectionVersionId) throw new WorkflowEffectError("action_component_operation_invalid");
|
|
392
|
+
reservations = await reserveBudgets(env.DB, {
|
|
393
|
+
installationId: proposal.installationId,
|
|
394
|
+
scopes: [
|
|
395
|
+
{ type: "workflow", id: proposal.workflowVersionId },
|
|
396
|
+
{ type: "component", id: proposal.componentVersionId },
|
|
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),
|
|
401
|
+
});
|
|
402
|
+
const result = await executeConnectionJson(env, {
|
|
403
|
+
connectionVersionId: component.connectionVersionId,
|
|
404
|
+
actionProposalId: proposal.id,
|
|
405
|
+
operationId: proposal.operationId,
|
|
406
|
+
attemptNumber: proposal.deliveryAttemptCount,
|
|
407
|
+
method: reference[1] ?? "POST",
|
|
408
|
+
path: reference[2]!,
|
|
409
|
+
body: {
|
|
410
|
+
schema_version: "1",
|
|
411
|
+
action_id: proposal.id,
|
|
412
|
+
operation_id: proposal.operationId,
|
|
413
|
+
payload_digest: proposal.payloadDigest,
|
|
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,
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
const outputErrors = validateJsonSchemaValue(result.data as JsonValue, parsedObject(component.outputSchemaJson));
|
|
423
|
+
if (outputErrors.length) throw new WorkflowEffectError("action_response_schema_invalid");
|
|
424
|
+
const finishedAt = now();
|
|
425
|
+
await env.DB.batch([
|
|
426
|
+
env.DB.prepare(`UPDATE action_proposals SET state = 'applied', delivery_terminal = 1, lease_expires_at = NULL, last_error_code = NULL, updated_at = ?2 WHERE id = ?1 AND state = 'applying'`).bind(proposal.id, finishedAt),
|
|
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);
|
|
441
|
+
} catch (error) {
|
|
442
|
+
const failure = error instanceof ConnectionExecutionError
|
|
443
|
+
? new WorkflowEffectError(error.code, error.retryable, error.delaySeconds)
|
|
444
|
+
: error instanceof BudgetControlError
|
|
445
|
+
? new WorkflowEffectError(error.code, error.retryable, error.delaySeconds)
|
|
446
|
+
: error instanceof WorkflowEffectError
|
|
447
|
+
? error
|
|
448
|
+
: new WorkflowEffectError("action_delivery_failed", true, 15);
|
|
449
|
+
const terminal = !failure.retryable || proposal.deliveryAttemptCount >= ACTION_MAX_ATTEMPTS;
|
|
450
|
+
const delaySeconds = Math.max(1, Math.min(21_600, failure.delaySeconds * (2 ** Math.min(proposal.deliveryAttemptCount - 1, 7))));
|
|
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);
|
|
460
|
+
proposal = await loadProposal(env.DB, proposal.id);
|
|
461
|
+
if (proposal.state === "applied") {
|
|
462
|
+
await emitActionResolution(env, proposal);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (terminal) {
|
|
466
|
+
await emitActionResolution(env, proposal);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
throw new RetryableActionProposalError(delaySeconds);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export async function acceptActionResult(
|
|
474
|
+
env: Env,
|
|
475
|
+
value: Record<string, unknown>,
|
|
476
|
+
integrationId: string,
|
|
477
|
+
): Promise<{ proposalId: string; state: string; idempotentReplay: boolean }> {
|
|
478
|
+
const proposalId = typeof value.action_id === "string" ? value.action_id : "";
|
|
479
|
+
const operationId = typeof value.operation_id === "string" ? value.operation_id : "";
|
|
480
|
+
const payloadDigest = typeof value.payload_digest === "string" ? value.payload_digest : "";
|
|
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.");
|
|
486
|
+
}
|
|
487
|
+
let proposal = await loadProposal(env.DB, proposalId);
|
|
488
|
+
if (proposal.operationId !== operationId || proposal.payloadDigest !== payloadDigest) {
|
|
489
|
+
throw new ApiError(409, "action_result_identity_mismatch", "The action result does not match the immutable proposal operation and payload digest.");
|
|
490
|
+
}
|
|
491
|
+
if (proposal.state === "applied" && status === "applied") return { proposalId, state: "applied", idempotentReplay: true };
|
|
492
|
+
if (proposal.state === "failed" && proposal.deliveryTerminal === 1 && status === "failed") return { proposalId, state: "failed", idempotentReplay: true };
|
|
493
|
+
if (!["queued", "applying", "failed"].includes(proposal.state) || proposal.deliveryTerminal === 1) {
|
|
494
|
+
throw new ApiError(409, "action_result_terminal", "The action proposal is not awaiting an asynchronous result.");
|
|
495
|
+
}
|
|
496
|
+
const timestamp = now();
|
|
497
|
+
const terminal = value.terminal !== false;
|
|
498
|
+
const errorCode = typeof value.error_code === "string" && value.error_code.length <= 200
|
|
499
|
+
? value.error_code : "action_provider_failed";
|
|
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 };
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export async function completeWorkflowAction(
|
|
530
|
+
env: Env,
|
|
531
|
+
input: { proposalId: string; eventResult: { status: "received"; event: unknown } | { status: "timed_out" } },
|
|
532
|
+
): Promise<Record<string, unknown>> {
|
|
533
|
+
const proposal = await loadProposal(env.DB, input.proposalId);
|
|
534
|
+
if (proposal.state === "applied") return { port: "applied", proposalId: proposal.id, payloadDigest: proposal.payloadDigest };
|
|
535
|
+
if (proposal.state === "rejected") return { port: "rejected", proposalId: proposal.id, payloadDigest: proposal.payloadDigest };
|
|
536
|
+
if (proposal.state === "failed" && proposal.deliveryTerminal === 1) {
|
|
537
|
+
return { port: "error", proposalId: proposal.id, payloadDigest: proposal.payloadDigest, errorCode: proposal.lastErrorCode ?? "action_delivery_failed" };
|
|
538
|
+
}
|
|
539
|
+
if (input.eventResult.status === "timed_out") {
|
|
540
|
+
await env.DB.prepare(`UPDATE action_proposals SET state = 'cancelled', delivery_terminal = 1, updated_at = ?2 WHERE id = ?1 AND state IN ('approval_required','approved','queued','failed') AND delivery_terminal = 0`)
|
|
541
|
+
.bind(proposal.id, now()).run();
|
|
542
|
+
return { port: "timeout", proposalId: proposal.id, payloadDigest: proposal.payloadDigest };
|
|
543
|
+
}
|
|
544
|
+
if (["queued", "applying", "failed"].includes(proposal.state)) {
|
|
545
|
+
const approvalPolicy = parsedObject(proposal.approvalPolicyJson);
|
|
546
|
+
if (typeof approvalPolicy.expires_at !== "string") throw new WorkflowEffectError("action_approval_expiry_missing");
|
|
547
|
+
if (proposal.state !== "applying") {
|
|
548
|
+
await env.DELIVERY_JOBS.send(actionJob(proposal.id, proposal.runId), { contentType: "json" }).catch(() => {});
|
|
549
|
+
}
|
|
550
|
+
return deliveryPending(proposal.id, proposal.payloadDigest, approvalPolicy.expires_at);
|
|
551
|
+
}
|
|
552
|
+
const approval = await env.DB.prepare(`SELECT decision, payload_digest AS payloadDigest FROM action_approvals WHERE proposal_id = ?1 LIMIT 1`)
|
|
553
|
+
.bind(proposal.id).first<{ decision: "approved" | "rejected"; payloadDigest: string }>();
|
|
554
|
+
if (!approval || approval.payloadDigest !== proposal.payloadDigest) throw new WorkflowEffectError("action_approval_digest_mismatch");
|
|
555
|
+
if (approval.decision === "rejected") return { port: "rejected", proposalId: proposal.id, payloadDigest: proposal.payloadDigest };
|
|
556
|
+
const approvalPolicy = parsedObject(proposal.approvalPolicyJson);
|
|
557
|
+
if (typeof approvalPolicy.expires_at !== "string") throw new WorkflowEffectError("action_approval_expiry_missing");
|
|
558
|
+
await env.DB.prepare(`UPDATE action_proposals SET state = 'queued', next_attempt_at = ?2, lease_expires_at = NULL, last_error_code = NULL, delivery_terminal = 0, updated_at = ?2 WHERE id = ?1 AND state = 'approved'`)
|
|
559
|
+
.bind(proposal.id, now()).run();
|
|
560
|
+
await env.DELIVERY_JOBS.send(actionJob(proposal.id, proposal.runId), { contentType: "json" }).catch(() => {});
|
|
561
|
+
return deliveryPending(proposal.id, proposal.payloadDigest, approvalPolicy.expires_at);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
export async function loadActionProposalDetail(db: D1Database, proposalId: string): Promise<Record<string, unknown> | null> {
|
|
565
|
+
const proposal = await db.prepare(`
|
|
566
|
+
SELECT p.id, p.report_id AS reportId, r.public_reference AS reportReference,
|
|
567
|
+
p.run_id AS runId, p.step_run_id AS stepRunId, p.decision_id AS decisionId,
|
|
568
|
+
p.action_code AS actionCode, p.target_json AS targetJson,
|
|
569
|
+
p.payload_json AS payloadJson, p.preconditions_json AS preconditionsJson,
|
|
570
|
+
p.payload_digest AS payloadDigest, p.component_version_id AS componentVersionId,
|
|
571
|
+
p.authority_mode AS authorityMode, p.approval_policy_json AS approvalPolicyJson,
|
|
572
|
+
p.state, p.operation_id AS operationId, p.compensates_proposal_id AS compensatesProposalId,
|
|
573
|
+
p.delivery_attempt_count AS deliveryAttemptCount, p.delivery_terminal AS deliveryTerminal,
|
|
574
|
+
p.next_attempt_at AS nextAttemptAt, p.last_error_code AS lastErrorCode,
|
|
575
|
+
p.created_by_type AS createdByType, p.created_by_id AS createdById,
|
|
576
|
+
p.created_at AS createdAt, p.updated_at AS updatedAt
|
|
577
|
+
FROM action_proposals p JOIN reports r ON r.id = p.report_id
|
|
578
|
+
WHERE p.id = ?1 LIMIT 1
|
|
579
|
+
`).bind(proposalId).first<Record<string, unknown>>();
|
|
580
|
+
if (!proposal) return null;
|
|
581
|
+
const [approval, task, attempts] = await Promise.all([
|
|
582
|
+
db.prepare(`SELECT id, task_id AS taskId, approver_id AS approverId, payload_digest AS payloadDigest, decision, reason, created_at AS createdAt FROM action_approvals WHERE proposal_id = ?1 LIMIT 1`).bind(proposalId).first(),
|
|
583
|
+
db.prepare(`SELECT id, state, assigned_operator_id AS assignedOperatorId, due_at AS dueAt, completed_at AS completedAt FROM human_tasks WHERE action_proposal_id = ?1 LIMIT 1`).bind(proposalId).first(),
|
|
584
|
+
db.prepare(`SELECT id, attempt_number AS attemptNumber, connection_version_id AS connectionVersionId, request_digest AS requestDigest, response_status AS responseStatus, response_digest AS responseDigest, response_class AS responseClass, error_code AS errorCode, started_at AS startedAt, finished_at AS finishedAt FROM connection_attempts WHERE action_proposal_id = ?1 ORDER BY attempt_number, id`).bind(proposalId).all(),
|
|
585
|
+
]);
|
|
586
|
+
return {
|
|
587
|
+
proposal: {
|
|
588
|
+
...proposal,
|
|
589
|
+
target: typeof proposal.targetJson === "string" ? JSON.parse(proposal.targetJson) : {},
|
|
590
|
+
payload: typeof proposal.payloadJson === "string" ? JSON.parse(proposal.payloadJson) : {},
|
|
591
|
+
preconditions: typeof proposal.preconditionsJson === "string" ? JSON.parse(proposal.preconditionsJson) : {},
|
|
592
|
+
approvalPolicy: typeof proposal.approvalPolicyJson === "string" ? JSON.parse(proposal.approvalPolicyJson) : {},
|
|
593
|
+
targetJson: undefined, payloadJson: undefined, preconditionsJson: undefined, approvalPolicyJson: undefined,
|
|
594
|
+
},
|
|
595
|
+
approval: approval ?? null,
|
|
596
|
+
task: task ?? null,
|
|
597
|
+
attempts: attempts.results,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
async function emitApprovalEvent(
|
|
602
|
+
env: Env,
|
|
603
|
+
proposal: ProposalRow,
|
|
604
|
+
approvalId: string,
|
|
605
|
+
decision: "approved" | "rejected",
|
|
606
|
+
actorId: string,
|
|
607
|
+
): Promise<void> {
|
|
608
|
+
const eventType = `action.${proposal.id}.resolved`;
|
|
609
|
+
const eventKey = `action-approval:${approvalId}`;
|
|
610
|
+
const payload = { proposal_id: proposal.id, approval_id: approvalId, decision, payload_digest: proposal.payloadDigest };
|
|
611
|
+
const payloadJson = canonicalJson(payload);
|
|
612
|
+
const timestamp = now();
|
|
613
|
+
await env.DB.batch([
|
|
614
|
+
env.DB.prepare(`
|
|
615
|
+
INSERT OR IGNORE INTO workflow_event_inbox (
|
|
616
|
+
id, run_id, event_type, event_key, source_type, source_id,
|
|
617
|
+
payload_json, payload_digest, received_at
|
|
618
|
+
) VALUES (?1, ?2, ?3, ?4, 'operator', ?5, ?6, ?7, ?8)
|
|
619
|
+
`).bind(approvalId, proposal.runId, eventType, eventKey, actorId, payloadJson, `sha256:${await sha256(payloadJson)}`, timestamp),
|
|
620
|
+
env.DB.prepare(`
|
|
621
|
+
INSERT OR IGNORE INTO workflow_dispatch_outbox (
|
|
622
|
+
id, run_id, operation, event_inbox_id, operation_key,
|
|
623
|
+
state, available_at, created_at, updated_at
|
|
624
|
+
) VALUES (?1, ?2, 'event', ?1, ?3, 'pending', ?4, ?4, ?4)
|
|
625
|
+
`).bind(approvalId, proposal.runId, `workflow-event:${proposal.runId}:${eventType}:${eventKey}`, timestamp),
|
|
626
|
+
]);
|
|
627
|
+
const instance = await env.DB.prepare(`SELECT cloudflare_instance_id AS instanceId FROM workflow_runs WHERE id = ?1 LIMIT 1`)
|
|
628
|
+
.bind(proposal.runId).first<{ instanceId: string | null }>();
|
|
629
|
+
if (!instance?.instanceId) return;
|
|
630
|
+
try {
|
|
631
|
+
await (await env.WORKFLOWS.get(instance.instanceId)).sendEvent({ type: eventType, payload });
|
|
632
|
+
const deliveredAt = now();
|
|
633
|
+
await env.DB.batch([
|
|
634
|
+
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),
|
|
635
|
+
env.DB.prepare(`UPDATE workflow_dispatch_outbox SET state = 'delivered', last_error_code = NULL, updated_at = ?2 WHERE id = ?1`).bind(approvalId, deliveredAt),
|
|
636
|
+
]);
|
|
637
|
+
} catch {
|
|
638
|
+
await env.DB.batch([
|
|
639
|
+
env.DB.prepare(`UPDATE workflow_event_inbox SET delivery_attempts = delivery_attempts + 1, last_error_code = 'platform_event_send_failed' WHERE id = ?1`).bind(approvalId),
|
|
640
|
+
env.DB.prepare(`UPDATE workflow_dispatch_outbox SET state = 'failed', last_error_code = 'platform_event_send_failed', updated_at = ?2 WHERE id = ?1`).bind(approvalId, now()),
|
|
641
|
+
]);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
export async function decideActionProposal(
|
|
646
|
+
env: Env,
|
|
647
|
+
proposalId: string,
|
|
648
|
+
input: { decision: "approved" | "rejected"; payloadDigest: string; reason?: string },
|
|
649
|
+
session: OperatorSession,
|
|
650
|
+
idempotencyKey: string,
|
|
651
|
+
): Promise<{ proposalId: string; approvalId: string; state: string; idempotentReplay: boolean }> {
|
|
652
|
+
const prior = await env.DB.prepare(`SELECT id, proposal_id AS proposalId, decision FROM action_approvals WHERE approver_id = ?1 AND idempotency_key = ?2 LIMIT 1`)
|
|
653
|
+
.bind(session.actor.id, idempotencyKey).first<{ id: string; proposalId: string; decision: string }>();
|
|
654
|
+
if (prior) {
|
|
655
|
+
if (prior.proposalId !== proposalId || prior.decision !== input.decision) throw new ApiError(409, "idempotency_key_reused", "This Idempotency-Key was already used for another action decision.");
|
|
656
|
+
return { proposalId, approvalId: prior.id, state: input.decision, idempotentReplay: true };
|
|
657
|
+
}
|
|
658
|
+
const proposal = await loadProposal(env.DB, proposalId);
|
|
659
|
+
if (proposal.state !== "approval_required") throw new ApiError(409, "action_not_awaiting_approval", "The action is not awaiting approval.");
|
|
660
|
+
if (input.payloadDigest !== proposal.payloadDigest) throw new ApiError(409, "action_approval_digest_mismatch", "The approval payload digest does not match the immutable proposal.");
|
|
661
|
+
if (input.reason !== undefined && (input.reason.trim().length < 3 || input.reason.length > 2_000)) {
|
|
662
|
+
throw new ApiError(400, "action_decision_reason_invalid", "The decision reason must contain 3 to 2,000 characters.");
|
|
663
|
+
}
|
|
664
|
+
const approvalId = crypto.randomUUID();
|
|
665
|
+
const timestamp = now();
|
|
666
|
+
await env.DB.batch([
|
|
667
|
+
env.DB.prepare(`
|
|
668
|
+
INSERT INTO action_approvals (
|
|
669
|
+
id, proposal_id, approver_id, payload_digest, decision,
|
|
670
|
+
reason, idempotency_key, created_at
|
|
671
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
|
672
|
+
`).bind(approvalId, proposalId, session.actor.id, proposal.payloadDigest, input.decision, input.reason?.trim() ?? null, idempotencyKey, timestamp),
|
|
673
|
+
env.DB.prepare(`UPDATE action_proposals SET state = ?2, updated_at = ?3 WHERE id = ?1 AND state = 'approval_required'`)
|
|
674
|
+
.bind(proposalId, input.decision, timestamp),
|
|
675
|
+
env.DB.prepare(`UPDATE human_tasks SET state = 'completed', completed_at = ?2, updated_at = ?2 WHERE action_proposal_id = ?1 AND state IN ('open','claimed')`)
|
|
676
|
+
.bind(proposalId, timestamp),
|
|
677
|
+
env.DB.prepare(`UPDATE human_task_claims SET released_at = ?2, release_reason = 'direct_action_decision' WHERE task_id = (SELECT id FROM human_tasks WHERE action_proposal_id = ?1 LIMIT 1) AND released_at IS NULL`)
|
|
678
|
+
.bind(proposalId, timestamp),
|
|
679
|
+
env.DB.prepare(`
|
|
680
|
+
INSERT INTO audit_events (
|
|
681
|
+
id, idempotency_key, action, actor_type, actor_id,
|
|
682
|
+
target_type, target_id, details_json, created_at
|
|
683
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, 'action_proposal', ?6, ?7, ?8)
|
|
684
|
+
`).bind(
|
|
685
|
+
crypto.randomUUID(), `action-decision:${idempotencyKey}`,
|
|
686
|
+
input.decision === "approved" ? "action.approved" : "action.rejected",
|
|
687
|
+
session.actor.type, session.actor.id, proposalId,
|
|
688
|
+
canonicalJson({ payloadDigest: proposal.payloadDigest, reason: input.reason?.trim() ?? null }), timestamp,
|
|
689
|
+
),
|
|
690
|
+
]);
|
|
691
|
+
const updated = await loadProposal(env.DB, proposalId);
|
|
692
|
+
await emitApprovalEvent(env, updated, approvalId, input.decision, session.actor.id);
|
|
693
|
+
return { proposalId, approvalId, state: input.decision, idempotentReplay: false };
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
export async function retryActionProposal(
|
|
697
|
+
env: Env,
|
|
698
|
+
proposalId: string,
|
|
699
|
+
payloadDigest: string,
|
|
700
|
+
session: OperatorSession,
|
|
701
|
+
idempotencyKey: string,
|
|
702
|
+
): Promise<{ proposalId: string; state: string; idempotentReplay: boolean }> {
|
|
703
|
+
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 }>();
|
|
706
|
+
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
|
+
return { proposalId, state: "queued", idempotentReplay: true };
|
|
709
|
+
}
|
|
710
|
+
const proposal = await loadProposal(env.DB, proposalId);
|
|
711
|
+
if (proposal.payloadDigest !== payloadDigest) throw new ApiError(409, "action_retry_digest_mismatch", "Retry requires the exact immutable payload digest.");
|
|
712
|
+
if (proposal.state !== "failed" || proposal.deliveryTerminal !== 1) throw new ApiError(409, "action_not_retryable", "Only a terminal failed action can be retried.");
|
|
713
|
+
const timestamp = now();
|
|
714
|
+
await env.DB.batch([
|
|
715
|
+
env.DB.prepare(`UPDATE action_proposals SET state = 'queued', delivery_terminal = 0, delivery_attempt_count = 0, next_attempt_at = ?2, lease_expires_at = NULL, last_error_code = NULL, updated_at = ?2 WHERE id = ?1 AND state = 'failed' AND delivery_terminal = 1`)
|
|
716
|
+
.bind(proposalId, timestamp),
|
|
717
|
+
env.DB.prepare(`INSERT INTO audit_events (id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at) VALUES (?1, ?2, 'action.retry_requested', ?3, ?4, 'action_proposal', ?5, ?6, ?7)`)
|
|
718
|
+
.bind(crypto.randomUUID(), key, session.actor.type, session.actor.id, proposalId, canonicalJson({ payloadDigest }), timestamp),
|
|
719
|
+
]);
|
|
720
|
+
await env.DELIVERY_JOBS.send(actionJob(proposalId, proposal.runId), { contentType: "json" }).catch(() => {});
|
|
721
|
+
return { proposalId, state: "queued", idempotentReplay: false };
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
export async function proposeCompensatingAction(
|
|
725
|
+
env: Env,
|
|
726
|
+
proposalId: string,
|
|
727
|
+
value: Record<string, unknown>,
|
|
728
|
+
session: OperatorSession,
|
|
729
|
+
idempotencyKey: string,
|
|
730
|
+
): Promise<{ proposalId: string; payloadDigest: string; state: "approval_required"; idempotentReplay: boolean }> {
|
|
731
|
+
const original = await loadProposal(env.DB, proposalId);
|
|
732
|
+
if (original.state !== "applied" && original.state !== "compensation_required") {
|
|
733
|
+
throw new ApiError(409, "action_compensation_unavailable", "Only an applied or compensation-required action can be compensated.");
|
|
734
|
+
}
|
|
735
|
+
const componentVersionId = typeof value.component_version_id === "string" ? value.component_version_id : "";
|
|
736
|
+
const actionCode = typeof value.action_code === "string" ? value.action_code : "";
|
|
737
|
+
const target = object(value.target as JsonValue, "action_target_invalid");
|
|
738
|
+
const payload = object(value.payload as JsonValue, "action_payload_invalid");
|
|
739
|
+
const preconditions = object((value.preconditions ?? {}) as JsonValue, "action_preconditions_invalid");
|
|
740
|
+
if (!componentVersionId || !actionCode || actionCode.length > 200) throw new ApiError(400, "action_compensation_invalid", "component_version_id and action_code are required.");
|
|
741
|
+
const component = await actionComponent(env.DB, componentVersionId);
|
|
742
|
+
if (validateJsonSchemaValue(payload, parsedObject(component.inputSchemaJson)).length) {
|
|
743
|
+
throw new ApiError(422, "action_payload_schema_invalid", "The compensation payload does not match the published component schema.");
|
|
744
|
+
}
|
|
745
|
+
const operationId = `compensation:${original.operationId}:${idempotencyKey}`;
|
|
746
|
+
const existing = await env.DB.prepare(`SELECT id, payload_digest AS payloadDigest, state FROM action_proposals WHERE operation_id = ?1 LIMIT 1`)
|
|
747
|
+
.bind(operationId).first<{ id: string; payloadDigest: string; state: string }>();
|
|
748
|
+
if (existing) return { proposalId: existing.id, payloadDigest: existing.payloadDigest, state: "approval_required", idempotentReplay: true };
|
|
749
|
+
const compensationId = crypto.randomUUID();
|
|
750
|
+
const timestamp = now();
|
|
751
|
+
const expiresAt = new Date(Date.now() + 86_400_000).toISOString();
|
|
752
|
+
const digestMaterial = {
|
|
753
|
+
schema_version: "1", action_id: compensationId, action_code: actionCode,
|
|
754
|
+
target, payload, preconditions, component_version_id: componentVersionId,
|
|
755
|
+
compensates_proposal_id: proposalId, expires_at: expiresAt,
|
|
756
|
+
installation_id: original.installationId,
|
|
757
|
+
};
|
|
758
|
+
const payloadDigest = `sha256:${await sha256(canonicalJson(digestMaterial))}`;
|
|
759
|
+
await env.DB.batch([
|
|
760
|
+
env.DB.prepare(`
|
|
761
|
+
INSERT INTO action_proposals (
|
|
762
|
+
id, installation_id, report_id, run_id, step_run_id, action_code,
|
|
763
|
+
target_json, payload_json, preconditions_json, payload_digest,
|
|
764
|
+
component_version_id, authority_mode, approval_policy_json, state,
|
|
765
|
+
operation_id, compensates_proposal_id, created_by_type, created_by_id,
|
|
766
|
+
created_at, updated_at
|
|
767
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11,
|
|
768
|
+
'assist', ?12, 'approval_required', ?13, ?14, ?15, ?16, ?17, ?17)
|
|
769
|
+
`).bind(
|
|
770
|
+
compensationId, original.installationId, original.reportId, original.runId,
|
|
771
|
+
original.stepRunId, actionCode, canonicalJson(target), canonicalJson(payload),
|
|
772
|
+
canonicalJson(preconditions), payloadDigest, componentVersionId,
|
|
773
|
+
canonicalJson({ mode: "human", expires_at: expiresAt, compensation: true }),
|
|
774
|
+
operationId, proposalId, session.actor.type, session.actor.id, timestamp,
|
|
775
|
+
),
|
|
776
|
+
env.DB.prepare(`UPDATE action_proposals SET state = 'compensation_required', updated_at = ?2 WHERE id = ?1 AND state = 'applied'`)
|
|
777
|
+
.bind(proposalId, timestamp),
|
|
778
|
+
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) VALUES (?1, ?2, 'workflow.compensation_proposed', ?3, ?4, 'action_proposal', ?5, ?6, ?7)`)
|
|
779
|
+
.bind(crypto.randomUUID(), `action-compensation:${idempotencyKey}`, session.actor.type, session.actor.id, compensationId, canonicalJson({ compensatesProposalId: proposalId, payloadDigest, actionCode }), timestamp),
|
|
780
|
+
]);
|
|
781
|
+
return { proposalId: compensationId, payloadDigest, state: "approval_required", idempotentReplay: false };
|
|
782
|
+
}
|