@osolmaz/pi-workflows 0.12.1 → 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 +52 -28
- package/dist/builtins/autodoc.workflow.d.ts +4 -4
- package/dist/builtins/autoimplement.workflow.d.ts +369 -73
- 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 +6 -4
- package/dist/builtins/catalog.js.map +1 -1
- 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 +497 -216
- 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 +170 -22
- 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/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 +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 +156 -103
- 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 +19 -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/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 +104 -50
- 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/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/autoplan.workflow.ts +290 -40
- package/src/builtins/catalog.ts +6 -4
- package/src/builtins/index.ts +7 -1
- package/src/builtins/monitor.workflow.ts +663 -249
- package/src/builtins/plain-summary.workflow.ts +185 -0
- package/src/builtins/sanity-check.workflow.ts +62 -7
- 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 +105 -2
- package/src/extension/herdr-viewer.ts +0 -5
- package/src/extension/index.ts +180 -118
- 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,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,
|
|
@@ -309,10 +308,8 @@ export function validateHumanDecisionSubmission(
|
|
|
309
308
|
};
|
|
310
309
|
}
|
|
311
310
|
|
|
312
|
-
export function
|
|
313
|
-
return
|
|
314
|
-
? path.join(path.dirname(runsRoot), "decisions")
|
|
315
|
-
: `${runsRoot}.decisions`;
|
|
311
|
+
export function humanDecisionDatabasePath(homeDir?: string): string {
|
|
312
|
+
return workflowStatePath(homeDir);
|
|
316
313
|
}
|
|
317
314
|
|
|
318
315
|
export type HumanDecisionAcceptance =
|
|
@@ -347,59 +344,121 @@ function requireCurrentDecision(value: unknown): ResolvedHumanDecision {
|
|
|
347
344
|
return value as ResolvedHumanDecision;
|
|
348
345
|
}
|
|
349
346
|
|
|
350
|
-
function requireCurrentResolution(value: unknown): HumanDecisionResolution | null {
|
|
351
|
-
if (value === null) return null;
|
|
352
|
-
const resolution = requireRecord(value, "Human decision resolution");
|
|
353
|
-
if (resolution.schema !== "pi-workflows.human-decision-resolution.v1") {
|
|
354
|
-
throw new Error(INCOMPATIBLE_HUMAN_DECISION_STATE);
|
|
355
|
-
}
|
|
356
|
-
if (resolution.outcome === "accepted") {
|
|
357
|
-
return {
|
|
358
|
-
...resolution,
|
|
359
|
-
decision: requireCurrentDecision(resolution.decision),
|
|
360
|
-
} as HumanDecisionResolution;
|
|
361
|
-
}
|
|
362
|
-
if (resolution.outcome === "cancelled") return value as HumanDecisionResolution;
|
|
363
|
-
throw new Error(INCOMPATIBLE_HUMAN_DECISION_STATE);
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
function requireCurrentDelivery(value: unknown): HumanDecisionDeliveryRecord {
|
|
367
|
-
const delivery = requireRecord(value, "Human decision delivery");
|
|
368
|
-
if (
|
|
369
|
-
delivery.schema !== "pi-workflows.human-decision-delivery.v1" ||
|
|
370
|
-
!Object.hasOwn(delivery, "presentationDigest") ||
|
|
371
|
-
!Object.hasOwn(delivery, "phase")
|
|
372
|
-
) {
|
|
373
|
-
throw new Error(INCOMPATIBLE_HUMAN_DECISION_STATE);
|
|
374
|
-
}
|
|
375
|
-
return value as HumanDecisionDeliveryRecord;
|
|
376
|
-
}
|
|
377
|
-
|
|
378
347
|
export class HumanDecisionStore {
|
|
379
|
-
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;
|
|
380
354
|
|
|
381
|
-
constructor(
|
|
382
|
-
|
|
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;
|
|
383
373
|
}
|
|
384
374
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
return path.join(this.root, decisionId);
|
|
375
|
+
close(): void {
|
|
376
|
+
if (this.ownsState) this.state.close();
|
|
388
377
|
}
|
|
389
378
|
|
|
390
379
|
async createRequest(request: HumanDecisionRequest): Promise<"created" | "adopted"> {
|
|
391
380
|
validateHumanDecisionRequestIntegrity(request);
|
|
392
|
-
return
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
+
});
|
|
396
456
|
}
|
|
397
457
|
|
|
398
458
|
async readRequest(decisionId: string): Promise<HumanDecisionRequest | null> {
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
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);
|
|
403
462
|
}
|
|
404
463
|
|
|
405
464
|
async recordDelivery(
|
|
@@ -407,18 +466,7 @@ export class HumanDecisionStore {
|
|
|
407
466
|
channel: string,
|
|
408
467
|
value: HumanDecisionDeliveryRecord,
|
|
409
468
|
): Promise<"created" | "adopted"> {
|
|
410
|
-
|
|
411
|
-
const attemptId = requireString(value.attemptId, "Human decision delivery attempt");
|
|
412
|
-
assertId(attemptId, "delivery attempt id");
|
|
413
|
-
return await writeImmutableJson(
|
|
414
|
-
path.join(
|
|
415
|
-
this.decisionDir(request.decisionId),
|
|
416
|
-
"deliveries",
|
|
417
|
-
safeChannel,
|
|
418
|
-
`${attemptId}.json`,
|
|
419
|
-
),
|
|
420
|
-
value,
|
|
421
|
-
);
|
|
469
|
+
return this.recordChannelMessage(request.decisionId, channel, "delivery", value);
|
|
422
470
|
}
|
|
423
471
|
|
|
424
472
|
async accept(
|
|
@@ -426,101 +474,37 @@ export class HumanDecisionStore {
|
|
|
426
474
|
submission: HumanDecisionSubmission,
|
|
427
475
|
): Promise<HumanDecisionAcceptance> {
|
|
428
476
|
validateHumanDecisionRequestIntegrity(request);
|
|
429
|
-
const cancellation = await this.readCancellation(request.decisionId);
|
|
430
|
-
if (cancellation !== null) {
|
|
431
|
-
throw new Error(`Human decision request was ${cancellation.reason}`);
|
|
432
|
-
}
|
|
433
477
|
const normalized = validateHumanDecisionSubmission(request, submission);
|
|
434
|
-
|
|
478
|
+
const attemptedAt = new Date().toISOString();
|
|
435
479
|
const attemptId = digestHex({
|
|
436
480
|
decisionId: request.decisionId,
|
|
437
481
|
idempotencyKey: normalized.idempotencyKey,
|
|
438
482
|
}).slice(0, 40);
|
|
439
|
-
const
|
|
440
|
-
|
|
441
|
-
"answers",
|
|
442
|
-
`${attemptId}.json`,
|
|
443
|
-
);
|
|
444
|
-
const attempt = {
|
|
445
|
-
schema: "pi-workflows.human-decision-answer-attempt.v1",
|
|
446
|
-
attemptId,
|
|
447
|
-
attemptedAt,
|
|
448
|
-
...normalized,
|
|
449
|
-
};
|
|
450
|
-
const attemptWrite = await writeImmutableJson(attemptPath, attempt, false);
|
|
451
|
-
if (attemptWrite === "adopted") {
|
|
452
|
-
const existingAttempt = requireRecord(
|
|
453
|
-
await readJson(attemptPath),
|
|
454
|
-
"Existing human decision answer attempt",
|
|
455
|
-
);
|
|
456
|
-
const existingSubmission = {
|
|
457
|
-
decisionId: existingAttempt.decisionId,
|
|
458
|
-
requestDigest: existingAttempt.requestDigest,
|
|
459
|
-
choice: existingAttempt.choice,
|
|
460
|
-
...(existingAttempt.input !== undefined ? { input: existingAttempt.input } : {}),
|
|
461
|
-
source: existingAttempt.source,
|
|
462
|
-
idempotencyKey: existingAttempt.idempotencyKey,
|
|
463
|
-
};
|
|
464
|
-
if (canonicalJson(existingSubmission) !== canonicalJson(normalized)) {
|
|
465
|
-
throw new Error("Human decision idempotency key was reused for a different answer");
|
|
466
|
-
}
|
|
467
|
-
attemptedAt = requireString(existingAttempt.attemptedAt, "answer attempt time");
|
|
468
|
-
}
|
|
469
|
-
const response = validateHumanDecisionResponse(request, normalized);
|
|
470
|
-
const commonDecision = {
|
|
483
|
+
const decision: ResolvedHumanDecision = {
|
|
484
|
+
schema: "pi-workflows.human-decision-accepted.v1",
|
|
471
485
|
decisionId: request.decisionId,
|
|
472
486
|
requestDigest: request.requestDigest,
|
|
473
|
-
response,
|
|
474
|
-
provenance: "human"
|
|
487
|
+
response: validateHumanDecisionResponse(request, normalized),
|
|
488
|
+
provenance: "human",
|
|
475
489
|
source: normalized.source,
|
|
476
490
|
idempotencyKey: normalized.idempotencyKey,
|
|
477
491
|
acceptedAt: attemptedAt,
|
|
478
|
-
answerDigest: digest({
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
492
|
+
answerDigest: digest({
|
|
493
|
+
requestDigest: request.requestDigest,
|
|
494
|
+
response: validateHumanDecisionResponse(request, normalized),
|
|
495
|
+
source: normalized.source,
|
|
496
|
+
}),
|
|
483
497
|
subjectDigest: request.subjectDigest,
|
|
484
498
|
presentationDigest: request.presentationDigest,
|
|
485
499
|
revision: request.revision,
|
|
486
500
|
};
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
const winner =
|
|
495
|
-
result === "created"
|
|
496
|
-
? resolution
|
|
497
|
-
: ((await readJson(resolutionPath)) as HumanDecisionResolution | null);
|
|
498
|
-
if (winner === null) throw new Error("Human decision resolution became unreadable");
|
|
499
|
-
const winningCancellation = await this.readCancellation(request.decisionId);
|
|
500
|
-
if (winningCancellation !== null) {
|
|
501
|
-
throw new Error(`Human decision request was ${winningCancellation.reason}`);
|
|
502
|
-
}
|
|
503
|
-
if (winner.outcome === "cancelled") {
|
|
504
|
-
throw new Error(`Human decision request was ${winner.cancellation.reason}`);
|
|
505
|
-
}
|
|
506
|
-
const existing = winner.decision;
|
|
507
|
-
await writeImmutableJson(
|
|
508
|
-
path.join(this.decisionDir(request.decisionId), "accepted.json"),
|
|
509
|
-
existing,
|
|
510
|
-
);
|
|
511
|
-
if (result === "created") return { status: "accepted", decision: existing };
|
|
512
|
-
if (canonicalJson(existing) === canonicalJson(decision)) {
|
|
513
|
-
return { status: "adopted", decision: existing };
|
|
514
|
-
}
|
|
515
|
-
if (
|
|
516
|
-
existing.provenance === "human" &&
|
|
517
|
-
existing.idempotencyKey === decision.idempotencyKey &&
|
|
518
|
-
canonicalJson(existing.response) === canonicalJson(decision.response) &&
|
|
519
|
-
canonicalJson(existing.source) === canonicalJson(decision.source)
|
|
520
|
-
) {
|
|
521
|
-
return { status: "adopted", decision: existing };
|
|
522
|
-
}
|
|
523
|
-
return { status: "conflict", 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
|
+
});
|
|
524
508
|
}
|
|
525
509
|
|
|
526
510
|
async resolveTimeout(
|
|
@@ -534,63 +518,38 @@ export class HumanDecisionStore {
|
|
|
534
518
|
if (Date.parse(request.expiresAt) > now.getTime()) {
|
|
535
519
|
throw new Error("Human decision timeout default is not eligible yet");
|
|
536
520
|
}
|
|
521
|
+
this.assertRunOwner(request.runId, now.getTime());
|
|
537
522
|
const response = validateHumanDecisionResponse(request, request.defaultResponse);
|
|
538
|
-
const
|
|
523
|
+
const decision: ResolvedHumanDecision = {
|
|
524
|
+
schema: "pi-workflows.human-decision-accepted.v1",
|
|
539
525
|
decisionId: request.decisionId,
|
|
540
526
|
requestDigest: request.requestDigest,
|
|
541
527
|
response,
|
|
542
|
-
provenance: "timeout"
|
|
528
|
+
provenance: "timeout",
|
|
543
529
|
acceptedAt: now.toISOString(),
|
|
544
530
|
answerDigest: digest({
|
|
545
531
|
provenance: "timeout",
|
|
546
532
|
requestDigest: request.requestDigest,
|
|
547
533
|
response,
|
|
548
534
|
}),
|
|
549
|
-
};
|
|
550
|
-
const decision: ResolvedHumanDecision = {
|
|
551
|
-
schema: "pi-workflows.human-decision-accepted.v1",
|
|
552
|
-
...commonDecision,
|
|
553
535
|
subjectDigest: request.subjectDigest,
|
|
554
536
|
presentationDigest: request.presentationDigest,
|
|
555
537
|
revision: request.revision,
|
|
556
538
|
};
|
|
557
|
-
const
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
const winner =
|
|
565
|
-
result === "created"
|
|
566
|
-
? resolution
|
|
567
|
-
: ((await readJson(resolutionPath)) as HumanDecisionResolution | null);
|
|
568
|
-
if (winner === null) throw new Error("Human decision resolution became unreadable");
|
|
569
|
-
const winningCancellation = await this.readCancellation(request.decisionId);
|
|
570
|
-
if (winningCancellation !== null) {
|
|
571
|
-
throw new Error(`Human decision request was ${winningCancellation.reason}`);
|
|
572
|
-
}
|
|
573
|
-
if (winner.outcome === "cancelled") {
|
|
574
|
-
throw new Error(`Human decision request was ${winner.cancellation.reason}`);
|
|
575
|
-
}
|
|
576
|
-
const existing = winner.decision;
|
|
577
|
-
await writeImmutableJson(
|
|
578
|
-
path.join(this.decisionDir(request.decisionId), "accepted.json"),
|
|
579
|
-
existing,
|
|
580
|
-
);
|
|
581
|
-
if (result === "created") return { status: "accepted", decision: existing };
|
|
582
|
-
return canonicalJson(existing) === canonicalJson(decision)
|
|
583
|
-
? { status: "adopted", decision: existing }
|
|
584
|
-
: { status: "conflict", decision: existing };
|
|
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
|
+
});
|
|
585
546
|
}
|
|
586
547
|
|
|
587
548
|
async listDeliveries(
|
|
588
549
|
decisionId: string,
|
|
589
550
|
channel: string,
|
|
590
551
|
): Promise<HumanDecisionDeliveryRecord[]> {
|
|
591
|
-
|
|
592
|
-
const directory = path.join(this.decisionDir(decisionId), "deliveries", safeChannel);
|
|
593
|
-
return (await readJsonDirectory(directory)).map(requireCurrentDelivery);
|
|
552
|
+
return this.listChannelMessages<HumanDecisionDeliveryRecord>(decisionId, channel, "delivery");
|
|
594
553
|
}
|
|
595
554
|
|
|
596
555
|
async recordSettlement(
|
|
@@ -598,26 +557,18 @@ export class HumanDecisionStore {
|
|
|
598
557
|
channel: string,
|
|
599
558
|
value: HumanDecisionSettlementRecord,
|
|
600
559
|
): Promise<"created" | "adopted"> {
|
|
601
|
-
|
|
602
|
-
assertId(value.attemptId, "settlement attempt id");
|
|
603
|
-
return await writeImmutableJson(
|
|
604
|
-
path.join(
|
|
605
|
-
this.decisionDir(decisionId),
|
|
606
|
-
"settlements",
|
|
607
|
-
safeChannel,
|
|
608
|
-
`${value.attemptId}.json`,
|
|
609
|
-
),
|
|
610
|
-
value,
|
|
611
|
-
);
|
|
560
|
+
return this.recordChannelMessage(decisionId, channel, "settlement", value);
|
|
612
561
|
}
|
|
613
562
|
|
|
614
563
|
async listSettlements(
|
|
615
564
|
decisionId: string,
|
|
616
565
|
channel: string,
|
|
617
566
|
): Promise<HumanDecisionSettlementRecord[]> {
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
567
|
+
return this.listChannelMessages<HumanDecisionSettlementRecord>(
|
|
568
|
+
decisionId,
|
|
569
|
+
channel,
|
|
570
|
+
"settlement",
|
|
571
|
+
);
|
|
621
572
|
}
|
|
622
573
|
|
|
623
574
|
async cancel(
|
|
@@ -625,107 +576,677 @@ export class HumanDecisionStore {
|
|
|
625
576
|
reason: HumanDecisionCancellationRecord["reason"],
|
|
626
577
|
): Promise<"created" | "adopted"> {
|
|
627
578
|
validateHumanDecisionRequestIntegrity(request);
|
|
628
|
-
if (
|
|
629
|
-
throw new Error("
|
|
630
|
-
}
|
|
631
|
-
const filePath = path.join(this.decisionDir(request.decisionId), "cancelled.json");
|
|
632
|
-
const record: HumanDecisionCancellationRecord = {
|
|
633
|
-
schema: "pi-workflows.human-decision-cancellation.v1",
|
|
634
|
-
decisionId: request.decisionId,
|
|
635
|
-
requestDigest: request.requestDigest,
|
|
636
|
-
cancelledAt: new Date().toISOString(),
|
|
637
|
-
reason,
|
|
638
|
-
};
|
|
639
|
-
const resolutionPath = path.join(this.decisionDir(request.decisionId), "resolution.json");
|
|
640
|
-
const resolution: HumanDecisionResolution = {
|
|
641
|
-
schema: "pi-workflows.human-decision-resolution.v1",
|
|
642
|
-
outcome: "cancelled",
|
|
643
|
-
cancellation: record,
|
|
644
|
-
};
|
|
645
|
-
const result = await writeImmutableJson(resolutionPath, resolution, false);
|
|
646
|
-
const winner =
|
|
647
|
-
result === "created"
|
|
648
|
-
? resolution
|
|
649
|
-
: ((await readJson(resolutionPath)) as HumanDecisionResolution | null);
|
|
650
|
-
if (winner === null) throw new Error("Human decision resolution became unreadable");
|
|
651
|
-
if (winner.outcome === "accepted") {
|
|
652
|
-
throw new Error("Resolved human decision cannot be cancelled");
|
|
579
|
+
if (reason === "expired" && request.defaultResponse !== undefined) {
|
|
580
|
+
throw new Error("A defaulted human decision must resolve through its timeout policy");
|
|
653
581
|
}
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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
|
+
});
|
|
664
635
|
}
|
|
665
636
|
|
|
666
637
|
async readCancellation(decisionId: string): Promise<HumanDecisionCancellationRecord | null> {
|
|
667
|
-
const
|
|
668
|
-
|
|
669
|
-
)) as HumanDecisionCancellationRecord | null;
|
|
670
|
-
if (stored !== null) return stored;
|
|
671
|
-
const resolution = requireCurrentResolution(
|
|
672
|
-
await readJson(path.join(this.decisionDir(decisionId), "resolution.json")),
|
|
673
|
-
);
|
|
674
|
-
if (resolution?.outcome !== "cancelled") return null;
|
|
675
|
-
await writeImmutableJson(
|
|
676
|
-
path.join(this.decisionDir(decisionId), "cancelled.json"),
|
|
677
|
-
resolution.cancellation,
|
|
678
|
-
);
|
|
679
|
-
return resolution.cancellation;
|
|
638
|
+
const resolution = this.readResolution(decisionId);
|
|
639
|
+
return resolution?.outcome === "cancelled" ? resolution.cancellation : null;
|
|
680
640
|
}
|
|
681
641
|
|
|
682
642
|
async readResolved(decisionId: string): Promise<ResolvedHumanDecision | null> {
|
|
683
|
-
const
|
|
684
|
-
|
|
685
|
-
const stored = await readJson(path.join(this.decisionDir(decisionId), "accepted.json"));
|
|
686
|
-
if (stored !== null) return requireCurrentDecision(stored);
|
|
687
|
-
const resolution = requireCurrentResolution(
|
|
688
|
-
await readJson(path.join(this.decisionDir(decisionId), "resolution.json")),
|
|
689
|
-
);
|
|
690
|
-
if (resolution?.outcome !== "accepted") return null;
|
|
691
|
-
await writeImmutableJson(
|
|
692
|
-
path.join(this.decisionDir(decisionId), "accepted.json"),
|
|
693
|
-
resolution.decision,
|
|
694
|
-
);
|
|
695
|
-
return resolution.decision;
|
|
643
|
+
const resolution = this.readResolution(decisionId);
|
|
644
|
+
return resolution?.outcome === "accepted" ? resolution.decision : null;
|
|
696
645
|
}
|
|
697
646
|
|
|
698
647
|
async recordContinuation(
|
|
699
648
|
decisionId: string,
|
|
700
649
|
value: HumanDecisionContinuationRecord,
|
|
701
650
|
): Promise<"created" | "adopted"> {
|
|
702
|
-
return
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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
|
+
});
|
|
706
674
|
}
|
|
707
675
|
|
|
708
676
|
async readContinuation(decisionId: string): Promise<HumanDecisionContinuationRecord | null> {
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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
|
+
}
|
|
712
729
|
}
|
|
713
730
|
|
|
714
731
|
async listRequests(): Promise<HumanDecisionRequest[]> {
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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");
|
|
721
861
|
}
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
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
|
+
};
|
|
870
|
+
}
|
|
871
|
+
const cancellation: HumanDecisionCancellationRecord = {
|
|
872
|
+
schema: "pi-workflows.human-decision-cancellation.v1",
|
|
873
|
+
decisionId,
|
|
874
|
+
requestDigest: storedRequest.requestDigest,
|
|
875
|
+
cancelledAt: new Date(row.resolvedAt).toISOString(),
|
|
876
|
+
reason: row.reason === "expired" ? "expired" : "cancelled",
|
|
877
|
+
};
|
|
878
|
+
return {
|
|
879
|
+
schema: "pi-workflows.human-decision-resolution.v1",
|
|
880
|
+
outcome: "cancelled",
|
|
881
|
+
cancellation,
|
|
882
|
+
};
|
|
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
|
+
);
|
|
1003
|
+
}
|
|
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);
|
|
1019
|
+
if (
|
|
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
|
|
1028
|
+
) {
|
|
1029
|
+
throw new Error("Timeout policy run ownership is stale");
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
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
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
private listChannelMessages<T>(
|
|
1091
|
+
decisionId: string,
|
|
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);
|
|
728
1105
|
}
|
|
1106
|
+
|
|
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;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
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;
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
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);
|
|
729
1250
|
}
|
|
730
1251
|
|
|
731
1252
|
export function createHumanDecisionAttemptId(): string {
|
|
@@ -855,46 +1376,6 @@ function assertJsonValue(value: unknown, label: string): void {
|
|
|
855
1376
|
}
|
|
856
1377
|
}
|
|
857
1378
|
|
|
858
|
-
async function writeImmutableJson(
|
|
859
|
-
filePath: string,
|
|
860
|
-
value: unknown,
|
|
861
|
-
requireIdentical = true,
|
|
862
|
-
): Promise<"created" | "adopted"> {
|
|
863
|
-
const bytes = `${canonicalJson(value)}\n`;
|
|
864
|
-
const directory = path.dirname(filePath);
|
|
865
|
-
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
866
|
-
const temporary = path.join(
|
|
867
|
-
directory,
|
|
868
|
-
`.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`,
|
|
869
|
-
);
|
|
870
|
-
const handle = await fs.open(temporary, "wx", 0o600);
|
|
871
|
-
try {
|
|
872
|
-
await handle.writeFile(bytes, "utf8");
|
|
873
|
-
await handle.sync();
|
|
874
|
-
} finally {
|
|
875
|
-
await handle.close();
|
|
876
|
-
}
|
|
877
|
-
try {
|
|
878
|
-
await fs.link(temporary, filePath);
|
|
879
|
-
const directoryHandle = await fs.open(directory, "r");
|
|
880
|
-
try {
|
|
881
|
-
await directoryHandle.sync();
|
|
882
|
-
} finally {
|
|
883
|
-
await directoryHandle.close();
|
|
884
|
-
}
|
|
885
|
-
return "created";
|
|
886
|
-
} catch (error) {
|
|
887
|
-
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
888
|
-
const existing = await fs.readFile(filePath, "utf8");
|
|
889
|
-
if (requireIdentical && existing !== bytes) {
|
|
890
|
-
throw new Error(`Immutable human decision record conflicts: ${filePath}`);
|
|
891
|
-
}
|
|
892
|
-
return "adopted";
|
|
893
|
-
} finally {
|
|
894
|
-
await fs.unlink(temporary).catch(() => undefined);
|
|
895
|
-
}
|
|
896
|
-
}
|
|
897
|
-
|
|
898
1379
|
function validateExpiry(
|
|
899
1380
|
value: string | undefined,
|
|
900
1381
|
createdAt: string | undefined,
|
|
@@ -908,29 +1389,3 @@ function validateExpiry(
|
|
|
908
1389
|
}
|
|
909
1390
|
return new Date(parsed).toISOString();
|
|
910
1391
|
}
|
|
911
|
-
|
|
912
|
-
async function readJsonDirectory(directory: string): Promise<unknown[]> {
|
|
913
|
-
let names: string[];
|
|
914
|
-
try {
|
|
915
|
-
names = await fs.readdir(directory);
|
|
916
|
-
} catch (error) {
|
|
917
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
918
|
-
throw error;
|
|
919
|
-
}
|
|
920
|
-
const values = await Promise.all(
|
|
921
|
-
names
|
|
922
|
-
.filter((name) => name.endsWith(".json"))
|
|
923
|
-
.sort()
|
|
924
|
-
.map(async (name) => await readJson(path.join(directory, name))),
|
|
925
|
-
);
|
|
926
|
-
return values.filter((value) => value !== null);
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
async function readJson(filePath: string): Promise<unknown | null> {
|
|
930
|
-
try {
|
|
931
|
-
return JSON.parse(await fs.readFile(filePath, "utf8"));
|
|
932
|
-
} catch (error) {
|
|
933
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
934
|
-
throw error;
|
|
935
|
-
}
|
|
936
|
-
}
|