@osolmaz/pi-workflows 0.12.0 → 0.13.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 +56 -31
- package/dist/builtins/autodoc.workflow.d.ts +4 -4
- package/dist/builtins/autoimplement.workflow.d.ts +797 -142
- package/dist/builtins/autoimplement.workflow.js +17 -105
- package/dist/builtins/autoimplement.workflow.js.map +1 -1
- package/dist/builtins/autoplan.workflow.d.ts +126 -13
- package/dist/builtins/autoplan.workflow.js +200 -29
- package/dist/builtins/autoplan.workflow.js.map +1 -1
- package/dist/builtins/catalog.js +7 -5
- package/dist/builtins/catalog.js.map +1 -1
- package/dist/builtins/index.d.ts +4 -2
- package/dist/builtins/index.js +3 -1
- package/dist/builtins/index.js.map +1 -1
- package/dist/builtins/monitor.workflow.d.ts +56 -23
- package/dist/builtins/monitor.workflow.js +508 -329
- package/dist/builtins/monitor.workflow.js.map +1 -1
- package/dist/builtins/pi-agent-group.d.ts +72 -0
- package/dist/builtins/pi-agent-group.js +1087 -0
- package/dist/builtins/pi-agent-group.js.map +1 -0
- package/dist/builtins/plain-summary.workflow.d.ts +32 -0
- package/dist/builtins/plain-summary.workflow.js +134 -0
- package/dist/builtins/plain-summary.workflow.js.map +1 -0
- package/dist/builtins/plan-approval.workflow.d.ts +39 -5
- package/dist/builtins/plan-approval.workflow.js +92 -14
- package/dist/builtins/plan-approval.workflow.js.map +1 -1
- package/dist/builtins/plan-change.workflow.d.ts +449 -0
- package/dist/builtins/plan-change.workflow.js +256 -0
- package/dist/builtins/plan-change.workflow.js.map +1 -0
- package/dist/builtins/plan-presentation.js +2 -2
- package/dist/builtins/plan-presentation.js.map +1 -1
- package/dist/builtins/sanity-check.workflow.d.ts +50 -6
- package/dist/builtins/sanity-check.workflow.js +150 -28
- package/dist/builtins/sanity-check.workflow.js.map +1 -1
- package/dist/controllers/effects.d.ts +3 -2
- package/dist/controllers/effects.js +8 -1
- package/dist/controllers/effects.js.map +1 -1
- package/dist/controllers/index.d.ts +1 -1
- package/dist/controllers/index.js +1 -1
- package/dist/controllers/index.js.map +1 -1
- package/dist/controllers/manager.d.ts +1 -0
- package/dist/controllers/manager.js +41 -17
- package/dist/controllers/manager.js.map +1 -1
- package/dist/controllers/sqlite.d.ts +107 -85
- package/dist/controllers/sqlite.js +1611 -1486
- package/dist/controllers/sqlite.js.map +1 -1
- package/dist/controllers/store.d.ts +9 -8
- package/dist/controllers/store.js +1 -31
- package/dist/controllers/store.js.map +1 -1
- package/dist/controllers/types.d.ts +3 -0
- package/dist/controllers/workflow-engine-scheduler.d.ts +1 -1
- package/dist/controllers/workflow-engine-scheduler.js +3 -5
- package/dist/controllers/workflow-engine-scheduler.js.map +1 -1
- package/dist/controllers/workflows.d.ts +2 -2
- package/dist/controllers/workflows.js +11 -10
- package/dist/controllers/workflows.js.map +1 -1
- package/dist/extension/controller-host.js +2 -2
- package/dist/extension/controller-host.js.map +1 -1
- package/dist/extension/decision-channels.d.ts +4 -2
- package/dist/extension/decision-channels.js +333 -146
- package/dist/extension/decision-channels.js.map +1 -1
- package/dist/extension/executor.d.ts +9 -2
- package/dist/extension/executor.js +89 -2
- package/dist/extension/executor.js.map +1 -1
- package/dist/extension/herdr-viewer.d.ts +0 -1
- package/dist/extension/herdr-viewer.js +0 -4
- package/dist/extension/herdr-viewer.js.map +1 -1
- package/dist/extension/index.js +209 -127
- package/dist/extension/index.js.map +1 -1
- package/dist/extension/recorder.d.ts +3 -10
- package/dist/extension/recorder.js +13 -22
- package/dist/extension/recorder.js.map +1 -1
- package/dist/extension/session-events.d.ts +2 -2
- package/dist/extension/step-message.d.ts +7 -2
- package/dist/extension/step-message.js +94 -2
- package/dist/extension/step-message.js.map +1 -1
- package/dist/extension/widget.js +29 -3
- package/dist/extension/widget.js.map +1 -1
- package/dist/host/rpc-executor.d.ts +3 -0
- package/dist/host/rpc-executor.js +2 -0
- package/dist/host/rpc-executor.js.map +1 -1
- package/dist/host/runner.d.ts +3 -6
- package/dist/host/runner.js +83 -32
- package/dist/host/runner.js.map +1 -1
- package/dist/render/graph-render.js +15 -10
- package/dist/render/graph-render.js.map +1 -1
- package/dist/state/database.d.ts +35 -0
- package/dist/state/database.js +287 -0
- package/dist/state/database.js.map +1 -0
- package/dist/state/index.d.ts +4 -0
- package/dist/state/index.js +5 -0
- package/dist/state/index.js.map +1 -0
- package/dist/state/json.d.ts +6 -0
- package/dist/state/json.js +38 -0
- package/dist/state/json.js.map +1 -0
- package/dist/state/mutation.d.ts +57 -0
- package/dist/state/mutation.js +255 -0
- package/dist/state/mutation.js.map +1 -0
- package/dist/state/schema.d.ts +6 -0
- package/dist/state/schema.js +473 -0
- package/dist/state/schema.js.map +1 -0
- package/dist/viewer/cli.d.ts +2 -2
- package/dist/viewer/cli.js +142 -122
- package/dist/viewer/cli.js.map +1 -1
- package/dist/viewer/render.d.ts +4 -4
- package/dist/viewer/render.js +9 -23
- package/dist/viewer/render.js.map +1 -1
- package/dist/viewer/session-reducer.d.ts +1 -1
- package/dist/viewer/session-reducer.js +1 -1
- package/dist/viewer/tui.d.ts +3 -3
- package/dist/viewer/tui.js +15 -13
- package/dist/viewer/tui.js.map +1 -1
- package/dist/viewer/watch.d.ts +2 -5
- package/dist/viewer/watch.js +13 -13
- package/dist/viewer/watch.js.map +1 -1
- package/dist/workflows/catalog.js +7 -2
- package/dist/workflows/catalog.js.map +1 -1
- package/dist/workflows/composition.js +20 -2
- package/dist/workflows/composition.js.map +1 -1
- package/dist/workflows/decision-presentation.d.ts +1 -1
- package/dist/workflows/decision-presentation.js +51 -38
- package/dist/workflows/decision-presentation.js.map +1 -1
- package/dist/workflows/definition.d.ts +6 -2
- package/dist/workflows/definition.js +17 -0
- package/dist/workflows/definition.js.map +1 -1
- package/dist/workflows/engine.d.ts +7 -7
- package/dist/workflows/engine.js +196 -115
- package/dist/workflows/engine.js.map +1 -1
- package/dist/workflows/errors.d.ts +16 -3
- package/dist/workflows/errors.js +18 -3
- package/dist/workflows/errors.js.map +1 -1
- package/dist/workflows/human-decision.d.ts +45 -8
- package/dist/workflows/human-decision.js +641 -274
- package/dist/workflows/human-decision.js.map +1 -1
- package/dist/workflows/index.d.ts +5 -6
- package/dist/workflows/index.js +4 -5
- package/dist/workflows/index.js.map +1 -1
- package/dist/workflows/progress.d.ts +1 -0
- package/dist/workflows/progress.js +15 -3
- package/dist/workflows/progress.js.map +1 -1
- package/dist/workflows/schema.js +27 -1
- package/dist/workflows/schema.js.map +1 -1
- package/dist/workflows/store.d.ts +83 -103
- package/dist/workflows/store.js +907 -951
- package/dist/workflows/store.js.map +1 -1
- package/dist/workflows/types.d.ts +88 -126
- package/docs/CONTROLLERS.md +10 -8
- package/docs/DEFERRED_TURNS.md +2 -2
- package/docs/DESIGN_PHILOSOPHY.md +4 -2
- package/docs/HUMAN_DECISIONS.md +36 -47
- package/docs/HUMAN_DECISION_PRESENTATIONS.md +14 -24
- package/docs/MONITOR.md +179 -237
- package/docs/SQLITE_STATE.md +219 -0
- package/docs/WORKFLOW_COMPOSITION.md +27 -26
- package/docs/WORKFLOW_STEP_MESSAGES.md +20 -18
- package/docs/WORKFLOW_UPDATES.md +10 -9
- package/docs/development.md +9 -9
- package/docs/live-replay-protocol.md +28 -37
- package/docs/plans/2026-08-16-workflow-updates-plan.md +1 -1
- package/docs/plans/2026-08-19-human-decision-gates-plan.md +1 -1
- package/docs/plans/2026-08-21-autoimplement-timeout-fallback-plan.md +1 -1
- package/docs/plans/2026-08-21-plan-change-approval-policy-plan.md +322 -0
- package/docs/plans/2026-08-21-sanity-check-plan.md +217 -95
- package/docs/plans/2026-08-22-goal-finishing-monitor-plan.md +200 -0
- package/docs/plans/2026-08-23-assistant-agent-completion-plan.md +701 -0
- package/docs/plans/2026-08-23-sqlite-state-plan.md +970 -0
- package/docs/plans/replayable-run-bundles-implementation-plan.md +2 -2
- package/docs/plans/session-event-replay-implementation-plan.md +1 -1
- package/docs/plans/tui-viewer-implementation-plan.md +1 -1
- package/docs/session-event-journal.md +63 -434
- package/docs/tui-viewer.md +13 -15
- package/docs/workflows.md +127 -53
- package/examples/workflows/approved-plan.workflow.ts +19 -46
- package/examples/workflows/plain-summary.workflow.ts +1 -0
- package/herdr-plugin.toml +1 -1
- package/package.json +7 -7
- package/plugins/herdr/viewer.mjs +1 -11
- package/schemas/human-decision-accepted-v1.schema.json +15 -3
- package/schemas/human-decision-continuation-v1.schema.json +10 -1
- package/schemas/human-decision-delivery-v1.schema.json +8 -0
- package/schemas/human-decision-receipt-v1.schema.json +8 -0
- package/schemas/human-decision-request-v1.schema.json +24 -4
- package/skills/autoimplement/SKILL.md +27 -0
- package/skills/autoplan/SKILL.md +5 -4
- package/skills/monitor/SKILL.md +91 -135
- package/skills/pi-workflows/SKILL.md +9 -8
- package/skills/sanity-check/SKILL.md +44 -0
- package/src/builtins/autoimplement.workflow.ts +19 -118
- package/src/builtins/autoplan.workflow.ts +290 -40
- package/src/builtins/catalog.ts +7 -5
- package/src/builtins/index.ts +18 -1
- package/src/builtins/monitor.workflow.ts +673 -382
- package/src/builtins/pi-agent-group.ts +1407 -0
- package/src/builtins/plain-summary.workflow.ts +185 -0
- package/src/builtins/plan-approval.workflow.ts +157 -24
- package/src/builtins/plan-change.workflow.ts +321 -0
- package/src/builtins/plan-presentation.ts +2 -2
- package/src/builtins/sanity-check.workflow.ts +248 -48
- package/src/controllers/effects.ts +7 -0
- package/src/controllers/index.ts +0 -6
- package/src/controllers/manager.ts +72 -32
- package/src/controllers/sqlite.ts +2420 -1948
- package/src/controllers/store.ts +17 -38
- package/src/controllers/types.ts +3 -0
- package/src/controllers/workflow-engine-scheduler.ts +4 -8
- package/src/controllers/workflows.ts +45 -21
- package/src/extension/controller-host.ts +1 -2
- package/src/extension/decision-channels.ts +456 -203
- package/src/extension/executor.ts +105 -2
- package/src/extension/herdr-viewer.ts +0 -5
- package/src/extension/index.ts +251 -151
- package/src/extension/recorder.ts +13 -22
- package/src/extension/session-events.ts +2 -2
- package/src/extension/step-message.ts +106 -4
- package/src/extension/widget.ts +32 -5
- package/src/host/rpc-executor.ts +4 -0
- package/src/host/runner.ts +93 -41
- package/src/render/graph-render.ts +12 -6
- package/src/state/database.ts +375 -0
- package/src/state/index.ts +29 -0
- package/src/state/json.ts +42 -0
- package/src/state/mutation.ts +435 -0
- package/src/state/schema.ts +475 -0
- package/src/viewer/cli.ts +151 -144
- package/src/viewer/render.ts +19 -30
- package/src/viewer/session-reducer.ts +1 -1
- package/src/viewer/tui.ts +18 -17
- package/src/viewer/watch.ts +13 -17
- package/src/workflows/catalog.ts +7 -2
- package/src/workflows/composition.ts +21 -3
- package/src/workflows/decision-presentation.ts +56 -43
- package/src/workflows/definition.ts +36 -3
- package/src/workflows/engine.ts +228 -130
- package/src/workflows/errors.ts +27 -3
- package/src/workflows/human-decision.ts +921 -349
- package/src/workflows/index.ts +20 -30
- package/src/workflows/progress.ts +18 -3
- package/src/workflows/schema.ts +36 -1
- package/src/workflows/store.ts +1300 -1163
- package/src/workflows/types.ts +100 -137
- package/dist/builtins/sanity-check-session.d.ts +0 -17
- package/dist/builtins/sanity-check-session.js +0 -168
- package/dist/builtins/sanity-check-session.js.map +0 -1
- package/dist/workflows/artifacts.d.ts +0 -40
- package/dist/workflows/artifacts.js +0 -155
- package/dist/workflows/artifacts.js.map +0 -1
- package/dist/workflows/migrate-sources.d.ts +0 -42
- package/dist/workflows/migrate-sources.js +0 -133
- package/dist/workflows/migrate-sources.js.map +0 -1
- package/docs/run-bundles.md +0 -481
- package/schemas/human-decision-accepted-v2.schema.json +0 -50
- package/schemas/human-decision-delivery-v2.schema.json +0 -36
- package/schemas/human-decision-receipt-v2.schema.json +0 -39
- package/schemas/human-decision-request-v2.schema.json +0 -69
- package/schemas/human-decision-resolution-v2.schema.json +0 -27
- package/src/builtins/sanity-check-session.ts +0 -205
- package/src/workflows/artifacts.ts +0 -188
- package/src/workflows/migrate-sources.ts +0 -178
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import path from "node:path";
|
|
2
|
+
import { StateDatabase, workflowStatePath } from "../state/database.js";
|
|
3
|
+
import { resourceIdFor, tokenHash } from "../state/mutation.js";
|
|
5
4
|
import {
|
|
6
5
|
digestCanonical,
|
|
7
6
|
normalizeDecisionPresentation,
|
|
8
7
|
validateHumanDecisionRequestIntegrity,
|
|
9
8
|
} from "./decision-presentation.js";
|
|
10
9
|
import { checkpoint } from "./definition.js";
|
|
10
|
+
import type { RunWriteAuthority } from "./store.js";
|
|
11
11
|
import type {
|
|
12
|
-
AcceptedHumanDecision,
|
|
13
12
|
CheckpointNodeDefinition,
|
|
14
13
|
HumanDecisionChoice,
|
|
15
14
|
HumanDecisionAudience,
|
|
@@ -24,6 +23,8 @@ import type {
|
|
|
24
23
|
HumanDecisionSettlementRecord,
|
|
25
24
|
HumanDecisionSubmission,
|
|
26
25
|
HumanDecisionTextInput,
|
|
26
|
+
HumanDecisionTimeout,
|
|
27
|
+
ResolvedHumanDecision,
|
|
27
28
|
WorkflowNodeCommon,
|
|
28
29
|
WorkflowNodeContext,
|
|
29
30
|
} from "./types.js";
|
|
@@ -43,6 +44,14 @@ export type HumanDecisionDefinition<TChoices extends HumanDecisionChoiceMap> =
|
|
|
43
44
|
audience: HumanDecisionAudience;
|
|
44
45
|
choices: TChoices;
|
|
45
46
|
request: (context: WorkflowNodeContext) => HumanDecisionPrompt | Promise<HumanDecisionPrompt>;
|
|
47
|
+
onTimeout?:
|
|
48
|
+
| { afterMs: number; response: HumanDecisionResponseFor<TChoices> }
|
|
49
|
+
| ((
|
|
50
|
+
context: WorkflowNodeContext,
|
|
51
|
+
) =>
|
|
52
|
+
| { afterMs: number; response: HumanDecisionResponseFor<TChoices> }
|
|
53
|
+
| undefined
|
|
54
|
+
| Promise<{ afterMs: number; response: HumanDecisionResponseFor<TChoices> } | undefined>);
|
|
46
55
|
};
|
|
47
56
|
|
|
48
57
|
export type HumanDecisionResponseFor<TChoices extends HumanDecisionChoiceMap> = {
|
|
@@ -98,11 +107,25 @@ export function humanDecision<const TChoices extends HumanDecisionChoiceMap>(
|
|
|
98
107
|
if (typeof definition.request !== "function") {
|
|
99
108
|
throw new Error("Human decision request must be a function");
|
|
100
109
|
}
|
|
101
|
-
const { choices, request, ...common } = definition;
|
|
110
|
+
const { choices, request, onTimeout, ...common } = definition;
|
|
111
|
+
if (
|
|
112
|
+
onTimeout !== undefined &&
|
|
113
|
+
typeof onTimeout !== "function" &&
|
|
114
|
+
(typeof onTimeout.afterMs !== "number" ||
|
|
115
|
+
!Number.isFinite(onTimeout.afterMs) ||
|
|
116
|
+
onTimeout.afterMs <= 0)
|
|
117
|
+
) {
|
|
118
|
+
throw new Error("Human decision onTimeout afterMs must be a finite positive number");
|
|
119
|
+
}
|
|
102
120
|
return checkpoint({
|
|
103
121
|
...common,
|
|
104
122
|
summary: typeof audience === "string" ? `human decision for ${audience}` : "human decision",
|
|
105
|
-
humanDecision: {
|
|
123
|
+
humanDecision: {
|
|
124
|
+
audience,
|
|
125
|
+
choices,
|
|
126
|
+
request,
|
|
127
|
+
...(onTimeout !== undefined ? { onTimeout } : {}),
|
|
128
|
+
},
|
|
106
129
|
}) as CheckpointNodeDefinition & { readonly __humanChoices?: TChoices };
|
|
107
130
|
}
|
|
108
131
|
|
|
@@ -136,11 +159,31 @@ export function createHumanDecisionRequest(input: {
|
|
|
136
159
|
attemptId: string;
|
|
137
160
|
contract: { audience: string; choices: HumanDecisionChoiceMap };
|
|
138
161
|
prompt: HumanDecisionPrompt;
|
|
162
|
+
timeout?: HumanDecisionTimeout;
|
|
139
163
|
createdAt?: string;
|
|
140
164
|
}): HumanDecisionRequest {
|
|
141
165
|
validateChoices(input.contract.choices);
|
|
166
|
+
const createdAt = input.createdAt ?? new Date().toISOString();
|
|
142
167
|
const title = requireString(input.prompt.title, "Human decision title");
|
|
143
|
-
|
|
168
|
+
if (input.timeout !== undefined && input.prompt.expiresAt !== undefined) {
|
|
169
|
+
throw new Error("Human decision request cannot combine expiresAt with onTimeout");
|
|
170
|
+
}
|
|
171
|
+
if (
|
|
172
|
+
input.timeout !== undefined &&
|
|
173
|
+
(typeof input.timeout.afterMs !== "number" ||
|
|
174
|
+
!Number.isFinite(input.timeout.afterMs) ||
|
|
175
|
+
input.timeout.afterMs <= 0)
|
|
176
|
+
) {
|
|
177
|
+
throw new Error("Human decision onTimeout afterMs must be a finite positive number");
|
|
178
|
+
}
|
|
179
|
+
const defaultResponse =
|
|
180
|
+
input.timeout === undefined
|
|
181
|
+
? undefined
|
|
182
|
+
: validateResponseForChoices(input.contract.choices, input.timeout.response);
|
|
183
|
+
const expiresAt =
|
|
184
|
+
input.timeout === undefined
|
|
185
|
+
? validateExpiry(input.prompt.expiresAt, createdAt)
|
|
186
|
+
: new Date(Date.parse(createdAt) + input.timeout.afterMs).toISOString();
|
|
144
187
|
const common = {
|
|
145
188
|
runId: input.runId,
|
|
146
189
|
workflowName: input.workflowName,
|
|
@@ -150,58 +193,35 @@ export function createHumanDecisionRequest(input: {
|
|
|
150
193
|
title,
|
|
151
194
|
choices: input.contract.choices,
|
|
152
195
|
...(expiresAt !== undefined ? { expiresAt } : {}),
|
|
196
|
+
...(defaultResponse !== undefined ? { defaultResponse } : {}),
|
|
153
197
|
} as const;
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
if (!Object.hasOwn(input.prompt, "subject") || !Object.hasOwn(input.prompt, "presentation")) {
|
|
163
|
-
throw new Error("Presented human decision requests require subject and presentation");
|
|
164
|
-
}
|
|
165
|
-
const prompt = input.prompt as Extract<HumanDecisionPrompt, { subject: unknown }>;
|
|
166
|
-
assertJsonValue(prompt.subject, "Human decision subject");
|
|
167
|
-
const presentation = normalizeDecisionPresentation(prompt.presentation);
|
|
168
|
-
const revision = prompt.revision ?? 1;
|
|
169
|
-
if (!Number.isInteger(revision) || revision < 1) {
|
|
170
|
-
throw new Error("Human decision revision must be a positive integer");
|
|
171
|
-
}
|
|
172
|
-
const basis = {
|
|
173
|
-
schema: "pi-workflows.human-decision-request.v2" as const,
|
|
174
|
-
...common,
|
|
175
|
-
subject: prompt.subject,
|
|
176
|
-
presentation,
|
|
177
|
-
revision,
|
|
178
|
-
};
|
|
179
|
-
const subjectDigest = digestCanonical(prompt.subject);
|
|
180
|
-
const presentationDigest = digestCanonical(presentation);
|
|
181
|
-
const requestDigest = digestCanonical(basis);
|
|
182
|
-
const decisionId = decisionIdFor(input, requestDigest);
|
|
183
|
-
return {
|
|
184
|
-
...basis,
|
|
185
|
-
decisionId,
|
|
186
|
-
requestDigest,
|
|
187
|
-
subjectDigest,
|
|
188
|
-
presentationDigest,
|
|
189
|
-
createdAt: input.createdAt ?? new Date().toISOString(),
|
|
190
|
-
};
|
|
198
|
+
validatePresentedText(title, "Human decision title");
|
|
199
|
+
validatePresentedChoices(input.contract.choices);
|
|
200
|
+
const prompt = input.prompt;
|
|
201
|
+
assertJsonValue(prompt.subject, "Human decision subject");
|
|
202
|
+
const presentation = normalizeDecisionPresentation(prompt.presentation);
|
|
203
|
+
const revision = prompt.revision ?? 1;
|
|
204
|
+
if (!Number.isInteger(revision) || revision < 1) {
|
|
205
|
+
throw new Error("Human decision revision must be a positive integer");
|
|
191
206
|
}
|
|
192
|
-
const prompt = input.prompt as Extract<HumanDecisionPrompt, { body: unknown }>;
|
|
193
|
-
assertJsonValue(prompt.body, "Human decision body");
|
|
194
207
|
const basis = {
|
|
195
208
|
schema: "pi-workflows.human-decision-request.v1" as const,
|
|
196
209
|
...common,
|
|
197
|
-
|
|
210
|
+
subject: prompt.subject,
|
|
211
|
+
presentation,
|
|
212
|
+
revision,
|
|
198
213
|
};
|
|
199
|
-
const
|
|
214
|
+
const subjectDigest = digestCanonical(prompt.subject);
|
|
215
|
+
const presentationDigest = digestCanonical(presentation);
|
|
216
|
+
const requestDigest = digestCanonical(basis);
|
|
217
|
+
const decisionId = decisionIdFor(input, requestDigest);
|
|
200
218
|
return {
|
|
201
219
|
...basis,
|
|
202
|
-
decisionId
|
|
220
|
+
decisionId,
|
|
203
221
|
requestDigest,
|
|
204
|
-
|
|
222
|
+
subjectDigest,
|
|
223
|
+
presentationDigest,
|
|
224
|
+
createdAt,
|
|
205
225
|
};
|
|
206
226
|
}
|
|
207
227
|
|
|
@@ -220,13 +240,20 @@ function decisionIdFor(
|
|
|
220
240
|
export function validateHumanDecisionResponse(
|
|
221
241
|
request: HumanDecisionRequest,
|
|
222
242
|
value: unknown,
|
|
243
|
+
): HumanDecisionResponse {
|
|
244
|
+
return validateResponseForChoices(request.choices, value);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function validateResponseForChoices(
|
|
248
|
+
choices: HumanDecisionChoiceMap,
|
|
249
|
+
value: unknown,
|
|
223
250
|
): HumanDecisionResponse {
|
|
224
251
|
const response = requireRecord(value, "Human decision response");
|
|
225
252
|
const selected = response.choice;
|
|
226
|
-
if (typeof selected !== "string" || !Object.hasOwn(
|
|
253
|
+
if (typeof selected !== "string" || !Object.hasOwn(choices, selected)) {
|
|
227
254
|
throw new Error(`Human decision choice ${JSON.stringify(selected)} is not available`);
|
|
228
255
|
}
|
|
229
|
-
const selectedChoice =
|
|
256
|
+
const selectedChoice = choices[selected];
|
|
230
257
|
if (!selectedChoice) throw new Error("Human decision choice contract is missing");
|
|
231
258
|
if (selectedChoice.input === undefined) {
|
|
232
259
|
if (response.input !== undefined) {
|
|
@@ -281,66 +308,157 @@ export function validateHumanDecisionSubmission(
|
|
|
281
308
|
};
|
|
282
309
|
}
|
|
283
310
|
|
|
284
|
-
export function
|
|
285
|
-
return
|
|
286
|
-
? path.join(path.dirname(runsRoot), "decisions")
|
|
287
|
-
: `${runsRoot}.decisions`;
|
|
311
|
+
export function humanDecisionDatabasePath(homeDir?: string): string {
|
|
312
|
+
return workflowStatePath(homeDir);
|
|
288
313
|
}
|
|
289
314
|
|
|
290
315
|
export type HumanDecisionAcceptance =
|
|
291
|
-
| { status: "accepted" | "adopted"; decision:
|
|
292
|
-
| { status: "conflict"; decision:
|
|
316
|
+
| { status: "accepted" | "adopted"; decision: ResolvedHumanDecision }
|
|
317
|
+
| { status: "conflict"; decision: ResolvedHumanDecision };
|
|
293
318
|
|
|
294
319
|
type HumanDecisionResolution =
|
|
295
320
|
| {
|
|
296
321
|
schema: "pi-workflows.human-decision-resolution.v1";
|
|
297
322
|
outcome: "accepted";
|
|
298
|
-
decision:
|
|
299
|
-
AcceptedHumanDecision,
|
|
300
|
-
{ schema: "pi-workflows.human-decision-accepted.v1" }
|
|
301
|
-
>;
|
|
323
|
+
decision: ResolvedHumanDecision;
|
|
302
324
|
}
|
|
303
325
|
| {
|
|
304
|
-
schema: "pi-workflows.human-decision-resolution.
|
|
305
|
-
outcome: "accepted";
|
|
306
|
-
decision: Extract<
|
|
307
|
-
AcceptedHumanDecision,
|
|
308
|
-
{ schema: "pi-workflows.human-decision-accepted.v2" }
|
|
309
|
-
>;
|
|
310
|
-
}
|
|
311
|
-
| {
|
|
312
|
-
schema:
|
|
313
|
-
| "pi-workflows.human-decision-resolution.v1"
|
|
314
|
-
| "pi-workflows.human-decision-resolution.v2";
|
|
326
|
+
schema: "pi-workflows.human-decision-resolution.v1";
|
|
315
327
|
outcome: "cancelled";
|
|
316
328
|
cancellation: HumanDecisionCancellationRecord;
|
|
317
329
|
};
|
|
318
330
|
|
|
331
|
+
const INCOMPATIBLE_HUMAN_DECISION_STATE =
|
|
332
|
+
"Human decision state uses an incompatible alpha contract; reset the affected workflow run and decision state.";
|
|
333
|
+
|
|
334
|
+
function requireCurrentDecision(value: unknown): ResolvedHumanDecision {
|
|
335
|
+
const decision = requireRecord(value, "Resolved human decision");
|
|
336
|
+
if (
|
|
337
|
+
decision.schema !== "pi-workflows.human-decision-accepted.v1" ||
|
|
338
|
+
!Object.hasOwn(decision, "subjectDigest") ||
|
|
339
|
+
!Object.hasOwn(decision, "presentationDigest") ||
|
|
340
|
+
!Object.hasOwn(decision, "revision")
|
|
341
|
+
) {
|
|
342
|
+
throw new Error(INCOMPATIBLE_HUMAN_DECISION_STATE);
|
|
343
|
+
}
|
|
344
|
+
return value as ResolvedHumanDecision;
|
|
345
|
+
}
|
|
346
|
+
|
|
319
347
|
export class HumanDecisionStore {
|
|
320
|
-
readonly
|
|
348
|
+
readonly databasePath: string;
|
|
349
|
+
readonly state: StateDatabase;
|
|
350
|
+
private readonly ownsState: boolean;
|
|
351
|
+
private readonly authorityProvider:
|
|
352
|
+
| ((runId: string) => RunWriteAuthority | undefined)
|
|
353
|
+
| undefined;
|
|
321
354
|
|
|
322
|
-
constructor(
|
|
323
|
-
|
|
355
|
+
constructor(
|
|
356
|
+
databasePath: string = workflowStatePath(),
|
|
357
|
+
options: {
|
|
358
|
+
state?: StateDatabase;
|
|
359
|
+
authorityProvider?: (runId: string) => RunWriteAuthority | undefined;
|
|
360
|
+
readOnly?: boolean;
|
|
361
|
+
} = {},
|
|
362
|
+
) {
|
|
363
|
+
this.ownsState = options.state === undefined;
|
|
364
|
+
this.state =
|
|
365
|
+
options.state ??
|
|
366
|
+
new StateDatabase({
|
|
367
|
+
filePath: databasePath,
|
|
368
|
+
mode: options.readOnly === true ? "read-only" : "read-write",
|
|
369
|
+
checkLegacyState: databasePath === workflowStatePath(),
|
|
370
|
+
});
|
|
371
|
+
this.databasePath = this.state.filePath;
|
|
372
|
+
this.authorityProvider = options.authorityProvider;
|
|
324
373
|
}
|
|
325
374
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
return path.join(this.root, decisionId);
|
|
375
|
+
close(): void {
|
|
376
|
+
if (this.ownsState) this.state.close();
|
|
329
377
|
}
|
|
330
378
|
|
|
331
379
|
async createRequest(request: HumanDecisionRequest): Promise<"created" | "adopted"> {
|
|
332
380
|
validateHumanDecisionRequestIntegrity(request);
|
|
333
|
-
return
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
381
|
+
return this.state.transaction(() => {
|
|
382
|
+
const existing = this.readRequestRow(request.decisionId);
|
|
383
|
+
if (existing !== undefined) {
|
|
384
|
+
const stored = this.readRequestFromHash(existing.requestHash);
|
|
385
|
+
if (canonicalJson(stored) !== canonicalJson(request)) {
|
|
386
|
+
throw new Error("Immutable human decision request conflicts");
|
|
387
|
+
}
|
|
388
|
+
return "adopted";
|
|
389
|
+
}
|
|
390
|
+
const run = this.state.connection
|
|
391
|
+
.prepare("SELECT resource_id AS resourceId FROM runs WHERE run_id = ?")
|
|
392
|
+
.get(request.runId);
|
|
393
|
+
if (!isResourceIdRow(run)) throw new Error(`Human decision run is missing: ${request.runId}`);
|
|
394
|
+
const attempt = this.state.connection
|
|
395
|
+
.prepare("SELECT 1 AS present FROM node_attempts WHERE attempt_id = ? AND run_id = ?")
|
|
396
|
+
.get(request.attemptId, request.runId);
|
|
397
|
+
if (!isPresentRow(attempt)) {
|
|
398
|
+
throw new Error(`Human decision attempt is missing: ${request.attemptId}`);
|
|
399
|
+
}
|
|
400
|
+
const now = Date.parse(request.createdAt);
|
|
401
|
+
const resourceId = resourceIdFor("decision", request.decisionId);
|
|
402
|
+
const subjectHash = this.state.putJson(request.subject, now);
|
|
403
|
+
const presentationHash = this.state.putJson(request.presentation, now);
|
|
404
|
+
const choicesHash = this.state.putJson(request.choices, now);
|
|
405
|
+
const defaultHash =
|
|
406
|
+
request.defaultResponse === undefined
|
|
407
|
+
? null
|
|
408
|
+
: this.state.putJson(request.defaultResponse, now);
|
|
409
|
+
const requestHash = this.state.putJson(request, now);
|
|
410
|
+
this.state.connection
|
|
411
|
+
.prepare(
|
|
412
|
+
`INSERT INTO resources(
|
|
413
|
+
resource_id, resource_type, aggregate_key, revision, created_at, updated_at
|
|
414
|
+
) VALUES (?, 'decision', ?, 1, ?, ?)`,
|
|
415
|
+
)
|
|
416
|
+
.run(resourceId, request.decisionId, now, now);
|
|
417
|
+
this.state.connection
|
|
418
|
+
.prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
|
|
419
|
+
.run(resourceId);
|
|
420
|
+
this.state.connection
|
|
421
|
+
.prepare(
|
|
422
|
+
`INSERT INTO human_decisions(
|
|
423
|
+
decision_id, resource_id, run_id, attempt_id, audience, title,
|
|
424
|
+
subject_hash, presentation_hash, choices_hash, request_digest,
|
|
425
|
+
presentation_revision, deadline_at, default_response_hash, request_hash, created_at
|
|
426
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
427
|
+
)
|
|
428
|
+
.run(
|
|
429
|
+
request.decisionId,
|
|
430
|
+
resourceId,
|
|
431
|
+
request.runId,
|
|
432
|
+
request.attemptId,
|
|
433
|
+
request.audience,
|
|
434
|
+
request.title,
|
|
435
|
+
subjectHash,
|
|
436
|
+
presentationHash,
|
|
437
|
+
choicesHash,
|
|
438
|
+
digestBuffer(request.requestDigest),
|
|
439
|
+
request.revision,
|
|
440
|
+
request.expiresAt === undefined ? null : Date.parse(request.expiresAt),
|
|
441
|
+
defaultHash,
|
|
442
|
+
requestHash,
|
|
443
|
+
now,
|
|
444
|
+
);
|
|
445
|
+
this.insertDecisionEvent(
|
|
446
|
+
resourceId,
|
|
447
|
+
1,
|
|
448
|
+
"decision.requested",
|
|
449
|
+
"system",
|
|
450
|
+
null,
|
|
451
|
+
requestHash,
|
|
452
|
+
now,
|
|
453
|
+
);
|
|
454
|
+
return "created";
|
|
455
|
+
});
|
|
337
456
|
}
|
|
338
457
|
|
|
339
458
|
async readRequest(decisionId: string): Promise<HumanDecisionRequest | null> {
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
return request === null ? null : validateHumanDecisionRequestIntegrity(request);
|
|
459
|
+
assertId(decisionId, "decision id");
|
|
460
|
+
const row = this.readRequestRow(decisionId);
|
|
461
|
+
return row === undefined ? null : this.readRequestFromHash(row.requestHash);
|
|
344
462
|
}
|
|
345
463
|
|
|
346
464
|
async recordDelivery(
|
|
@@ -348,18 +466,7 @@ export class HumanDecisionStore {
|
|
|
348
466
|
channel: string,
|
|
349
467
|
value: HumanDecisionDeliveryRecord,
|
|
350
468
|
): Promise<"created" | "adopted"> {
|
|
351
|
-
|
|
352
|
-
const attemptId = requireString(value.attemptId, "Human decision delivery attempt");
|
|
353
|
-
assertId(attemptId, "delivery attempt id");
|
|
354
|
-
return await writeImmutableJson(
|
|
355
|
-
path.join(
|
|
356
|
-
this.decisionDir(request.decisionId),
|
|
357
|
-
"deliveries",
|
|
358
|
-
safeChannel,
|
|
359
|
-
`${attemptId}.json`,
|
|
360
|
-
),
|
|
361
|
-
value,
|
|
362
|
-
);
|
|
469
|
+
return this.recordChannelMessage(request.decisionId, channel, "delivery", value);
|
|
363
470
|
}
|
|
364
471
|
|
|
365
472
|
async accept(
|
|
@@ -367,110 +474,82 @@ export class HumanDecisionStore {
|
|
|
367
474
|
submission: HumanDecisionSubmission,
|
|
368
475
|
): Promise<HumanDecisionAcceptance> {
|
|
369
476
|
validateHumanDecisionRequestIntegrity(request);
|
|
370
|
-
const cancellation = await this.readCancellation(request.decisionId);
|
|
371
|
-
if (cancellation !== null) {
|
|
372
|
-
throw new Error(`Human decision request was ${cancellation.reason}`);
|
|
373
|
-
}
|
|
374
477
|
const normalized = validateHumanDecisionSubmission(request, submission);
|
|
375
|
-
|
|
478
|
+
const attemptedAt = new Date().toISOString();
|
|
376
479
|
const attemptId = digestHex({
|
|
377
480
|
decisionId: request.decisionId,
|
|
378
481
|
idempotencyKey: normalized.idempotencyKey,
|
|
379
482
|
}).slice(0, 40);
|
|
380
|
-
const
|
|
381
|
-
|
|
382
|
-
"answers",
|
|
383
|
-
`${attemptId}.json`,
|
|
384
|
-
);
|
|
385
|
-
const attempt = {
|
|
386
|
-
schema: "pi-workflows.human-decision-answer-attempt.v1",
|
|
387
|
-
attemptId,
|
|
388
|
-
attemptedAt,
|
|
389
|
-
...normalized,
|
|
390
|
-
};
|
|
391
|
-
const attemptWrite = await writeImmutableJson(attemptPath, attempt, false);
|
|
392
|
-
if (attemptWrite === "adopted") {
|
|
393
|
-
const existingAttempt = requireRecord(
|
|
394
|
-
await readJson(attemptPath),
|
|
395
|
-
"Existing human decision answer attempt",
|
|
396
|
-
);
|
|
397
|
-
const existingSubmission = {
|
|
398
|
-
decisionId: existingAttempt.decisionId,
|
|
399
|
-
requestDigest: existingAttempt.requestDigest,
|
|
400
|
-
choice: existingAttempt.choice,
|
|
401
|
-
...(existingAttempt.input !== undefined ? { input: existingAttempt.input } : {}),
|
|
402
|
-
source: existingAttempt.source,
|
|
403
|
-
idempotencyKey: existingAttempt.idempotencyKey,
|
|
404
|
-
};
|
|
405
|
-
if (canonicalJson(existingSubmission) !== canonicalJson(normalized)) {
|
|
406
|
-
throw new Error("Human decision idempotency key was reused for a different answer");
|
|
407
|
-
}
|
|
408
|
-
attemptedAt = requireString(existingAttempt.attemptedAt, "answer attempt time");
|
|
409
|
-
}
|
|
410
|
-
const response = validateHumanDecisionResponse(request, normalized);
|
|
411
|
-
const commonDecision = {
|
|
483
|
+
const decision: ResolvedHumanDecision = {
|
|
484
|
+
schema: "pi-workflows.human-decision-accepted.v1",
|
|
412
485
|
decisionId: request.decisionId,
|
|
413
486
|
requestDigest: request.requestDigest,
|
|
414
|
-
response,
|
|
487
|
+
response: validateHumanDecisionResponse(request, normalized),
|
|
488
|
+
provenance: "human",
|
|
415
489
|
source: normalized.source,
|
|
416
490
|
idempotencyKey: normalized.idempotencyKey,
|
|
417
491
|
acceptedAt: attemptedAt,
|
|
418
|
-
answerDigest: digest({
|
|
492
|
+
answerDigest: digest({
|
|
493
|
+
requestDigest: request.requestDigest,
|
|
494
|
+
response: validateHumanDecisionResponse(request, normalized),
|
|
495
|
+
source: normalized.source,
|
|
496
|
+
}),
|
|
497
|
+
subjectDigest: request.subjectDigest,
|
|
498
|
+
presentationDigest: request.presentationDigest,
|
|
499
|
+
revision: request.revision,
|
|
419
500
|
};
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
outcome: "accepted" as const,
|
|
437
|
-
decision,
|
|
438
|
-
} as HumanDecisionResolution;
|
|
439
|
-
const result = await writeImmutableJson(resolutionPath, resolution, false);
|
|
440
|
-
const winner =
|
|
441
|
-
result === "created"
|
|
442
|
-
? resolution
|
|
443
|
-
: ((await readJson(resolutionPath)) as HumanDecisionResolution | null);
|
|
444
|
-
if (winner === null) throw new Error("Human decision resolution became unreadable");
|
|
445
|
-
if (winner.outcome === "cancelled") {
|
|
446
|
-
throw new Error(`Human decision request was ${winner.cancellation.reason}`);
|
|
447
|
-
}
|
|
448
|
-
const existing = winner.decision;
|
|
449
|
-
await writeImmutableJson(
|
|
450
|
-
path.join(this.decisionDir(request.decisionId), "accepted.json"),
|
|
451
|
-
existing,
|
|
452
|
-
);
|
|
453
|
-
if (result === "created") return { status: "accepted", decision: existing };
|
|
454
|
-
if (canonicalJson(existing) === canonicalJson(decision)) {
|
|
455
|
-
return { status: "adopted", decision: existing };
|
|
501
|
+
return this.attemptAcceptance(request, decision, {
|
|
502
|
+
attemptId,
|
|
503
|
+
source: "human",
|
|
504
|
+
actorId: normalized.source.actorId,
|
|
505
|
+
channel: normalized.source.channel,
|
|
506
|
+
candidate: normalized,
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async resolveTimeout(
|
|
511
|
+
request: HumanDecisionRequest,
|
|
512
|
+
now = new Date(),
|
|
513
|
+
): Promise<HumanDecisionAcceptance> {
|
|
514
|
+
validateHumanDecisionRequestIntegrity(request);
|
|
515
|
+
if (request.expiresAt === undefined || request.defaultResponse === undefined) {
|
|
516
|
+
throw new Error("Human decision request has no timeout default");
|
|
456
517
|
}
|
|
457
|
-
if (
|
|
458
|
-
|
|
459
|
-
canonicalJson(existing.response) === canonicalJson(decision.response) &&
|
|
460
|
-
canonicalJson(existing.source) === canonicalJson(decision.source)
|
|
461
|
-
) {
|
|
462
|
-
return { status: "adopted", decision: existing };
|
|
518
|
+
if (Date.parse(request.expiresAt) > now.getTime()) {
|
|
519
|
+
throw new Error("Human decision timeout default is not eligible yet");
|
|
463
520
|
}
|
|
464
|
-
|
|
521
|
+
this.assertRunOwner(request.runId, now.getTime());
|
|
522
|
+
const response = validateHumanDecisionResponse(request, request.defaultResponse);
|
|
523
|
+
const decision: ResolvedHumanDecision = {
|
|
524
|
+
schema: "pi-workflows.human-decision-accepted.v1",
|
|
525
|
+
decisionId: request.decisionId,
|
|
526
|
+
requestDigest: request.requestDigest,
|
|
527
|
+
response,
|
|
528
|
+
provenance: "timeout",
|
|
529
|
+
acceptedAt: now.toISOString(),
|
|
530
|
+
answerDigest: digest({
|
|
531
|
+
provenance: "timeout",
|
|
532
|
+
requestDigest: request.requestDigest,
|
|
533
|
+
response,
|
|
534
|
+
}),
|
|
535
|
+
subjectDigest: request.subjectDigest,
|
|
536
|
+
presentationDigest: request.presentationDigest,
|
|
537
|
+
revision: request.revision,
|
|
538
|
+
};
|
|
539
|
+
const actorId = this.authorityProvider?.(request.runId)?.ownerId;
|
|
540
|
+
return this.attemptAcceptance(request, decision, {
|
|
541
|
+
attemptId: digestHex({ decisionId: request.decisionId, provenance: "timeout" }).slice(0, 40),
|
|
542
|
+
source: "policy",
|
|
543
|
+
...(actorId === undefined ? {} : { actorId }),
|
|
544
|
+
candidate: response,
|
|
545
|
+
});
|
|
465
546
|
}
|
|
466
547
|
|
|
467
548
|
async listDeliveries(
|
|
468
549
|
decisionId: string,
|
|
469
550
|
channel: string,
|
|
470
551
|
): Promise<HumanDecisionDeliveryRecord[]> {
|
|
471
|
-
|
|
472
|
-
const directory = path.join(this.decisionDir(decisionId), "deliveries", safeChannel);
|
|
473
|
-
return (await readJsonDirectory(directory)) as HumanDecisionDeliveryRecord[];
|
|
552
|
+
return this.listChannelMessages<HumanDecisionDeliveryRecord>(decisionId, channel, "delivery");
|
|
474
553
|
}
|
|
475
554
|
|
|
476
555
|
async recordSettlement(
|
|
@@ -478,26 +557,18 @@ export class HumanDecisionStore {
|
|
|
478
557
|
channel: string,
|
|
479
558
|
value: HumanDecisionSettlementRecord,
|
|
480
559
|
): Promise<"created" | "adopted"> {
|
|
481
|
-
|
|
482
|
-
assertId(value.attemptId, "settlement attempt id");
|
|
483
|
-
return await writeImmutableJson(
|
|
484
|
-
path.join(
|
|
485
|
-
this.decisionDir(decisionId),
|
|
486
|
-
"settlements",
|
|
487
|
-
safeChannel,
|
|
488
|
-
`${value.attemptId}.json`,
|
|
489
|
-
),
|
|
490
|
-
value,
|
|
491
|
-
);
|
|
560
|
+
return this.recordChannelMessage(decisionId, channel, "settlement", value);
|
|
492
561
|
}
|
|
493
562
|
|
|
494
563
|
async listSettlements(
|
|
495
564
|
decisionId: string,
|
|
496
565
|
channel: string,
|
|
497
566
|
): Promise<HumanDecisionSettlementRecord[]> {
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
567
|
+
return this.listChannelMessages<HumanDecisionSettlementRecord>(
|
|
568
|
+
decisionId,
|
|
569
|
+
channel,
|
|
570
|
+
"settlement",
|
|
571
|
+
);
|
|
501
572
|
}
|
|
502
573
|
|
|
503
574
|
async cancel(
|
|
@@ -505,112 +576,679 @@ export class HumanDecisionStore {
|
|
|
505
576
|
reason: HumanDecisionCancellationRecord["reason"],
|
|
506
577
|
): Promise<"created" | "adopted"> {
|
|
507
578
|
validateHumanDecisionRequestIntegrity(request);
|
|
508
|
-
if (
|
|
509
|
-
throw new Error("
|
|
579
|
+
if (reason === "expired" && request.defaultResponse !== undefined) {
|
|
580
|
+
throw new Error("A defaulted human decision must resolve through its timeout policy");
|
|
581
|
+
}
|
|
582
|
+
return this.state.transaction(() => {
|
|
583
|
+
this.requireStoredRequest(request);
|
|
584
|
+
const winner = this.readResolution(request.decisionId);
|
|
585
|
+
if (winner !== null) {
|
|
586
|
+
if (winner.outcome === "cancelled") {
|
|
587
|
+
if (winner.cancellation.reason === reason) return "adopted";
|
|
588
|
+
throw new Error(
|
|
589
|
+
`Human decision was already cancelled with reason ${winner.cancellation.reason}`,
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
throw new Error(`Human decision was already accepted by ${winner.decision.provenance}`);
|
|
593
|
+
}
|
|
594
|
+
const now = Date.now();
|
|
595
|
+
if (reason === "expired") {
|
|
596
|
+
if (request.expiresAt === undefined) {
|
|
597
|
+
throw new Error("Human decision request has no expiry");
|
|
598
|
+
}
|
|
599
|
+
if (Date.parse(request.expiresAt) > now) {
|
|
600
|
+
throw new Error("Human decision expiry cancellation is not eligible yet");
|
|
601
|
+
}
|
|
602
|
+
this.assertRunOwner(request.runId, now);
|
|
603
|
+
}
|
|
604
|
+
const record: HumanDecisionCancellationRecord = {
|
|
605
|
+
schema: "pi-workflows.human-decision-cancellation.v1",
|
|
606
|
+
decisionId: request.decisionId,
|
|
607
|
+
requestDigest: request.requestDigest,
|
|
608
|
+
cancelledAt: new Date(now).toISOString(),
|
|
609
|
+
reason,
|
|
610
|
+
};
|
|
611
|
+
const provenance = reason === "expired" ? "expired_no_default" : "explicit_cancel";
|
|
612
|
+
this.state.connection
|
|
613
|
+
.prepare(
|
|
614
|
+
`INSERT INTO human_decision_resolutions(
|
|
615
|
+
decision_id, outcome, provenance, response_hash, reason, channel,
|
|
616
|
+
actor_id, request_digest, resolved_at
|
|
617
|
+
) VALUES (?, 'cancelled', ?, NULL, ?, NULL, NULL, ?, ?)`,
|
|
618
|
+
)
|
|
619
|
+
.run(request.decisionId, provenance, reason, digestBuffer(request.requestDigest), now);
|
|
620
|
+
const row = this.requireDecisionResource(request.decisionId);
|
|
621
|
+
this.bumpDecision(row.resourceId, row.revision, now);
|
|
622
|
+
const recordHash = this.state.putJson(record, now);
|
|
623
|
+
this.insertDecisionEvent(
|
|
624
|
+
row.resourceId,
|
|
625
|
+
row.revision + 1,
|
|
626
|
+
"decision.cancelled",
|
|
627
|
+
reason === "expired" ? "policy" : "control",
|
|
628
|
+
null,
|
|
629
|
+
recordHash,
|
|
630
|
+
now,
|
|
631
|
+
);
|
|
632
|
+
this.enqueueResolutionEffects(row.resourceId, row.revision + 1, request, "cancelled", now);
|
|
633
|
+
return "created";
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async readCancellation(decisionId: string): Promise<HumanDecisionCancellationRecord | null> {
|
|
638
|
+
const resolution = this.readResolution(decisionId);
|
|
639
|
+
return resolution?.outcome === "cancelled" ? resolution.cancellation : null;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
async readResolved(decisionId: string): Promise<ResolvedHumanDecision | null> {
|
|
643
|
+
const resolution = this.readResolution(decisionId);
|
|
644
|
+
return resolution?.outcome === "accepted" ? resolution.decision : null;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
async recordContinuation(
|
|
648
|
+
decisionId: string,
|
|
649
|
+
value: HumanDecisionContinuationRecord,
|
|
650
|
+
): Promise<"created" | "adopted"> {
|
|
651
|
+
return this.state.transaction(() => {
|
|
652
|
+
const existing = this.readContinuationRow(decisionId);
|
|
653
|
+
if (existing !== undefined) {
|
|
654
|
+
const stored = this.state.readJson(existing.recordHash) as HumanDecisionContinuationRecord;
|
|
655
|
+
if (canonicalJson(stored) !== canonicalJson(value)) {
|
|
656
|
+
throw new Error("Immutable human decision continuation conflicts");
|
|
657
|
+
}
|
|
658
|
+
return "adopted";
|
|
659
|
+
}
|
|
660
|
+
const resolution = this.readResolution(decisionId);
|
|
661
|
+
if (resolution?.outcome !== "accepted") {
|
|
662
|
+
throw new Error("A continuation requires an accepted human decision");
|
|
663
|
+
}
|
|
664
|
+
const recordHash = this.state.putJson(value, Date.parse(value.createdAt));
|
|
665
|
+
this.state.connection
|
|
666
|
+
.prepare(
|
|
667
|
+
`INSERT INTO continuations(
|
|
668
|
+
decision_id, parent_run_id, continuation_run_id, response_hash, created_at
|
|
669
|
+
) VALUES (?, ?, ?, ?, ?)`,
|
|
670
|
+
)
|
|
671
|
+
.run(decisionId, value.parentRunId, value.runId, recordHash, Date.parse(value.createdAt));
|
|
672
|
+
return "created";
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
async readContinuation(decisionId: string): Promise<HumanDecisionContinuationRecord | null> {
|
|
677
|
+
const row = this.readContinuationRow(decisionId);
|
|
678
|
+
return row === undefined
|
|
679
|
+
? null
|
|
680
|
+
: (this.state.readJson(row.recordHash) as HumanDecisionContinuationRecord);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
markEffectApplied(
|
|
684
|
+
decisionId: string,
|
|
685
|
+
effectType: "decision.continue" | "decision.cancel_parent" | "decision.settle_presentations",
|
|
686
|
+
): void {
|
|
687
|
+
const rows = this.state.connection
|
|
688
|
+
.prepare(
|
|
689
|
+
`SELECT e.effect_id AS effectId, e.resource_id AS resourceId
|
|
690
|
+
FROM effects e
|
|
691
|
+
JOIN human_decisions d ON d.resource_id = e.source_resource_id
|
|
692
|
+
WHERE d.decision_id = ? AND e.effect_type = ? AND e.status = 'pending'`,
|
|
693
|
+
)
|
|
694
|
+
.all(decisionId, effectType);
|
|
695
|
+
for (const row of rows) {
|
|
696
|
+
/* istanbul ignore if -- exact schema and internal query shape */
|
|
697
|
+
if (!isEffectIdentityRecord(row)) continue;
|
|
698
|
+
this.state.transaction(() => {
|
|
699
|
+
const now = Date.now();
|
|
700
|
+
const revisionRow = this.state.connection
|
|
701
|
+
.prepare("SELECT revision FROM resources WHERE resource_id = ?")
|
|
702
|
+
.get(row.resourceId);
|
|
703
|
+
/* istanbul ignore if -- exact schema and internal query shape */
|
|
704
|
+
if (!isRevisionRecord(revisionRow)) throw new Error("Decision effect resource is missing");
|
|
705
|
+
this.state.connection
|
|
706
|
+
.prepare(
|
|
707
|
+
`UPDATE effects SET status = 'applied', updated_at = ?, settled_at = ?
|
|
708
|
+
WHERE effect_id = ? AND status = 'pending'`,
|
|
709
|
+
)
|
|
710
|
+
.run(now, now, row.effectId);
|
|
711
|
+
this.state.connection
|
|
712
|
+
.prepare(
|
|
713
|
+
`UPDATE resources SET revision = revision + 1, updated_at = ?
|
|
714
|
+
WHERE resource_id = ? AND revision = ?`,
|
|
715
|
+
)
|
|
716
|
+
.run(now, row.resourceId, revisionRow.revision);
|
|
717
|
+
const payloadHash = this.state.putJson({ decisionId, effectType }, now);
|
|
718
|
+
this.insertDecisionEvent(
|
|
719
|
+
row.resourceId,
|
|
720
|
+
revisionRow.revision + 1,
|
|
721
|
+
"effect.applied",
|
|
722
|
+
"system",
|
|
723
|
+
null,
|
|
724
|
+
payloadHash,
|
|
725
|
+
now,
|
|
726
|
+
);
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
async listRequests(): Promise<HumanDecisionRequest[]> {
|
|
732
|
+
const rows = this.state.connection
|
|
733
|
+
.prepare("SELECT request_hash AS requestHash FROM human_decisions ORDER BY created_at")
|
|
734
|
+
.all();
|
|
735
|
+
return rows.filter(isRequestHashRow).map((row) => this.readRequestFromHash(row.requestHash));
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
private attemptAcceptance(
|
|
739
|
+
request: HumanDecisionRequest,
|
|
740
|
+
decision: ResolvedHumanDecision,
|
|
741
|
+
attempt: {
|
|
742
|
+
attemptId: string;
|
|
743
|
+
source: "human" | "policy";
|
|
744
|
+
actorId?: string;
|
|
745
|
+
channel?: string;
|
|
746
|
+
candidate: unknown;
|
|
747
|
+
},
|
|
748
|
+
): HumanDecisionAcceptance {
|
|
749
|
+
return this.state.transaction(() => {
|
|
750
|
+
this.requireStoredRequest(request);
|
|
751
|
+
const timing = this.state.connection
|
|
752
|
+
.prepare("SELECT deadline_at AS deadlineAt FROM human_decisions WHERE decision_id = ?")
|
|
753
|
+
.get(request.decisionId);
|
|
754
|
+
if (!isDeadlineRow(timing)) throw new Error("Human decision request is missing");
|
|
755
|
+
const arbitrationTime =
|
|
756
|
+
attempt.source === "policy" ? Date.parse(decision.acceptedAt) : Date.now();
|
|
757
|
+
if (
|
|
758
|
+
attempt.source === "human" &&
|
|
759
|
+
timing.deadlineAt !== null &&
|
|
760
|
+
arbitrationTime > timing.deadlineAt
|
|
761
|
+
) {
|
|
762
|
+
throw new Error("Human decision request expired before the answer was accepted");
|
|
763
|
+
}
|
|
764
|
+
if (
|
|
765
|
+
attempt.source === "policy" &&
|
|
766
|
+
(timing.deadlineAt === null || arbitrationTime < timing.deadlineAt)
|
|
767
|
+
) {
|
|
768
|
+
throw new Error("Human decision timeout default is not eligible yet");
|
|
769
|
+
}
|
|
770
|
+
if (attempt.source === "policy") this.assertRunOwner(request.runId, arbitrationTime);
|
|
771
|
+
const candidateHash = this.state.putJson(attempt.candidate);
|
|
772
|
+
const existingSubmission = this.state.connection
|
|
773
|
+
.prepare(
|
|
774
|
+
`SELECT candidate_hash AS candidateHash
|
|
775
|
+
FROM human_decision_submissions WHERE decision_id = ? AND attempt_id = ?`,
|
|
776
|
+
)
|
|
777
|
+
.get(request.decisionId, attempt.attemptId);
|
|
778
|
+
if (
|
|
779
|
+
isCandidateHashRow(existingSubmission) &&
|
|
780
|
+
!existingSubmission.candidateHash.equals(candidateHash)
|
|
781
|
+
) {
|
|
782
|
+
throw new Error("Human decision submission idempotency key conflicts");
|
|
783
|
+
}
|
|
784
|
+
this.state.connection
|
|
785
|
+
.prepare(
|
|
786
|
+
`INSERT INTO human_decision_submissions(
|
|
787
|
+
decision_id, attempt_id, source, actor_id, channel,
|
|
788
|
+
candidate_hash, outcome, submitted_at
|
|
789
|
+
) VALUES (?, ?, ?, ?, ?, ?, 'rejected', ?)
|
|
790
|
+
ON CONFLICT(decision_id, attempt_id) DO NOTHING`,
|
|
791
|
+
)
|
|
792
|
+
.run(
|
|
793
|
+
request.decisionId,
|
|
794
|
+
attempt.attemptId,
|
|
795
|
+
attempt.source,
|
|
796
|
+
attempt.actorId ?? null,
|
|
797
|
+
attempt.channel ?? null,
|
|
798
|
+
candidateHash,
|
|
799
|
+
Date.parse(decision.acceptedAt),
|
|
800
|
+
);
|
|
801
|
+
const winner = this.readResolution(request.decisionId);
|
|
802
|
+
if (winner !== null) {
|
|
803
|
+
this.markSubmission(request.decisionId, attempt.attemptId, "already_resolved", null);
|
|
804
|
+
if (winner.outcome === "cancelled") {
|
|
805
|
+
throw new Error(`Human decision request was ${winner.cancellation.reason}`);
|
|
806
|
+
}
|
|
807
|
+
return canonicalJson(winner.decision) === canonicalJson(decision) ||
|
|
808
|
+
sameHumanAnswer(winner.decision, decision)
|
|
809
|
+
? { status: "adopted", decision: winner.decision }
|
|
810
|
+
: { status: "conflict", decision: winner.decision };
|
|
811
|
+
}
|
|
812
|
+
const now = Date.parse(decision.acceptedAt);
|
|
813
|
+
const responseHash = this.state.putJson(decision, now);
|
|
814
|
+
this.state.connection
|
|
815
|
+
.prepare(
|
|
816
|
+
`INSERT INTO human_decision_resolutions(
|
|
817
|
+
decision_id, outcome, provenance, response_hash, reason, channel,
|
|
818
|
+
actor_id, request_digest, resolved_at
|
|
819
|
+
) VALUES (?, 'accepted', ?, ?, NULL, ?, ?, ?, ?)`,
|
|
820
|
+
)
|
|
821
|
+
.run(
|
|
822
|
+
request.decisionId,
|
|
823
|
+
decision.provenance === "timeout" ? "timeout_policy" : "human",
|
|
824
|
+
responseHash,
|
|
825
|
+
attempt.channel ?? null,
|
|
826
|
+
attempt.actorId ?? null,
|
|
827
|
+
digestBuffer(request.requestDigest),
|
|
828
|
+
now,
|
|
829
|
+
);
|
|
830
|
+
const row = this.requireDecisionResource(request.decisionId);
|
|
831
|
+
this.bumpDecision(row.resourceId, row.revision, now);
|
|
832
|
+
this.insertDecisionEvent(
|
|
833
|
+
row.resourceId,
|
|
834
|
+
row.revision + 1,
|
|
835
|
+
"decision.accepted",
|
|
836
|
+
decision.provenance === "timeout" ? "policy" : "human",
|
|
837
|
+
attempt.actorId ?? null,
|
|
838
|
+
responseHash,
|
|
839
|
+
now,
|
|
840
|
+
);
|
|
841
|
+
this.enqueueResolutionEffects(row.resourceId, row.revision + 1, request, "accepted", now);
|
|
842
|
+
this.markSubmission(request.decisionId, attempt.attemptId, "won", responseHash);
|
|
843
|
+
return { status: "accepted", decision };
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
private readResolution(decisionId: string): HumanDecisionResolution | null {
|
|
848
|
+
const row = this.state.connection
|
|
849
|
+
.prepare(
|
|
850
|
+
`SELECT outcome, provenance, response_hash AS responseHash, reason,
|
|
851
|
+
request_digest AS requestDigest, resolved_at AS resolvedAt
|
|
852
|
+
FROM human_decision_resolutions WHERE decision_id = ?`,
|
|
853
|
+
)
|
|
854
|
+
.get(decisionId);
|
|
855
|
+
if (!isResolutionRow(row)) return null;
|
|
856
|
+
const request = this.readRequestRow(decisionId);
|
|
857
|
+
if (request === undefined) throw new Error("Human decision request is missing");
|
|
858
|
+
const storedRequest = this.readRequestFromHash(request.requestHash);
|
|
859
|
+
if (!row.requestDigest.equals(digestBuffer(storedRequest.requestDigest))) {
|
|
860
|
+
throw new Error("Human decision resolution request digest conflicts");
|
|
861
|
+
}
|
|
862
|
+
if (row.outcome === "accepted") {
|
|
863
|
+
if (row.responseHash === null) throw new Error("Accepted decision response is missing");
|
|
864
|
+
const decision = this.state.readJson(row.responseHash) as ResolvedHumanDecision;
|
|
865
|
+
return {
|
|
866
|
+
schema: "pi-workflows.human-decision-resolution.v1",
|
|
867
|
+
outcome: "accepted",
|
|
868
|
+
decision: requireCurrentDecision(decision),
|
|
869
|
+
};
|
|
510
870
|
}
|
|
511
|
-
const
|
|
512
|
-
const record: HumanDecisionCancellationRecord = {
|
|
871
|
+
const cancellation: HumanDecisionCancellationRecord = {
|
|
513
872
|
schema: "pi-workflows.human-decision-cancellation.v1",
|
|
514
|
-
decisionId
|
|
515
|
-
requestDigest:
|
|
516
|
-
cancelledAt: new Date().toISOString(),
|
|
517
|
-
reason,
|
|
873
|
+
decisionId,
|
|
874
|
+
requestDigest: storedRequest.requestDigest,
|
|
875
|
+
cancelledAt: new Date(row.resolvedAt).toISOString(),
|
|
876
|
+
reason: row.reason === "expired" ? "expired" : "cancelled",
|
|
518
877
|
};
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
schema:
|
|
522
|
-
request.schema === "pi-workflows.human-decision-request.v2"
|
|
523
|
-
? "pi-workflows.human-decision-resolution.v2"
|
|
524
|
-
: "pi-workflows.human-decision-resolution.v1",
|
|
878
|
+
return {
|
|
879
|
+
schema: "pi-workflows.human-decision-resolution.v1",
|
|
525
880
|
outcome: "cancelled",
|
|
526
|
-
cancellation
|
|
881
|
+
cancellation,
|
|
527
882
|
};
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
if (
|
|
535
|
-
throw new Error("
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
private requireStoredRequest(request: HumanDecisionRequest): void {
|
|
886
|
+
const row = this.readRequestRow(request.decisionId);
|
|
887
|
+
if (row === undefined) throw new Error("Human decision request is not durable");
|
|
888
|
+
const stored = this.readRequestFromHash(row.requestHash);
|
|
889
|
+
if (canonicalJson(stored) !== canonicalJson(request)) {
|
|
890
|
+
throw new Error("Human decision request does not match durable state");
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
private readRequestRow(decisionId: string): { requestHash: Buffer } | undefined {
|
|
895
|
+
const row = this.state.connection
|
|
896
|
+
.prepare("SELECT request_hash AS requestHash FROM human_decisions WHERE decision_id = ?")
|
|
897
|
+
.get(decisionId);
|
|
898
|
+
return isRequestHashRow(row) ? row : undefined;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
private readRequestFromHash(hash: Buffer): HumanDecisionRequest {
|
|
902
|
+
return validateHumanDecisionRequestIntegrity(this.state.readJson(hash) as HumanDecisionRequest);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
private requireDecisionResource(decisionId: string): { resourceId: string; revision: number } {
|
|
906
|
+
const row = this.state.connection
|
|
907
|
+
.prepare(
|
|
908
|
+
`SELECT d.resource_id AS resourceId, r.revision
|
|
909
|
+
FROM human_decisions d JOIN resources r ON r.resource_id = d.resource_id
|
|
910
|
+
WHERE d.decision_id = ?`,
|
|
911
|
+
)
|
|
912
|
+
.get(decisionId);
|
|
913
|
+
if (!isDecisionResourceRow(row)) throw new Error(`Human decision is missing: ${decisionId}`);
|
|
914
|
+
return row;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
private bumpDecision(resourceId: string, expectedRevision: number, now: number): void {
|
|
918
|
+
const result = this.state.connection
|
|
919
|
+
.prepare(
|
|
920
|
+
`UPDATE resources SET revision = revision + 1, updated_at = ?
|
|
921
|
+
WHERE resource_id = ? AND revision = ?`,
|
|
922
|
+
)
|
|
923
|
+
.run(now, resourceId, expectedRevision);
|
|
924
|
+
if (result.changes !== 1) throw new Error("Human decision revision conflict");
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
private insertDecisionEvent(
|
|
928
|
+
resourceId: string,
|
|
929
|
+
revision: number,
|
|
930
|
+
eventType: string,
|
|
931
|
+
actorType: string,
|
|
932
|
+
actorId: string | null,
|
|
933
|
+
payloadHash: Buffer,
|
|
934
|
+
now: number,
|
|
935
|
+
): void {
|
|
936
|
+
this.state.connection
|
|
937
|
+
.prepare(
|
|
938
|
+
`INSERT INTO events(
|
|
939
|
+
event_id, resource_id, resource_revision, event_type,
|
|
940
|
+
actor_type, actor_id, payload_hash, recorded_at
|
|
941
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
942
|
+
)
|
|
943
|
+
.run(
|
|
944
|
+
`event-${randomUUID()}`,
|
|
945
|
+
resourceId,
|
|
946
|
+
revision,
|
|
947
|
+
eventType,
|
|
948
|
+
actorType,
|
|
949
|
+
actorId,
|
|
950
|
+
payloadHash,
|
|
951
|
+
now,
|
|
952
|
+
);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
private enqueueResolutionEffects(
|
|
956
|
+
decisionResourceId: string,
|
|
957
|
+
revision: number,
|
|
958
|
+
request: HumanDecisionRequest,
|
|
959
|
+
outcome: "accepted" | "cancelled",
|
|
960
|
+
now: number,
|
|
961
|
+
): void {
|
|
962
|
+
const types =
|
|
963
|
+
outcome === "accepted"
|
|
964
|
+
? ["decision.continue", "decision.settle_presentations"]
|
|
965
|
+
: ["decision.cancel_parent", "decision.settle_presentations"];
|
|
966
|
+
for (const effectType of types) {
|
|
967
|
+
const idempotencyKey = request.decisionId;
|
|
968
|
+
const effectId = `effect-${digestHex({ decisionId: request.decisionId, effectType }).slice(0, 40)}`;
|
|
969
|
+
const effectResourceId = resourceIdFor("effect", effectId);
|
|
970
|
+
const payloadHash = this.state.putJson(
|
|
971
|
+
{ decisionId: request.decisionId, runId: request.runId, outcome },
|
|
972
|
+
now,
|
|
973
|
+
);
|
|
974
|
+
this.state.connection
|
|
975
|
+
.prepare(
|
|
976
|
+
`INSERT INTO resources(
|
|
977
|
+
resource_id, resource_type, aggregate_key, revision, created_at, updated_at
|
|
978
|
+
) VALUES (?, 'effect', ?, 0, ?, ?)`,
|
|
979
|
+
)
|
|
980
|
+
.run(effectResourceId, effectId, now, now);
|
|
981
|
+
this.state.connection
|
|
982
|
+
.prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
|
|
983
|
+
.run(effectResourceId);
|
|
984
|
+
this.state.connection
|
|
985
|
+
.prepare(
|
|
986
|
+
`INSERT INTO effects(
|
|
987
|
+
effect_id, resource_id, source_resource_id, source_revision,
|
|
988
|
+
effect_type, idempotency_key, payload_hash, owner_scope,
|
|
989
|
+
status, attempt_count, created_at, updated_at
|
|
990
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, 'run', 'pending', 0, ?, ?)`,
|
|
991
|
+
)
|
|
992
|
+
.run(
|
|
993
|
+
effectId,
|
|
994
|
+
effectResourceId,
|
|
995
|
+
decisionResourceId,
|
|
996
|
+
revision,
|
|
997
|
+
effectType,
|
|
998
|
+
idempotencyKey,
|
|
999
|
+
payloadHash,
|
|
1000
|
+
now,
|
|
1001
|
+
now,
|
|
1002
|
+
);
|
|
536
1003
|
}
|
|
537
|
-
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
private assertRunOwner(runId: string, now: number): void {
|
|
1007
|
+
const authority = this.authorityProvider?.(runId);
|
|
1008
|
+
if (authority === undefined) {
|
|
1009
|
+
throw new Error("Timeout policy requires the current run owner");
|
|
1010
|
+
}
|
|
1011
|
+
const row = this.state.connection
|
|
1012
|
+
.prepare(
|
|
1013
|
+
`SELECT l.generation, l.owner_type AS ownerType, l.owner_id AS ownerId,
|
|
1014
|
+
l.token_hash AS tokenHash, l.expires_at AS expiresAt
|
|
1015
|
+
FROM runs r JOIN leases l ON l.resource_id = r.resource_id
|
|
1016
|
+
WHERE r.run_id = ?`,
|
|
1017
|
+
)
|
|
1018
|
+
.get(runId);
|
|
538
1019
|
if (
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
1020
|
+
!isOwnerRow(row) ||
|
|
1021
|
+
row.ownerType !== authority.ownerType ||
|
|
1022
|
+
row.ownerId !== authority.ownerId ||
|
|
1023
|
+
row.generation !== authority.generation ||
|
|
1024
|
+
row.tokenHash === null ||
|
|
1025
|
+
!row.tokenHash.equals(tokenHash(authority.token)) ||
|
|
1026
|
+
row.expiresAt === null ||
|
|
1027
|
+
row.expiresAt <= now
|
|
542
1028
|
) {
|
|
543
|
-
throw new Error("
|
|
1029
|
+
throw new Error("Timeout policy run ownership is stale");
|
|
544
1030
|
}
|
|
545
|
-
await writeImmutableJson(filePath, existing);
|
|
546
|
-
return result;
|
|
547
1031
|
}
|
|
548
1032
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
1033
|
+
private recordChannelMessage<
|
|
1034
|
+
T extends HumanDecisionDeliveryRecord | HumanDecisionSettlementRecord,
|
|
1035
|
+
>(
|
|
1036
|
+
decisionId: string,
|
|
1037
|
+
channel: string,
|
|
1038
|
+
purpose: "delivery" | "settlement",
|
|
1039
|
+
value: T,
|
|
1040
|
+
): "created" | "adopted" {
|
|
1041
|
+
const safeChannel = requireSimpleId(channel, "Human decision channel");
|
|
1042
|
+
assertId(value.attemptId, `${purpose} attempt id`);
|
|
1043
|
+
return this.state.transaction(() => {
|
|
1044
|
+
if (this.readRequestRow(decisionId) === undefined) {
|
|
1045
|
+
throw new Error(`Human decision is missing: ${decisionId}`);
|
|
1046
|
+
}
|
|
1047
|
+
const channelId = this.ensureChannel(safeChannel, Date.parse(value.createdAt));
|
|
1048
|
+
const messageId = `message-${digestHex({
|
|
1049
|
+
decisionId,
|
|
1050
|
+
channel: safeChannel,
|
|
1051
|
+
purpose,
|
|
1052
|
+
attemptId: value.attemptId,
|
|
1053
|
+
phase: "phase" in value ? value.phase : undefined,
|
|
1054
|
+
partIndex: "partIndex" in value ? value.partIndex : undefined,
|
|
1055
|
+
}).slice(0, 40)}`;
|
|
1056
|
+
const contentHash = this.state.putJson(value, Date.parse(value.createdAt));
|
|
1057
|
+
const existing = this.state.connection
|
|
1058
|
+
.prepare("SELECT content_hash AS contentHash FROM channel_messages WHERE message_id = ?")
|
|
1059
|
+
.get(messageId);
|
|
1060
|
+
if (isContentHashRow(existing)) {
|
|
1061
|
+
if (!existing.contentHash.equals(contentHash)) {
|
|
1062
|
+
throw new Error(`Immutable human decision ${purpose} conflicts`);
|
|
1063
|
+
}
|
|
1064
|
+
return "adopted";
|
|
1065
|
+
}
|
|
1066
|
+
const status = channelMessageStatus(value);
|
|
1067
|
+
this.state.connection
|
|
1068
|
+
.prepare(
|
|
1069
|
+
`INSERT INTO channel_messages(
|
|
1070
|
+
message_id, channel_id, decision_id, purpose, content_hash,
|
|
1071
|
+
status, created_at, updated_at
|
|
1072
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1073
|
+
)
|
|
1074
|
+
.run(
|
|
1075
|
+
messageId,
|
|
1076
|
+
channelId,
|
|
1077
|
+
decisionId,
|
|
1078
|
+
purpose,
|
|
1079
|
+
contentHash,
|
|
1080
|
+
status,
|
|
1081
|
+
Date.parse(value.createdAt),
|
|
1082
|
+
"finishedAt" in value && value.finishedAt !== undefined
|
|
1083
|
+
? Date.parse(value.finishedAt)
|
|
1084
|
+
: Date.parse(value.createdAt),
|
|
1085
|
+
);
|
|
1086
|
+
return "created";
|
|
1087
|
+
});
|
|
579
1088
|
}
|
|
580
1089
|
|
|
581
|
-
|
|
1090
|
+
private listChannelMessages<T>(
|
|
582
1091
|
decisionId: string,
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
1092
|
+
channel: string,
|
|
1093
|
+
purpose: "delivery" | "settlement",
|
|
1094
|
+
): T[] {
|
|
1095
|
+
const channelId = channelIdFor(requireSimpleId(channel, "Human decision channel"));
|
|
1096
|
+
const rows = this.state.connection
|
|
1097
|
+
.prepare(
|
|
1098
|
+
`SELECT content_hash AS contentHash
|
|
1099
|
+
FROM channel_messages
|
|
1100
|
+
WHERE decision_id = ? AND channel_id = ? AND purpose = ?
|
|
1101
|
+
ORDER BY created_at, message_id`,
|
|
1102
|
+
)
|
|
1103
|
+
.all(decisionId, channelId, purpose);
|
|
1104
|
+
return rows.filter(isContentHashRow).map((row) => this.state.readJson(row.contentHash) as T);
|
|
589
1105
|
}
|
|
590
1106
|
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
1107
|
+
private ensureChannel(channel: string, now: number): string {
|
|
1108
|
+
const channelId = channelIdFor(channel);
|
|
1109
|
+
const resourceId = resourceIdFor("channel", channelId);
|
|
1110
|
+
this.state.connection
|
|
1111
|
+
.prepare(
|
|
1112
|
+
`INSERT INTO resources(
|
|
1113
|
+
resource_id, resource_type, aggregate_key, revision, created_at, updated_at
|
|
1114
|
+
) VALUES (?, 'channel', ?, 1, ?, ?)
|
|
1115
|
+
ON CONFLICT(resource_type, aggregate_key) DO NOTHING`,
|
|
1116
|
+
)
|
|
1117
|
+
.run(resourceId, channelId, now, now);
|
|
1118
|
+
this.state.connection
|
|
1119
|
+
.prepare(
|
|
1120
|
+
`INSERT INTO leases(resource_id, generation)
|
|
1121
|
+
VALUES (?, 0) ON CONFLICT(resource_id) DO NOTHING`,
|
|
1122
|
+
)
|
|
1123
|
+
.run(resourceId);
|
|
1124
|
+
this.state.connection
|
|
1125
|
+
.prepare(
|
|
1126
|
+
`INSERT INTO channels(channel_id, resource_id, adapter_type, profile_key, created_at)
|
|
1127
|
+
VALUES (?, ?, ?, ?, ?)
|
|
1128
|
+
ON CONFLICT(channel_id) DO NOTHING`,
|
|
1129
|
+
)
|
|
1130
|
+
.run(channelId, resourceId, channel.split("-")[0] ?? channel, channel, now);
|
|
1131
|
+
return channelId;
|
|
595
1132
|
}
|
|
596
1133
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
1134
|
+
private markSubmission(
|
|
1135
|
+
decisionId: string,
|
|
1136
|
+
attemptId: string,
|
|
1137
|
+
outcome: "won" | "adopted" | "already_resolved" | "rejected",
|
|
1138
|
+
resultHash: Buffer | null,
|
|
1139
|
+
): void {
|
|
1140
|
+
this.state.connection
|
|
1141
|
+
.prepare(
|
|
1142
|
+
`UPDATE human_decision_submissions SET outcome = ?, result_hash = ?
|
|
1143
|
+
WHERE decision_id = ? AND attempt_id = ?`,
|
|
1144
|
+
)
|
|
1145
|
+
.run(outcome, resultHash, decisionId, attemptId);
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
private readContinuationRow(decisionId: string): { recordHash: Buffer } | undefined {
|
|
1149
|
+
const row = this.state.connection
|
|
1150
|
+
.prepare("SELECT response_hash AS recordHash FROM continuations WHERE decision_id = ?")
|
|
1151
|
+
.get(decisionId);
|
|
1152
|
+
return isContinuationRow(row) ? row : undefined;
|
|
611
1153
|
}
|
|
612
1154
|
}
|
|
613
1155
|
|
|
1156
|
+
function digestBuffer(value: string): Buffer {
|
|
1157
|
+
const hex = value.startsWith("sha256:") ? value.slice(7) : value;
|
|
1158
|
+
if (!/^[a-f0-9]{64}$/i.test(hex)) throw new Error("Expected a SHA-256 digest");
|
|
1159
|
+
return Buffer.from(hex, "hex");
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
function channelIdFor(channel: string): string {
|
|
1163
|
+
return `channel-${digestHex(channel).slice(0, 40)}`;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
function sameHumanAnswer(left: ResolvedHumanDecision, right: ResolvedHumanDecision): boolean {
|
|
1167
|
+
return (
|
|
1168
|
+
left.provenance === "human" &&
|
|
1169
|
+
right.provenance === "human" &&
|
|
1170
|
+
left.idempotencyKey === right.idempotencyKey &&
|
|
1171
|
+
canonicalJson(left.response) === canonicalJson(right.response) &&
|
|
1172
|
+
canonicalJson(left.source) === canonicalJson(right.source)
|
|
1173
|
+
);
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
function channelMessageStatus(
|
|
1177
|
+
value: HumanDecisionDeliveryRecord | HumanDecisionSettlementRecord,
|
|
1178
|
+
): "pending" | "confirmed" | "failed" | "ambiguous" {
|
|
1179
|
+
if (value.state === "confirmed") return "confirmed";
|
|
1180
|
+
if (value.state === "failed") return "failed";
|
|
1181
|
+
if (value.state === "unknown") return "ambiguous";
|
|
1182
|
+
return "pending";
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
type ResolutionRow = {
|
|
1186
|
+
outcome: "accepted" | "cancelled";
|
|
1187
|
+
provenance: string;
|
|
1188
|
+
responseHash: Buffer | null;
|
|
1189
|
+
reason: string | null;
|
|
1190
|
+
requestDigest: Buffer;
|
|
1191
|
+
resolvedAt: number;
|
|
1192
|
+
};
|
|
1193
|
+
|
|
1194
|
+
function isResourceIdRow(value: unknown): value is { resourceId: string } {
|
|
1195
|
+
return isRecordValue(value);
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
function isPresentRow(value: unknown): value is { present: number } {
|
|
1199
|
+
return isRecordValue(value);
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
function isRequestHashRow(value: unknown): value is { requestHash: Buffer } {
|
|
1203
|
+
return isRecordValue(value);
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
function isDeadlineRow(value: unknown): value is { deadlineAt: number | null } {
|
|
1207
|
+
return isRecordValue(value);
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
function isCandidateHashRow(value: unknown): value is { candidateHash: Buffer } {
|
|
1211
|
+
return isRecordValue(value);
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
function isContentHashRow(value: unknown): value is { contentHash: Buffer } {
|
|
1215
|
+
return isRecordValue(value);
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
function isContinuationRow(value: unknown): value is { recordHash: Buffer } {
|
|
1219
|
+
return isRecordValue(value);
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
function isDecisionResourceRow(value: unknown): value is { resourceId: string; revision: number } {
|
|
1223
|
+
return isRecordValue(value);
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
function isResolutionRow(value: unknown): value is ResolutionRow {
|
|
1227
|
+
return isRecordValue(value);
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
function isEffectIdentityRecord(value: unknown): value is { effectId: string; resourceId: string } {
|
|
1231
|
+
return isRecordValue(value);
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
function isRevisionRecord(value: unknown): value is { revision: number } {
|
|
1235
|
+
return isRecordValue(value);
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
function isOwnerRow(value: unknown): value is {
|
|
1239
|
+
generation: number;
|
|
1240
|
+
ownerType: string | null;
|
|
1241
|
+
ownerId: string | null;
|
|
1242
|
+
tokenHash: Buffer | null;
|
|
1243
|
+
expiresAt: number | null;
|
|
1244
|
+
} {
|
|
1245
|
+
return isRecordValue(value);
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
function isRecordValue(value: unknown): value is Record<string, unknown> {
|
|
1249
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1250
|
+
}
|
|
1251
|
+
|
|
614
1252
|
export function createHumanDecisionAttemptId(): string {
|
|
615
1253
|
return randomUUID();
|
|
616
1254
|
}
|
|
@@ -738,46 +1376,6 @@ function assertJsonValue(value: unknown, label: string): void {
|
|
|
738
1376
|
}
|
|
739
1377
|
}
|
|
740
1378
|
|
|
741
|
-
async function writeImmutableJson(
|
|
742
|
-
filePath: string,
|
|
743
|
-
value: unknown,
|
|
744
|
-
requireIdentical = true,
|
|
745
|
-
): Promise<"created" | "adopted"> {
|
|
746
|
-
const bytes = `${canonicalJson(value)}\n`;
|
|
747
|
-
const directory = path.dirname(filePath);
|
|
748
|
-
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
749
|
-
const temporary = path.join(
|
|
750
|
-
directory,
|
|
751
|
-
`.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`,
|
|
752
|
-
);
|
|
753
|
-
const handle = await fs.open(temporary, "wx", 0o600);
|
|
754
|
-
try {
|
|
755
|
-
await handle.writeFile(bytes, "utf8");
|
|
756
|
-
await handle.sync();
|
|
757
|
-
} finally {
|
|
758
|
-
await handle.close();
|
|
759
|
-
}
|
|
760
|
-
try {
|
|
761
|
-
await fs.link(temporary, filePath);
|
|
762
|
-
const directoryHandle = await fs.open(directory, "r");
|
|
763
|
-
try {
|
|
764
|
-
await directoryHandle.sync();
|
|
765
|
-
} finally {
|
|
766
|
-
await directoryHandle.close();
|
|
767
|
-
}
|
|
768
|
-
return "created";
|
|
769
|
-
} catch (error) {
|
|
770
|
-
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
771
|
-
const existing = await fs.readFile(filePath, "utf8");
|
|
772
|
-
if (requireIdentical && existing !== bytes) {
|
|
773
|
-
throw new Error(`Immutable human decision record conflicts: ${filePath}`);
|
|
774
|
-
}
|
|
775
|
-
return "adopted";
|
|
776
|
-
} finally {
|
|
777
|
-
await fs.unlink(temporary).catch(() => undefined);
|
|
778
|
-
}
|
|
779
|
-
}
|
|
780
|
-
|
|
781
1379
|
function validateExpiry(
|
|
782
1380
|
value: string | undefined,
|
|
783
1381
|
createdAt: string | undefined,
|
|
@@ -791,29 +1389,3 @@ function validateExpiry(
|
|
|
791
1389
|
}
|
|
792
1390
|
return new Date(parsed).toISOString();
|
|
793
1391
|
}
|
|
794
|
-
|
|
795
|
-
async function readJsonDirectory(directory: string): Promise<unknown[]> {
|
|
796
|
-
let names: string[];
|
|
797
|
-
try {
|
|
798
|
-
names = await fs.readdir(directory);
|
|
799
|
-
} catch (error) {
|
|
800
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
801
|
-
throw error;
|
|
802
|
-
}
|
|
803
|
-
const values = await Promise.all(
|
|
804
|
-
names
|
|
805
|
-
.filter((name) => name.endsWith(".json"))
|
|
806
|
-
.sort()
|
|
807
|
-
.map(async (name) => await readJson(path.join(directory, name))),
|
|
808
|
-
);
|
|
809
|
-
return values.filter((value) => value !== null);
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
async function readJson(filePath: string): Promise<unknown | null> {
|
|
813
|
-
try {
|
|
814
|
-
return JSON.parse(await fs.readFile(filePath, "utf8"));
|
|
815
|
-
} catch (error) {
|
|
816
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
817
|
-
throw error;
|
|
818
|
-
}
|
|
819
|
-
}
|