@osolmaz/pi-workflows 0.12.1 → 0.13.1
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 +59 -33
- package/dist/builtins/autodoc.workflow.d.ts +194 -7
- package/dist/builtins/autodoc.workflow.js +156 -30
- package/dist/builtins/autodoc.workflow.js.map +1 -1
- package/dist/builtins/autoimplement-command-batches.d.ts +1 -0
- package/dist/builtins/autoimplement-command-batches.js +29 -31
- package/dist/builtins/autoimplement-command-batches.js.map +1 -1
- package/dist/builtins/autoimplement.workflow.d.ts +1619 -97
- package/dist/builtins/autoimplement.workflow.js +416 -153
- 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/change-verification.workflow.d.ts +110 -0
- package/dist/builtins/change-verification.workflow.js +860 -0
- package/dist/builtins/change-verification.workflow.js.map +1 -0
- package/dist/builtins/index.d.ts +2 -1
- package/dist/builtins/index.js +1 -0
- package/dist/builtins/index.js.map +1 -1
- package/dist/builtins/monitor.workflow.d.ts +56 -21
- package/dist/builtins/monitor.workflow.js +499 -217
- package/dist/builtins/monitor.workflow.js.map +1 -1
- 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-change.workflow.d.ts +527 -26
- package/dist/builtins/plan-change.workflow.js +26 -0
- package/dist/builtins/plan-change.workflow.js.map +1 -1
- package/dist/builtins/sanity-check.workflow.d.ts +45 -3
- package/dist/builtins/sanity-check.workflow.js +45 -7
- package/dist/builtins/sanity-check.workflow.js.map +1 -1
- package/dist/builtins/workspace-preparation.workflow.d.ts +75 -0
- package/dist/builtins/workspace-preparation.workflow.js +498 -0
- package/dist/builtins/workspace-preparation.workflow.js.map +1 -0
- 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 +2 -0
- package/dist/extension/decision-channels.js +311 -118
- package/dist/extension/decision-channels.js.map +1 -1
- package/dist/extension/executor.d.ts +9 -2
- package/dist/extension/executor.js +90 -6
- 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 +156 -117
- 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/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 +6 -0
- 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 +14 -8
- 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 +2 -17
- 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/composition.js +12 -2
- package/dist/workflows/composition.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 +6 -6
- package/dist/workflows/engine.js +183 -103
- package/dist/workflows/engine.js.map +1 -1
- package/dist/workflows/errors.d.ts +3 -3
- package/dist/workflows/errors.js +3 -3
- package/dist/workflows/human-decision.d.ts +29 -4
- package/dist/workflows/human-decision.js +547 -283
- package/dist/workflows/human-decision.js.map +1 -1
- package/dist/workflows/index.d.ts +4 -5
- package/dist/workflows/index.js +3 -4
- package/dist/workflows/index.js.map +1 -1
- package/dist/workflows/schema.js +17 -1
- package/dist/workflows/schema.js.map +1 -1
- package/dist/workflows/store.d.ts +83 -103
- package/dist/workflows/store.js +902 -951
- package/dist/workflows/store.js.map +1 -1
- package/dist/workflows/types.d.ts +55 -81
- 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 +13 -14
- package/docs/MONITOR.md +179 -231
- package/docs/SQLITE_STATE.md +219 -0
- package/docs/WORKFLOW_COMPOSITION.md +27 -19
- 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 +1 -1
- package/docs/plans/2026-08-21-sanity-check-plan.md +30 -16
- 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/2026-08-24-change-scoped-verification-plan.md +419 -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 +111 -62
- package/examples/workflows/plain-summary.workflow.ts +1 -0
- package/herdr-plugin.toml +1 -1
- package/package.json +1 -1
- package/plugins/herdr/viewer.mjs +1 -11
- package/skills/autodoc/SKILL.md +7 -0
- package/skills/autoimplement/SKILL.md +4 -0
- package/skills/autoplan/SKILL.md +5 -4
- package/skills/monitor/SKILL.md +91 -163
- package/skills/pi-workflows/SKILL.md +7 -7
- package/skills/sanity-check/SKILL.md +1 -1
- package/src/builtins/autodoc.workflow.ts +184 -33
- package/src/builtins/autoimplement-command-batches.ts +39 -33
- package/src/builtins/autoimplement.workflow.ts +483 -175
- package/src/builtins/autoplan.workflow.ts +290 -40
- package/src/builtins/catalog.ts +7 -5
- package/src/builtins/change-verification.workflow.ts +1143 -0
- package/src/builtins/index.ts +7 -1
- package/src/builtins/monitor.workflow.ts +667 -250
- package/src/builtins/plain-summary.workflow.ts +185 -0
- package/src/builtins/plan-change.workflow.ts +35 -0
- package/src/builtins/sanity-check.workflow.ts +62 -7
- package/src/builtins/workspace-preparation.workflow.ts +668 -0
- 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 +427 -144
- package/src/extension/executor.ts +106 -6
- package/src/extension/herdr-viewer.ts +0 -5
- package/src/extension/index.ts +180 -132
- package/src/extension/recorder.ts +13 -22
- package/src/extension/step-message.ts +106 -4
- package/src/extension/widget.ts +8 -0
- package/src/host/rpc-executor.ts +4 -0
- package/src/host/runner.ts +93 -41
- package/src/render/graph-render.ts +11 -4
- 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 +12 -24
- 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/composition.ts +12 -3
- package/src/workflows/definition.ts +36 -3
- package/src/workflows/engine.ts +212 -116
- package/src/workflows/errors.ts +3 -3
- package/src/workflows/human-decision.ts +795 -340
- package/src/workflows/index.ts +15 -19
- package/src/workflows/schema.ts +19 -1
- package/src/workflows/store.ts +1295 -1163
- package/src/workflows/types.ts +61 -81
- 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/src/workflows/artifacts.ts +0 -188
- package/src/workflows/migrate-sources.ts +0 -178
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import
|
|
3
|
-
import
|
|
2
|
+
import { StateDatabase, workflowStatePath } from "../state/database.js";
|
|
3
|
+
import { resourceIdFor, tokenHash } from "../state/mutation.js";
|
|
4
4
|
import { digestCanonical, normalizeDecisionPresentation, validateHumanDecisionRequestIntegrity, } from "./decision-presentation.js";
|
|
5
5
|
import { checkpoint } from "./definition.js";
|
|
6
6
|
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
|
|
@@ -192,10 +192,8 @@ export function validateHumanDecisionSubmission(request, value) {
|
|
|
192
192
|
idempotencyKey,
|
|
193
193
|
};
|
|
194
194
|
}
|
|
195
|
-
export function
|
|
196
|
-
return
|
|
197
|
-
? path.join(path.dirname(runsRoot), "decisions")
|
|
198
|
-
: `${runsRoot}.decisions`;
|
|
195
|
+
export function humanDecisionDatabasePath(homeDir) {
|
|
196
|
+
return workflowStatePath(homeDir);
|
|
199
197
|
}
|
|
200
198
|
const INCOMPATIBLE_HUMAN_DECISION_STATE = "Human decision state uses an incompatible alpha contract; reset the affected workflow run and decision state.";
|
|
201
199
|
function requireCurrentDecision(value) {
|
|
@@ -208,141 +206,118 @@ function requireCurrentDecision(value) {
|
|
|
208
206
|
}
|
|
209
207
|
return value;
|
|
210
208
|
}
|
|
211
|
-
function requireCurrentResolution(value) {
|
|
212
|
-
if (value === null)
|
|
213
|
-
return null;
|
|
214
|
-
const resolution = requireRecord(value, "Human decision resolution");
|
|
215
|
-
if (resolution.schema !== "pi-workflows.human-decision-resolution.v1") {
|
|
216
|
-
throw new Error(INCOMPATIBLE_HUMAN_DECISION_STATE);
|
|
217
|
-
}
|
|
218
|
-
if (resolution.outcome === "accepted") {
|
|
219
|
-
return {
|
|
220
|
-
...resolution,
|
|
221
|
-
decision: requireCurrentDecision(resolution.decision),
|
|
222
|
-
};
|
|
223
|
-
}
|
|
224
|
-
if (resolution.outcome === "cancelled")
|
|
225
|
-
return value;
|
|
226
|
-
throw new Error(INCOMPATIBLE_HUMAN_DECISION_STATE);
|
|
227
|
-
}
|
|
228
|
-
function requireCurrentDelivery(value) {
|
|
229
|
-
const delivery = requireRecord(value, "Human decision delivery");
|
|
230
|
-
if (delivery.schema !== "pi-workflows.human-decision-delivery.v1" ||
|
|
231
|
-
!Object.hasOwn(delivery, "presentationDigest") ||
|
|
232
|
-
!Object.hasOwn(delivery, "phase")) {
|
|
233
|
-
throw new Error(INCOMPATIBLE_HUMAN_DECISION_STATE);
|
|
234
|
-
}
|
|
235
|
-
return value;
|
|
236
|
-
}
|
|
237
209
|
export class HumanDecisionStore {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
210
|
+
databasePath;
|
|
211
|
+
state;
|
|
212
|
+
ownsState;
|
|
213
|
+
authorityProvider;
|
|
214
|
+
constructor(databasePath = workflowStatePath(), options = {}) {
|
|
215
|
+
this.ownsState = options.state === undefined;
|
|
216
|
+
this.state =
|
|
217
|
+
options.state ??
|
|
218
|
+
new StateDatabase({
|
|
219
|
+
filePath: databasePath,
|
|
220
|
+
mode: options.readOnly === true ? "read-only" : "read-write",
|
|
221
|
+
checkLegacyState: databasePath === workflowStatePath(),
|
|
222
|
+
});
|
|
223
|
+
this.databasePath = this.state.filePath;
|
|
224
|
+
this.authorityProvider = options.authorityProvider;
|
|
225
|
+
}
|
|
226
|
+
close() {
|
|
227
|
+
if (this.ownsState)
|
|
228
|
+
this.state.close();
|
|
245
229
|
}
|
|
246
230
|
async createRequest(request) {
|
|
247
231
|
validateHumanDecisionRequestIntegrity(request);
|
|
248
|
-
return
|
|
232
|
+
return this.state.transaction(() => {
|
|
233
|
+
const existing = this.readRequestRow(request.decisionId);
|
|
234
|
+
if (existing !== undefined) {
|
|
235
|
+
const stored = this.readRequestFromHash(existing.requestHash);
|
|
236
|
+
if (canonicalJson(stored) !== canonicalJson(request)) {
|
|
237
|
+
throw new Error("Immutable human decision request conflicts");
|
|
238
|
+
}
|
|
239
|
+
return "adopted";
|
|
240
|
+
}
|
|
241
|
+
const run = this.state.connection
|
|
242
|
+
.prepare("SELECT resource_id AS resourceId FROM runs WHERE run_id = ?")
|
|
243
|
+
.get(request.runId);
|
|
244
|
+
if (!isResourceIdRow(run))
|
|
245
|
+
throw new Error(`Human decision run is missing: ${request.runId}`);
|
|
246
|
+
const attempt = this.state.connection
|
|
247
|
+
.prepare("SELECT 1 AS present FROM node_attempts WHERE attempt_id = ? AND run_id = ?")
|
|
248
|
+
.get(request.attemptId, request.runId);
|
|
249
|
+
if (!isPresentRow(attempt)) {
|
|
250
|
+
throw new Error(`Human decision attempt is missing: ${request.attemptId}`);
|
|
251
|
+
}
|
|
252
|
+
const now = Date.parse(request.createdAt);
|
|
253
|
+
const resourceId = resourceIdFor("decision", request.decisionId);
|
|
254
|
+
const subjectHash = this.state.putJson(request.subject, now);
|
|
255
|
+
const presentationHash = this.state.putJson(request.presentation, now);
|
|
256
|
+
const choicesHash = this.state.putJson(request.choices, now);
|
|
257
|
+
const defaultHash = request.defaultResponse === undefined
|
|
258
|
+
? null
|
|
259
|
+
: this.state.putJson(request.defaultResponse, now);
|
|
260
|
+
const requestHash = this.state.putJson(request, now);
|
|
261
|
+
this.state.connection
|
|
262
|
+
.prepare(`INSERT INTO resources(
|
|
263
|
+
resource_id, resource_type, aggregate_key, revision, created_at, updated_at
|
|
264
|
+
) VALUES (?, 'decision', ?, 1, ?, ?)`)
|
|
265
|
+
.run(resourceId, request.decisionId, now, now);
|
|
266
|
+
this.state.connection
|
|
267
|
+
.prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
|
|
268
|
+
.run(resourceId);
|
|
269
|
+
this.state.connection
|
|
270
|
+
.prepare(`INSERT INTO human_decisions(
|
|
271
|
+
decision_id, resource_id, run_id, attempt_id, audience, title,
|
|
272
|
+
subject_hash, presentation_hash, choices_hash, request_digest,
|
|
273
|
+
presentation_revision, deadline_at, default_response_hash, request_hash, created_at
|
|
274
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
275
|
+
.run(request.decisionId, resourceId, request.runId, request.attemptId, request.audience, request.title, subjectHash, presentationHash, choicesHash, digestBuffer(request.requestDigest), request.revision, request.expiresAt === undefined ? null : Date.parse(request.expiresAt), defaultHash, requestHash, now);
|
|
276
|
+
this.insertDecisionEvent(resourceId, 1, "decision.requested", "system", null, requestHash, now);
|
|
277
|
+
return "created";
|
|
278
|
+
});
|
|
249
279
|
}
|
|
250
280
|
async readRequest(decisionId) {
|
|
251
|
-
|
|
252
|
-
|
|
281
|
+
assertId(decisionId, "decision id");
|
|
282
|
+
const row = this.readRequestRow(decisionId);
|
|
283
|
+
return row === undefined ? null : this.readRequestFromHash(row.requestHash);
|
|
253
284
|
}
|
|
254
285
|
async recordDelivery(request, channel, value) {
|
|
255
|
-
|
|
256
|
-
const attemptId = requireString(value.attemptId, "Human decision delivery attempt");
|
|
257
|
-
assertId(attemptId, "delivery attempt id");
|
|
258
|
-
return await writeImmutableJson(path.join(this.decisionDir(request.decisionId), "deliveries", safeChannel, `${attemptId}.json`), value);
|
|
286
|
+
return this.recordChannelMessage(request.decisionId, channel, "delivery", value);
|
|
259
287
|
}
|
|
260
288
|
async accept(request, submission) {
|
|
261
289
|
validateHumanDecisionRequestIntegrity(request);
|
|
262
|
-
const cancellation = await this.readCancellation(request.decisionId);
|
|
263
|
-
if (cancellation !== null) {
|
|
264
|
-
throw new Error(`Human decision request was ${cancellation.reason}`);
|
|
265
|
-
}
|
|
266
290
|
const normalized = validateHumanDecisionSubmission(request, submission);
|
|
267
|
-
|
|
291
|
+
const attemptedAt = new Date().toISOString();
|
|
268
292
|
const attemptId = digestHex({
|
|
269
293
|
decisionId: request.decisionId,
|
|
270
294
|
idempotencyKey: normalized.idempotencyKey,
|
|
271
295
|
}).slice(0, 40);
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
schema: "pi-workflows.human-decision-answer-attempt.v1",
|
|
275
|
-
attemptId,
|
|
276
|
-
attemptedAt,
|
|
277
|
-
...normalized,
|
|
278
|
-
};
|
|
279
|
-
const attemptWrite = await writeImmutableJson(attemptPath, attempt, false);
|
|
280
|
-
if (attemptWrite === "adopted") {
|
|
281
|
-
const existingAttempt = requireRecord(await readJson(attemptPath), "Existing human decision answer attempt");
|
|
282
|
-
const existingSubmission = {
|
|
283
|
-
decisionId: existingAttempt.decisionId,
|
|
284
|
-
requestDigest: existingAttempt.requestDigest,
|
|
285
|
-
choice: existingAttempt.choice,
|
|
286
|
-
...(existingAttempt.input !== undefined ? { input: existingAttempt.input } : {}),
|
|
287
|
-
source: existingAttempt.source,
|
|
288
|
-
idempotencyKey: existingAttempt.idempotencyKey,
|
|
289
|
-
};
|
|
290
|
-
if (canonicalJson(existingSubmission) !== canonicalJson(normalized)) {
|
|
291
|
-
throw new Error("Human decision idempotency key was reused for a different answer");
|
|
292
|
-
}
|
|
293
|
-
attemptedAt = requireString(existingAttempt.attemptedAt, "answer attempt time");
|
|
294
|
-
}
|
|
295
|
-
const response = validateHumanDecisionResponse(request, normalized);
|
|
296
|
-
const commonDecision = {
|
|
296
|
+
const decision = {
|
|
297
|
+
schema: "pi-workflows.human-decision-accepted.v1",
|
|
297
298
|
decisionId: request.decisionId,
|
|
298
299
|
requestDigest: request.requestDigest,
|
|
299
|
-
response,
|
|
300
|
+
response: validateHumanDecisionResponse(request, normalized),
|
|
300
301
|
provenance: "human",
|
|
301
302
|
source: normalized.source,
|
|
302
303
|
idempotencyKey: normalized.idempotencyKey,
|
|
303
304
|
acceptedAt: attemptedAt,
|
|
304
|
-
answerDigest: digest({
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
305
|
+
answerDigest: digest({
|
|
306
|
+
requestDigest: request.requestDigest,
|
|
307
|
+
response: validateHumanDecisionResponse(request, normalized),
|
|
308
|
+
source: normalized.source,
|
|
309
|
+
}),
|
|
309
310
|
subjectDigest: request.subjectDigest,
|
|
310
311
|
presentationDigest: request.presentationDigest,
|
|
311
312
|
revision: request.revision,
|
|
312
313
|
};
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
const winner = result === "created"
|
|
321
|
-
? resolution
|
|
322
|
-
: (await readJson(resolutionPath));
|
|
323
|
-
if (winner === null)
|
|
324
|
-
throw new Error("Human decision resolution became unreadable");
|
|
325
|
-
const winningCancellation = await this.readCancellation(request.decisionId);
|
|
326
|
-
if (winningCancellation !== null) {
|
|
327
|
-
throw new Error(`Human decision request was ${winningCancellation.reason}`);
|
|
328
|
-
}
|
|
329
|
-
if (winner.outcome === "cancelled") {
|
|
330
|
-
throw new Error(`Human decision request was ${winner.cancellation.reason}`);
|
|
331
|
-
}
|
|
332
|
-
const existing = winner.decision;
|
|
333
|
-
await writeImmutableJson(path.join(this.decisionDir(request.decisionId), "accepted.json"), existing);
|
|
334
|
-
if (result === "created")
|
|
335
|
-
return { status: "accepted", decision: existing };
|
|
336
|
-
if (canonicalJson(existing) === canonicalJson(decision)) {
|
|
337
|
-
return { status: "adopted", decision: existing };
|
|
338
|
-
}
|
|
339
|
-
if (existing.provenance === "human" &&
|
|
340
|
-
existing.idempotencyKey === decision.idempotencyKey &&
|
|
341
|
-
canonicalJson(existing.response) === canonicalJson(decision.response) &&
|
|
342
|
-
canonicalJson(existing.source) === canonicalJson(decision.source)) {
|
|
343
|
-
return { status: "adopted", decision: existing };
|
|
344
|
-
}
|
|
345
|
-
return { status: "conflict", decision: existing };
|
|
314
|
+
return this.attemptAcceptance(request, decision, {
|
|
315
|
+
attemptId,
|
|
316
|
+
source: "human",
|
|
317
|
+
actorId: normalized.source.actorId,
|
|
318
|
+
channel: normalized.source.channel,
|
|
319
|
+
candidate: normalized,
|
|
320
|
+
});
|
|
346
321
|
}
|
|
347
322
|
async resolveTimeout(request, now = new Date()) {
|
|
348
323
|
validateHumanDecisionRequestIntegrity(request);
|
|
@@ -352,8 +327,10 @@ export class HumanDecisionStore {
|
|
|
352
327
|
if (Date.parse(request.expiresAt) > now.getTime()) {
|
|
353
328
|
throw new Error("Human decision timeout default is not eligible yet");
|
|
354
329
|
}
|
|
330
|
+
this.assertRunOwner(request.runId, now.getTime());
|
|
355
331
|
const response = validateHumanDecisionResponse(request, request.defaultResponse);
|
|
356
|
-
const
|
|
332
|
+
const decision = {
|
|
333
|
+
schema: "pi-workflows.human-decision-accepted.v1",
|
|
357
334
|
decisionId: request.decisionId,
|
|
358
335
|
requestDigest: request.requestDigest,
|
|
359
336
|
response,
|
|
@@ -364,137 +341,487 @@ export class HumanDecisionStore {
|
|
|
364
341
|
requestDigest: request.requestDigest,
|
|
365
342
|
response,
|
|
366
343
|
}),
|
|
367
|
-
};
|
|
368
|
-
const decision = {
|
|
369
|
-
schema: "pi-workflows.human-decision-accepted.v1",
|
|
370
|
-
...commonDecision,
|
|
371
344
|
subjectDigest: request.subjectDigest,
|
|
372
345
|
presentationDigest: request.presentationDigest,
|
|
373
346
|
revision: request.revision,
|
|
374
347
|
};
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
const winner = result === "created"
|
|
383
|
-
? resolution
|
|
384
|
-
: (await readJson(resolutionPath));
|
|
385
|
-
if (winner === null)
|
|
386
|
-
throw new Error("Human decision resolution became unreadable");
|
|
387
|
-
const winningCancellation = await this.readCancellation(request.decisionId);
|
|
388
|
-
if (winningCancellation !== null) {
|
|
389
|
-
throw new Error(`Human decision request was ${winningCancellation.reason}`);
|
|
390
|
-
}
|
|
391
|
-
if (winner.outcome === "cancelled") {
|
|
392
|
-
throw new Error(`Human decision request was ${winner.cancellation.reason}`);
|
|
393
|
-
}
|
|
394
|
-
const existing = winner.decision;
|
|
395
|
-
await writeImmutableJson(path.join(this.decisionDir(request.decisionId), "accepted.json"), existing);
|
|
396
|
-
if (result === "created")
|
|
397
|
-
return { status: "accepted", decision: existing };
|
|
398
|
-
return canonicalJson(existing) === canonicalJson(decision)
|
|
399
|
-
? { status: "adopted", decision: existing }
|
|
400
|
-
: { status: "conflict", decision: existing };
|
|
348
|
+
const actorId = this.authorityProvider?.(request.runId)?.ownerId;
|
|
349
|
+
return this.attemptAcceptance(request, decision, {
|
|
350
|
+
attemptId: digestHex({ decisionId: request.decisionId, provenance: "timeout" }).slice(0, 40),
|
|
351
|
+
source: "policy",
|
|
352
|
+
...(actorId === undefined ? {} : { actorId }),
|
|
353
|
+
candidate: response,
|
|
354
|
+
});
|
|
401
355
|
}
|
|
402
356
|
async listDeliveries(decisionId, channel) {
|
|
403
|
-
|
|
404
|
-
const directory = path.join(this.decisionDir(decisionId), "deliveries", safeChannel);
|
|
405
|
-
return (await readJsonDirectory(directory)).map(requireCurrentDelivery);
|
|
357
|
+
return this.listChannelMessages(decisionId, channel, "delivery");
|
|
406
358
|
}
|
|
407
359
|
async recordSettlement(decisionId, channel, value) {
|
|
408
|
-
|
|
409
|
-
assertId(value.attemptId, "settlement attempt id");
|
|
410
|
-
return await writeImmutableJson(path.join(this.decisionDir(decisionId), "settlements", safeChannel, `${value.attemptId}.json`), value);
|
|
360
|
+
return this.recordChannelMessage(decisionId, channel, "settlement", value);
|
|
411
361
|
}
|
|
412
362
|
async listSettlements(decisionId, channel) {
|
|
413
|
-
|
|
414
|
-
const directory = path.join(this.decisionDir(decisionId), "settlements", safeChannel);
|
|
415
|
-
return (await readJsonDirectory(directory));
|
|
363
|
+
return this.listChannelMessages(decisionId, channel, "settlement");
|
|
416
364
|
}
|
|
417
365
|
async cancel(request, reason) {
|
|
418
366
|
validateHumanDecisionRequestIntegrity(request);
|
|
419
|
-
if (
|
|
420
|
-
throw new Error("
|
|
421
|
-
}
|
|
422
|
-
const filePath = path.join(this.decisionDir(request.decisionId), "cancelled.json");
|
|
423
|
-
const record = {
|
|
424
|
-
schema: "pi-workflows.human-decision-cancellation.v1",
|
|
425
|
-
decisionId: request.decisionId,
|
|
426
|
-
requestDigest: request.requestDigest,
|
|
427
|
-
cancelledAt: new Date().toISOString(),
|
|
428
|
-
reason,
|
|
429
|
-
};
|
|
430
|
-
const resolutionPath = path.join(this.decisionDir(request.decisionId), "resolution.json");
|
|
431
|
-
const resolution = {
|
|
432
|
-
schema: "pi-workflows.human-decision-resolution.v1",
|
|
433
|
-
outcome: "cancelled",
|
|
434
|
-
cancellation: record,
|
|
435
|
-
};
|
|
436
|
-
const result = await writeImmutableJson(resolutionPath, resolution, false);
|
|
437
|
-
const winner = result === "created"
|
|
438
|
-
? resolution
|
|
439
|
-
: (await readJson(resolutionPath));
|
|
440
|
-
if (winner === null)
|
|
441
|
-
throw new Error("Human decision resolution became unreadable");
|
|
442
|
-
if (winner.outcome === "accepted") {
|
|
443
|
-
throw new Error("Resolved human decision cannot be cancelled");
|
|
444
|
-
}
|
|
445
|
-
const existing = winner.cancellation;
|
|
446
|
-
if (existing.decisionId !== request.decisionId ||
|
|
447
|
-
existing.requestDigest !== request.requestDigest ||
|
|
448
|
-
existing.reason !== reason) {
|
|
449
|
-
throw new Error("Immutable human decision cancellation conflicts");
|
|
367
|
+
if (reason === "expired" && request.defaultResponse !== undefined) {
|
|
368
|
+
throw new Error("A defaulted human decision must resolve through its timeout policy");
|
|
450
369
|
}
|
|
451
|
-
|
|
452
|
-
|
|
370
|
+
return this.state.transaction(() => {
|
|
371
|
+
this.requireStoredRequest(request);
|
|
372
|
+
const winner = this.readResolution(request.decisionId);
|
|
373
|
+
if (winner !== null) {
|
|
374
|
+
if (winner.outcome === "cancelled") {
|
|
375
|
+
if (winner.cancellation.reason === reason)
|
|
376
|
+
return "adopted";
|
|
377
|
+
throw new Error(`Human decision was already cancelled with reason ${winner.cancellation.reason}`);
|
|
378
|
+
}
|
|
379
|
+
throw new Error(`Human decision was already accepted by ${winner.decision.provenance}`);
|
|
380
|
+
}
|
|
381
|
+
const now = Date.now();
|
|
382
|
+
if (reason === "expired") {
|
|
383
|
+
if (request.expiresAt === undefined) {
|
|
384
|
+
throw new Error("Human decision request has no expiry");
|
|
385
|
+
}
|
|
386
|
+
if (Date.parse(request.expiresAt) > now) {
|
|
387
|
+
throw new Error("Human decision expiry cancellation is not eligible yet");
|
|
388
|
+
}
|
|
389
|
+
this.assertRunOwner(request.runId, now);
|
|
390
|
+
}
|
|
391
|
+
const record = {
|
|
392
|
+
schema: "pi-workflows.human-decision-cancellation.v1",
|
|
393
|
+
decisionId: request.decisionId,
|
|
394
|
+
requestDigest: request.requestDigest,
|
|
395
|
+
cancelledAt: new Date(now).toISOString(),
|
|
396
|
+
reason,
|
|
397
|
+
};
|
|
398
|
+
const provenance = reason === "expired" ? "expired_no_default" : "explicit_cancel";
|
|
399
|
+
this.state.connection
|
|
400
|
+
.prepare(`INSERT INTO human_decision_resolutions(
|
|
401
|
+
decision_id, outcome, provenance, response_hash, reason, channel,
|
|
402
|
+
actor_id, request_digest, resolved_at
|
|
403
|
+
) VALUES (?, 'cancelled', ?, NULL, ?, NULL, NULL, ?, ?)`)
|
|
404
|
+
.run(request.decisionId, provenance, reason, digestBuffer(request.requestDigest), now);
|
|
405
|
+
const row = this.requireDecisionResource(request.decisionId);
|
|
406
|
+
this.bumpDecision(row.resourceId, row.revision, now);
|
|
407
|
+
const recordHash = this.state.putJson(record, now);
|
|
408
|
+
this.insertDecisionEvent(row.resourceId, row.revision + 1, "decision.cancelled", reason === "expired" ? "policy" : "control", null, recordHash, now);
|
|
409
|
+
this.enqueueResolutionEffects(row.resourceId, row.revision + 1, request, "cancelled", now);
|
|
410
|
+
return "created";
|
|
411
|
+
});
|
|
453
412
|
}
|
|
454
413
|
async readCancellation(decisionId) {
|
|
455
|
-
const
|
|
456
|
-
|
|
457
|
-
return stored;
|
|
458
|
-
const resolution = requireCurrentResolution(await readJson(path.join(this.decisionDir(decisionId), "resolution.json")));
|
|
459
|
-
if (resolution?.outcome !== "cancelled")
|
|
460
|
-
return null;
|
|
461
|
-
await writeImmutableJson(path.join(this.decisionDir(decisionId), "cancelled.json"), resolution.cancellation);
|
|
462
|
-
return resolution.cancellation;
|
|
414
|
+
const resolution = this.readResolution(decisionId);
|
|
415
|
+
return resolution?.outcome === "cancelled" ? resolution.cancellation : null;
|
|
463
416
|
}
|
|
464
417
|
async readResolved(decisionId) {
|
|
465
|
-
const
|
|
466
|
-
|
|
467
|
-
return null;
|
|
468
|
-
const stored = await readJson(path.join(this.decisionDir(decisionId), "accepted.json"));
|
|
469
|
-
if (stored !== null)
|
|
470
|
-
return requireCurrentDecision(stored);
|
|
471
|
-
const resolution = requireCurrentResolution(await readJson(path.join(this.decisionDir(decisionId), "resolution.json")));
|
|
472
|
-
if (resolution?.outcome !== "accepted")
|
|
473
|
-
return null;
|
|
474
|
-
await writeImmutableJson(path.join(this.decisionDir(decisionId), "accepted.json"), resolution.decision);
|
|
475
|
-
return resolution.decision;
|
|
418
|
+
const resolution = this.readResolution(decisionId);
|
|
419
|
+
return resolution?.outcome === "accepted" ? resolution.decision : null;
|
|
476
420
|
}
|
|
477
421
|
async recordContinuation(decisionId, value) {
|
|
478
|
-
return
|
|
422
|
+
return this.state.transaction(() => {
|
|
423
|
+
const existing = this.readContinuationRow(decisionId);
|
|
424
|
+
if (existing !== undefined) {
|
|
425
|
+
const stored = this.state.readJson(existing.recordHash);
|
|
426
|
+
if (canonicalJson(stored) !== canonicalJson(value)) {
|
|
427
|
+
throw new Error("Immutable human decision continuation conflicts");
|
|
428
|
+
}
|
|
429
|
+
return "adopted";
|
|
430
|
+
}
|
|
431
|
+
const resolution = this.readResolution(decisionId);
|
|
432
|
+
if (resolution?.outcome !== "accepted") {
|
|
433
|
+
throw new Error("A continuation requires an accepted human decision");
|
|
434
|
+
}
|
|
435
|
+
const recordHash = this.state.putJson(value, Date.parse(value.createdAt));
|
|
436
|
+
this.state.connection
|
|
437
|
+
.prepare(`INSERT INTO continuations(
|
|
438
|
+
decision_id, parent_run_id, continuation_run_id, response_hash, created_at
|
|
439
|
+
) VALUES (?, ?, ?, ?, ?)`)
|
|
440
|
+
.run(decisionId, value.parentRunId, value.runId, recordHash, Date.parse(value.createdAt));
|
|
441
|
+
return "created";
|
|
442
|
+
});
|
|
479
443
|
}
|
|
480
444
|
async readContinuation(decisionId) {
|
|
481
|
-
|
|
445
|
+
const row = this.readContinuationRow(decisionId);
|
|
446
|
+
return row === undefined
|
|
447
|
+
? null
|
|
448
|
+
: this.state.readJson(row.recordHash);
|
|
449
|
+
}
|
|
450
|
+
markEffectApplied(decisionId, effectType) {
|
|
451
|
+
const rows = this.state.connection
|
|
452
|
+
.prepare(`SELECT e.effect_id AS effectId, e.resource_id AS resourceId
|
|
453
|
+
FROM effects e
|
|
454
|
+
JOIN human_decisions d ON d.resource_id = e.source_resource_id
|
|
455
|
+
WHERE d.decision_id = ? AND e.effect_type = ? AND e.status = 'pending'`)
|
|
456
|
+
.all(decisionId, effectType);
|
|
457
|
+
for (const row of rows) {
|
|
458
|
+
/* istanbul ignore if -- exact schema and internal query shape */
|
|
459
|
+
if (!isEffectIdentityRecord(row))
|
|
460
|
+
continue;
|
|
461
|
+
this.state.transaction(() => {
|
|
462
|
+
const now = Date.now();
|
|
463
|
+
const revisionRow = this.state.connection
|
|
464
|
+
.prepare("SELECT revision FROM resources WHERE resource_id = ?")
|
|
465
|
+
.get(row.resourceId);
|
|
466
|
+
/* istanbul ignore if -- exact schema and internal query shape */
|
|
467
|
+
if (!isRevisionRecord(revisionRow))
|
|
468
|
+
throw new Error("Decision effect resource is missing");
|
|
469
|
+
this.state.connection
|
|
470
|
+
.prepare(`UPDATE effects SET status = 'applied', updated_at = ?, settled_at = ?
|
|
471
|
+
WHERE effect_id = ? AND status = 'pending'`)
|
|
472
|
+
.run(now, now, row.effectId);
|
|
473
|
+
this.state.connection
|
|
474
|
+
.prepare(`UPDATE resources SET revision = revision + 1, updated_at = ?
|
|
475
|
+
WHERE resource_id = ? AND revision = ?`)
|
|
476
|
+
.run(now, row.resourceId, revisionRow.revision);
|
|
477
|
+
const payloadHash = this.state.putJson({ decisionId, effectType }, now);
|
|
478
|
+
this.insertDecisionEvent(row.resourceId, revisionRow.revision + 1, "effect.applied", "system", null, payloadHash, now);
|
|
479
|
+
});
|
|
480
|
+
}
|
|
482
481
|
}
|
|
483
482
|
async listRequests() {
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
483
|
+
const rows = this.state.connection
|
|
484
|
+
.prepare("SELECT request_hash AS requestHash FROM human_decisions ORDER BY created_at")
|
|
485
|
+
.all();
|
|
486
|
+
return rows.filter(isRequestHashRow).map((row) => this.readRequestFromHash(row.requestHash));
|
|
487
|
+
}
|
|
488
|
+
attemptAcceptance(request, decision, attempt) {
|
|
489
|
+
return this.state.transaction(() => {
|
|
490
|
+
this.requireStoredRequest(request);
|
|
491
|
+
const timing = this.state.connection
|
|
492
|
+
.prepare("SELECT deadline_at AS deadlineAt FROM human_decisions WHERE decision_id = ?")
|
|
493
|
+
.get(request.decisionId);
|
|
494
|
+
if (!isDeadlineRow(timing))
|
|
495
|
+
throw new Error("Human decision request is missing");
|
|
496
|
+
const arbitrationTime = attempt.source === "policy" ? Date.parse(decision.acceptedAt) : Date.now();
|
|
497
|
+
if (attempt.source === "human" &&
|
|
498
|
+
timing.deadlineAt !== null &&
|
|
499
|
+
arbitrationTime > timing.deadlineAt) {
|
|
500
|
+
throw new Error("Human decision request expired before the answer was accepted");
|
|
501
|
+
}
|
|
502
|
+
if (attempt.source === "policy" &&
|
|
503
|
+
(timing.deadlineAt === null || arbitrationTime < timing.deadlineAt)) {
|
|
504
|
+
throw new Error("Human decision timeout default is not eligible yet");
|
|
505
|
+
}
|
|
506
|
+
if (attempt.source === "policy")
|
|
507
|
+
this.assertRunOwner(request.runId, arbitrationTime);
|
|
508
|
+
const candidateHash = this.state.putJson(attempt.candidate);
|
|
509
|
+
const existingSubmission = this.state.connection
|
|
510
|
+
.prepare(`SELECT candidate_hash AS candidateHash
|
|
511
|
+
FROM human_decision_submissions WHERE decision_id = ? AND attempt_id = ?`)
|
|
512
|
+
.get(request.decisionId, attempt.attemptId);
|
|
513
|
+
if (isCandidateHashRow(existingSubmission) &&
|
|
514
|
+
!existingSubmission.candidateHash.equals(candidateHash)) {
|
|
515
|
+
throw new Error("Human decision submission idempotency key conflicts");
|
|
516
|
+
}
|
|
517
|
+
this.state.connection
|
|
518
|
+
.prepare(`INSERT INTO human_decision_submissions(
|
|
519
|
+
decision_id, attempt_id, source, actor_id, channel,
|
|
520
|
+
candidate_hash, outcome, submitted_at
|
|
521
|
+
) VALUES (?, ?, ?, ?, ?, ?, 'rejected', ?)
|
|
522
|
+
ON CONFLICT(decision_id, attempt_id) DO NOTHING`)
|
|
523
|
+
.run(request.decisionId, attempt.attemptId, attempt.source, attempt.actorId ?? null, attempt.channel ?? null, candidateHash, Date.parse(decision.acceptedAt));
|
|
524
|
+
const winner = this.readResolution(request.decisionId);
|
|
525
|
+
if (winner !== null) {
|
|
526
|
+
this.markSubmission(request.decisionId, attempt.attemptId, "already_resolved", null);
|
|
527
|
+
if (winner.outcome === "cancelled") {
|
|
528
|
+
throw new Error(`Human decision request was ${winner.cancellation.reason}`);
|
|
529
|
+
}
|
|
530
|
+
return canonicalJson(winner.decision) === canonicalJson(decision) ||
|
|
531
|
+
sameHumanAnswer(winner.decision, decision)
|
|
532
|
+
? { status: "adopted", decision: winner.decision }
|
|
533
|
+
: { status: "conflict", decision: winner.decision };
|
|
534
|
+
}
|
|
535
|
+
const now = Date.parse(decision.acceptedAt);
|
|
536
|
+
const responseHash = this.state.putJson(decision, now);
|
|
537
|
+
this.state.connection
|
|
538
|
+
.prepare(`INSERT INTO human_decision_resolutions(
|
|
539
|
+
decision_id, outcome, provenance, response_hash, reason, channel,
|
|
540
|
+
actor_id, request_digest, resolved_at
|
|
541
|
+
) VALUES (?, 'accepted', ?, ?, NULL, ?, ?, ?, ?)`)
|
|
542
|
+
.run(request.decisionId, decision.provenance === "timeout" ? "timeout_policy" : "human", responseHash, attempt.channel ?? null, attempt.actorId ?? null, digestBuffer(request.requestDigest), now);
|
|
543
|
+
const row = this.requireDecisionResource(request.decisionId);
|
|
544
|
+
this.bumpDecision(row.resourceId, row.revision, now);
|
|
545
|
+
this.insertDecisionEvent(row.resourceId, row.revision + 1, "decision.accepted", decision.provenance === "timeout" ? "policy" : "human", attempt.actorId ?? null, responseHash, now);
|
|
546
|
+
this.enqueueResolutionEffects(row.resourceId, row.revision + 1, request, "accepted", now);
|
|
547
|
+
this.markSubmission(request.decisionId, attempt.attemptId, "won", responseHash);
|
|
548
|
+
return { status: "accepted", decision };
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
readResolution(decisionId) {
|
|
552
|
+
const row = this.state.connection
|
|
553
|
+
.prepare(`SELECT outcome, provenance, response_hash AS responseHash, reason,
|
|
554
|
+
request_digest AS requestDigest, resolved_at AS resolvedAt
|
|
555
|
+
FROM human_decision_resolutions WHERE decision_id = ?`)
|
|
556
|
+
.get(decisionId);
|
|
557
|
+
if (!isResolutionRow(row))
|
|
558
|
+
return null;
|
|
559
|
+
const request = this.readRequestRow(decisionId);
|
|
560
|
+
if (request === undefined)
|
|
561
|
+
throw new Error("Human decision request is missing");
|
|
562
|
+
const storedRequest = this.readRequestFromHash(request.requestHash);
|
|
563
|
+
if (!row.requestDigest.equals(digestBuffer(storedRequest.requestDigest))) {
|
|
564
|
+
throw new Error("Human decision resolution request digest conflicts");
|
|
565
|
+
}
|
|
566
|
+
if (row.outcome === "accepted") {
|
|
567
|
+
if (row.responseHash === null)
|
|
568
|
+
throw new Error("Accepted decision response is missing");
|
|
569
|
+
const decision = this.state.readJson(row.responseHash);
|
|
570
|
+
return {
|
|
571
|
+
schema: "pi-workflows.human-decision-resolution.v1",
|
|
572
|
+
outcome: "accepted",
|
|
573
|
+
decision: requireCurrentDecision(decision),
|
|
574
|
+
};
|
|
487
575
|
}
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
576
|
+
const cancellation = {
|
|
577
|
+
schema: "pi-workflows.human-decision-cancellation.v1",
|
|
578
|
+
decisionId,
|
|
579
|
+
requestDigest: storedRequest.requestDigest,
|
|
580
|
+
cancelledAt: new Date(row.resolvedAt).toISOString(),
|
|
581
|
+
reason: row.reason === "expired" ? "expired" : "cancelled",
|
|
582
|
+
};
|
|
583
|
+
return {
|
|
584
|
+
schema: "pi-workflows.human-decision-resolution.v1",
|
|
585
|
+
outcome: "cancelled",
|
|
586
|
+
cancellation,
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
requireStoredRequest(request) {
|
|
590
|
+
const row = this.readRequestRow(request.decisionId);
|
|
591
|
+
if (row === undefined)
|
|
592
|
+
throw new Error("Human decision request is not durable");
|
|
593
|
+
const stored = this.readRequestFromHash(row.requestHash);
|
|
594
|
+
if (canonicalJson(stored) !== canonicalJson(request)) {
|
|
595
|
+
throw new Error("Human decision request does not match durable state");
|
|
492
596
|
}
|
|
493
|
-
const requests = await Promise.all(entries
|
|
494
|
-
.filter((entry) => entry.isDirectory())
|
|
495
|
-
.map(async (entry) => await this.readRequest(entry.name)));
|
|
496
|
-
return requests.filter((request) => request !== null);
|
|
497
597
|
}
|
|
598
|
+
readRequestRow(decisionId) {
|
|
599
|
+
const row = this.state.connection
|
|
600
|
+
.prepare("SELECT request_hash AS requestHash FROM human_decisions WHERE decision_id = ?")
|
|
601
|
+
.get(decisionId);
|
|
602
|
+
return isRequestHashRow(row) ? row : undefined;
|
|
603
|
+
}
|
|
604
|
+
readRequestFromHash(hash) {
|
|
605
|
+
return validateHumanDecisionRequestIntegrity(this.state.readJson(hash));
|
|
606
|
+
}
|
|
607
|
+
requireDecisionResource(decisionId) {
|
|
608
|
+
const row = this.state.connection
|
|
609
|
+
.prepare(`SELECT d.resource_id AS resourceId, r.revision
|
|
610
|
+
FROM human_decisions d JOIN resources r ON r.resource_id = d.resource_id
|
|
611
|
+
WHERE d.decision_id = ?`)
|
|
612
|
+
.get(decisionId);
|
|
613
|
+
if (!isDecisionResourceRow(row))
|
|
614
|
+
throw new Error(`Human decision is missing: ${decisionId}`);
|
|
615
|
+
return row;
|
|
616
|
+
}
|
|
617
|
+
bumpDecision(resourceId, expectedRevision, now) {
|
|
618
|
+
const result = this.state.connection
|
|
619
|
+
.prepare(`UPDATE resources SET revision = revision + 1, updated_at = ?
|
|
620
|
+
WHERE resource_id = ? AND revision = ?`)
|
|
621
|
+
.run(now, resourceId, expectedRevision);
|
|
622
|
+
if (result.changes !== 1)
|
|
623
|
+
throw new Error("Human decision revision conflict");
|
|
624
|
+
}
|
|
625
|
+
insertDecisionEvent(resourceId, revision, eventType, actorType, actorId, payloadHash, now) {
|
|
626
|
+
this.state.connection
|
|
627
|
+
.prepare(`INSERT INTO events(
|
|
628
|
+
event_id, resource_id, resource_revision, event_type,
|
|
629
|
+
actor_type, actor_id, payload_hash, recorded_at
|
|
630
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
631
|
+
.run(`event-${randomUUID()}`, resourceId, revision, eventType, actorType, actorId, payloadHash, now);
|
|
632
|
+
}
|
|
633
|
+
enqueueResolutionEffects(decisionResourceId, revision, request, outcome, now) {
|
|
634
|
+
const types = outcome === "accepted"
|
|
635
|
+
? ["decision.continue", "decision.settle_presentations"]
|
|
636
|
+
: ["decision.cancel_parent", "decision.settle_presentations"];
|
|
637
|
+
for (const effectType of types) {
|
|
638
|
+
const idempotencyKey = request.decisionId;
|
|
639
|
+
const effectId = `effect-${digestHex({ decisionId: request.decisionId, effectType }).slice(0, 40)}`;
|
|
640
|
+
const effectResourceId = resourceIdFor("effect", effectId);
|
|
641
|
+
const payloadHash = this.state.putJson({ decisionId: request.decisionId, runId: request.runId, outcome }, now);
|
|
642
|
+
this.state.connection
|
|
643
|
+
.prepare(`INSERT INTO resources(
|
|
644
|
+
resource_id, resource_type, aggregate_key, revision, created_at, updated_at
|
|
645
|
+
) VALUES (?, 'effect', ?, 0, ?, ?)`)
|
|
646
|
+
.run(effectResourceId, effectId, now, now);
|
|
647
|
+
this.state.connection
|
|
648
|
+
.prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
|
|
649
|
+
.run(effectResourceId);
|
|
650
|
+
this.state.connection
|
|
651
|
+
.prepare(`INSERT INTO effects(
|
|
652
|
+
effect_id, resource_id, source_resource_id, source_revision,
|
|
653
|
+
effect_type, idempotency_key, payload_hash, owner_scope,
|
|
654
|
+
status, attempt_count, created_at, updated_at
|
|
655
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, 'run', 'pending', 0, ?, ?)`)
|
|
656
|
+
.run(effectId, effectResourceId, decisionResourceId, revision, effectType, idempotencyKey, payloadHash, now, now);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
assertRunOwner(runId, now) {
|
|
660
|
+
const authority = this.authorityProvider?.(runId);
|
|
661
|
+
if (authority === undefined) {
|
|
662
|
+
throw new Error("Timeout policy requires the current run owner");
|
|
663
|
+
}
|
|
664
|
+
const row = this.state.connection
|
|
665
|
+
.prepare(`SELECT l.generation, l.owner_type AS ownerType, l.owner_id AS ownerId,
|
|
666
|
+
l.token_hash AS tokenHash, l.expires_at AS expiresAt
|
|
667
|
+
FROM runs r JOIN leases l ON l.resource_id = r.resource_id
|
|
668
|
+
WHERE r.run_id = ?`)
|
|
669
|
+
.get(runId);
|
|
670
|
+
if (!isOwnerRow(row) ||
|
|
671
|
+
row.ownerType !== authority.ownerType ||
|
|
672
|
+
row.ownerId !== authority.ownerId ||
|
|
673
|
+
row.generation !== authority.generation ||
|
|
674
|
+
row.tokenHash === null ||
|
|
675
|
+
!row.tokenHash.equals(tokenHash(authority.token)) ||
|
|
676
|
+
row.expiresAt === null ||
|
|
677
|
+
row.expiresAt <= now) {
|
|
678
|
+
throw new Error("Timeout policy run ownership is stale");
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
recordChannelMessage(decisionId, channel, purpose, value) {
|
|
682
|
+
const safeChannel = requireSimpleId(channel, "Human decision channel");
|
|
683
|
+
assertId(value.attemptId, `${purpose} attempt id`);
|
|
684
|
+
return this.state.transaction(() => {
|
|
685
|
+
if (this.readRequestRow(decisionId) === undefined) {
|
|
686
|
+
throw new Error(`Human decision is missing: ${decisionId}`);
|
|
687
|
+
}
|
|
688
|
+
const channelId = this.ensureChannel(safeChannel, Date.parse(value.createdAt));
|
|
689
|
+
const messageId = `message-${digestHex({
|
|
690
|
+
decisionId,
|
|
691
|
+
channel: safeChannel,
|
|
692
|
+
purpose,
|
|
693
|
+
attemptId: value.attemptId,
|
|
694
|
+
phase: "phase" in value ? value.phase : undefined,
|
|
695
|
+
partIndex: "partIndex" in value ? value.partIndex : undefined,
|
|
696
|
+
}).slice(0, 40)}`;
|
|
697
|
+
const contentHash = this.state.putJson(value, Date.parse(value.createdAt));
|
|
698
|
+
const existing = this.state.connection
|
|
699
|
+
.prepare("SELECT content_hash AS contentHash FROM channel_messages WHERE message_id = ?")
|
|
700
|
+
.get(messageId);
|
|
701
|
+
if (isContentHashRow(existing)) {
|
|
702
|
+
if (!existing.contentHash.equals(contentHash)) {
|
|
703
|
+
throw new Error(`Immutable human decision ${purpose} conflicts`);
|
|
704
|
+
}
|
|
705
|
+
return "adopted";
|
|
706
|
+
}
|
|
707
|
+
const status = channelMessageStatus(value);
|
|
708
|
+
this.state.connection
|
|
709
|
+
.prepare(`INSERT INTO channel_messages(
|
|
710
|
+
message_id, channel_id, decision_id, purpose, content_hash,
|
|
711
|
+
status, created_at, updated_at
|
|
712
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
713
|
+
.run(messageId, channelId, decisionId, purpose, contentHash, status, Date.parse(value.createdAt), "finishedAt" in value && value.finishedAt !== undefined
|
|
714
|
+
? Date.parse(value.finishedAt)
|
|
715
|
+
: Date.parse(value.createdAt));
|
|
716
|
+
return "created";
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
listChannelMessages(decisionId, channel, purpose) {
|
|
720
|
+
const channelId = channelIdFor(requireSimpleId(channel, "Human decision channel"));
|
|
721
|
+
const rows = this.state.connection
|
|
722
|
+
.prepare(`SELECT content_hash AS contentHash
|
|
723
|
+
FROM channel_messages
|
|
724
|
+
WHERE decision_id = ? AND channel_id = ? AND purpose = ?
|
|
725
|
+
ORDER BY created_at, message_id`)
|
|
726
|
+
.all(decisionId, channelId, purpose);
|
|
727
|
+
return rows.filter(isContentHashRow).map((row) => this.state.readJson(row.contentHash));
|
|
728
|
+
}
|
|
729
|
+
ensureChannel(channel, now) {
|
|
730
|
+
const channelId = channelIdFor(channel);
|
|
731
|
+
const resourceId = resourceIdFor("channel", channelId);
|
|
732
|
+
this.state.connection
|
|
733
|
+
.prepare(`INSERT INTO resources(
|
|
734
|
+
resource_id, resource_type, aggregate_key, revision, created_at, updated_at
|
|
735
|
+
) VALUES (?, 'channel', ?, 1, ?, ?)
|
|
736
|
+
ON CONFLICT(resource_type, aggregate_key) DO NOTHING`)
|
|
737
|
+
.run(resourceId, channelId, now, now);
|
|
738
|
+
this.state.connection
|
|
739
|
+
.prepare(`INSERT INTO leases(resource_id, generation)
|
|
740
|
+
VALUES (?, 0) ON CONFLICT(resource_id) DO NOTHING`)
|
|
741
|
+
.run(resourceId);
|
|
742
|
+
this.state.connection
|
|
743
|
+
.prepare(`INSERT INTO channels(channel_id, resource_id, adapter_type, profile_key, created_at)
|
|
744
|
+
VALUES (?, ?, ?, ?, ?)
|
|
745
|
+
ON CONFLICT(channel_id) DO NOTHING`)
|
|
746
|
+
.run(channelId, resourceId, channel.split("-")[0] ?? channel, channel, now);
|
|
747
|
+
return channelId;
|
|
748
|
+
}
|
|
749
|
+
markSubmission(decisionId, attemptId, outcome, resultHash) {
|
|
750
|
+
this.state.connection
|
|
751
|
+
.prepare(`UPDATE human_decision_submissions SET outcome = ?, result_hash = ?
|
|
752
|
+
WHERE decision_id = ? AND attempt_id = ?`)
|
|
753
|
+
.run(outcome, resultHash, decisionId, attemptId);
|
|
754
|
+
}
|
|
755
|
+
readContinuationRow(decisionId) {
|
|
756
|
+
const row = this.state.connection
|
|
757
|
+
.prepare("SELECT response_hash AS recordHash FROM continuations WHERE decision_id = ?")
|
|
758
|
+
.get(decisionId);
|
|
759
|
+
return isContinuationRow(row) ? row : undefined;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
function digestBuffer(value) {
|
|
763
|
+
const hex = value.startsWith("sha256:") ? value.slice(7) : value;
|
|
764
|
+
if (!/^[a-f0-9]{64}$/i.test(hex))
|
|
765
|
+
throw new Error("Expected a SHA-256 digest");
|
|
766
|
+
return Buffer.from(hex, "hex");
|
|
767
|
+
}
|
|
768
|
+
function channelIdFor(channel) {
|
|
769
|
+
return `channel-${digestHex(channel).slice(0, 40)}`;
|
|
770
|
+
}
|
|
771
|
+
function sameHumanAnswer(left, right) {
|
|
772
|
+
return (left.provenance === "human" &&
|
|
773
|
+
right.provenance === "human" &&
|
|
774
|
+
left.idempotencyKey === right.idempotencyKey &&
|
|
775
|
+
canonicalJson(left.response) === canonicalJson(right.response) &&
|
|
776
|
+
canonicalJson(left.source) === canonicalJson(right.source));
|
|
777
|
+
}
|
|
778
|
+
function channelMessageStatus(value) {
|
|
779
|
+
if (value.state === "confirmed")
|
|
780
|
+
return "confirmed";
|
|
781
|
+
if (value.state === "failed")
|
|
782
|
+
return "failed";
|
|
783
|
+
if (value.state === "unknown")
|
|
784
|
+
return "ambiguous";
|
|
785
|
+
return "pending";
|
|
786
|
+
}
|
|
787
|
+
function isResourceIdRow(value) {
|
|
788
|
+
return isRecordValue(value);
|
|
789
|
+
}
|
|
790
|
+
function isPresentRow(value) {
|
|
791
|
+
return isRecordValue(value);
|
|
792
|
+
}
|
|
793
|
+
function isRequestHashRow(value) {
|
|
794
|
+
return isRecordValue(value);
|
|
795
|
+
}
|
|
796
|
+
function isDeadlineRow(value) {
|
|
797
|
+
return isRecordValue(value);
|
|
798
|
+
}
|
|
799
|
+
function isCandidateHashRow(value) {
|
|
800
|
+
return isRecordValue(value);
|
|
801
|
+
}
|
|
802
|
+
function isContentHashRow(value) {
|
|
803
|
+
return isRecordValue(value);
|
|
804
|
+
}
|
|
805
|
+
function isContinuationRow(value) {
|
|
806
|
+
return isRecordValue(value);
|
|
807
|
+
}
|
|
808
|
+
function isDecisionResourceRow(value) {
|
|
809
|
+
return isRecordValue(value);
|
|
810
|
+
}
|
|
811
|
+
function isResolutionRow(value) {
|
|
812
|
+
return isRecordValue(value);
|
|
813
|
+
}
|
|
814
|
+
function isEffectIdentityRecord(value) {
|
|
815
|
+
return isRecordValue(value);
|
|
816
|
+
}
|
|
817
|
+
function isRevisionRecord(value) {
|
|
818
|
+
return isRecordValue(value);
|
|
819
|
+
}
|
|
820
|
+
function isOwnerRow(value) {
|
|
821
|
+
return isRecordValue(value);
|
|
822
|
+
}
|
|
823
|
+
function isRecordValue(value) {
|
|
824
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
498
825
|
}
|
|
499
826
|
export function createHumanDecisionAttemptId() {
|
|
500
827
|
return randomUUID();
|
|
@@ -608,43 +935,6 @@ function assertJsonValue(value, label) {
|
|
|
608
935
|
throw new Error(`${label} must be JSON-serializable`);
|
|
609
936
|
}
|
|
610
937
|
}
|
|
611
|
-
async function writeImmutableJson(filePath, value, requireIdentical = true) {
|
|
612
|
-
const bytes = `${canonicalJson(value)}\n`;
|
|
613
|
-
const directory = path.dirname(filePath);
|
|
614
|
-
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
615
|
-
const temporary = path.join(directory, `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`);
|
|
616
|
-
const handle = await fs.open(temporary, "wx", 0o600);
|
|
617
|
-
try {
|
|
618
|
-
await handle.writeFile(bytes, "utf8");
|
|
619
|
-
await handle.sync();
|
|
620
|
-
}
|
|
621
|
-
finally {
|
|
622
|
-
await handle.close();
|
|
623
|
-
}
|
|
624
|
-
try {
|
|
625
|
-
await fs.link(temporary, filePath);
|
|
626
|
-
const directoryHandle = await fs.open(directory, "r");
|
|
627
|
-
try {
|
|
628
|
-
await directoryHandle.sync();
|
|
629
|
-
}
|
|
630
|
-
finally {
|
|
631
|
-
await directoryHandle.close();
|
|
632
|
-
}
|
|
633
|
-
return "created";
|
|
634
|
-
}
|
|
635
|
-
catch (error) {
|
|
636
|
-
if (error.code !== "EEXIST")
|
|
637
|
-
throw error;
|
|
638
|
-
const existing = await fs.readFile(filePath, "utf8");
|
|
639
|
-
if (requireIdentical && existing !== bytes) {
|
|
640
|
-
throw new Error(`Immutable human decision record conflicts: ${filePath}`);
|
|
641
|
-
}
|
|
642
|
-
return "adopted";
|
|
643
|
-
}
|
|
644
|
-
finally {
|
|
645
|
-
await fs.unlink(temporary).catch(() => undefined);
|
|
646
|
-
}
|
|
647
|
-
}
|
|
648
938
|
function validateExpiry(value, createdAt) {
|
|
649
939
|
if (value === undefined)
|
|
650
940
|
return undefined;
|
|
@@ -657,30 +947,4 @@ function validateExpiry(value, createdAt) {
|
|
|
657
947
|
}
|
|
658
948
|
return new Date(parsed).toISOString();
|
|
659
949
|
}
|
|
660
|
-
async function readJsonDirectory(directory) {
|
|
661
|
-
let names;
|
|
662
|
-
try {
|
|
663
|
-
names = await fs.readdir(directory);
|
|
664
|
-
}
|
|
665
|
-
catch (error) {
|
|
666
|
-
if (error.code === "ENOENT")
|
|
667
|
-
return [];
|
|
668
|
-
throw error;
|
|
669
|
-
}
|
|
670
|
-
const values = await Promise.all(names
|
|
671
|
-
.filter((name) => name.endsWith(".json"))
|
|
672
|
-
.sort()
|
|
673
|
-
.map(async (name) => await readJson(path.join(directory, name))));
|
|
674
|
-
return values.filter((value) => value !== null);
|
|
675
|
-
}
|
|
676
|
-
async function readJson(filePath) {
|
|
677
|
-
try {
|
|
678
|
-
return JSON.parse(await fs.readFile(filePath, "utf8"));
|
|
679
|
-
}
|
|
680
|
-
catch (error) {
|
|
681
|
-
if (error.code === "ENOENT")
|
|
682
|
-
return null;
|
|
683
|
-
throw error;
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
950
|
//# sourceMappingURL=human-decision.js.map
|