@osolmaz/pi-workflows 0.15.3 → 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 +43 -0
- package/dist/controllers/sqlite.js +137 -2
- package/dist/controllers/sqlite.js.map +1 -1
- package/dist/extension/index.d.ts +1 -0
- package/dist/extension/index.js +278 -183
- package/dist/extension/index.js.map +1 -1
- package/dist/extension/session-view.d.ts +7 -2
- package/dist/extension/session-view.js +60 -52
- package/dist/extension/session-view.js.map +1 -1
- package/dist/extension/widget.d.ts +2 -1
- package/dist/extension/widget.js +14 -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 +11 -0
- package/dist/host/runner.js +473 -17
- package/dist/host/runner.js.map +1 -1
- package/dist/host/state.d.ts +6 -3
- package/dist/host/state.js +52 -26
- 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-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 +74 -61
- 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 +44 -1
- 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 +213 -2
- package/src/extension/index.ts +349 -208
- package/src/extension/session-view.ts +73 -50
- package/src/extension/widget.ts +16 -8
- package/src/host/child-worker-supervisor.ts +1 -1
- package/src/host/resolver-entry.ts +11 -26
- package/src/host/runner.ts +648 -48
- package/src/host/state.ts +71 -43
- 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/dist/host/runner.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { once } from "node:events";
|
|
3
2
|
import fs from "node:fs";
|
|
4
3
|
import net, {} from "node:net";
|
|
5
4
|
import path from "node:path";
|
|
5
|
+
import { WorkflowClient } from "../client/client.js";
|
|
6
|
+
import { CLIENT_PROTOCOL_SCHEMA, encodeProtocolLine, clientSocketPath, NdjsonFrameDecoder, parseClientRequest, } from "../client/protocol.js";
|
|
6
7
|
import { applyStatusPatch } from "../controllers/conditions.js";
|
|
7
8
|
import { ResourceConflictError } from "../controllers/errors.js";
|
|
8
9
|
import { controllerFileStem, controllerSearchDirs, discoverControllers, } from "../controllers/loader.js";
|
|
@@ -10,20 +11,21 @@ import { SqliteControllerStore, } from "../controllers/sqlite.js";
|
|
|
10
11
|
import { ControllerWorkflowCoordinator, } from "../controllers/workflows.js";
|
|
11
12
|
import { StateDatabase, workflowStatePath } from "../state/database.js";
|
|
12
13
|
import { canonicalJson } from "../state/json.js";
|
|
14
|
+
import { pruneState } from "../state/prune.js";
|
|
13
15
|
import { recordViewerDeltas } from "../state/viewer.js";
|
|
14
16
|
import { errorMessage } from "../workflows/errors.js";
|
|
15
17
|
import { HumanDecisionStore } from "../workflows/human-decision.js";
|
|
16
18
|
import {} from "../workflows/settings.js";
|
|
17
19
|
import { WorkflowRunStore } from "../workflows/store.js";
|
|
18
20
|
import { validateWorkflowUpdate } from "../workflows/updates.js";
|
|
19
|
-
import { WorkflowHostClient } from "./client.js";
|
|
20
21
|
import { ControllerWorkerSupervisor } from "./controller-worker-supervisor.js";
|
|
21
22
|
import { HostProcessRegistry, matchesProcessIdentity, processParentPid, processStartIdentity, } from "./processes.js";
|
|
22
|
-
import { encodeProtocolLine, hostSocketPath, NdjsonFrameDecoder, parseHostRequest, } from "./protocol.js";
|
|
23
23
|
import { HostStateStore, } from "./state.js";
|
|
24
|
+
import { HostViewStore, WORKFLOW_PAGE_KINDS, } from "./view.js";
|
|
24
25
|
import { WorkflowWorkerSupervisor } from "./worker-supervisor.js";
|
|
25
26
|
const HOST_LEASE_MS = 30_000;
|
|
26
27
|
const HOST_RENEW_MS = 10_000;
|
|
28
|
+
const PACKAGE_VERSION = runtimePackageVersion();
|
|
27
29
|
const CLAIM_POLL_MS = 2_000;
|
|
28
30
|
const RUN_CLAIM_LEASE_MS = 30_000;
|
|
29
31
|
const PRESENTATION_CLAIM_LEASE_MS = 10_000;
|
|
@@ -45,22 +47,26 @@ export class WorkflowHost {
|
|
|
45
47
|
queue;
|
|
46
48
|
decisions;
|
|
47
49
|
runStore;
|
|
50
|
+
views;
|
|
48
51
|
registry;
|
|
49
52
|
activeRuns = new Map();
|
|
50
53
|
workerDescendants = new Map();
|
|
51
54
|
activeControllers = new Map();
|
|
52
55
|
controlClaims = new Map();
|
|
53
56
|
activationTasks = new Map();
|
|
57
|
+
maintenanceCommands = new Map();
|
|
54
58
|
pendingStarts = new Set();
|
|
55
59
|
pendingRunClaims = new Map();
|
|
56
60
|
pendingResumes = new Set();
|
|
57
61
|
blockedRuns = new Set();
|
|
58
62
|
deliveryClaims = new Map();
|
|
59
63
|
sockets = new Set();
|
|
64
|
+
connections = new Map();
|
|
60
65
|
server = null;
|
|
61
66
|
claim = null;
|
|
62
67
|
heartbeatTimer = null;
|
|
63
68
|
pollTimer = null;
|
|
69
|
+
viewTimer = null;
|
|
64
70
|
stopping = false;
|
|
65
71
|
started = false;
|
|
66
72
|
controllerPollActive = false;
|
|
@@ -70,7 +76,7 @@ export class WorkflowHost {
|
|
|
70
76
|
this.hostId = options.runnerId ?? `host-${randomUUID()}`;
|
|
71
77
|
this.databasePath = path.resolve(options.databasePath ?? workflowStatePath());
|
|
72
78
|
this.stateDirectory = path.join(path.dirname(this.databasePath), "host");
|
|
73
|
-
this.socketPath =
|
|
79
|
+
this.socketPath = clientSocketPath(this.databasePath);
|
|
74
80
|
this.lockPath = path.join(this.stateDirectory, "host.lock.json");
|
|
75
81
|
this.state = new StateDatabase({ filePath: this.databasePath });
|
|
76
82
|
this.hostState = new HostStateStore(this.databasePath, { state: this.state });
|
|
@@ -90,6 +96,7 @@ export class WorkflowHost {
|
|
|
90
96
|
},
|
|
91
97
|
snapshotLifecycle: (context) => this.applyLifecycleProjection(context),
|
|
92
98
|
});
|
|
99
|
+
this.views = new HostViewStore(this.state, this.queue, this.hostState, this.runStore, (runId) => this.activeRuns.has(runId));
|
|
93
100
|
this.registry = options.registry ?? new HostProcessRegistry(this.stateDirectory);
|
|
94
101
|
}
|
|
95
102
|
get endpoint() {
|
|
@@ -156,8 +163,11 @@ export class WorkflowHost {
|
|
|
156
163
|
clearInterval(this.heartbeatTimer);
|
|
157
164
|
if (this.pollTimer !== null)
|
|
158
165
|
clearInterval(this.pollTimer);
|
|
166
|
+
if (this.viewTimer !== null)
|
|
167
|
+
clearInterval(this.viewTimer);
|
|
159
168
|
this.heartbeatTimer = null;
|
|
160
169
|
this.pollTimer = null;
|
|
170
|
+
this.viewTimer = null;
|
|
161
171
|
const server = this.server;
|
|
162
172
|
this.server = null;
|
|
163
173
|
await this.closeServer(server);
|
|
@@ -179,6 +189,7 @@ export class WorkflowHost {
|
|
|
179
189
|
}
|
|
180
190
|
}));
|
|
181
191
|
await Promise.allSettled(this.activationTasks.values());
|
|
192
|
+
await Promise.allSettled(this.maintenanceCommands.values());
|
|
182
193
|
this.registry.killAll();
|
|
183
194
|
this.deliveryClaims.clear();
|
|
184
195
|
if (this.claim !== null)
|
|
@@ -258,6 +269,11 @@ export class WorkflowHost {
|
|
|
258
269
|
void this.claimControllerOne();
|
|
259
270
|
}, this.options.claimPollMs ?? CLAIM_POLL_MS);
|
|
260
271
|
this.pollTimer.unref?.();
|
|
272
|
+
this.viewTimer = setInterval(() => {
|
|
273
|
+
this.views.expireActivity();
|
|
274
|
+
this.publishViews();
|
|
275
|
+
}, 250);
|
|
276
|
+
this.viewTimer.unref?.();
|
|
261
277
|
}
|
|
262
278
|
async listen() {
|
|
263
279
|
if (process.platform !== "win32")
|
|
@@ -293,8 +309,20 @@ export class WorkflowHost {
|
|
|
293
309
|
}
|
|
294
310
|
handleConnection(socket) {
|
|
295
311
|
this.sockets.add(socket);
|
|
312
|
+
const connection = {
|
|
313
|
+
id: `connection-${randomUUID()}`,
|
|
314
|
+
socket,
|
|
315
|
+
subscriptions: new Map(),
|
|
316
|
+
publishing: false,
|
|
317
|
+
};
|
|
318
|
+
this.connections.set(socket, connection);
|
|
319
|
+
socket.write(encodeProtocolLine({
|
|
320
|
+
schema: CLIENT_PROTOCOL_SCHEMA,
|
|
321
|
+
type: "hello",
|
|
322
|
+
connectionId: connection.id,
|
|
323
|
+
packageVersion: PACKAGE_VERSION,
|
|
324
|
+
}));
|
|
296
325
|
const decoder = new NdjsonFrameDecoder();
|
|
297
|
-
let processing = Promise.resolve();
|
|
298
326
|
socket.on("data", (chunk) => {
|
|
299
327
|
let frames;
|
|
300
328
|
try {
|
|
@@ -305,14 +333,13 @@ export class WorkflowHost {
|
|
|
305
333
|
return;
|
|
306
334
|
}
|
|
307
335
|
for (const frame of frames) {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
const
|
|
311
|
-
const response = this.handleRequest(request);
|
|
336
|
+
void (async () => {
|
|
337
|
+
const request = parseClientRequest(frame);
|
|
338
|
+
const response = await this.handleClientRequest(connection, request);
|
|
312
339
|
if (!socket.write(encodeProtocolLine(response)))
|
|
313
|
-
await
|
|
314
|
-
|
|
315
|
-
|
|
340
|
+
await waitForSocketDrain(socket);
|
|
341
|
+
this.publishConnection(connection);
|
|
342
|
+
})().catch(() => {
|
|
316
343
|
socket.destroy();
|
|
317
344
|
});
|
|
318
345
|
}
|
|
@@ -323,12 +350,243 @@ export class WorkflowHost {
|
|
|
323
350
|
});
|
|
324
351
|
socket.on("close", () => {
|
|
325
352
|
this.sockets.delete(socket);
|
|
353
|
+
this.connections.delete(socket);
|
|
354
|
+
this.views.clearConnection(connection.id);
|
|
326
355
|
});
|
|
327
356
|
}
|
|
357
|
+
async handleClientRequest(connection, request) {
|
|
358
|
+
try {
|
|
359
|
+
switch (request.operation) {
|
|
360
|
+
case "view.runs.watch":
|
|
361
|
+
this.addSubscription(connection, request, "runs");
|
|
362
|
+
return clientResponse(request.requestId, "accepted", { subscribed: true });
|
|
363
|
+
case "view.runs.page": {
|
|
364
|
+
const payload = requireRecord(request.payload, "view.runs.page payload");
|
|
365
|
+
const revision = requireString(payload.revision, "revision");
|
|
366
|
+
const page = this.views.list(requireNonNegativeInteger(payload.cursor, "cursor"), payload.limit === undefined
|
|
367
|
+
? undefined
|
|
368
|
+
: requirePositiveInteger(payload.limit, "limit"));
|
|
369
|
+
return page.revision === revision
|
|
370
|
+
? clientResponse(request.requestId, "accepted", toJsonValue(page))
|
|
371
|
+
: clientResponse(request.requestId, "conflict", toJsonValue(page), "Workflow run list changed while paging");
|
|
372
|
+
}
|
|
373
|
+
case "view.run.get": {
|
|
374
|
+
const view = this.views.run(requireRunId(request));
|
|
375
|
+
return view === null
|
|
376
|
+
? clientResponse(request.requestId, "notFound", undefined, "Workflow run not found")
|
|
377
|
+
: clientResponse(request.requestId, "accepted", toJsonValue(view), undefined, view.revision);
|
|
378
|
+
}
|
|
379
|
+
case "view.run.watch": {
|
|
380
|
+
const runId = requireRunId(request);
|
|
381
|
+
if (this.views.run(runId) === null) {
|
|
382
|
+
return clientResponse(request.requestId, "notFound", undefined, "Workflow run not found");
|
|
383
|
+
}
|
|
384
|
+
this.addSubscription(connection, request, "run", runId);
|
|
385
|
+
return clientResponse(request.requestId, "accepted", { subscribed: true });
|
|
386
|
+
}
|
|
387
|
+
case "view.session.watch": {
|
|
388
|
+
const payload = requireRecord(request.payload, "view.session.watch payload");
|
|
389
|
+
this.addSubscription(connection, request, "session", requireString(payload.sessionId, "sessionId"));
|
|
390
|
+
return clientResponse(request.requestId, "accepted", { subscribed: true });
|
|
391
|
+
}
|
|
392
|
+
case "view.run.unwatch": {
|
|
393
|
+
const payload = requireRecord(request.payload, "view.run.unwatch payload");
|
|
394
|
+
connection.subscriptions.delete(requireString(payload.subscriptionId, "subscriptionId"));
|
|
395
|
+
return clientResponse(request.requestId, "accepted", { subscribed: false });
|
|
396
|
+
}
|
|
397
|
+
case "view.page": {
|
|
398
|
+
const payload = requireRecord(request.payload, "view.page payload");
|
|
399
|
+
const kind = requireString(payload.kind, "kind");
|
|
400
|
+
if (!WORKFLOW_PAGE_KINDS.includes(kind)) {
|
|
401
|
+
throw new Error("view.page kind is invalid");
|
|
402
|
+
}
|
|
403
|
+
const view = this.views.page(requireRunId(request), {
|
|
404
|
+
kind: kind,
|
|
405
|
+
cursor: requireNonNegativeInteger(payload.cursor, "cursor"),
|
|
406
|
+
});
|
|
407
|
+
return view === null
|
|
408
|
+
? clientResponse(request.requestId, "notFound", undefined, "Workflow run not found")
|
|
409
|
+
: clientResponse(request.requestId, "accepted", runPageReceipt(view, kind, requireNonNegativeInteger(payload.cursor, "cursor")), undefined, view.revision);
|
|
410
|
+
}
|
|
411
|
+
case "view.content": {
|
|
412
|
+
const payload = requireRecord(request.payload, "view.content payload");
|
|
413
|
+
const content = this.views.content(requireRunId(request), requireString(payload.path, "path"), requireNonNegativeInteger(payload.offset, "offset"));
|
|
414
|
+
return content === null
|
|
415
|
+
? clientResponse(request.requestId, "notFound", undefined, "Workflow content not found")
|
|
416
|
+
: clientResponse(request.requestId, "accepted", content);
|
|
417
|
+
}
|
|
418
|
+
case "activity.report": {
|
|
419
|
+
this.views.reportActivity(connection.id, parseActivityReport(request.payload));
|
|
420
|
+
return clientResponse(request.requestId, "accepted", { recorded: true });
|
|
421
|
+
}
|
|
422
|
+
case "interaction.submit":
|
|
423
|
+
return await this.submitInteractionAndWait(request);
|
|
424
|
+
case "state.status":
|
|
425
|
+
return clientResponse(request.requestId, "accepted", this.stateStatusReceipt());
|
|
426
|
+
case "state.verify":
|
|
427
|
+
this.state.integrityCheck();
|
|
428
|
+
return clientResponse(request.requestId, "accepted", { valid: true });
|
|
429
|
+
case "state.backup":
|
|
430
|
+
return await this.executeMaintenanceCommand(request, async () => {
|
|
431
|
+
const payload = requireRecord(request.payload, "state.backup payload");
|
|
432
|
+
const destination = requireAbsolutePath(payload.destination, "destination");
|
|
433
|
+
await this.state.backup(destination);
|
|
434
|
+
return { destination };
|
|
435
|
+
});
|
|
436
|
+
case "state.prune":
|
|
437
|
+
return await this.executeMaintenanceCommand(request, async () => {
|
|
438
|
+
const payload = requireRecord(request.payload, "state.prune payload");
|
|
439
|
+
const before = requireString(payload.before, "before");
|
|
440
|
+
const apply = requireBoolean(payload.apply, "apply");
|
|
441
|
+
const backupPath = payload.backupPath === undefined
|
|
442
|
+
? undefined
|
|
443
|
+
: requireAbsolutePath(payload.backupPath, "backupPath");
|
|
444
|
+
const report = await pruneState(this.state, this.databasePath, {
|
|
445
|
+
before,
|
|
446
|
+
apply,
|
|
447
|
+
...(backupPath === undefined ? {} : { backupPath }),
|
|
448
|
+
});
|
|
449
|
+
return toJsonValue(report);
|
|
450
|
+
});
|
|
451
|
+
default:
|
|
452
|
+
return this.handleRequest(request);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
catch (error) {
|
|
456
|
+
return clientResponse(request.requestId, "rejected", undefined, errorMessage(error));
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
async executeMaintenanceCommand(request, operation) {
|
|
460
|
+
const adopted = this.hostState.adoptCommand(request);
|
|
461
|
+
if (adopted !== undefined)
|
|
462
|
+
return adopted;
|
|
463
|
+
const claim = this.claim;
|
|
464
|
+
if (claim === null || this.stopping) {
|
|
465
|
+
return clientResponse(request.requestId, "unavailable", undefined, "Workflow host is stopping");
|
|
466
|
+
}
|
|
467
|
+
const key = canonicalJson([request.clientId, request.idempotencyKey]);
|
|
468
|
+
const active = this.maintenanceCommands.get(key);
|
|
469
|
+
if (active !== undefined) {
|
|
470
|
+
await active;
|
|
471
|
+
return (this.hostState.adoptCommand(request) ??
|
|
472
|
+
clientResponse(request.requestId, "unavailable", undefined, "Workflow maintenance command did not complete durably"));
|
|
473
|
+
}
|
|
474
|
+
const execution = (async () => {
|
|
475
|
+
let result;
|
|
476
|
+
try {
|
|
477
|
+
result = { outcome: "accepted", receipt: await operation() };
|
|
478
|
+
}
|
|
479
|
+
catch (error) {
|
|
480
|
+
result = { outcome: "rejected", error: errorMessage(error) };
|
|
481
|
+
}
|
|
482
|
+
return this.hostState.executeCommand(request, claim.epoch, () => result);
|
|
483
|
+
})();
|
|
484
|
+
this.maintenanceCommands.set(key, execution);
|
|
485
|
+
try {
|
|
486
|
+
return await execution;
|
|
487
|
+
}
|
|
488
|
+
finally {
|
|
489
|
+
if (this.maintenanceCommands.get(key) === execution) {
|
|
490
|
+
this.maintenanceCommands.delete(key);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
addSubscription(connection, request, kind, target) {
|
|
495
|
+
const payload = requireRecord(request.payload, `${request.operation} payload`);
|
|
496
|
+
const id = requireString(payload.subscriptionId, "subscriptionId");
|
|
497
|
+
const limit = kind === "runs" && payload.limit !== undefined
|
|
498
|
+
? requirePositiveInteger(payload.limit, "limit")
|
|
499
|
+
: undefined;
|
|
500
|
+
connection.subscriptions.set(id, {
|
|
501
|
+
id,
|
|
502
|
+
kind,
|
|
503
|
+
...(target === undefined ? {} : { target }),
|
|
504
|
+
...(limit === undefined ? {} : { limit }),
|
|
505
|
+
revision: 0,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
publishViews() {
|
|
509
|
+
for (const connection of this.connections.values())
|
|
510
|
+
void this.publishConnection(connection);
|
|
511
|
+
}
|
|
512
|
+
async publishConnection(connection) {
|
|
513
|
+
if (connection.publishing || connection.socket.destroyed)
|
|
514
|
+
return;
|
|
515
|
+
connection.publishing = true;
|
|
516
|
+
try {
|
|
517
|
+
for (const subscription of connection.subscriptions.values()) {
|
|
518
|
+
const payload = subscription.kind === "runs"
|
|
519
|
+
? toJsonValue(this.views.list(0, subscription.limit))
|
|
520
|
+
: subscription.kind === "run"
|
|
521
|
+
? toJsonValue(this.views.run(subscription.target ?? ""))
|
|
522
|
+
: toJsonValue(this.views.session(subscription.target ?? ""));
|
|
523
|
+
const digest = createHash("sha256").update(canonicalJson(payload)).digest("hex");
|
|
524
|
+
if (subscription.digest === digest)
|
|
525
|
+
continue;
|
|
526
|
+
subscription.digest = digest;
|
|
527
|
+
subscription.revision += 1;
|
|
528
|
+
const event = {
|
|
529
|
+
schema: CLIENT_PROTOCOL_SCHEMA,
|
|
530
|
+
type: "event",
|
|
531
|
+
subscriptionId: subscription.id,
|
|
532
|
+
event: subscription.kind === "runs"
|
|
533
|
+
? "runs"
|
|
534
|
+
: subscription.kind === "run"
|
|
535
|
+
? "run_snapshot"
|
|
536
|
+
: "session_snapshot",
|
|
537
|
+
revision: subscription.revision,
|
|
538
|
+
...(subscription.kind === "run" && subscription.target !== undefined
|
|
539
|
+
? { runId: subscription.target }
|
|
540
|
+
: {}),
|
|
541
|
+
payload,
|
|
542
|
+
};
|
|
543
|
+
if (!connection.socket.write(encodeProtocolLine(event))) {
|
|
544
|
+
await waitForSocketDrain(connection.socket);
|
|
545
|
+
if (connection.socket.destroyed)
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
catch (error) {
|
|
551
|
+
this.log(`client view error: ${errorMessage(error)}`);
|
|
552
|
+
connection.socket.destroy();
|
|
553
|
+
}
|
|
554
|
+
finally {
|
|
555
|
+
connection.publishing = false;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
async submitInteractionAndWait(request) {
|
|
559
|
+
const started = this.submitInteraction(request);
|
|
560
|
+
if (started.outcome !== "accepted" && started.outcome !== "adopted") {
|
|
561
|
+
return clientResponse(request.requestId, started.outcome, started.receipt, started.error);
|
|
562
|
+
}
|
|
563
|
+
const payload = requireRecord(request.payload, "interaction payload");
|
|
564
|
+
const requestId = requireString(payload.requestId, "requestId");
|
|
565
|
+
const startedReceipt = requireRecord(started.receipt, "interaction submission receipt");
|
|
566
|
+
const submissionId = requireString(startedReceipt.submissionId, "submissionId");
|
|
567
|
+
for (;;) {
|
|
568
|
+
if (this.stopping) {
|
|
569
|
+
return clientResponse(request.requestId, "unavailable", undefined, "Workflow host stopped while validating the submission");
|
|
570
|
+
}
|
|
571
|
+
const submission = this.hostState.interactionSubmission(requestId, submissionId);
|
|
572
|
+
if (submission?.outcome === "accepted" || submission?.outcome === "adopted") {
|
|
573
|
+
return clientResponse(request.requestId, started.outcome, submission.receipt ?? { requestId, submissionId });
|
|
574
|
+
}
|
|
575
|
+
if (submission?.outcome === "rejected") {
|
|
576
|
+
const receipt = submission.receipt ?? { requestId, submissionId };
|
|
577
|
+
const detail = isObjectRecord(receipt) && typeof receipt.error === "string"
|
|
578
|
+
? receipt.error
|
|
579
|
+
: "Workflow step output failed validation";
|
|
580
|
+
return clientResponse(request.requestId, "rejected", receipt, detail);
|
|
581
|
+
}
|
|
582
|
+
await hostDelay(25);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
328
585
|
handleRequest(request) {
|
|
329
586
|
if (this.claim === null || this.stopping) {
|
|
330
587
|
return {
|
|
331
|
-
schema:
|
|
588
|
+
schema: CLIENT_PROTOCOL_SCHEMA,
|
|
589
|
+
type: "response",
|
|
332
590
|
requestId: request.requestId,
|
|
333
591
|
outcome: "unavailable",
|
|
334
592
|
error: "Workflow host is stopping",
|
|
@@ -341,7 +599,8 @@ export class WorkflowHost {
|
|
|
341
599
|
}
|
|
342
600
|
catch (error) {
|
|
343
601
|
response = {
|
|
344
|
-
schema:
|
|
602
|
+
schema: CLIENT_PROTOCOL_SCHEMA,
|
|
603
|
+
type: "response",
|
|
345
604
|
requestId: request.requestId,
|
|
346
605
|
outcome: "rejected",
|
|
347
606
|
error: errorMessage(error),
|
|
@@ -469,6 +728,21 @@ export class WorkflowHost {
|
|
|
469
728
|
return this.executeControllerOperation(request);
|
|
470
729
|
case "interaction.update": {
|
|
471
730
|
const payload = requireRecord(request.payload, "interaction update payload");
|
|
731
|
+
if (payload.validatePresentation === true) {
|
|
732
|
+
const interaction = this.hostState.getInteraction(requireString(payload.requestId, "requestId"));
|
|
733
|
+
const expectedRevision = requireNonNegativeInteger(request.expectedRevision, "expectedRevision");
|
|
734
|
+
const expiresAt = interaction?.presentationClaimExpiresAt;
|
|
735
|
+
const live = interaction !== undefined &&
|
|
736
|
+
interaction.runId === requireRunId(request) &&
|
|
737
|
+
interaction.status === "presenting" &&
|
|
738
|
+
interaction.presenterId === request.clientId &&
|
|
739
|
+
interaction.revision === expectedRevision &&
|
|
740
|
+
interaction.presentationSessionEntryId === null &&
|
|
741
|
+
typeof expiresAt === "string" &&
|
|
742
|
+
Date.parse(expiresAt) > Date.now() &&
|
|
743
|
+
!this.queue.isWorkflowRunPaused(interaction.runId);
|
|
744
|
+
return { outcome: "accepted", receipt: { live } };
|
|
745
|
+
}
|
|
472
746
|
if (payload.claimPresentation === true) {
|
|
473
747
|
try {
|
|
474
748
|
const interaction = this.hostState.claimInteractionPresentation({
|
|
@@ -508,6 +782,20 @@ export class WorkflowHost {
|
|
|
508
782
|
return this.submitInteraction(request);
|
|
509
783
|
case "decision.answer":
|
|
510
784
|
return this.answerDecision(request, afterCommit);
|
|
785
|
+
case "view.runs.watch":
|
|
786
|
+
case "view.runs.page":
|
|
787
|
+
case "view.run.get":
|
|
788
|
+
case "view.run.watch":
|
|
789
|
+
case "view.run.unwatch":
|
|
790
|
+
case "view.page":
|
|
791
|
+
case "view.content":
|
|
792
|
+
case "view.session.watch":
|
|
793
|
+
case "activity.report":
|
|
794
|
+
case "state.status":
|
|
795
|
+
case "state.verify":
|
|
796
|
+
case "state.backup":
|
|
797
|
+
case "state.prune":
|
|
798
|
+
throw new Error(`${request.operation} must use the live client connection`);
|
|
511
799
|
}
|
|
512
800
|
}
|
|
513
801
|
executeControllerOperation(request) {
|
|
@@ -608,6 +896,27 @@ export class WorkflowHost {
|
|
|
608
896
|
)`),
|
|
609
897
|
};
|
|
610
898
|
}
|
|
899
|
+
stateStatusReceipt() {
|
|
900
|
+
const count = (sql, ...params) => {
|
|
901
|
+
const row = this.state.connection.prepare(sql).get(...params);
|
|
902
|
+
return typeof row?.count === "number" ? row.count : 0;
|
|
903
|
+
};
|
|
904
|
+
const now = Date.now();
|
|
905
|
+
return {
|
|
906
|
+
sizeBytes: fs.statSync(this.databasePath).size,
|
|
907
|
+
counts: {
|
|
908
|
+
resources: count("SELECT COUNT(*) AS count FROM resources"),
|
|
909
|
+
runs: count("SELECT COUNT(*) AS count FROM runs"),
|
|
910
|
+
controllers: count("SELECT COUNT(*) AS count FROM controller_resources"),
|
|
911
|
+
decisions: count("SELECT COUNT(*) AS count FROM human_decisions"),
|
|
912
|
+
settingsScopes: count("SELECT COUNT(*) AS count FROM workflow_settings"),
|
|
913
|
+
pendingInteractions: count("SELECT COUNT(*) AS count FROM interactive_requests WHERE status IN ('pending', 'presenting')"),
|
|
914
|
+
pendingFollowUps: count("SELECT COUNT(*) AS count FROM workflow_follow_ups WHERE status IN ('queued', 'pending_presentation', 'ready')"),
|
|
915
|
+
activeLeases: count("SELECT COUNT(*) AS count FROM leases WHERE owner_id IS NOT NULL AND expires_at > ?", now),
|
|
916
|
+
unsettledEffects: count("SELECT COUNT(*) AS count FROM effects WHERE status IN ('pending', 'applying', 'ambiguous')"),
|
|
917
|
+
},
|
|
918
|
+
};
|
|
919
|
+
}
|
|
611
920
|
rememberDeliveryClaim(options) {
|
|
612
921
|
const now = Date.now();
|
|
613
922
|
for (const [claimId, claim] of this.deliveryClaims) {
|
|
@@ -1020,6 +1329,9 @@ export class WorkflowHost {
|
|
|
1020
1329
|
},
|
|
1021
1330
|
});
|
|
1022
1331
|
const interaction = submission.interaction;
|
|
1332
|
+
const receipt = isObjectRecord(submission.receipt)
|
|
1333
|
+
? { ...submission.receipt, requestId, submissionId: submission.submissionId }
|
|
1334
|
+
: { requestId, submissionId: submission.submissionId, receipt: submission.receipt };
|
|
1023
1335
|
if (this.activeRuns.has(interaction.runId) || this.activationTasks.has(interaction.runId)) {
|
|
1024
1336
|
this.pendingResumes.add(interaction.runId);
|
|
1025
1337
|
}
|
|
@@ -1039,7 +1351,7 @@ export class WorkflowHost {
|
|
|
1039
1351
|
return {
|
|
1040
1352
|
outcome: submission.outcome,
|
|
1041
1353
|
revision: interaction.revision,
|
|
1042
|
-
receipt
|
|
1354
|
+
receipt,
|
|
1043
1355
|
};
|
|
1044
1356
|
}
|
|
1045
1357
|
async claimControllerOne() {
|
|
@@ -1322,7 +1634,7 @@ export class WorkflowHost {
|
|
|
1322
1634
|
const projectPath = this.queue.workflowRunProjectPath(request.runId);
|
|
1323
1635
|
if (projectPath === undefined)
|
|
1324
1636
|
throw new Error("Workflow run project is missing");
|
|
1325
|
-
const resolver = new
|
|
1637
|
+
const resolver = new WorkflowClient({
|
|
1326
1638
|
databasePath: this.databasePath,
|
|
1327
1639
|
...(this.options.env === undefined ? {} : { env: this.options.env }),
|
|
1328
1640
|
});
|
|
@@ -1369,7 +1681,7 @@ export class WorkflowHost {
|
|
|
1369
1681
|
const existing = this.queue.getWorkflowRun(request.runId);
|
|
1370
1682
|
if (existing !== undefined)
|
|
1371
1683
|
return controllerWorkflowResult(existing);
|
|
1372
|
-
const resolver = new
|
|
1684
|
+
const resolver = new WorkflowClient({
|
|
1373
1685
|
databasePath: this.databasePath,
|
|
1374
1686
|
...(this.options.env === undefined ? {} : { env: this.options.env }),
|
|
1375
1687
|
});
|
|
@@ -2430,6 +2742,129 @@ function controllerPathAllowed(projectPath, controllerName, controllerPath) {
|
|
|
2430
2742
|
}
|
|
2431
2743
|
return controllerSearchDirs({ cwd: projectPath }).some(({ dir }) => path.dirname(controllerPath) === path.resolve(dir));
|
|
2432
2744
|
}
|
|
2745
|
+
function waitForSocketDrain(socket) {
|
|
2746
|
+
if (socket.destroyed)
|
|
2747
|
+
return Promise.resolve();
|
|
2748
|
+
return new Promise((resolve, reject) => {
|
|
2749
|
+
const cleanup = () => {
|
|
2750
|
+
socket.off("drain", onDrain);
|
|
2751
|
+
socket.off("close", onClose);
|
|
2752
|
+
socket.off("error", onError);
|
|
2753
|
+
};
|
|
2754
|
+
const onDrain = () => {
|
|
2755
|
+
cleanup();
|
|
2756
|
+
resolve();
|
|
2757
|
+
};
|
|
2758
|
+
const onClose = () => {
|
|
2759
|
+
cleanup();
|
|
2760
|
+
resolve();
|
|
2761
|
+
};
|
|
2762
|
+
const onError = (error) => {
|
|
2763
|
+
cleanup();
|
|
2764
|
+
reject(error);
|
|
2765
|
+
};
|
|
2766
|
+
socket.once("drain", onDrain);
|
|
2767
|
+
socket.once("close", onClose);
|
|
2768
|
+
socket.once("error", onError);
|
|
2769
|
+
if (socket.destroyed)
|
|
2770
|
+
onClose();
|
|
2771
|
+
});
|
|
2772
|
+
}
|
|
2773
|
+
function hostDelay(ms) {
|
|
2774
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2775
|
+
}
|
|
2776
|
+
function runPageReceipt(view, kind, cursor) {
|
|
2777
|
+
const base = {
|
|
2778
|
+
schema: "pi-workflows.run-page.v1",
|
|
2779
|
+
runId: view.runId,
|
|
2780
|
+
revision: view.revision,
|
|
2781
|
+
kind,
|
|
2782
|
+
cursor,
|
|
2783
|
+
};
|
|
2784
|
+
if (kind === "steps") {
|
|
2785
|
+
const state = requireRecord(view.state, "run state view");
|
|
2786
|
+
return {
|
|
2787
|
+
...base,
|
|
2788
|
+
start: view.stepStart,
|
|
2789
|
+
total: view.stepTotal,
|
|
2790
|
+
items: Array.isArray(state.steps) ? state.steps : [],
|
|
2791
|
+
graphSteps: view.graphSteps,
|
|
2792
|
+
graphCursor: view.graphCursor,
|
|
2793
|
+
takenTransitions: view.takenTransitions,
|
|
2794
|
+
};
|
|
2795
|
+
}
|
|
2796
|
+
if (kind === "trace" || kind === "trace_at_step") {
|
|
2797
|
+
return { ...base, ...requireRecord(view.tracePage, "trace page") };
|
|
2798
|
+
}
|
|
2799
|
+
if (kind === "session_entries" || kind === "session_events") {
|
|
2800
|
+
const session = requireRecord(view.session, "session view");
|
|
2801
|
+
const page = requireRecord(kind === "session_entries" ? session.entryPage : session.eventPage, "session page");
|
|
2802
|
+
return {
|
|
2803
|
+
...base,
|
|
2804
|
+
...page,
|
|
2805
|
+
...(kind === "session_events" ? { replayCheckpoint: session.replayCheckpoint } : {}),
|
|
2806
|
+
};
|
|
2807
|
+
}
|
|
2808
|
+
if (kind === "settings") {
|
|
2809
|
+
return {
|
|
2810
|
+
...base,
|
|
2811
|
+
start: view.settingsStart,
|
|
2812
|
+
total: view.settingsTotal,
|
|
2813
|
+
items: view.settingsScopes,
|
|
2814
|
+
};
|
|
2815
|
+
}
|
|
2816
|
+
if (kind === "follow_ups") {
|
|
2817
|
+
const queue = isObjectRecord(view.followUpQueue) ? view.followUpQueue : {};
|
|
2818
|
+
return {
|
|
2819
|
+
...base,
|
|
2820
|
+
start: view.followUpStart,
|
|
2821
|
+
total: view.followUpTotal,
|
|
2822
|
+
items: Array.isArray(queue.items) ? queue.items : [],
|
|
2823
|
+
};
|
|
2824
|
+
}
|
|
2825
|
+
return {
|
|
2826
|
+
...base,
|
|
2827
|
+
start: view.updateStart,
|
|
2828
|
+
total: view.updateTotal,
|
|
2829
|
+
items: view.updates,
|
|
2830
|
+
};
|
|
2831
|
+
}
|
|
2832
|
+
function clientResponse(requestId, outcome, receipt, error, revision) {
|
|
2833
|
+
return {
|
|
2834
|
+
schema: CLIENT_PROTOCOL_SCHEMA,
|
|
2835
|
+
type: "response",
|
|
2836
|
+
requestId,
|
|
2837
|
+
outcome,
|
|
2838
|
+
...(revision === undefined ? {} : { revision }),
|
|
2839
|
+
...(receipt === undefined ? {} : { receipt }),
|
|
2840
|
+
...(error === undefined ? {} : { error: boundedClientError(error) }),
|
|
2841
|
+
};
|
|
2842
|
+
}
|
|
2843
|
+
function boundedClientError(error) {
|
|
2844
|
+
const singleLine = error.replaceAll(/[\r\n\t]+/gu, " ").trim();
|
|
2845
|
+
if (singleLine.length === 0)
|
|
2846
|
+
return "Workflow request failed";
|
|
2847
|
+
return singleLine.length <= 1_000 ? singleLine : `${singleLine.slice(0, 997)}...`;
|
|
2848
|
+
}
|
|
2849
|
+
function parseActivityReport(payload) {
|
|
2850
|
+
const value = requireRecord(payload, "activity.report payload");
|
|
2851
|
+
const state = requireString(value.state, "state");
|
|
2852
|
+
if (state !== "started" && state !== "refresh" && state !== "settled") {
|
|
2853
|
+
throw new Error("activity state must be started, refresh, or settled");
|
|
2854
|
+
}
|
|
2855
|
+
return {
|
|
2856
|
+
sessionId: requireString(value.sessionId, "sessionId"),
|
|
2857
|
+
runId: requireString(value.runId, "runId"),
|
|
2858
|
+
requestId: requireString(value.requestId, "requestId"),
|
|
2859
|
+
deliveryId: requireString(value.deliveryId, "deliveryId"),
|
|
2860
|
+
sessionEntryId: requireString(value.sessionEntryId, "sessionEntryId"),
|
|
2861
|
+
sequence: requireNonNegativeInteger(value.sequence, "sequence"),
|
|
2862
|
+
state,
|
|
2863
|
+
};
|
|
2864
|
+
}
|
|
2865
|
+
function toJsonValue(value) {
|
|
2866
|
+
return JSON.parse(canonicalJson(value));
|
|
2867
|
+
}
|
|
2433
2868
|
function payloadLimit(payload) {
|
|
2434
2869
|
if (typeof payload !== "object" || payload === null || Array.isArray(payload))
|
|
2435
2870
|
return 100;
|
|
@@ -2441,6 +2876,9 @@ function requireRunId(request) {
|
|
|
2441
2876
|
throw new Error(`${request.operation} requires runId`);
|
|
2442
2877
|
return request.runId;
|
|
2443
2878
|
}
|
|
2879
|
+
function isObjectRecord(value) {
|
|
2880
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2881
|
+
}
|
|
2444
2882
|
function requireRecord(value, name) {
|
|
2445
2883
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
2446
2884
|
throw new Error(`${name} must be an object`);
|
|
@@ -2459,6 +2897,17 @@ function requireAbsolutePath(value, name) {
|
|
|
2459
2897
|
throw new Error(`${name} must be an absolute path`);
|
|
2460
2898
|
return parsed;
|
|
2461
2899
|
}
|
|
2900
|
+
function requireBoolean(value, name) {
|
|
2901
|
+
if (typeof value !== "boolean")
|
|
2902
|
+
throw new Error(`${name} must be a boolean`);
|
|
2903
|
+
return value;
|
|
2904
|
+
}
|
|
2905
|
+
function requirePositiveInteger(value, name) {
|
|
2906
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
2907
|
+
throw new Error(`${name} must be a positive integer`);
|
|
2908
|
+
}
|
|
2909
|
+
return value;
|
|
2910
|
+
}
|
|
2462
2911
|
function requireNonNegativeInteger(value, name) {
|
|
2463
2912
|
if (!Number.isSafeInteger(value) || value < 0) {
|
|
2464
2913
|
throw new Error(`${name} must be a non-negative integer`);
|
|
@@ -2470,6 +2919,13 @@ function isRevisionRow(value) {
|
|
|
2470
2919
|
value !== null &&
|
|
2471
2920
|
typeof value.revision === "number");
|
|
2472
2921
|
}
|
|
2922
|
+
function runtimePackageVersion() {
|
|
2923
|
+
const parsed = JSON.parse(fs.readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
|
|
2924
|
+
if (typeof parsed.version !== "string" || parsed.version.length === 0) {
|
|
2925
|
+
throw new Error("Package version is missing");
|
|
2926
|
+
}
|
|
2927
|
+
return parsed.version;
|
|
2928
|
+
}
|
|
2473
2929
|
function isLockRecord(value) {
|
|
2474
2930
|
return (typeof value === "object" &&
|
|
2475
2931
|
value !== null &&
|