@osolmaz/pi-workflows 0.13.4 → 0.15.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 +146 -160
- package/dist/builtins/autoimplement.workflow.js +3 -1
- package/dist/builtins/autoimplement.workflow.js.map +1 -1
- package/dist/builtins/change-verification.workflow.js +7 -2
- package/dist/builtins/change-verification.workflow.js.map +1 -1
- package/dist/builtins/metadata.d.ts +22 -0
- package/dist/builtins/metadata.js +10 -0
- package/dist/builtins/metadata.js.map +1 -0
- package/dist/builtins/monitor.workflow.js +4 -1
- package/dist/builtins/monitor.workflow.js.map +1 -1
- package/dist/builtins/sanity-check.workflow.js +4 -1
- package/dist/builtins/sanity-check.workflow.js.map +1 -1
- package/dist/builtins/workspace-preparation.workflow.js +3 -1
- package/dist/builtins/workspace-preparation.workflow.js.map +1 -1
- package/dist/controllers/index.d.ts +1 -2
- package/dist/controllers/index.js +0 -1
- package/dist/controllers/index.js.map +1 -1
- package/dist/controllers/sqlite.d.ts +85 -31
- package/dist/controllers/sqlite.js +541 -126
- package/dist/controllers/sqlite.js.map +1 -1
- package/dist/controllers/store.d.ts +1 -0
- package/dist/extension/controller-command.d.ts +22 -0
- package/dist/extension/controller-command.js +32 -0
- package/dist/extension/controller-command.js.map +1 -0
- package/dist/extension/index.d.ts +2 -9
- package/dist/extension/index.js +876 -2928
- package/dist/extension/index.js.map +1 -1
- package/dist/extension/message-card.d.ts +13 -0
- package/dist/extension/message-card.js +46 -0
- package/dist/extension/message-card.js.map +1 -0
- package/dist/extension/step-message.d.ts +5 -6
- package/dist/extension/step-message.js +41 -52
- package/dist/extension/step-message.js.map +1 -1
- package/dist/host/child-worker-supervisor.d.ts +50 -0
- package/dist/host/child-worker-supervisor.js +159 -0
- package/dist/host/child-worker-supervisor.js.map +1 -0
- package/dist/host/client.d.ts +48 -0
- package/dist/host/client.js +216 -0
- package/dist/host/client.js.map +1 -0
- package/dist/host/controller-worker-entry.d.ts +2 -0
- package/dist/host/controller-worker-entry.js +244 -0
- package/dist/host/controller-worker-entry.js.map +1 -0
- package/dist/host/controller-worker-protocol.d.ts +36 -0
- package/dist/host/controller-worker-protocol.js +49 -0
- package/dist/host/controller-worker-protocol.js.map +1 -0
- package/dist/host/controller-worker-supervisor.d.ts +21 -0
- package/dist/host/controller-worker-supervisor.js +54 -0
- package/dist/host/controller-worker-supervisor.js.map +1 -0
- package/dist/host/host-entry.d.ts +2 -0
- package/dist/host/host-entry.js +23 -0
- package/dist/host/host-entry.js.map +1 -0
- package/dist/host/processes.d.ts +17 -12
- package/dist/host/processes.js +154 -50
- package/dist/host/processes.js.map +1 -1
- package/dist/host/protocol.d.ts +38 -0
- package/dist/host/protocol.js +156 -0
- package/dist/host/protocol.js.map +1 -0
- package/dist/host/resolver-entry.d.ts +49 -0
- package/dist/host/resolver-entry.js +149 -0
- package/dist/host/resolver-entry.js.map +1 -0
- package/dist/host/rpc-executor.d.ts +14 -3
- package/dist/host/rpc-executor.js +63 -30
- package/dist/host/rpc-executor.js.map +1 -1
- package/dist/host/runner.d.ts +95 -29
- package/dist/host/runner.js +2311 -348
- package/dist/host/runner.js.map +1 -1
- package/dist/host/state.d.ts +174 -0
- package/dist/host/state.js +689 -0
- package/dist/host/state.js.map +1 -0
- package/dist/host/worker-entry.d.ts +10 -0
- package/dist/host/worker-entry.js +446 -0
- package/dist/host/worker-entry.js.map +1 -0
- package/dist/host/worker-protocol.d.ts +31 -0
- package/dist/host/worker-protocol.js +122 -0
- package/dist/host/worker-protocol.js.map +1 -0
- package/dist/host/worker-store.d.ts +80 -0
- package/dist/host/worker-store.js +91 -0
- package/dist/host/worker-store.js.map +1 -0
- package/dist/host/worker-supervisor.d.ts +22 -0
- package/dist/host/worker-supervisor.js +54 -0
- package/dist/host/worker-supervisor.js.map +1 -0
- package/dist/render/canvas.d.ts +2 -1
- package/dist/render/canvas.js +40 -15
- package/dist/render/canvas.js.map +1 -1
- package/dist/render/graph-render.d.ts +7 -3
- package/dist/render/graph-render.js +125 -74
- package/dist/render/graph-render.js.map +1 -1
- package/dist/state/database.js +3 -1
- package/dist/state/database.js.map +1 -1
- package/dist/state/index.d.ts +1 -0
- package/dist/state/index.js +1 -0
- package/dist/state/index.js.map +1 -1
- package/dist/state/prune.js +36 -10
- package/dist/state/prune.js.map +1 -1
- package/dist/state/schema.d.ts +1 -1
- package/dist/state/schema.js +171 -3
- package/dist/state/schema.js.map +1 -1
- package/dist/state/viewer.d.ts +46 -0
- package/dist/state/viewer.js +249 -0
- package/dist/state/viewer.js.map +1 -0
- package/dist/viewer/cli.d.ts +1 -1
- package/dist/viewer/cli.js +47 -18
- package/dist/viewer/cli.js.map +1 -1
- package/dist/workflows/composition.js +25 -2
- package/dist/workflows/composition.js.map +1 -1
- package/dist/workflows/definition.d.ts +3 -1
- package/dist/workflows/definition.js +25 -0
- package/dist/workflows/definition.js.map +1 -1
- package/dist/workflows/engine.d.ts +1 -0
- package/dist/workflows/engine.js +157 -42
- package/dist/workflows/engine.js.map +1 -1
- package/dist/workflows/errors.d.ts +3 -1
- package/dist/workflows/errors.js +4 -7
- package/dist/workflows/errors.js.map +1 -1
- package/dist/workflows/human-decision.d.ts +3 -0
- package/dist/workflows/human-decision.js +31 -0
- package/dist/workflows/human-decision.js.map +1 -1
- package/dist/workflows/index.d.ts +1 -1
- package/dist/workflows/index.js +1 -1
- package/dist/workflows/index.js.map +1 -1
- package/dist/workflows/schema.js +17 -6
- package/dist/workflows/schema.js.map +1 -1
- package/dist/{viewer → workflows}/session-reducer.d.ts +3 -1
- package/dist/{viewer → workflows}/session-reducer.js +4 -0
- package/dist/workflows/session-reducer.js.map +1 -0
- package/dist/workflows/store.d.ts +87 -1
- package/dist/workflows/store.js +892 -93
- package/dist/workflows/store.js.map +1 -1
- package/dist/workflows/tool-input.d.ts +0 -22
- package/dist/workflows/tool-input.js +1 -44
- package/dist/workflows/tool-input.js.map +1 -1
- package/dist/workflows/types.d.ts +36 -4
- package/docs/2026-08-25-workflow-follow-ups.md +8 -6
- package/docs/2026-08-30-out-of-process-workflow-host-plan.md +353 -0
- package/docs/CONTROLLERS.md +11 -11
- package/docs/DEFERRED_TURNS.md +61 -27
- package/docs/HUMAN_DECISIONS.md +12 -4
- package/docs/SQLITE_STATE.md +42 -8
- package/docs/WORKFLOW_HOST.md +452 -0
- package/docs/development.md +46 -30
- package/docs/live-replay-protocol.md +129 -100
- package/docs/plans/2026-08-19-human-decision-gates-plan.md +34 -8
- package/docs/plans/2026-08-27-workflow-terminal-restart-plan.md +399 -0
- package/docs/plans/2026-08-28-piw-incremental-viewer-plan.md +293 -0
- package/docs/plans/piw-viewer-experience-implementation-plan.md +11 -1
- package/docs/plans/tui-viewer-implementation-plan.md +5 -0
- package/docs/tui-viewer.md +18 -3
- package/docs/workflows.md +164 -156
- package/examples/workflows/command-batch.workflow.ts +2 -0
- package/examples/workflows/shell.workflow.ts +2 -1
- package/herdr-plugin.toml +1 -1
- package/package.json +1 -1
- package/skills/autodoc/SKILL.md +1 -1
- package/skills/autoimplement/SKILL.md +1 -1
- package/skills/autoplan/SKILL.md +1 -1
- package/skills/pi-workflows/SKILL.md +2 -0
- package/src/builtins/autoimplement.workflow.ts +3 -0
- package/src/builtins/change-verification.workflow.ts +7 -2
- package/src/builtins/metadata.ts +9 -0
- package/src/builtins/monitor.workflow.ts +4 -0
- package/src/builtins/sanity-check.workflow.ts +4 -0
- package/src/builtins/workspace-preparation.workflow.ts +3 -1
- package/src/controllers/index.ts +3 -5
- package/src/controllers/sqlite.ts +929 -225
- package/src/controllers/store.ts +1 -0
- package/src/extension/controller-command.ts +45 -0
- package/src/extension/index.ts +948 -3506
- package/src/extension/message-card.ts +61 -0
- package/src/extension/step-message.ts +58 -63
- package/src/host/child-worker-supervisor.ts +183 -0
- package/src/host/client.ts +293 -0
- package/src/host/controller-worker-entry.ts +311 -0
- package/src/host/controller-worker-protocol.ts +104 -0
- package/src/host/controller-worker-supervisor.ts +79 -0
- package/src/host/host-entry.ts +23 -0
- package/src/host/processes.ts +171 -54
- package/src/host/protocol.ts +196 -0
- package/src/host/resolver-entry.ts +241 -0
- package/src/host/rpc-executor.ts +76 -34
- package/src/host/runner.ts +2813 -422
- package/src/host/state.ts +1160 -0
- package/src/host/worker-entry.ts +533 -0
- package/src/host/worker-protocol.ts +165 -0
- package/src/host/worker-store.ts +229 -0
- package/src/host/worker-supervisor.ts +74 -0
- package/src/render/canvas.ts +44 -10
- package/src/render/graph-render.ts +145 -90
- package/src/state/database.ts +2 -1
- package/src/state/index.ts +14 -0
- package/src/state/prune.ts +35 -9
- package/src/state/schema.ts +171 -3
- package/src/state/viewer.ts +356 -0
- package/src/viewer/cli.ts +49 -17
- package/src/workflows/composition.ts +36 -2
- package/src/workflows/definition.ts +32 -0
- package/src/workflows/engine.ts +157 -54
- package/src/workflows/errors.ts +11 -2
- package/src/workflows/human-decision.ts +49 -0
- package/src/workflows/index.ts +2 -0
- package/src/workflows/schema.ts +19 -6
- package/src/{viewer → workflows}/session-reducer.ts +11 -1
- package/src/workflows/store.ts +1316 -108
- package/src/workflows/tool-input.ts +2 -60
- package/src/workflows/types.ts +32 -4
- package/dist/controllers/workflow-engine-scheduler.d.ts +0 -29
- package/dist/controllers/workflow-engine-scheduler.js +0 -175
- package/dist/controllers/workflow-engine-scheduler.js.map +0 -1
- package/dist/extension/controller-host.d.ts +0 -48
- package/dist/extension/controller-host.js +0 -110
- package/dist/extension/controller-host.js.map +0 -1
- package/dist/extension/deferred-turn-coordinator.d.ts +0 -32
- package/dist/extension/deferred-turn-coordinator.js +0 -143
- package/dist/extension/deferred-turn-coordinator.js.map +0 -1
- package/dist/extension/deferred-turn.d.ts +0 -44
- package/dist/extension/deferred-turn.js +0 -110
- package/dist/extension/deferred-turn.js.map +0 -1
- package/dist/extension/executor.d.ts +0 -86
- package/dist/extension/executor.js +0 -311
- package/dist/extension/executor.js.map +0 -1
- package/dist/extension/follow-up-coordinator.d.ts +0 -27
- package/dist/extension/follow-up-coordinator.js +0 -131
- package/dist/extension/follow-up-coordinator.js.map +0 -1
- package/dist/extension/recorder.d.ts +0 -84
- package/dist/extension/recorder.js +0 -528
- package/dist/extension/recorder.js.map +0 -1
- package/dist/extension/session-events.d.ts +0 -133
- package/dist/extension/session-events.js +0 -61
- package/dist/extension/session-events.js.map +0 -1
- package/dist/viewer/session-reducer.js.map +0 -1
- package/src/controllers/workflow-engine-scheduler.ts +0 -246
- package/src/extension/controller-host.ts +0 -167
- package/src/extension/deferred-turn-coordinator.ts +0 -171
- package/src/extension/deferred-turn.ts +0 -166
- package/src/extension/executor.ts +0 -411
- package/src/extension/follow-up-coordinator.ts +0 -151
- package/src/extension/recorder.ts +0 -655
- package/src/extension/session-events.ts +0 -121
|
@@ -0,0 +1,1160 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { StateDatabase } from "../state/database.js";
|
|
3
|
+
import { canonicalJson, type JsonValue } from "../state/json.js";
|
|
4
|
+
import { tokenHash } from "../state/mutation.js";
|
|
5
|
+
import type { HostRequest, HostResponse } from "./protocol.js";
|
|
6
|
+
import { requestFingerprint } from "./protocol.js";
|
|
7
|
+
import type { WorkerMessage, WorkerResponse } from "./worker-protocol.js";
|
|
8
|
+
|
|
9
|
+
export type HostClaim = {
|
|
10
|
+
hostId: string;
|
|
11
|
+
token: string;
|
|
12
|
+
epoch: number;
|
|
13
|
+
pid: number;
|
|
14
|
+
processStartIdentity: string;
|
|
15
|
+
expiresAt: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type HostStatusRecord = {
|
|
19
|
+
epoch: number;
|
|
20
|
+
hostId: string | null;
|
|
21
|
+
pid: number | null;
|
|
22
|
+
processStartIdentity: string | null;
|
|
23
|
+
startedAt: string | null;
|
|
24
|
+
heartbeatAt: string | null;
|
|
25
|
+
expiresAt: string | null;
|
|
26
|
+
live: boolean;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type WorkerLaunchEnvelope = {
|
|
30
|
+
schema: "pi-workflows.worker-launch.v1";
|
|
31
|
+
runId: string;
|
|
32
|
+
generation: number;
|
|
33
|
+
workerEpoch: string;
|
|
34
|
+
projectPath: string;
|
|
35
|
+
workflowSource: JsonValue;
|
|
36
|
+
definitionDigest: string;
|
|
37
|
+
inputHash: string;
|
|
38
|
+
protocolVersion: 1;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type WorkerOutcome =
|
|
42
|
+
| "exited"
|
|
43
|
+
| "cancelled"
|
|
44
|
+
| "timedOut"
|
|
45
|
+
| "crashed"
|
|
46
|
+
| "claimLost"
|
|
47
|
+
| "orphaned";
|
|
48
|
+
|
|
49
|
+
export type InteractiveSubmissionRecord = {
|
|
50
|
+
requestId: string;
|
|
51
|
+
submissionId: string;
|
|
52
|
+
idempotencyKey: string;
|
|
53
|
+
outcome: "validating" | "accepted" | "rejected" | "adopted";
|
|
54
|
+
payload: JsonValue;
|
|
55
|
+
receipt: JsonValue | null;
|
|
56
|
+
submittedAt: string;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type InteractiveRequestRecord = {
|
|
60
|
+
requestId: string;
|
|
61
|
+
runId: string;
|
|
62
|
+
attemptId: string;
|
|
63
|
+
targetSessionId: string;
|
|
64
|
+
kind: "agent" | "assistant" | "decision";
|
|
65
|
+
contract: JsonValue;
|
|
66
|
+
revision: number;
|
|
67
|
+
status: "pending" | "presenting" | "settled" | "cancelled";
|
|
68
|
+
presentationSessionEntryId: string | null;
|
|
69
|
+
acceptedSubmissionId: string | null;
|
|
70
|
+
createdAt: string;
|
|
71
|
+
updatedAt: string;
|
|
72
|
+
settledAt: string | null;
|
|
73
|
+
consumedAt: string | null;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export class HostStateStore {
|
|
77
|
+
readonly state: StateDatabase;
|
|
78
|
+
private readonly ownsState: boolean;
|
|
79
|
+
|
|
80
|
+
constructor(databasePath: string, options: { state?: StateDatabase; readOnly?: boolean } = {}) {
|
|
81
|
+
this.ownsState = options.state === undefined;
|
|
82
|
+
this.state =
|
|
83
|
+
options.state ??
|
|
84
|
+
new StateDatabase({
|
|
85
|
+
filePath: databasePath,
|
|
86
|
+
mode: options.readOnly === true ? "read-only" : "read-write",
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
close(): void {
|
|
91
|
+
if (this.ownsState) this.state.close();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
acquireHost(options: {
|
|
95
|
+
hostId: string;
|
|
96
|
+
pid: number;
|
|
97
|
+
processStartIdentity: string;
|
|
98
|
+
leaseMs: number;
|
|
99
|
+
now?: number;
|
|
100
|
+
}): HostClaim {
|
|
101
|
+
const now = options.now ?? Date.now();
|
|
102
|
+
requireLeaseMs(options.leaseMs);
|
|
103
|
+
return this.state.transaction(() => {
|
|
104
|
+
const current = this.hostRow();
|
|
105
|
+
if (current.hostId !== null && current.expiresAt !== null && current.expiresAt > now) {
|
|
106
|
+
throw new Error(`A live Pi Workflows host already owns epoch ${current.epoch}`);
|
|
107
|
+
}
|
|
108
|
+
const token = randomBytes(32).toString("base64url");
|
|
109
|
+
const epoch = current.epoch + 1;
|
|
110
|
+
const expiresAt = now + options.leaseMs;
|
|
111
|
+
const changed = this.state.connection
|
|
112
|
+
.prepare(
|
|
113
|
+
`UPDATE workflow_host_state
|
|
114
|
+
SET epoch = ?, host_id = ?, token_hash = ?, pid = ?, process_start_identity = ?,
|
|
115
|
+
started_at = ?, heartbeat_at = ?, expires_at = ?
|
|
116
|
+
WHERE id = 1 AND epoch = ? AND (host_id IS NULL OR expires_at IS NULL OR expires_at <= ?)`,
|
|
117
|
+
)
|
|
118
|
+
.run(
|
|
119
|
+
epoch,
|
|
120
|
+
options.hostId,
|
|
121
|
+
tokenHash(token),
|
|
122
|
+
options.pid,
|
|
123
|
+
options.processStartIdentity,
|
|
124
|
+
now,
|
|
125
|
+
now,
|
|
126
|
+
expiresAt,
|
|
127
|
+
current.epoch,
|
|
128
|
+
now,
|
|
129
|
+
);
|
|
130
|
+
if (changed.changes !== 1) throw new Error("Pi Workflows host claim changed during startup");
|
|
131
|
+
return {
|
|
132
|
+
hostId: options.hostId,
|
|
133
|
+
token,
|
|
134
|
+
epoch,
|
|
135
|
+
pid: options.pid,
|
|
136
|
+
processStartIdentity: options.processStartIdentity,
|
|
137
|
+
expiresAt,
|
|
138
|
+
};
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
renewHost(claim: HostClaim, leaseMs: number, now: number = Date.now()): HostClaim {
|
|
143
|
+
requireLeaseMs(leaseMs);
|
|
144
|
+
return this.state.transaction(() => {
|
|
145
|
+
const expiresAt = now + leaseMs;
|
|
146
|
+
const changed = this.state.connection
|
|
147
|
+
.prepare(
|
|
148
|
+
`UPDATE workflow_host_state SET heartbeat_at = ?, expires_at = ?
|
|
149
|
+
WHERE id = 1 AND epoch = ? AND host_id = ? AND token_hash = ?
|
|
150
|
+
AND pid = ? AND process_start_identity = ? AND expires_at > ?`,
|
|
151
|
+
)
|
|
152
|
+
.run(
|
|
153
|
+
now,
|
|
154
|
+
expiresAt,
|
|
155
|
+
claim.epoch,
|
|
156
|
+
claim.hostId,
|
|
157
|
+
tokenHash(claim.token),
|
|
158
|
+
claim.pid,
|
|
159
|
+
claim.processStartIdentity,
|
|
160
|
+
now,
|
|
161
|
+
);
|
|
162
|
+
if (changed.changes !== 1) throw new Error("Pi Workflows host claim lost");
|
|
163
|
+
return { ...claim, expiresAt };
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
releaseHost(claim: HostClaim, _now: number = Date.now()): boolean {
|
|
168
|
+
return this.state.transaction(
|
|
169
|
+
() =>
|
|
170
|
+
this.state.connection
|
|
171
|
+
.prepare(
|
|
172
|
+
`UPDATE workflow_host_state
|
|
173
|
+
SET host_id = NULL, token_hash = NULL, pid = NULL, process_start_identity = NULL,
|
|
174
|
+
started_at = NULL, heartbeat_at = NULL, expires_at = NULL
|
|
175
|
+
WHERE id = 1 AND epoch = ? AND host_id = ? AND token_hash = ?
|
|
176
|
+
AND pid = ? AND process_start_identity = ?`,
|
|
177
|
+
)
|
|
178
|
+
.run(
|
|
179
|
+
claim.epoch,
|
|
180
|
+
claim.hostId,
|
|
181
|
+
tokenHash(claim.token),
|
|
182
|
+
claim.pid,
|
|
183
|
+
claim.processStartIdentity,
|
|
184
|
+
).changes === 1,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
hostStatus(now: number = Date.now()): HostStatusRecord {
|
|
189
|
+
const row = this.hostRow();
|
|
190
|
+
return {
|
|
191
|
+
epoch: row.epoch,
|
|
192
|
+
hostId: row.hostId,
|
|
193
|
+
pid: row.pid,
|
|
194
|
+
processStartIdentity: row.processStartIdentity,
|
|
195
|
+
startedAt: iso(row.startedAt),
|
|
196
|
+
heartbeatAt: iso(row.heartbeatAt),
|
|
197
|
+
expiresAt: iso(row.expiresAt),
|
|
198
|
+
live: row.hostId !== null && row.expiresAt !== null && row.expiresAt > now,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
readCommand(request: HostRequest): HostResponse | undefined {
|
|
203
|
+
const row = this.state.connection
|
|
204
|
+
.prepare(
|
|
205
|
+
`SELECT request_fingerprint AS requestFingerprint, outcome, accepted_revision AS revision,
|
|
206
|
+
receipt_hash AS receiptHash, error_hash AS errorHash
|
|
207
|
+
FROM host_commands WHERE request_id = ?`,
|
|
208
|
+
)
|
|
209
|
+
.get(request.requestId);
|
|
210
|
+
if (!isCommandRow(row)) return undefined;
|
|
211
|
+
if (!row.requestFingerprint.equals(requestFingerprint(request))) {
|
|
212
|
+
return conflictResponse(request.requestId, "Request ID was reused with another payload");
|
|
213
|
+
}
|
|
214
|
+
return this.commandResponse(request.requestId, row);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
executeCommand(
|
|
218
|
+
request: HostRequest,
|
|
219
|
+
hostEpoch: number,
|
|
220
|
+
operation: () => Omit<HostResponse, "schema" | "requestId">,
|
|
221
|
+
): HostResponse {
|
|
222
|
+
const existing = this.readCommand(request);
|
|
223
|
+
if (existing !== undefined) {
|
|
224
|
+
return existing.outcome === "conflict" ? existing : { ...existing, outcome: "adopted" };
|
|
225
|
+
}
|
|
226
|
+
return this.state.transaction(() => {
|
|
227
|
+
const idempotent = this.state.connection
|
|
228
|
+
.prepare(
|
|
229
|
+
`SELECT request_id AS requestId, request_fingerprint AS requestFingerprint,
|
|
230
|
+
outcome, accepted_revision AS revision, receipt_hash AS receiptHash,
|
|
231
|
+
error_hash AS errorHash
|
|
232
|
+
FROM host_commands WHERE client_id = ? AND idempotency_key = ?`,
|
|
233
|
+
)
|
|
234
|
+
.get(request.clientId, request.idempotencyKey);
|
|
235
|
+
if (isIdempotentCommandRow(idempotent)) {
|
|
236
|
+
if (!idempotent.requestFingerprint.equals(requestFingerprint(request))) {
|
|
237
|
+
return conflictResponse(
|
|
238
|
+
request.requestId,
|
|
239
|
+
"Idempotency key was reused with another payload",
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
const adopted = this.commandResponse(idempotent.requestId, idempotent);
|
|
243
|
+
return { ...adopted, requestId: request.requestId, outcome: "adopted" };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const result = operation();
|
|
247
|
+
const response: HostResponse = {
|
|
248
|
+
schema: "pi-workflows.host-response.v1",
|
|
249
|
+
requestId: request.requestId,
|
|
250
|
+
...result,
|
|
251
|
+
};
|
|
252
|
+
const now = Date.now();
|
|
253
|
+
const receiptHash =
|
|
254
|
+
response.receipt === undefined ? null : this.state.putJson(response.receipt, now);
|
|
255
|
+
const errorHash =
|
|
256
|
+
response.error === undefined ? null : this.state.putText(response.error, now);
|
|
257
|
+
this.state.connection
|
|
258
|
+
.prepare(
|
|
259
|
+
`INSERT INTO host_commands(
|
|
260
|
+
request_id, client_id, operation, idempotency_key, request_fingerprint,
|
|
261
|
+
run_id, accepted_revision, outcome, receipt_hash, error_hash,
|
|
262
|
+
host_epoch, created_at, completed_at
|
|
263
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
264
|
+
)
|
|
265
|
+
.run(
|
|
266
|
+
request.requestId,
|
|
267
|
+
request.clientId,
|
|
268
|
+
request.operation,
|
|
269
|
+
request.idempotencyKey,
|
|
270
|
+
requestFingerprint(request),
|
|
271
|
+
request.runId ?? null,
|
|
272
|
+
response.revision ?? null,
|
|
273
|
+
response.outcome,
|
|
274
|
+
receiptHash,
|
|
275
|
+
errorHash,
|
|
276
|
+
hostEpoch,
|
|
277
|
+
now,
|
|
278
|
+
now,
|
|
279
|
+
);
|
|
280
|
+
return response;
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
recordWorkerStart(envelope: WorkerLaunchEnvelope, hostEpoch: number): void {
|
|
285
|
+
const now = Date.now();
|
|
286
|
+
this.state.transaction(() => {
|
|
287
|
+
const launchHash = this.state.putJson(envelope, now);
|
|
288
|
+
this.state.connection
|
|
289
|
+
.prepare(
|
|
290
|
+
`INSERT INTO run_workers(
|
|
291
|
+
worker_epoch, run_id, generation, host_epoch, launch_envelope_hash,
|
|
292
|
+
status, started_at
|
|
293
|
+
) VALUES (?, ?, ?, ?, ?, 'starting', ?)`,
|
|
294
|
+
)
|
|
295
|
+
.run(envelope.workerEpoch, envelope.runId, envelope.generation, hostEpoch, launchHash, now);
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
attachWorkerProcess(workerEpoch: string, pid: number, processStartIdentity: string): void {
|
|
300
|
+
const changed = this.state.connection
|
|
301
|
+
.prepare(
|
|
302
|
+
`UPDATE run_workers SET pid = ?, process_start_identity = ?
|
|
303
|
+
WHERE worker_epoch = ? AND status = 'starting' AND pid IS NULL`,
|
|
304
|
+
)
|
|
305
|
+
.run(pid, processStartIdentity, workerEpoch);
|
|
306
|
+
if (changed.changes !== 1) throw new Error(`Worker epoch is not starting: ${workerEpoch}`);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
markWorkerReady(workerEpoch: string): void {
|
|
310
|
+
const now = Date.now();
|
|
311
|
+
const changed = this.state.connection
|
|
312
|
+
.prepare(
|
|
313
|
+
`UPDATE run_workers SET status = 'running', ready_at = ?
|
|
314
|
+
WHERE worker_epoch = ? AND status = 'starting'`,
|
|
315
|
+
)
|
|
316
|
+
.run(now, workerEpoch);
|
|
317
|
+
if (changed.changes !== 1) throw new Error(`Worker epoch cannot become ready: ${workerEpoch}`);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
finishWorker(options: {
|
|
321
|
+
workerEpoch: string;
|
|
322
|
+
outcome: WorkerOutcome;
|
|
323
|
+
exitCode?: number | null;
|
|
324
|
+
signal?: string | null;
|
|
325
|
+
diagnostic?: string;
|
|
326
|
+
}): void {
|
|
327
|
+
const now = Date.now();
|
|
328
|
+
this.state.transaction(() => {
|
|
329
|
+
const diagnosticHash =
|
|
330
|
+
options.diagnostic === undefined ? null : this.state.putText(options.diagnostic, now);
|
|
331
|
+
const changed = this.state.connection
|
|
332
|
+
.prepare(
|
|
333
|
+
`UPDATE run_workers
|
|
334
|
+
SET status = ?, finished_at = ?, exit_code = ?, signal = ?, diagnostic_hash = ?
|
|
335
|
+
WHERE worker_epoch = ? AND status IN ('starting', 'ready', 'running')`,
|
|
336
|
+
)
|
|
337
|
+
.run(
|
|
338
|
+
options.outcome,
|
|
339
|
+
now,
|
|
340
|
+
options.exitCode ?? null,
|
|
341
|
+
options.signal ?? null,
|
|
342
|
+
diagnosticHash,
|
|
343
|
+
options.workerEpoch,
|
|
344
|
+
);
|
|
345
|
+
if (changed.changes !== 1)
|
|
346
|
+
throw new Error(`Worker epoch is not active: ${options.workerEpoch}`);
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
readWorkerMessage(message: WorkerMessage): WorkerResponse | undefined {
|
|
351
|
+
const row = this.state.connection
|
|
352
|
+
.prepare(
|
|
353
|
+
`SELECT request_fingerprint AS requestFingerprint, outcome,
|
|
354
|
+
accepted_revision AS revision, result_hash AS resultHash, error_hash AS errorHash
|
|
355
|
+
FROM worker_messages WHERE worker_epoch = ? AND message_id = ?`,
|
|
356
|
+
)
|
|
357
|
+
.get(message.workerEpoch, message.messageId);
|
|
358
|
+
if (!isWorkerMessageRow(row)) return undefined;
|
|
359
|
+
if (!row.requestFingerprint.equals(workerMessageFingerprint(message))) {
|
|
360
|
+
return {
|
|
361
|
+
schema: "pi-workflows.worker-response.v1",
|
|
362
|
+
messageId: message.messageId,
|
|
363
|
+
outcome: "rejected",
|
|
364
|
+
error: "Worker message ID was reused with another payload",
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
return workerMessageResponse(this.state, message.messageId, row);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
recordWorkerMessage(message: WorkerMessage, response: WorkerResponse): WorkerResponse {
|
|
371
|
+
return this.state.transaction(() => {
|
|
372
|
+
const existing = this.readWorkerMessage(message);
|
|
373
|
+
if (existing !== undefined) return existing;
|
|
374
|
+
const now = Date.now();
|
|
375
|
+
const resultHash =
|
|
376
|
+
response.result === undefined ? null : this.state.putJson(response.result, now);
|
|
377
|
+
const errorHash =
|
|
378
|
+
response.error === undefined ? null : this.state.putText(response.error, now);
|
|
379
|
+
this.state.connection
|
|
380
|
+
.prepare(
|
|
381
|
+
`INSERT INTO worker_messages(
|
|
382
|
+
worker_epoch, message_id, request_fingerprint, outcome, accepted_revision,
|
|
383
|
+
result_hash, error_hash, completed_at
|
|
384
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
385
|
+
)
|
|
386
|
+
.run(
|
|
387
|
+
message.workerEpoch,
|
|
388
|
+
message.messageId,
|
|
389
|
+
workerMessageFingerprint(message),
|
|
390
|
+
response.outcome,
|
|
391
|
+
response.revision ?? null,
|
|
392
|
+
resultHash,
|
|
393
|
+
errorHash,
|
|
394
|
+
now,
|
|
395
|
+
);
|
|
396
|
+
return response;
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
createInteractiveRequest(options: {
|
|
401
|
+
requestId: string;
|
|
402
|
+
runId: string;
|
|
403
|
+
attemptId: string;
|
|
404
|
+
targetSessionId: string;
|
|
405
|
+
kind: InteractiveRequestRecord["kind"];
|
|
406
|
+
contract: JsonValue;
|
|
407
|
+
}): InteractiveRequestRecord {
|
|
408
|
+
const now = Date.now();
|
|
409
|
+
return this.state.transaction(() => {
|
|
410
|
+
const existing = this.interactiveRequest(options.requestId);
|
|
411
|
+
if (existing !== undefined) {
|
|
412
|
+
if (
|
|
413
|
+
existing.runId !== options.runId ||
|
|
414
|
+
existing.attemptId !== options.attemptId ||
|
|
415
|
+
canonicalJson(existing.contract) !== canonicalJson(options.contract)
|
|
416
|
+
) {
|
|
417
|
+
throw new Error(`Interactive request conflicts: ${options.requestId}`);
|
|
418
|
+
}
|
|
419
|
+
if (existing.status === "settled" && existing.consumedAt === null) {
|
|
420
|
+
this.state.connection
|
|
421
|
+
.prepare(
|
|
422
|
+
`UPDATE interactive_requests
|
|
423
|
+
SET status = 'pending', presenter_id = NULL,
|
|
424
|
+
presentation_claim_expires_at = NULL, presentation_session_entry_id = NULL,
|
|
425
|
+
accepted_submission_id = NULL, settled_at = NULL, consumed_at = NULL,
|
|
426
|
+
revision = revision + 1, updated_at = ?
|
|
427
|
+
WHERE request_id = ? AND status = 'settled' AND consumed_at IS NULL`,
|
|
428
|
+
)
|
|
429
|
+
.run(now, options.requestId);
|
|
430
|
+
return this.requireInteractiveRequest(options.requestId);
|
|
431
|
+
}
|
|
432
|
+
return existing;
|
|
433
|
+
}
|
|
434
|
+
const contractHash = this.state.putJson(options.contract, now);
|
|
435
|
+
this.state.connection
|
|
436
|
+
.prepare(
|
|
437
|
+
`INSERT INTO interactive_requests(
|
|
438
|
+
request_id, run_id, attempt_id, target_session_id, kind, contract_hash,
|
|
439
|
+
revision, status, created_at, updated_at
|
|
440
|
+
) VALUES (?, ?, ?, ?, ?, ?, 1, 'pending', ?, ?)`,
|
|
441
|
+
)
|
|
442
|
+
.run(
|
|
443
|
+
options.requestId,
|
|
444
|
+
options.runId,
|
|
445
|
+
options.attemptId,
|
|
446
|
+
options.targetSessionId,
|
|
447
|
+
options.kind,
|
|
448
|
+
contractHash,
|
|
449
|
+
now,
|
|
450
|
+
now,
|
|
451
|
+
);
|
|
452
|
+
return this.requireInteractiveRequest(options.requestId);
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
getInteraction(requestId: string): InteractiveRequestRecord | undefined {
|
|
457
|
+
return this.interactiveRequest(requestId);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
acceptedInteraction(runId: string):
|
|
461
|
+
| {
|
|
462
|
+
requestId: string;
|
|
463
|
+
attemptId: string;
|
|
464
|
+
nodeId: string;
|
|
465
|
+
submissionId: string;
|
|
466
|
+
payload: JsonValue;
|
|
467
|
+
}
|
|
468
|
+
| undefined {
|
|
469
|
+
const row = this.state.connection
|
|
470
|
+
.prepare(
|
|
471
|
+
`SELECT i.request_id AS requestId, i.attempt_id AS attemptId, a.node_id AS nodeId,
|
|
472
|
+
s.submission_id AS submissionId, s.payload_hash AS payloadHash
|
|
473
|
+
FROM interactive_requests i
|
|
474
|
+
JOIN node_attempts a ON a.attempt_id = i.attempt_id
|
|
475
|
+
JOIN interactive_submissions s ON s.submission_id = i.accepted_submission_id
|
|
476
|
+
WHERE i.run_id = ? AND i.status = 'settled' AND i.consumed_at IS NULL
|
|
477
|
+
ORDER BY i.settled_at DESC LIMIT 1`,
|
|
478
|
+
)
|
|
479
|
+
.get(runId);
|
|
480
|
+
if (!isAcceptedInteractionRow(row)) return undefined;
|
|
481
|
+
return {
|
|
482
|
+
requestId: row.requestId,
|
|
483
|
+
attemptId: row.attemptId,
|
|
484
|
+
nodeId: row.nodeId,
|
|
485
|
+
submissionId: row.submissionId,
|
|
486
|
+
payload: this.state.readJson(row.payloadHash),
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
validatingInteraction(runId: string):
|
|
491
|
+
| {
|
|
492
|
+
requestId: string;
|
|
493
|
+
attemptId: string;
|
|
494
|
+
nodeId: string;
|
|
495
|
+
submissionId: string;
|
|
496
|
+
payload: JsonValue;
|
|
497
|
+
}
|
|
498
|
+
| undefined {
|
|
499
|
+
const row = this.state.connection
|
|
500
|
+
.prepare(
|
|
501
|
+
`SELECT i.request_id AS requestId, i.attempt_id AS attemptId, a.node_id AS nodeId,
|
|
502
|
+
s.submission_id AS submissionId, s.payload_hash AS payloadHash
|
|
503
|
+
FROM interactive_requests i
|
|
504
|
+
JOIN node_attempts a ON a.attempt_id = i.attempt_id
|
|
505
|
+
JOIN interactive_submissions s ON s.request_id = i.request_id
|
|
506
|
+
WHERE i.run_id = ? AND i.status IN ('pending', 'presenting')
|
|
507
|
+
AND s.outcome = 'validating'
|
|
508
|
+
ORDER BY s.submitted_at DESC LIMIT 1`,
|
|
509
|
+
)
|
|
510
|
+
.get(runId);
|
|
511
|
+
if (!isAcceptedInteractionRow(row)) return undefined;
|
|
512
|
+
return {
|
|
513
|
+
requestId: row.requestId,
|
|
514
|
+
attemptId: row.attemptId,
|
|
515
|
+
nodeId: row.nodeId,
|
|
516
|
+
submissionId: row.submissionId,
|
|
517
|
+
payload: this.state.readJson(row.payloadHash),
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
validatingInteractionRunIds(): string[] {
|
|
522
|
+
return this.state.connection
|
|
523
|
+
.prepare(
|
|
524
|
+
`SELECT i.run_id AS runId
|
|
525
|
+
FROM interactive_requests i
|
|
526
|
+
JOIN interactive_submissions s ON s.request_id = i.request_id
|
|
527
|
+
JOIN run_queue q ON q.run_id = i.run_id
|
|
528
|
+
WHERE i.status IN ('pending', 'presenting') AND s.outcome = 'validating'
|
|
529
|
+
AND q.status NOT IN ('done', 'failed', 'cancelled')
|
|
530
|
+
GROUP BY i.run_id
|
|
531
|
+
ORDER BY MIN(s.submitted_at), i.run_id`,
|
|
532
|
+
)
|
|
533
|
+
.all()
|
|
534
|
+
.flatMap((row) => (isRunIdRow(row) ? [row.runId] : []));
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
expiredInteractionRunIds(now: number = Date.now()): string[] {
|
|
538
|
+
return this.state.connection
|
|
539
|
+
.prepare(
|
|
540
|
+
`SELECT i.run_id AS runId
|
|
541
|
+
FROM interactive_requests i
|
|
542
|
+
JOIN node_attempts a ON a.attempt_id = i.attempt_id
|
|
543
|
+
JOIN run_queue q ON q.run_id = i.run_id
|
|
544
|
+
WHERE i.status IN ('pending', 'presenting')
|
|
545
|
+
AND a.deadline_at IS NOT NULL AND a.deadline_at <= ?
|
|
546
|
+
AND q.status NOT IN ('done', 'failed', 'cancelled')
|
|
547
|
+
GROUP BY i.run_id
|
|
548
|
+
ORDER BY MIN(a.deadline_at), i.run_id`,
|
|
549
|
+
)
|
|
550
|
+
.all(now)
|
|
551
|
+
.flatMap((row) => (isRunIdRow(row) ? [row.runId] : []));
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
timedOutInteraction(runId: string): TimedOutInteractionRow | undefined {
|
|
555
|
+
const row = this.state.connection
|
|
556
|
+
.prepare(
|
|
557
|
+
`SELECT i.request_id AS requestId, i.attempt_id AS attemptId, a.node_id AS nodeId
|
|
558
|
+
FROM interactive_requests i
|
|
559
|
+
JOIN node_attempts a ON a.attempt_id = i.attempt_id
|
|
560
|
+
JOIN runs r ON r.run_id = i.run_id
|
|
561
|
+
WHERE i.run_id = ? AND i.status = 'cancelled' AND r.status = 'running'
|
|
562
|
+
AND a.deadline_at IS NOT NULL AND a.deadline_at <= ?
|
|
563
|
+
AND a.status IN ('pending', 'running', 'waiting', 'interrupted')
|
|
564
|
+
ORDER BY a.deadline_at, i.request_id LIMIT 1`,
|
|
565
|
+
)
|
|
566
|
+
.get(runId, Date.now());
|
|
567
|
+
return isTimedOutInteractionRow(row) ? row : undefined;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
interactionSubmission(
|
|
571
|
+
requestId: string,
|
|
572
|
+
submissionId: string,
|
|
573
|
+
): InteractiveSubmissionRecord | undefined {
|
|
574
|
+
const row = this.state.connection
|
|
575
|
+
.prepare(
|
|
576
|
+
`SELECT request_id AS requestId, submission_id AS submissionId,
|
|
577
|
+
idempotency_key AS idempotencyKey, outcome, payload_hash AS payloadHash,
|
|
578
|
+
receipt_hash AS receiptHash, submitted_at AS submittedAt
|
|
579
|
+
FROM interactive_submissions WHERE request_id = ? AND submission_id = ?`,
|
|
580
|
+
)
|
|
581
|
+
.get(requestId, submissionId);
|
|
582
|
+
if (!isSubmissionDetailRow(row)) return undefined;
|
|
583
|
+
return {
|
|
584
|
+
requestId: row.requestId,
|
|
585
|
+
submissionId: row.submissionId,
|
|
586
|
+
idempotencyKey: row.idempotencyKey,
|
|
587
|
+
outcome: row.outcome,
|
|
588
|
+
payload: this.state.readJson(row.payloadHash),
|
|
589
|
+
receipt: row.receiptHash === null ? null : this.state.readJson(row.receiptHash),
|
|
590
|
+
submittedAt: new Date(row.submittedAt).toISOString(),
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
finishInteractionValidation(options: {
|
|
595
|
+
requestId: string;
|
|
596
|
+
submissionId: string;
|
|
597
|
+
accepted: boolean;
|
|
598
|
+
receipt: JsonValue;
|
|
599
|
+
}): InteractiveSubmissionRecord {
|
|
600
|
+
const now = Date.now();
|
|
601
|
+
return this.state.transaction(() => {
|
|
602
|
+
const submission = this.interactionSubmission(options.requestId, options.submissionId);
|
|
603
|
+
const expectedOutcome = options.accepted ? "accepted" : "rejected";
|
|
604
|
+
if (submission?.outcome === expectedOutcome) return submission;
|
|
605
|
+
if (submission === undefined || submission.outcome !== "validating") {
|
|
606
|
+
throw new Error("Interactive submission is not awaiting validation");
|
|
607
|
+
}
|
|
608
|
+
const request = this.requireInteractiveRequest(options.requestId);
|
|
609
|
+
const receiptHash = this.state.putJson(options.receipt, now);
|
|
610
|
+
this.state.connection
|
|
611
|
+
.prepare(
|
|
612
|
+
`UPDATE interactive_submissions SET outcome = ?, receipt_hash = ?
|
|
613
|
+
WHERE request_id = ? AND submission_id = ? AND outcome = 'validating'`,
|
|
614
|
+
)
|
|
615
|
+
.run(
|
|
616
|
+
options.accepted ? "accepted" : "rejected",
|
|
617
|
+
receiptHash,
|
|
618
|
+
options.requestId,
|
|
619
|
+
options.submissionId,
|
|
620
|
+
);
|
|
621
|
+
if (options.accepted) {
|
|
622
|
+
const changed = this.state.connection
|
|
623
|
+
.prepare(
|
|
624
|
+
`UPDATE interactive_requests
|
|
625
|
+
SET status = 'settled', presenter_id = NULL,
|
|
626
|
+
presentation_claim_expires_at = NULL, accepted_submission_id = ?,
|
|
627
|
+
revision = revision + 1, updated_at = ?, settled_at = ?
|
|
628
|
+
WHERE request_id = ? AND revision = ? AND status IN ('pending', 'presenting')`,
|
|
629
|
+
)
|
|
630
|
+
.run(options.submissionId, now, now, options.requestId, request.revision);
|
|
631
|
+
if (changed.changes !== 1) throw new Error("Interactive request validation is stale");
|
|
632
|
+
}
|
|
633
|
+
const settled = this.interactionSubmission(options.requestId, options.submissionId);
|
|
634
|
+
if (settled === undefined) throw new Error("Interactive submission result is missing");
|
|
635
|
+
return settled;
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
consumeAcceptedInteraction(runId: string, attemptId: string, now: number = Date.now()): boolean {
|
|
640
|
+
const changed = this.state.connection
|
|
641
|
+
.prepare(
|
|
642
|
+
`UPDATE interactive_requests SET consumed_at = ?, updated_at = ?
|
|
643
|
+
WHERE run_id = ? AND attempt_id = ? AND status = 'settled'
|
|
644
|
+
AND accepted_submission_id IS NOT NULL AND consumed_at IS NULL`,
|
|
645
|
+
)
|
|
646
|
+
.run(now, now, runId, attemptId);
|
|
647
|
+
return changed.changes === 1;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
listPendingInteractions(sessionId: string): InteractiveRequestRecord[] {
|
|
651
|
+
return this.state.connection
|
|
652
|
+
.prepare(
|
|
653
|
+
`SELECT request_id AS requestId FROM interactive_requests
|
|
654
|
+
WHERE target_session_id = ? AND status IN ('pending', 'presenting')
|
|
655
|
+
ORDER BY created_at, request_id`,
|
|
656
|
+
)
|
|
657
|
+
.all(sessionId)
|
|
658
|
+
.flatMap((row) =>
|
|
659
|
+
isRequestIdRow(row) ? [this.requireInteractiveRequest(row.requestId)] : [],
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
claimInteractionPresentation(options: {
|
|
664
|
+
requestId: string;
|
|
665
|
+
expectedRevision: number;
|
|
666
|
+
presenterId: string;
|
|
667
|
+
leaseMs: number;
|
|
668
|
+
}): InteractiveRequestRecord {
|
|
669
|
+
requireLeaseMs(options.leaseMs);
|
|
670
|
+
const now = Date.now();
|
|
671
|
+
const changed = this.state.connection
|
|
672
|
+
.prepare(
|
|
673
|
+
`UPDATE interactive_requests
|
|
674
|
+
SET status = 'presenting', presenter_id = ?, presentation_claim_expires_at = ?,
|
|
675
|
+
revision = revision + 1, updated_at = ?
|
|
676
|
+
WHERE request_id = ? AND revision = ? AND presentation_session_entry_id IS NULL
|
|
677
|
+
AND (
|
|
678
|
+
status = 'pending'
|
|
679
|
+
OR (status = 'presenting' AND (presenter_id = ? OR presentation_claim_expires_at <= ?))
|
|
680
|
+
)`,
|
|
681
|
+
)
|
|
682
|
+
.run(
|
|
683
|
+
options.presenterId,
|
|
684
|
+
now + options.leaseMs,
|
|
685
|
+
now,
|
|
686
|
+
options.requestId,
|
|
687
|
+
options.expectedRevision,
|
|
688
|
+
options.presenterId,
|
|
689
|
+
now,
|
|
690
|
+
);
|
|
691
|
+
if (changed.changes !== 1) throw new Error("Interactive request presentation claim conflict");
|
|
692
|
+
return this.requireInteractiveRequest(options.requestId);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
markInteractionPresented(options: {
|
|
696
|
+
requestId: string;
|
|
697
|
+
expectedRevision: number;
|
|
698
|
+
sessionEntryId: string;
|
|
699
|
+
}): InteractiveRequestRecord {
|
|
700
|
+
const now = Date.now();
|
|
701
|
+
const changed = this.state.connection
|
|
702
|
+
.prepare(
|
|
703
|
+
`UPDATE interactive_requests
|
|
704
|
+
SET status = 'presenting', presenter_id = NULL, presentation_claim_expires_at = NULL,
|
|
705
|
+
presentation_session_entry_id = ?, revision = revision + 1, updated_at = ?
|
|
706
|
+
WHERE request_id = ? AND revision = ? AND status IN ('pending', 'presenting')
|
|
707
|
+
AND (presentation_session_entry_id IS NULL OR presentation_session_entry_id = ?)`,
|
|
708
|
+
)
|
|
709
|
+
.run(
|
|
710
|
+
options.sessionEntryId,
|
|
711
|
+
now,
|
|
712
|
+
options.requestId,
|
|
713
|
+
options.expectedRevision,
|
|
714
|
+
options.sessionEntryId,
|
|
715
|
+
);
|
|
716
|
+
if (changed.changes !== 1) throw new Error("Interactive request presentation conflict");
|
|
717
|
+
return this.requireInteractiveRequest(options.requestId);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
submitInteraction(options: {
|
|
721
|
+
requestId: string;
|
|
722
|
+
submissionId: string;
|
|
723
|
+
idempotencyKey: string;
|
|
724
|
+
expectedRevision: number;
|
|
725
|
+
payload: JsonValue;
|
|
726
|
+
accepted: boolean;
|
|
727
|
+
receipt?: JsonValue;
|
|
728
|
+
}): {
|
|
729
|
+
interaction: InteractiveRequestRecord;
|
|
730
|
+
outcome: "accepted" | "adopted";
|
|
731
|
+
receipt: JsonValue;
|
|
732
|
+
} {
|
|
733
|
+
return this.recordInteractionSubmission({
|
|
734
|
+
...options,
|
|
735
|
+
outcome: options.accepted ? "accepted" : "rejected",
|
|
736
|
+
settle: options.accepted,
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
beginInteractionValidation(options: {
|
|
741
|
+
requestId: string;
|
|
742
|
+
submissionId: string;
|
|
743
|
+
idempotencyKey: string;
|
|
744
|
+
expectedRevision: number;
|
|
745
|
+
payload: JsonValue;
|
|
746
|
+
receipt?: JsonValue;
|
|
747
|
+
}): {
|
|
748
|
+
interaction: InteractiveRequestRecord;
|
|
749
|
+
outcome: "accepted" | "adopted";
|
|
750
|
+
receipt: JsonValue;
|
|
751
|
+
} {
|
|
752
|
+
return this.recordInteractionSubmission({
|
|
753
|
+
...options,
|
|
754
|
+
outcome: "validating",
|
|
755
|
+
settle: false,
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
private recordInteractionSubmission(options: {
|
|
760
|
+
requestId: string;
|
|
761
|
+
submissionId: string;
|
|
762
|
+
idempotencyKey: string;
|
|
763
|
+
expectedRevision: number;
|
|
764
|
+
payload: JsonValue;
|
|
765
|
+
outcome: InteractiveSubmissionRecord["outcome"];
|
|
766
|
+
settle: boolean;
|
|
767
|
+
receipt?: JsonValue;
|
|
768
|
+
}): {
|
|
769
|
+
interaction: InteractiveRequestRecord;
|
|
770
|
+
outcome: "accepted" | "adopted";
|
|
771
|
+
receipt: JsonValue;
|
|
772
|
+
} {
|
|
773
|
+
const now = Date.now();
|
|
774
|
+
return this.state.transaction(() => {
|
|
775
|
+
const existing = this.state.connection
|
|
776
|
+
.prepare(
|
|
777
|
+
`SELECT submission_id AS submissionId, payload_hash AS payloadHash,
|
|
778
|
+
outcome, receipt_hash AS receiptHash
|
|
779
|
+
FROM interactive_submissions WHERE request_id = ? AND idempotency_key = ?`,
|
|
780
|
+
)
|
|
781
|
+
.get(options.requestId, options.idempotencyKey);
|
|
782
|
+
const payloadHash = createHash("sha256").update(canonicalJson(options.payload)).digest();
|
|
783
|
+
if (isSubmissionRow(existing)) {
|
|
784
|
+
if (!existing.payloadHash.equals(payloadHash)) {
|
|
785
|
+
throw new Error("Interactive submission idempotency key conflicts");
|
|
786
|
+
}
|
|
787
|
+
return {
|
|
788
|
+
interaction: this.requireInteractiveRequest(options.requestId),
|
|
789
|
+
outcome: "adopted",
|
|
790
|
+
receipt:
|
|
791
|
+
existing.receiptHash === null
|
|
792
|
+
? { requestId: options.requestId, submissionId: existing.submissionId }
|
|
793
|
+
: this.state.readJson(existing.receiptHash),
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
if (options.outcome === "validating") {
|
|
797
|
+
const active = this.state.connection
|
|
798
|
+
.prepare(
|
|
799
|
+
`SELECT submission_id AS submissionId FROM interactive_submissions
|
|
800
|
+
WHERE request_id = ? AND outcome = 'validating' LIMIT 1`,
|
|
801
|
+
)
|
|
802
|
+
.get(options.requestId);
|
|
803
|
+
if (isSubmissionIdRow(active)) {
|
|
804
|
+
throw new Error("Interactive submission validation is already active");
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
const request = this.requireInteractiveRequest(options.requestId);
|
|
808
|
+
if (
|
|
809
|
+
request.revision !== options.expectedRevision ||
|
|
810
|
+
!["pending", "presenting"].includes(request.status)
|
|
811
|
+
) {
|
|
812
|
+
throw new Error("Interactive request revision conflict");
|
|
813
|
+
}
|
|
814
|
+
const savedPayloadHash = this.state.putJson(options.payload, now);
|
|
815
|
+
const receiptHash =
|
|
816
|
+
options.receipt === undefined ? null : this.state.putJson(options.receipt, now);
|
|
817
|
+
this.state.connection
|
|
818
|
+
.prepare(
|
|
819
|
+
`INSERT INTO interactive_submissions(
|
|
820
|
+
submission_id, request_id, idempotency_key, request_revision,
|
|
821
|
+
payload_hash, outcome, receipt_hash, submitted_at
|
|
822
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
823
|
+
)
|
|
824
|
+
.run(
|
|
825
|
+
options.submissionId,
|
|
826
|
+
options.requestId,
|
|
827
|
+
options.idempotencyKey,
|
|
828
|
+
options.expectedRevision,
|
|
829
|
+
savedPayloadHash,
|
|
830
|
+
options.outcome,
|
|
831
|
+
receiptHash,
|
|
832
|
+
now,
|
|
833
|
+
);
|
|
834
|
+
if (options.settle) {
|
|
835
|
+
this.state.connection
|
|
836
|
+
.prepare(
|
|
837
|
+
`UPDATE interactive_requests
|
|
838
|
+
SET status = 'settled', presenter_id = NULL, presentation_claim_expires_at = NULL,
|
|
839
|
+
accepted_submission_id = ?, revision = revision + 1,
|
|
840
|
+
updated_at = ?, settled_at = ?
|
|
841
|
+
WHERE request_id = ? AND revision = ? AND status IN ('pending', 'presenting')`,
|
|
842
|
+
)
|
|
843
|
+
.run(options.submissionId, now, now, options.requestId, options.expectedRevision);
|
|
844
|
+
}
|
|
845
|
+
return {
|
|
846
|
+
interaction: this.requireInteractiveRequest(options.requestId),
|
|
847
|
+
outcome: "accepted",
|
|
848
|
+
receipt: options.receipt ?? {
|
|
849
|
+
requestId: options.requestId,
|
|
850
|
+
submissionId: options.submissionId,
|
|
851
|
+
},
|
|
852
|
+
};
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
private hostRow(): HostRow {
|
|
857
|
+
const row = this.state.connection
|
|
858
|
+
.prepare(
|
|
859
|
+
`SELECT epoch, host_id AS hostId, token_hash AS tokenHash, pid,
|
|
860
|
+
process_start_identity AS processStartIdentity, started_at AS startedAt,
|
|
861
|
+
heartbeat_at AS heartbeatAt, expires_at AS expiresAt
|
|
862
|
+
FROM workflow_host_state WHERE id = 1`,
|
|
863
|
+
)
|
|
864
|
+
.get();
|
|
865
|
+
if (!isHostRow(row)) throw new Error("Pi Workflows host state is missing");
|
|
866
|
+
return row;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
private commandResponse(requestId: string, row: CommandRow): HostResponse {
|
|
870
|
+
const receipt = row.receiptHash === null ? undefined : this.state.readJson(row.receiptHash);
|
|
871
|
+
const error =
|
|
872
|
+
row.errorHash === null
|
|
873
|
+
? undefined
|
|
874
|
+
: this.state.readBlob(row.errorHash)?.content.toString("utf8");
|
|
875
|
+
return {
|
|
876
|
+
schema: "pi-workflows.host-response.v1",
|
|
877
|
+
requestId,
|
|
878
|
+
outcome: row.outcome,
|
|
879
|
+
...(row.revision === null ? {} : { revision: row.revision }),
|
|
880
|
+
...(receipt === undefined ? {} : { receipt }),
|
|
881
|
+
...(error === undefined ? {} : { error }),
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
private interactiveRequest(requestId: string): InteractiveRequestRecord | undefined {
|
|
886
|
+
const row = this.state.connection
|
|
887
|
+
.prepare(
|
|
888
|
+
`SELECT request_id AS requestId, run_id AS runId, attempt_id AS attemptId,
|
|
889
|
+
target_session_id AS targetSessionId, kind, contract_hash AS contractHash,
|
|
890
|
+
revision, status, presentation_session_entry_id AS presentationSessionEntryId,
|
|
891
|
+
accepted_submission_id AS acceptedSubmissionId, created_at AS createdAt,
|
|
892
|
+
updated_at AS updatedAt, settled_at AS settledAt, consumed_at AS consumedAt
|
|
893
|
+
FROM interactive_requests WHERE request_id = ?`,
|
|
894
|
+
)
|
|
895
|
+
.get(requestId);
|
|
896
|
+
if (!isInteractiveRequestRow(row)) return undefined;
|
|
897
|
+
return {
|
|
898
|
+
requestId: row.requestId,
|
|
899
|
+
runId: row.runId,
|
|
900
|
+
attemptId: row.attemptId,
|
|
901
|
+
targetSessionId: row.targetSessionId,
|
|
902
|
+
kind: row.kind,
|
|
903
|
+
contract: this.state.readJson(row.contractHash),
|
|
904
|
+
revision: row.revision,
|
|
905
|
+
status: row.status,
|
|
906
|
+
presentationSessionEntryId: row.presentationSessionEntryId,
|
|
907
|
+
acceptedSubmissionId: row.acceptedSubmissionId,
|
|
908
|
+
createdAt: new Date(row.createdAt).toISOString(),
|
|
909
|
+
updatedAt: new Date(row.updatedAt).toISOString(),
|
|
910
|
+
settledAt: iso(row.settledAt),
|
|
911
|
+
consumedAt: iso(row.consumedAt),
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
private requireInteractiveRequest(requestId: string): InteractiveRequestRecord {
|
|
916
|
+
const request = this.interactiveRequest(requestId);
|
|
917
|
+
if (request === undefined) throw new Error(`Interactive request not found: ${requestId}`);
|
|
918
|
+
return request;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function conflictResponse(requestId: string, error: string): HostResponse {
|
|
923
|
+
return { schema: "pi-workflows.host-response.v1", requestId, outcome: "conflict", error };
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function requireLeaseMs(value: number): void {
|
|
927
|
+
if (!Number.isSafeInteger(value) || value <= 0)
|
|
928
|
+
throw new Error("Host lease duration must be positive");
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function iso(value: number | null): string | null {
|
|
932
|
+
return value === null ? null : new Date(value).toISOString();
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
type HostRow = {
|
|
936
|
+
epoch: number;
|
|
937
|
+
hostId: string | null;
|
|
938
|
+
tokenHash: Buffer | null;
|
|
939
|
+
pid: number | null;
|
|
940
|
+
processStartIdentity: string | null;
|
|
941
|
+
startedAt: number | null;
|
|
942
|
+
heartbeatAt: number | null;
|
|
943
|
+
expiresAt: number | null;
|
|
944
|
+
};
|
|
945
|
+
|
|
946
|
+
type CommandRow = {
|
|
947
|
+
requestFingerprint: Buffer;
|
|
948
|
+
outcome: HostResponse["outcome"];
|
|
949
|
+
revision: number | null;
|
|
950
|
+
receiptHash: Buffer | null;
|
|
951
|
+
errorHash: Buffer | null;
|
|
952
|
+
};
|
|
953
|
+
|
|
954
|
+
type IdempotentCommandRow = CommandRow & { requestId: string };
|
|
955
|
+
type RequestIdRow = { requestId: string };
|
|
956
|
+
type RunIdRow = { runId: string };
|
|
957
|
+
type SubmissionRow = {
|
|
958
|
+
submissionId: string;
|
|
959
|
+
payloadHash: Buffer;
|
|
960
|
+
outcome: string;
|
|
961
|
+
receiptHash: Buffer | null;
|
|
962
|
+
};
|
|
963
|
+
type SubmissionIdRow = { submissionId: string };
|
|
964
|
+
type SubmissionDetailRow = {
|
|
965
|
+
requestId: string;
|
|
966
|
+
submissionId: string;
|
|
967
|
+
idempotencyKey: string;
|
|
968
|
+
outcome: InteractiveSubmissionRecord["outcome"];
|
|
969
|
+
payloadHash: Buffer;
|
|
970
|
+
receiptHash: Buffer | null;
|
|
971
|
+
submittedAt: number;
|
|
972
|
+
};
|
|
973
|
+
type WorkerMessageRow = {
|
|
974
|
+
requestFingerprint: Buffer;
|
|
975
|
+
outcome: WorkerResponse["outcome"];
|
|
976
|
+
revision: number | null;
|
|
977
|
+
resultHash: Buffer | null;
|
|
978
|
+
errorHash: Buffer | null;
|
|
979
|
+
};
|
|
980
|
+
type AcceptedInteractionRow = {
|
|
981
|
+
requestId: string;
|
|
982
|
+
attemptId: string;
|
|
983
|
+
nodeId: string;
|
|
984
|
+
submissionId: string;
|
|
985
|
+
payloadHash: Buffer;
|
|
986
|
+
};
|
|
987
|
+
type TimedOutInteractionRow = {
|
|
988
|
+
requestId: string;
|
|
989
|
+
attemptId: string;
|
|
990
|
+
nodeId: string;
|
|
991
|
+
};
|
|
992
|
+
type InteractiveRequestRow = {
|
|
993
|
+
requestId: string;
|
|
994
|
+
runId: string;
|
|
995
|
+
attemptId: string;
|
|
996
|
+
targetSessionId: string;
|
|
997
|
+
kind: InteractiveRequestRecord["kind"];
|
|
998
|
+
contractHash: Buffer;
|
|
999
|
+
revision: number;
|
|
1000
|
+
status: InteractiveRequestRecord["status"];
|
|
1001
|
+
presentationSessionEntryId: string | null;
|
|
1002
|
+
acceptedSubmissionId: string | null;
|
|
1003
|
+
createdAt: number;
|
|
1004
|
+
updatedAt: number;
|
|
1005
|
+
settledAt: number | null;
|
|
1006
|
+
consumedAt: number | null;
|
|
1007
|
+
};
|
|
1008
|
+
|
|
1009
|
+
function isHostRow(value: unknown): value is HostRow {
|
|
1010
|
+
return (
|
|
1011
|
+
isRecord(value) &&
|
|
1012
|
+
typeof value.epoch === "number" &&
|
|
1013
|
+
nullableString(value.hostId) &&
|
|
1014
|
+
(value.tokenHash === null || Buffer.isBuffer(value.tokenHash)) &&
|
|
1015
|
+
(value.pid === null || typeof value.pid === "number") &&
|
|
1016
|
+
nullableString(value.processStartIdentity) &&
|
|
1017
|
+
nullableNumber(value.startedAt) &&
|
|
1018
|
+
nullableNumber(value.heartbeatAt) &&
|
|
1019
|
+
nullableNumber(value.expiresAt)
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function isCommandRow(value: unknown): value is CommandRow {
|
|
1024
|
+
return (
|
|
1025
|
+
isRecord(value) &&
|
|
1026
|
+
Buffer.isBuffer(value.requestFingerprint) &&
|
|
1027
|
+
typeof value.outcome === "string" &&
|
|
1028
|
+
nullableNumber(value.revision) &&
|
|
1029
|
+
(value.receiptHash === null || Buffer.isBuffer(value.receiptHash)) &&
|
|
1030
|
+
(value.errorHash === null || Buffer.isBuffer(value.errorHash))
|
|
1031
|
+
);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
function isIdempotentCommandRow(value: unknown): value is IdempotentCommandRow {
|
|
1035
|
+
return (
|
|
1036
|
+
isRecord(value) &&
|
|
1037
|
+
isCommandRow(value) &&
|
|
1038
|
+
typeof (value as Record<string, unknown>).requestId === "string"
|
|
1039
|
+
);
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function isRequestIdRow(value: unknown): value is RequestIdRow {
|
|
1043
|
+
return isRecord(value) && typeof value.requestId === "string";
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
function isRunIdRow(value: unknown): value is RunIdRow {
|
|
1047
|
+
return isRecord(value) && typeof value.runId === "string";
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
function isWorkerMessageRow(value: unknown): value is WorkerMessageRow {
|
|
1051
|
+
return (
|
|
1052
|
+
isRecord(value) &&
|
|
1053
|
+
Buffer.isBuffer(value.requestFingerprint) &&
|
|
1054
|
+
["accepted", "adopted", "rejected", "claimLost"].includes(value.outcome as string) &&
|
|
1055
|
+
nullableNumber(value.revision) &&
|
|
1056
|
+
(value.resultHash === null || Buffer.isBuffer(value.resultHash)) &&
|
|
1057
|
+
(value.errorHash === null || Buffer.isBuffer(value.errorHash))
|
|
1058
|
+
);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
function isAcceptedInteractionRow(value: unknown): value is AcceptedInteractionRow {
|
|
1062
|
+
return (
|
|
1063
|
+
isRecord(value) &&
|
|
1064
|
+
typeof value.requestId === "string" &&
|
|
1065
|
+
typeof value.attemptId === "string" &&
|
|
1066
|
+
typeof value.nodeId === "string" &&
|
|
1067
|
+
typeof value.submissionId === "string" &&
|
|
1068
|
+
Buffer.isBuffer(value.payloadHash)
|
|
1069
|
+
);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
function isTimedOutInteractionRow(value: unknown): value is TimedOutInteractionRow {
|
|
1073
|
+
return (
|
|
1074
|
+
isRecord(value) &&
|
|
1075
|
+
typeof value.requestId === "string" &&
|
|
1076
|
+
typeof value.attemptId === "string" &&
|
|
1077
|
+
typeof value.nodeId === "string"
|
|
1078
|
+
);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
function isSubmissionRow(value: unknown): value is SubmissionRow {
|
|
1082
|
+
return (
|
|
1083
|
+
isRecord(value) &&
|
|
1084
|
+
typeof value.submissionId === "string" &&
|
|
1085
|
+
Buffer.isBuffer(value.payloadHash) &&
|
|
1086
|
+
typeof value.outcome === "string" &&
|
|
1087
|
+
(value.receiptHash === null || Buffer.isBuffer(value.receiptHash))
|
|
1088
|
+
);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
function isSubmissionIdRow(value: unknown): value is SubmissionIdRow {
|
|
1092
|
+
return isRecord(value) && typeof value.submissionId === "string";
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
function isSubmissionDetailRow(value: unknown): value is SubmissionDetailRow {
|
|
1096
|
+
return (
|
|
1097
|
+
isRecord(value) &&
|
|
1098
|
+
typeof value.requestId === "string" &&
|
|
1099
|
+
typeof value.submissionId === "string" &&
|
|
1100
|
+
typeof value.idempotencyKey === "string" &&
|
|
1101
|
+
["validating", "accepted", "rejected", "adopted"].includes(value.outcome as string) &&
|
|
1102
|
+
Buffer.isBuffer(value.payloadHash) &&
|
|
1103
|
+
(value.receiptHash === null || Buffer.isBuffer(value.receiptHash)) &&
|
|
1104
|
+
typeof value.submittedAt === "number"
|
|
1105
|
+
);
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
function isInteractiveRequestRow(value: unknown): value is InteractiveRequestRow {
|
|
1109
|
+
return (
|
|
1110
|
+
isRecord(value) &&
|
|
1111
|
+
typeof value.requestId === "string" &&
|
|
1112
|
+
typeof value.runId === "string" &&
|
|
1113
|
+
typeof value.attemptId === "string" &&
|
|
1114
|
+
typeof value.targetSessionId === "string" &&
|
|
1115
|
+
["agent", "assistant", "decision"].includes(value.kind as string) &&
|
|
1116
|
+
Buffer.isBuffer(value.contractHash) &&
|
|
1117
|
+
typeof value.revision === "number" &&
|
|
1118
|
+
["pending", "presenting", "settled", "cancelled"].includes(value.status as string) &&
|
|
1119
|
+
nullableString(value.presentationSessionEntryId) &&
|
|
1120
|
+
nullableString(value.acceptedSubmissionId) &&
|
|
1121
|
+
typeof value.createdAt === "number" &&
|
|
1122
|
+
typeof value.updatedAt === "number" &&
|
|
1123
|
+
nullableNumber(value.settledAt) &&
|
|
1124
|
+
nullableNumber(value.consumedAt)
|
|
1125
|
+
);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
function workerMessageFingerprint(message: WorkerMessage): Buffer {
|
|
1129
|
+
return createHash("sha256").update(canonicalJson(message)).digest();
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
function workerMessageResponse(
|
|
1133
|
+
state: StateDatabase,
|
|
1134
|
+
messageId: string,
|
|
1135
|
+
row: WorkerMessageRow,
|
|
1136
|
+
): WorkerResponse {
|
|
1137
|
+
const result = row.resultHash === null ? undefined : state.readJson(row.resultHash);
|
|
1138
|
+
const error =
|
|
1139
|
+
row.errorHash === null ? undefined : state.readBlob(row.errorHash)?.content.toString("utf8");
|
|
1140
|
+
return {
|
|
1141
|
+
schema: "pi-workflows.worker-response.v1",
|
|
1142
|
+
messageId,
|
|
1143
|
+
outcome: row.outcome,
|
|
1144
|
+
...(row.revision === null ? {} : { revision: row.revision }),
|
|
1145
|
+
...(result === undefined ? {} : { result }),
|
|
1146
|
+
...(error === undefined ? {} : { error }),
|
|
1147
|
+
};
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
function nullableString(value: unknown): value is string | null {
|
|
1151
|
+
return value === null || typeof value === "string";
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
function nullableNumber(value: unknown): value is number | null {
|
|
1155
|
+
return value === null || typeof value === "number";
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
1159
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1160
|
+
}
|