@osolmaz/pi-workflows 0.15.2 → 0.16.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 +6 -6
- package/dist/client/activity.d.ts +2 -0
- package/dist/client/activity.js +6 -0
- package/dist/client/activity.js.map +1 -0
- package/dist/client/client.d.ts +101 -0
- package/dist/client/client.js +733 -0
- package/dist/client/client.js.map +1 -0
- package/dist/client/index.d.ts +3 -0
- package/dist/client/index.js +3 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/materialize.d.ts +7 -0
- package/dist/client/materialize.js +177 -0
- package/dist/client/materialize.js.map +1 -0
- package/dist/client/protocol.d.ts +60 -0
- package/dist/client/protocol.js +269 -0
- package/dist/client/protocol.js.map +1 -0
- package/dist/client/resolver.d.ts +23 -0
- package/dist/client/resolver.js +2 -0
- package/dist/client/resolver.js.map +1 -0
- package/dist/client/view.d.ts +118 -0
- package/dist/client/view.js +3 -0
- package/dist/client/view.js.map +1 -0
- package/dist/controllers/sqlite.d.ts +64 -0
- package/dist/controllers/sqlite.js +219 -3
- package/dist/controllers/sqlite.js.map +1 -1
- package/dist/extension/index.d.ts +1 -0
- package/dist/extension/index.js +384 -156
- package/dist/extension/index.js.map +1 -1
- package/dist/extension/session-delivery.d.ts +6 -0
- package/dist/extension/session-delivery.js +80 -25
- package/dist/extension/session-delivery.js.map +1 -1
- package/dist/extension/session-view.d.ts +21 -0
- package/dist/extension/session-view.js +127 -0
- package/dist/extension/session-view.js.map +1 -0
- package/dist/extension/widget.d.ts +2 -1
- package/dist/extension/widget.js +15 -7
- package/dist/extension/widget.js.map +1 -1
- package/dist/host/child-worker-supervisor.js +1 -1
- package/dist/host/child-worker-supervisor.js.map +1 -1
- package/dist/host/resolver-entry.d.ts +2 -23
- package/dist/host/resolver-entry.js +1 -1
- package/dist/host/resolver-entry.js.map +1 -1
- package/dist/host/runner.d.ts +12 -0
- package/dist/host/runner.js +565 -43
- package/dist/host/runner.js.map +1 -1
- package/dist/host/state.d.ts +8 -3
- package/dist/host/state.js +59 -27
- package/dist/host/state.js.map +1 -1
- package/dist/host/view.d.ts +73 -0
- package/dist/host/view.js +871 -0
- package/dist/host/view.js.map +1 -0
- package/dist/host/worker-protocol.js +1 -1
- package/dist/host/worker-protocol.js.map +1 -1
- package/dist/state/database.d.ts +1 -0
- package/dist/state/database.js +15 -0
- package/dist/state/database.js.map +1 -1
- package/dist/state/prune.d.ts +3 -1
- package/dist/state/prune.js +6 -8
- package/dist/state/prune.js.map +1 -1
- package/dist/state/schema.js +12 -1
- package/dist/state/schema.js.map +1 -1
- package/dist/viewer/backup.d.ts +2 -0
- package/dist/viewer/backup.js +28 -0
- package/dist/viewer/backup.js.map +1 -0
- package/dist/viewer/cli.d.ts +4 -0
- package/dist/viewer/cli.js +150 -170
- package/dist/viewer/cli.js.map +1 -1
- package/dist/viewer/tui.d.ts +5 -7
- package/dist/viewer/tui.js +188 -108
- package/dist/viewer/tui.js.map +1 -1
- package/dist/workflows/store.d.ts +62 -1
- package/dist/workflows/store.js +350 -44
- package/dist/workflows/store.js.map +1 -1
- package/docs/2026-09-01-restore-session-delivery-controls-plan.md +139 -0
- package/docs/2026-09-01-unified-workflow-client-plan.md +381 -0
- package/docs/2026-09-02-installed-live-e2e-plan.md +225 -0
- package/docs/SQLITE_STATE.md +13 -11
- package/docs/WORKFLOW_HOST.md +81 -64
- package/docs/WORKFLOW_STEP_MESSAGES.md +4 -4
- package/docs/development.md +2 -1
- package/docs/live-replay-protocol.md +70 -132
- package/docs/tui-viewer.md +10 -14
- package/docs/workflows.md +56 -3
- package/herdr-plugin.toml +1 -1
- package/package.json +9 -3
- package/protocol/client.v1.schema.json +137 -0
- package/protocol/fixtures/client-v1.json +23 -0
- package/src/client/activity.ts +6 -0
- package/src/client/client.ts +935 -0
- package/src/client/index.ts +24 -0
- package/src/client/materialize.ts +228 -0
- package/src/client/protocol.ts +327 -0
- package/src/client/resolver.ts +26 -0
- package/src/client/view.ts +138 -0
- package/src/controllers/sqlite.ts +342 -3
- package/src/extension/index.ts +482 -171
- package/src/extension/session-delivery.ts +88 -25
- package/src/extension/session-view.ts +154 -0
- package/src/extension/widget.ts +18 -9
- package/src/host/child-worker-supervisor.ts +1 -1
- package/src/host/resolver-entry.ts +11 -26
- package/src/host/runner.ts +749 -75
- package/src/host/state.ts +82 -44
- package/src/host/view.ts +1084 -0
- package/src/host/worker-protocol.ts +1 -1
- package/src/state/database.ts +13 -0
- package/src/state/prune.ts +11 -11
- package/src/state/schema.ts +12 -1
- package/src/viewer/backup.ts +29 -0
- package/src/viewer/cli.ts +171 -185
- package/src/viewer/tui.ts +196 -124
- package/src/workflows/store.ts +500 -45
- package/dist/host/client.d.ts +0 -48
- package/dist/host/client.js +0 -216
- package/dist/host/client.js.map +0 -1
- package/dist/host/protocol.d.ts +0 -38
- package/dist/host/protocol.js +0 -156
- package/dist/host/protocol.js.map +0 -1
- package/dist/viewer/watch.d.ts +0 -6
- package/dist/viewer/watch.js +0 -46
- package/dist/viewer/watch.js.map +0 -1
- package/src/host/client.ts +0 -293
- package/src/host/protocol.ts +0 -196
- package/src/viewer/watch.ts +0 -51
package/src/host/client.ts
DELETED
|
@@ -1,293 +0,0 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { once } from "node:events";
|
|
4
|
-
import fs from "node:fs";
|
|
5
|
-
import { createRequire } from "node:module";
|
|
6
|
-
import net from "node:net";
|
|
7
|
-
import { fileURLToPath } from "node:url";
|
|
8
|
-
import { workflowStatePath } from "../state/database.js";
|
|
9
|
-
import { canonicalJson, parseJson, type JsonValue } from "../state/json.js";
|
|
10
|
-
import {
|
|
11
|
-
encodeProtocolLine,
|
|
12
|
-
hostSocketPath,
|
|
13
|
-
NdjsonFrameDecoder,
|
|
14
|
-
parseHostResponse,
|
|
15
|
-
type HostOperation,
|
|
16
|
-
type HostRequest,
|
|
17
|
-
type HostResponse,
|
|
18
|
-
} from "./protocol.js";
|
|
19
|
-
import type {
|
|
20
|
-
ResolvedControllerInitialization,
|
|
21
|
-
ResolvedSettingsChange,
|
|
22
|
-
ResolvedWorkflowLaunch,
|
|
23
|
-
} from "./resolver-entry.js";
|
|
24
|
-
|
|
25
|
-
const CONNECT_TIMEOUT_MS = 2_000;
|
|
26
|
-
const START_TIMEOUT_MS = 10_000;
|
|
27
|
-
|
|
28
|
-
export class WorkflowHostClient {
|
|
29
|
-
readonly clientId: string;
|
|
30
|
-
readonly databasePath: string;
|
|
31
|
-
readonly endpoint: string;
|
|
32
|
-
|
|
33
|
-
constructor(
|
|
34
|
-
options: {
|
|
35
|
-
clientId?: string;
|
|
36
|
-
databasePath?: string;
|
|
37
|
-
hostEntryPath?: string;
|
|
38
|
-
env?: Record<string, string>;
|
|
39
|
-
} = {},
|
|
40
|
-
) {
|
|
41
|
-
this.clientId = options.clientId ?? `client-${randomUUID()}`;
|
|
42
|
-
this.databasePath = options.databasePath ?? workflowStatePath();
|
|
43
|
-
this.endpoint = hostSocketPath(this.databasePath);
|
|
44
|
-
this.hostEntryPath = options.hostEntryPath;
|
|
45
|
-
this.env = options.env;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
private readonly hostEntryPath: string | undefined;
|
|
49
|
-
private readonly env: Record<string, string> | undefined;
|
|
50
|
-
|
|
51
|
-
async request(options: {
|
|
52
|
-
operation: HostOperation;
|
|
53
|
-
requestId?: string;
|
|
54
|
-
idempotencyKey?: string;
|
|
55
|
-
runId?: string;
|
|
56
|
-
expectedRevision?: number;
|
|
57
|
-
payload?: JsonValue;
|
|
58
|
-
}): Promise<HostResponse> {
|
|
59
|
-
const request: HostRequest = {
|
|
60
|
-
schema: "pi-workflows.host-request.v1",
|
|
61
|
-
requestId: options.requestId ?? randomUUID(),
|
|
62
|
-
clientId: this.clientId,
|
|
63
|
-
operation: options.operation,
|
|
64
|
-
idempotencyKey: options.idempotencyKey ?? randomUUID(),
|
|
65
|
-
...(options.runId === undefined ? {} : { runId: options.runId }),
|
|
66
|
-
...(options.expectedRevision === undefined
|
|
67
|
-
? {}
|
|
68
|
-
: { expectedRevision: options.expectedRevision }),
|
|
69
|
-
payload: options.payload ?? {},
|
|
70
|
-
};
|
|
71
|
-
return await sendRequest(this.endpoint, request);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
async resolveWorkflow(options: {
|
|
75
|
-
cwd: string;
|
|
76
|
-
workflowRef: string;
|
|
77
|
-
timeoutMs?: number;
|
|
78
|
-
}): Promise<ResolvedWorkflowLaunch> {
|
|
79
|
-
return (await this.runResolver(
|
|
80
|
-
{
|
|
81
|
-
schema: "pi-workflows.resolve-request.v1",
|
|
82
|
-
cwd: options.cwd,
|
|
83
|
-
workflowRef: options.workflowRef,
|
|
84
|
-
},
|
|
85
|
-
options.cwd,
|
|
86
|
-
"pi-workflows.resolved-launch.v1",
|
|
87
|
-
options.timeoutMs,
|
|
88
|
-
)) as unknown as ResolvedWorkflowLaunch;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
async resolveControllerInitialization(options: {
|
|
92
|
-
cwd: string;
|
|
93
|
-
controllerName: string;
|
|
94
|
-
spec: JsonValue;
|
|
95
|
-
timeoutMs?: number;
|
|
96
|
-
}): Promise<ResolvedControllerInitialization> {
|
|
97
|
-
return (await this.runResolver(
|
|
98
|
-
{
|
|
99
|
-
schema: "pi-workflows.controller-initialization-request.v1",
|
|
100
|
-
cwd: options.cwd,
|
|
101
|
-
controllerName: options.controllerName,
|
|
102
|
-
spec: options.spec,
|
|
103
|
-
},
|
|
104
|
-
options.cwd,
|
|
105
|
-
"pi-workflows.resolved-controller-initialization.v1",
|
|
106
|
-
options.timeoutMs,
|
|
107
|
-
)) as unknown as ResolvedControllerInitialization;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
async resolveSettingsChange(options: {
|
|
111
|
-
cwd: string;
|
|
112
|
-
workflowRef: string;
|
|
113
|
-
definitionDigest: string;
|
|
114
|
-
mountPath: string;
|
|
115
|
-
current: JsonValue;
|
|
116
|
-
patch: JsonValue;
|
|
117
|
-
actorId: string;
|
|
118
|
-
timeoutMs?: number;
|
|
119
|
-
}): Promise<ResolvedSettingsChange> {
|
|
120
|
-
return (await this.runResolver(
|
|
121
|
-
{
|
|
122
|
-
schema: "pi-workflows.settings-validation-request.v1",
|
|
123
|
-
cwd: options.cwd,
|
|
124
|
-
workflowRef: options.workflowRef,
|
|
125
|
-
definitionDigest: options.definitionDigest,
|
|
126
|
-
mountPath: options.mountPath,
|
|
127
|
-
current: options.current,
|
|
128
|
-
patch: options.patch,
|
|
129
|
-
actorId: options.actorId,
|
|
130
|
-
},
|
|
131
|
-
options.cwd,
|
|
132
|
-
"pi-workflows.resolved-settings-change.v1",
|
|
133
|
-
options.timeoutMs,
|
|
134
|
-
)) as unknown as ResolvedSettingsChange;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
private async runResolver(
|
|
138
|
-
request: JsonValue,
|
|
139
|
-
cwd: string,
|
|
140
|
-
expectedSchema: string,
|
|
141
|
-
timeoutMs = START_TIMEOUT_MS,
|
|
142
|
-
): Promise<JsonValue> {
|
|
143
|
-
const builtEntry = fileURLToPath(new URL("./resolver-entry.js", import.meta.url));
|
|
144
|
-
const sourceEntry = fileURLToPath(new URL("./resolver-entry.ts", import.meta.url));
|
|
145
|
-
const args = fs.existsSync(builtEntry)
|
|
146
|
-
? [builtEntry]
|
|
147
|
-
: ["--import", createRequire(import.meta.url).resolve("tsx"), sourceEntry];
|
|
148
|
-
const child = spawn(process.execPath, args, {
|
|
149
|
-
cwd,
|
|
150
|
-
detached: process.platform !== "win32",
|
|
151
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
152
|
-
env: { ...process.env, ...this.env },
|
|
153
|
-
});
|
|
154
|
-
let stdout: Buffer = Buffer.alloc(0);
|
|
155
|
-
let stderr: Buffer = Buffer.alloc(0);
|
|
156
|
-
let outputError: Error | undefined;
|
|
157
|
-
const append = (current: Buffer, chunk: Buffer): Buffer => {
|
|
158
|
-
const next = Buffer.concat([current, chunk]);
|
|
159
|
-
if (next.byteLength > 1024 * 1024) {
|
|
160
|
-
outputError = new Error("Workflow resolver output exceeds 1 MiB");
|
|
161
|
-
stopProcessGroup(child.pid);
|
|
162
|
-
return current;
|
|
163
|
-
}
|
|
164
|
-
return next;
|
|
165
|
-
};
|
|
166
|
-
child.stdout.on("data", (chunk: Buffer) => {
|
|
167
|
-
stdout = append(stdout, chunk);
|
|
168
|
-
});
|
|
169
|
-
child.stderr.on("data", (chunk: Buffer) => {
|
|
170
|
-
stderr = append(stderr, chunk);
|
|
171
|
-
});
|
|
172
|
-
child.stdin.end(canonicalJson(request));
|
|
173
|
-
const timeout = setTimeout(() => stopProcessGroup(child.pid), timeoutMs);
|
|
174
|
-
timeout.unref?.();
|
|
175
|
-
const [code, signal] = (await once(child, "exit")) as [number | null, NodeJS.Signals | null];
|
|
176
|
-
clearTimeout(timeout);
|
|
177
|
-
if (outputError !== undefined) throw outputError;
|
|
178
|
-
if (code !== 0) {
|
|
179
|
-
const detail = stderr.toString("utf8").trim().slice(0, 2_000);
|
|
180
|
-
throw new Error(
|
|
181
|
-
detail || `Workflow resolver exited before completion (code ${code}, signal ${signal})`,
|
|
182
|
-
);
|
|
183
|
-
}
|
|
184
|
-
const value = parseJson(stdout.toString("utf8").trimEnd());
|
|
185
|
-
if (!isRecord(value) || value.schema !== expectedSchema) {
|
|
186
|
-
throw new Error("Workflow resolver returned an invalid result envelope");
|
|
187
|
-
}
|
|
188
|
-
return value as JsonValue;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
async ensureRunning(): Promise<HostResponse> {
|
|
192
|
-
try {
|
|
193
|
-
return await this.request({
|
|
194
|
-
operation: "host.status",
|
|
195
|
-
requestId: `host-status-${randomUUID()}`,
|
|
196
|
-
idempotencyKey: `host-status-${randomUUID()}`,
|
|
197
|
-
});
|
|
198
|
-
} catch {
|
|
199
|
-
this.startDetached();
|
|
200
|
-
}
|
|
201
|
-
const deadline = Date.now() + START_TIMEOUT_MS;
|
|
202
|
-
let lastError: unknown;
|
|
203
|
-
while (Date.now() < deadline) {
|
|
204
|
-
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
205
|
-
try {
|
|
206
|
-
return await this.request({
|
|
207
|
-
operation: "host.status",
|
|
208
|
-
requestId: `host-status-${randomUUID()}`,
|
|
209
|
-
idempotencyKey: `host-status-${randomUUID()}`,
|
|
210
|
-
});
|
|
211
|
-
} catch (error) {
|
|
212
|
-
lastError = error;
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
throw new Error(
|
|
216
|
-
`Workflow host did not become ready: ${lastError instanceof Error ? lastError.message : String(lastError)}`,
|
|
217
|
-
);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
private startDetached(): void {
|
|
221
|
-
const builtEntry = fileURLToPath(new URL("./host-entry.js", import.meta.url));
|
|
222
|
-
const sourceEntry = fileURLToPath(new URL("./host-entry.ts", import.meta.url));
|
|
223
|
-
const entry = this.hostEntryPath ?? builtEntry;
|
|
224
|
-
const args =
|
|
225
|
-
this.hostEntryPath === undefined && !fs.existsSync(builtEntry)
|
|
226
|
-
? ["--import", createRequire(import.meta.url).resolve("tsx"), sourceEntry]
|
|
227
|
-
: [entry];
|
|
228
|
-
const child = spawn(process.execPath, [...args, "--database", this.databasePath], {
|
|
229
|
-
detached: true,
|
|
230
|
-
stdio: "ignore",
|
|
231
|
-
env: { ...process.env, ...this.env },
|
|
232
|
-
});
|
|
233
|
-
child.unref();
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
function stopProcessGroup(pid: number | undefined): void {
|
|
238
|
-
if (pid === undefined) return;
|
|
239
|
-
try {
|
|
240
|
-
if (process.platform !== "win32") process.kill(-pid, "SIGKILL");
|
|
241
|
-
else process.kill(pid, "SIGKILL");
|
|
242
|
-
} catch {
|
|
243
|
-
// The resolver has already exited.
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
248
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
async function sendRequest(endpoint: string, request: HostRequest): Promise<HostResponse> {
|
|
252
|
-
const socket = net.createConnection(endpoint);
|
|
253
|
-
const decoder = new NdjsonFrameDecoder();
|
|
254
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
255
|
-
try {
|
|
256
|
-
await Promise.race([
|
|
257
|
-
once(socket, "connect"),
|
|
258
|
-
new Promise<never>((_, reject) => {
|
|
259
|
-
timer = setTimeout(
|
|
260
|
-
() => reject(new Error("Workflow host connection timed out")),
|
|
261
|
-
CONNECT_TIMEOUT_MS,
|
|
262
|
-
);
|
|
263
|
-
timer.unref?.();
|
|
264
|
-
}),
|
|
265
|
-
once(socket, "error").then(([error]) => Promise.reject(error)),
|
|
266
|
-
]);
|
|
267
|
-
if (!socket.write(encodeProtocolLine(request))) await once(socket, "drain");
|
|
268
|
-
const response = await new Promise<HostResponse>((resolve, reject) => {
|
|
269
|
-
const timeout = setTimeout(
|
|
270
|
-
() => reject(new Error("Workflow host response timed out")),
|
|
271
|
-
CONNECT_TIMEOUT_MS,
|
|
272
|
-
);
|
|
273
|
-
timeout.unref?.();
|
|
274
|
-
socket.on("data", (chunk: Buffer) => {
|
|
275
|
-
try {
|
|
276
|
-
const frame = decoder.push(chunk)[0];
|
|
277
|
-
if (frame === undefined) return;
|
|
278
|
-
clearTimeout(timeout);
|
|
279
|
-
resolve(parseHostResponse(frame));
|
|
280
|
-
} catch (error) {
|
|
281
|
-
clearTimeout(timeout);
|
|
282
|
-
reject(error);
|
|
283
|
-
}
|
|
284
|
-
});
|
|
285
|
-
socket.once("error", reject);
|
|
286
|
-
socket.once("close", () => reject(new Error("Workflow host closed before responding")));
|
|
287
|
-
});
|
|
288
|
-
return response;
|
|
289
|
-
} finally {
|
|
290
|
-
if (timer !== undefined) clearTimeout(timer);
|
|
291
|
-
socket.destroy();
|
|
292
|
-
}
|
|
293
|
-
}
|
package/src/host/protocol.ts
DELETED
|
@@ -1,196 +0,0 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { canonicalJson, parseJson, type JsonValue } from "../state/json.js";
|
|
4
|
-
|
|
5
|
-
export const HOST_PROTOCOL_VERSION = 1;
|
|
6
|
-
export const HOST_REQUEST_SCHEMA = "pi-workflows.host-request.v1" as const;
|
|
7
|
-
export const HOST_RESPONSE_SCHEMA = "pi-workflows.host-response.v1" as const;
|
|
8
|
-
export const MAX_PROTOCOL_MESSAGE_BYTES = 1024 * 1024;
|
|
9
|
-
|
|
10
|
-
export const HOST_OPERATIONS = [
|
|
11
|
-
"run.start",
|
|
12
|
-
"run.pause",
|
|
13
|
-
"run.resume",
|
|
14
|
-
"run.cancel",
|
|
15
|
-
"run.status",
|
|
16
|
-
"run.list",
|
|
17
|
-
"checkpoint.answer",
|
|
18
|
-
"decision.answer",
|
|
19
|
-
"interaction.submit",
|
|
20
|
-
"interaction.update",
|
|
21
|
-
"notification.claim",
|
|
22
|
-
"notification.deliver",
|
|
23
|
-
"turn.claim",
|
|
24
|
-
"turn.resolve",
|
|
25
|
-
"controller.list",
|
|
26
|
-
"controller.get",
|
|
27
|
-
"controller.apply",
|
|
28
|
-
"controller.reconcile",
|
|
29
|
-
"controller.delete",
|
|
30
|
-
"host.status",
|
|
31
|
-
"host.stop",
|
|
32
|
-
] as const;
|
|
33
|
-
|
|
34
|
-
export type HostOperation = (typeof HOST_OPERATIONS)[number];
|
|
35
|
-
export type HostOutcome =
|
|
36
|
-
| "accepted"
|
|
37
|
-
| "adopted"
|
|
38
|
-
| "rejected"
|
|
39
|
-
| "conflict"
|
|
40
|
-
| "notFound"
|
|
41
|
-
| "claimLost"
|
|
42
|
-
| "unavailable";
|
|
43
|
-
|
|
44
|
-
export type HostRequest = {
|
|
45
|
-
schema: typeof HOST_REQUEST_SCHEMA;
|
|
46
|
-
requestId: string;
|
|
47
|
-
clientId: string;
|
|
48
|
-
operation: HostOperation;
|
|
49
|
-
idempotencyKey: string;
|
|
50
|
-
runId?: string;
|
|
51
|
-
expectedRevision?: number;
|
|
52
|
-
payload: JsonValue;
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
export type HostResponse = {
|
|
56
|
-
schema: typeof HOST_RESPONSE_SCHEMA;
|
|
57
|
-
requestId: string;
|
|
58
|
-
outcome: HostOutcome;
|
|
59
|
-
revision?: number;
|
|
60
|
-
receipt?: JsonValue;
|
|
61
|
-
error?: string;
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
export class HostProtocolError extends Error {
|
|
65
|
-
constructor(message: string) {
|
|
66
|
-
super(message);
|
|
67
|
-
this.name = "HostProtocolError";
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export function encodeProtocolLine(message: HostRequest | HostResponse): Buffer {
|
|
72
|
-
const encoded = Buffer.from(`${canonicalJson(message)}\n`, "utf8");
|
|
73
|
-
if (encoded.byteLength > MAX_PROTOCOL_MESSAGE_BYTES) {
|
|
74
|
-
throw new HostProtocolError("Host protocol message exceeds 1 MiB");
|
|
75
|
-
}
|
|
76
|
-
return encoded;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export function parseHostRequest(line: string | Buffer): HostRequest {
|
|
80
|
-
const value = parseProtocolValue(line);
|
|
81
|
-
if (value.schema !== HOST_REQUEST_SCHEMA)
|
|
82
|
-
throw new HostProtocolError("Invalid host request schema");
|
|
83
|
-
requireOpaqueId(value.requestId, "requestId");
|
|
84
|
-
requireOpaqueId(value.clientId, "clientId");
|
|
85
|
-
requireOpaqueId(value.idempotencyKey, "idempotencyKey");
|
|
86
|
-
if (!HOST_OPERATIONS.includes(value.operation as HostOperation)) {
|
|
87
|
-
throw new HostProtocolError("Invalid host request operation");
|
|
88
|
-
}
|
|
89
|
-
if (value.runId !== undefined) requireOpaqueId(value.runId, "runId");
|
|
90
|
-
if (
|
|
91
|
-
value.expectedRevision !== undefined &&
|
|
92
|
-
(!Number.isSafeInteger(value.expectedRevision) || (value.expectedRevision as number) < 0)
|
|
93
|
-
) {
|
|
94
|
-
throw new HostProtocolError("Host request expectedRevision must be a non-negative integer");
|
|
95
|
-
}
|
|
96
|
-
if (!Object.hasOwn(value, "payload"))
|
|
97
|
-
throw new HostProtocolError("Host request payload is required");
|
|
98
|
-
canonicalJson(value.payload);
|
|
99
|
-
return value as HostRequest;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
export function parseHostResponse(line: string | Buffer): HostResponse {
|
|
103
|
-
const value = parseProtocolValue(line);
|
|
104
|
-
if (value.schema !== HOST_RESPONSE_SCHEMA) {
|
|
105
|
-
throw new HostProtocolError("Invalid host response schema");
|
|
106
|
-
}
|
|
107
|
-
requireOpaqueId(value.requestId, "requestId");
|
|
108
|
-
const outcomes: HostOutcome[] = [
|
|
109
|
-
"accepted",
|
|
110
|
-
"adopted",
|
|
111
|
-
"rejected",
|
|
112
|
-
"conflict",
|
|
113
|
-
"notFound",
|
|
114
|
-
"claimLost",
|
|
115
|
-
"unavailable",
|
|
116
|
-
];
|
|
117
|
-
if (!outcomes.includes(value.outcome as HostOutcome)) {
|
|
118
|
-
throw new HostProtocolError("Invalid host response outcome");
|
|
119
|
-
}
|
|
120
|
-
if (
|
|
121
|
-
value.revision !== undefined &&
|
|
122
|
-
(!Number.isSafeInteger(value.revision) || (value.revision as number) < 0)
|
|
123
|
-
) {
|
|
124
|
-
throw new HostProtocolError("Host response revision must be a non-negative integer");
|
|
125
|
-
}
|
|
126
|
-
if (value.error !== undefined && typeof value.error !== "string") {
|
|
127
|
-
throw new HostProtocolError("Host response error must be text");
|
|
128
|
-
}
|
|
129
|
-
if (value.receipt !== undefined) canonicalJson(value.receipt);
|
|
130
|
-
return value as HostResponse;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
export function requestFingerprint(request: HostRequest): Buffer {
|
|
134
|
-
return createHash("sha256").update(canonicalJson(request)).digest();
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
export function hostSocketPath(databasePath: string): string {
|
|
138
|
-
const stateDirectory = path.dirname(path.resolve(databasePath));
|
|
139
|
-
if (process.platform === "win32") {
|
|
140
|
-
const suffix = createHash("sha256").update(stateDirectory).digest("hex").slice(0, 24);
|
|
141
|
-
return `\\\\.\\pipe\\pi-workflows-${suffix}`;
|
|
142
|
-
}
|
|
143
|
-
return path.join(stateDirectory, "host", "host.sock");
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
export class NdjsonFrameDecoder {
|
|
147
|
-
private buffered = Buffer.alloc(0);
|
|
148
|
-
|
|
149
|
-
push(chunk: Buffer): Buffer[] {
|
|
150
|
-
this.buffered = Buffer.concat([this.buffered, chunk]);
|
|
151
|
-
if (this.buffered.byteLength > MAX_PROTOCOL_MESSAGE_BYTES && !this.buffered.includes(0x0a)) {
|
|
152
|
-
throw new HostProtocolError("Host protocol message exceeds 1 MiB");
|
|
153
|
-
}
|
|
154
|
-
const frames: Buffer[] = [];
|
|
155
|
-
for (;;) {
|
|
156
|
-
const newline = this.buffered.indexOf(0x0a);
|
|
157
|
-
if (newline < 0) break;
|
|
158
|
-
const frame = this.buffered.subarray(0, newline);
|
|
159
|
-
this.buffered = this.buffered.subarray(newline + 1);
|
|
160
|
-
if (frame.byteLength === 0) continue;
|
|
161
|
-
if (frame.byteLength + 1 > MAX_PROTOCOL_MESSAGE_BYTES) {
|
|
162
|
-
throw new HostProtocolError("Host protocol message exceeds 1 MiB");
|
|
163
|
-
}
|
|
164
|
-
frames.push(frame);
|
|
165
|
-
}
|
|
166
|
-
return frames;
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function parseProtocolValue(line: string | Buffer): Record<string, unknown> {
|
|
171
|
-
const buffer = Buffer.isBuffer(line) ? line : Buffer.from(line, "utf8");
|
|
172
|
-
if (buffer.byteLength + 1 > MAX_PROTOCOL_MESSAGE_BYTES) {
|
|
173
|
-
throw new HostProtocolError("Host protocol message exceeds 1 MiB");
|
|
174
|
-
}
|
|
175
|
-
let value: unknown;
|
|
176
|
-
try {
|
|
177
|
-
value = parseJson(buffer.toString("utf8"));
|
|
178
|
-
} catch {
|
|
179
|
-
throw new HostProtocolError("Host protocol message is not valid canonical JSON");
|
|
180
|
-
}
|
|
181
|
-
if (!isRecord(value)) throw new HostProtocolError("Host protocol message must be an object");
|
|
182
|
-
if (canonicalJson(value) !== buffer.toString("utf8")) {
|
|
183
|
-
throw new HostProtocolError("Host protocol message must use canonical JSON");
|
|
184
|
-
}
|
|
185
|
-
return value;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function requireOpaqueId(value: unknown, field: string): asserts value is string {
|
|
189
|
-
if (typeof value !== "string" || value.length === 0 || value.length > 256) {
|
|
190
|
-
throw new HostProtocolError(`Host request ${field} must be nonempty text of at most 256 bytes`);
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
195
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
196
|
-
}
|
package/src/viewer/watch.ts
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
|
|
4
|
-
export type Unsubscribe = () => void;
|
|
5
|
-
|
|
6
|
-
/** Watch the canonical database and its WAL with a polling fallback. */
|
|
7
|
-
export function watchStateDatabase(
|
|
8
|
-
databasePath: string,
|
|
9
|
-
onChange: () => void,
|
|
10
|
-
options: { pollMs?: number; debounceMs?: number } = {},
|
|
11
|
-
): Unsubscribe {
|
|
12
|
-
const pollMs = options.pollMs ?? 1_000;
|
|
13
|
-
const debounceMs = options.debounceMs ?? 80;
|
|
14
|
-
const directory = path.dirname(databasePath);
|
|
15
|
-
const base = path.basename(databasePath);
|
|
16
|
-
const relevant = new Set([base, `${base}-wal`, `${base}-shm`]);
|
|
17
|
-
let debounceTimer: NodeJS.Timeout | null = null;
|
|
18
|
-
let closed = false;
|
|
19
|
-
|
|
20
|
-
const fire = () => {
|
|
21
|
-
if (closed) return;
|
|
22
|
-
if (debounceTimer !== null) clearTimeout(debounceTimer);
|
|
23
|
-
debounceTimer = setTimeout(() => {
|
|
24
|
-
debounceTimer = null;
|
|
25
|
-
onChange();
|
|
26
|
-
}, debounceMs);
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
let watcher: fs.FSWatcher | null = null;
|
|
30
|
-
try {
|
|
31
|
-
watcher = fs.watch(directory, (_event, filename) => {
|
|
32
|
-
if (filename === null || relevant.has(filename.toString())) fire();
|
|
33
|
-
});
|
|
34
|
-
watcher.on("error", () => {
|
|
35
|
-
watcher?.close();
|
|
36
|
-
watcher = null;
|
|
37
|
-
});
|
|
38
|
-
} catch {
|
|
39
|
-
watcher = null;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const poller = setInterval(fire, pollMs);
|
|
43
|
-
poller.unref?.();
|
|
44
|
-
|
|
45
|
-
return () => {
|
|
46
|
-
closed = true;
|
|
47
|
-
if (debounceTimer !== null) clearTimeout(debounceTimer);
|
|
48
|
-
clearInterval(poller);
|
|
49
|
-
watcher?.close();
|
|
50
|
-
};
|
|
51
|
-
}
|