@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/runner.ts
CHANGED
|
@@ -1,8 +1,20 @@
|
|
|
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, { type Socket } from "node:net";
|
|
5
4
|
import path from "node:path";
|
|
5
|
+
import { WorkflowClient } from "../client/client.js";
|
|
6
|
+
import {
|
|
7
|
+
CLIENT_PROTOCOL_SCHEMA,
|
|
8
|
+
encodeProtocolLine,
|
|
9
|
+
clientSocketPath,
|
|
10
|
+
NdjsonFrameDecoder,
|
|
11
|
+
parseClientRequest,
|
|
12
|
+
type ClientEvent,
|
|
13
|
+
type ClientOutcome,
|
|
14
|
+
type ClientRequest,
|
|
15
|
+
type ClientResponse,
|
|
16
|
+
} from "../client/protocol.js";
|
|
17
|
+
import type { WorkflowRunView } from "../client/view.js";
|
|
6
18
|
import { applyStatusPatch } from "../controllers/conditions.js";
|
|
7
19
|
import { ResourceConflictError } from "../controllers/errors.js";
|
|
8
20
|
import {
|
|
@@ -31,6 +43,7 @@ import {
|
|
|
31
43
|
} from "../controllers/workflows.js";
|
|
32
44
|
import { StateDatabase, workflowStatePath } from "../state/database.js";
|
|
33
45
|
import { canonicalJson, type JsonValue } from "../state/json.js";
|
|
46
|
+
import { pruneState } from "../state/prune.js";
|
|
34
47
|
import { recordViewerDeltas } from "../state/viewer.js";
|
|
35
48
|
import { errorMessage } from "../workflows/errors.js";
|
|
36
49
|
import { HumanDecisionStore } from "../workflows/human-decision.js";
|
|
@@ -49,7 +62,6 @@ import type {
|
|
|
49
62
|
WorkflowUpdateInput,
|
|
50
63
|
} from "../workflows/types.js";
|
|
51
64
|
import { validateWorkflowUpdate } from "../workflows/updates.js";
|
|
52
|
-
import { WorkflowHostClient } from "./client.js";
|
|
53
65
|
import type {
|
|
54
66
|
ControllerWorkerLaunchEnvelope,
|
|
55
67
|
ControllerWorkerMessage,
|
|
@@ -62,14 +74,6 @@ import {
|
|
|
62
74
|
processParentPid,
|
|
63
75
|
processStartIdentity,
|
|
64
76
|
} from "./processes.js";
|
|
65
|
-
import {
|
|
66
|
-
encodeProtocolLine,
|
|
67
|
-
hostSocketPath,
|
|
68
|
-
NdjsonFrameDecoder,
|
|
69
|
-
parseHostRequest,
|
|
70
|
-
type HostRequest,
|
|
71
|
-
type HostResponse,
|
|
72
|
-
} from "./protocol.js";
|
|
73
77
|
import {
|
|
74
78
|
HostStateStore,
|
|
75
79
|
type HostClaim,
|
|
@@ -77,11 +81,18 @@ import {
|
|
|
77
81
|
type InteractiveSubmissionRecord,
|
|
78
82
|
type WorkerLaunchEnvelope,
|
|
79
83
|
} from "./state.js";
|
|
84
|
+
import {
|
|
85
|
+
HostViewStore,
|
|
86
|
+
WORKFLOW_PAGE_KINDS,
|
|
87
|
+
type OriginActivityReport,
|
|
88
|
+
type WorkflowPageKind,
|
|
89
|
+
} from "./view.js";
|
|
80
90
|
import type { WorkerMessage, WorkerResponse } from "./worker-protocol.js";
|
|
81
91
|
import { WorkflowWorkerSupervisor } from "./worker-supervisor.js";
|
|
82
92
|
|
|
83
93
|
const HOST_LEASE_MS = 30_000;
|
|
84
94
|
const HOST_RENEW_MS = 10_000;
|
|
95
|
+
const PACKAGE_VERSION = runtimePackageVersion();
|
|
85
96
|
const CLAIM_POLL_MS = 2_000;
|
|
86
97
|
const RUN_CLAIM_LEASE_MS = 30_000;
|
|
87
98
|
const PRESENTATION_CLAIM_LEASE_MS = 10_000;
|
|
@@ -130,6 +141,22 @@ type DeliveryClaim = {
|
|
|
130
141
|
expiresAt: number;
|
|
131
142
|
};
|
|
132
143
|
|
|
144
|
+
type ClientSubscription = {
|
|
145
|
+
id: string;
|
|
146
|
+
kind: "runs" | "run" | "session";
|
|
147
|
+
target?: string;
|
|
148
|
+
revision: number;
|
|
149
|
+
limit?: number;
|
|
150
|
+
digest?: string;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
type ClientConnection = {
|
|
154
|
+
id: string;
|
|
155
|
+
socket: Socket;
|
|
156
|
+
subscriptions: Map<string, ClientSubscription>;
|
|
157
|
+
publishing: boolean;
|
|
158
|
+
};
|
|
159
|
+
|
|
133
160
|
type ActiveController = {
|
|
134
161
|
key: string;
|
|
135
162
|
projectPath: string;
|
|
@@ -156,22 +183,26 @@ export class WorkflowHost {
|
|
|
156
183
|
private readonly queue: SqliteControllerStore;
|
|
157
184
|
private readonly decisions: HumanDecisionStore;
|
|
158
185
|
private readonly runStore: WorkflowRunStore;
|
|
186
|
+
private readonly views: HostViewStore;
|
|
159
187
|
private readonly registry: HostProcessRegistry;
|
|
160
188
|
private readonly activeRuns = new Map<string, ActiveRun>();
|
|
161
189
|
private readonly workerDescendants = new Map<string, Set<number>>();
|
|
162
190
|
private readonly activeControllers = new Map<string, ActiveController>();
|
|
163
191
|
private readonly controlClaims = new Map<string, string>();
|
|
164
192
|
private readonly activationTasks = new Map<string, Promise<void>>();
|
|
193
|
+
private readonly maintenanceCommands = new Map<string, Promise<ClientResponse>>();
|
|
165
194
|
private readonly pendingStarts = new Set<string>();
|
|
166
195
|
private readonly pendingRunClaims = new Map<string, string>();
|
|
167
196
|
private readonly pendingResumes = new Set<string>();
|
|
168
197
|
private readonly blockedRuns = new Set<string>();
|
|
169
198
|
private readonly deliveryClaims = new Map<string, DeliveryClaim>();
|
|
170
199
|
private readonly sockets = new Set<Socket>();
|
|
200
|
+
private readonly connections = new Map<Socket, ClientConnection>();
|
|
171
201
|
private server: net.Server | null = null;
|
|
172
202
|
private claim: HostClaim | null = null;
|
|
173
203
|
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
174
204
|
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
205
|
+
private viewTimer: ReturnType<typeof setInterval> | null = null;
|
|
175
206
|
private stopping = false;
|
|
176
207
|
private started = false;
|
|
177
208
|
private controllerPollActive = false;
|
|
@@ -182,7 +213,7 @@ export class WorkflowHost {
|
|
|
182
213
|
this.hostId = options.runnerId ?? `host-${randomUUID()}`;
|
|
183
214
|
this.databasePath = path.resolve(options.databasePath ?? workflowStatePath());
|
|
184
215
|
this.stateDirectory = path.join(path.dirname(this.databasePath), "host");
|
|
185
|
-
this.socketPath =
|
|
216
|
+
this.socketPath = clientSocketPath(this.databasePath);
|
|
186
217
|
this.lockPath = path.join(this.stateDirectory, "host.lock.json");
|
|
187
218
|
this.state = new StateDatabase({ filePath: this.databasePath });
|
|
188
219
|
this.hostState = new HostStateStore(this.databasePath, { state: this.state });
|
|
@@ -202,6 +233,9 @@ export class WorkflowHost {
|
|
|
202
233
|
},
|
|
203
234
|
snapshotLifecycle: (context) => this.applyLifecycleProjection(context),
|
|
204
235
|
});
|
|
236
|
+
this.views = new HostViewStore(this.state, this.queue, this.hostState, this.runStore, (runId) =>
|
|
237
|
+
this.activeRuns.has(runId),
|
|
238
|
+
);
|
|
205
239
|
this.registry = options.registry ?? new HostProcessRegistry(this.stateDirectory);
|
|
206
240
|
}
|
|
207
241
|
|
|
@@ -264,8 +298,10 @@ export class WorkflowHost {
|
|
|
264
298
|
this.stopping = true;
|
|
265
299
|
if (this.heartbeatTimer !== null) clearInterval(this.heartbeatTimer);
|
|
266
300
|
if (this.pollTimer !== null) clearInterval(this.pollTimer);
|
|
301
|
+
if (this.viewTimer !== null) clearInterval(this.viewTimer);
|
|
267
302
|
this.heartbeatTimer = null;
|
|
268
303
|
this.pollTimer = null;
|
|
304
|
+
this.viewTimer = null;
|
|
269
305
|
const server = this.server;
|
|
270
306
|
this.server = null;
|
|
271
307
|
await this.closeServer(server);
|
|
@@ -295,6 +331,7 @@ export class WorkflowHost {
|
|
|
295
331
|
}),
|
|
296
332
|
);
|
|
297
333
|
await Promise.allSettled(this.activationTasks.values());
|
|
334
|
+
await Promise.allSettled(this.maintenanceCommands.values());
|
|
298
335
|
this.registry.killAll();
|
|
299
336
|
this.deliveryClaims.clear();
|
|
300
337
|
if (this.claim !== null) this.hostState.releaseHost(this.claim);
|
|
@@ -378,6 +415,11 @@ export class WorkflowHost {
|
|
|
378
415
|
void this.claimControllerOne();
|
|
379
416
|
}, this.options.claimPollMs ?? CLAIM_POLL_MS);
|
|
380
417
|
this.pollTimer.unref?.();
|
|
418
|
+
this.viewTimer = setInterval(() => {
|
|
419
|
+
this.views.expireActivity();
|
|
420
|
+
this.publishViews();
|
|
421
|
+
}, 250);
|
|
422
|
+
this.viewTimer.unref?.();
|
|
381
423
|
}
|
|
382
424
|
|
|
383
425
|
private async listen(): Promise<void> {
|
|
@@ -412,8 +454,22 @@ export class WorkflowHost {
|
|
|
412
454
|
|
|
413
455
|
private handleConnection(socket: Socket): void {
|
|
414
456
|
this.sockets.add(socket);
|
|
457
|
+
const connection: ClientConnection = {
|
|
458
|
+
id: `connection-${randomUUID()}`,
|
|
459
|
+
socket,
|
|
460
|
+
subscriptions: new Map(),
|
|
461
|
+
publishing: false,
|
|
462
|
+
};
|
|
463
|
+
this.connections.set(socket, connection);
|
|
464
|
+
socket.write(
|
|
465
|
+
encodeProtocolLine({
|
|
466
|
+
schema: CLIENT_PROTOCOL_SCHEMA,
|
|
467
|
+
type: "hello",
|
|
468
|
+
connectionId: connection.id,
|
|
469
|
+
packageVersion: PACKAGE_VERSION,
|
|
470
|
+
}),
|
|
471
|
+
);
|
|
415
472
|
const decoder = new NdjsonFrameDecoder();
|
|
416
|
-
let processing = Promise.resolve();
|
|
417
473
|
socket.on("data", (chunk: Buffer) => {
|
|
418
474
|
let frames: Buffer[];
|
|
419
475
|
try {
|
|
@@ -423,15 +479,14 @@ export class WorkflowHost {
|
|
|
423
479
|
return;
|
|
424
480
|
}
|
|
425
481
|
for (const frame of frames) {
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
.
|
|
433
|
-
|
|
434
|
-
});
|
|
482
|
+
void (async () => {
|
|
483
|
+
const request = parseClientRequest(frame);
|
|
484
|
+
const response = await this.handleClientRequest(connection, request);
|
|
485
|
+
if (!socket.write(encodeProtocolLine(response))) await waitForSocketDrain(socket);
|
|
486
|
+
this.publishConnection(connection);
|
|
487
|
+
})().catch(() => {
|
|
488
|
+
socket.destroy();
|
|
489
|
+
});
|
|
435
490
|
}
|
|
436
491
|
});
|
|
437
492
|
socket.on("error", (error) => {
|
|
@@ -440,27 +495,331 @@ export class WorkflowHost {
|
|
|
440
495
|
});
|
|
441
496
|
socket.on("close", () => {
|
|
442
497
|
this.sockets.delete(socket);
|
|
498
|
+
this.connections.delete(socket);
|
|
499
|
+
this.views.clearConnection(connection.id);
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
private async handleClientRequest(
|
|
504
|
+
connection: ClientConnection,
|
|
505
|
+
request: ClientRequest,
|
|
506
|
+
): Promise<ClientResponse> {
|
|
507
|
+
try {
|
|
508
|
+
switch (request.operation) {
|
|
509
|
+
case "view.runs.watch":
|
|
510
|
+
this.addSubscription(connection, request, "runs");
|
|
511
|
+
return clientResponse(request.requestId, "accepted", { subscribed: true });
|
|
512
|
+
case "view.runs.page": {
|
|
513
|
+
const payload = requireRecord(request.payload, "view.runs.page payload");
|
|
514
|
+
const revision = requireString(payload.revision, "revision");
|
|
515
|
+
const page = this.views.list(
|
|
516
|
+
requireNonNegativeInteger(payload.cursor, "cursor"),
|
|
517
|
+
payload.limit === undefined
|
|
518
|
+
? undefined
|
|
519
|
+
: requirePositiveInteger(payload.limit, "limit"),
|
|
520
|
+
);
|
|
521
|
+
return page.revision === revision
|
|
522
|
+
? clientResponse(request.requestId, "accepted", toJsonValue(page))
|
|
523
|
+
: clientResponse(
|
|
524
|
+
request.requestId,
|
|
525
|
+
"conflict",
|
|
526
|
+
toJsonValue(page),
|
|
527
|
+
"Workflow run list changed while paging",
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
case "view.run.get": {
|
|
531
|
+
const view = this.views.run(requireRunId(request));
|
|
532
|
+
return view === null
|
|
533
|
+
? clientResponse(request.requestId, "notFound", undefined, "Workflow run not found")
|
|
534
|
+
: clientResponse(
|
|
535
|
+
request.requestId,
|
|
536
|
+
"accepted",
|
|
537
|
+
toJsonValue(view),
|
|
538
|
+
undefined,
|
|
539
|
+
view.revision,
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
case "view.run.watch": {
|
|
543
|
+
const runId = requireRunId(request);
|
|
544
|
+
if (this.views.run(runId) === null) {
|
|
545
|
+
return clientResponse(
|
|
546
|
+
request.requestId,
|
|
547
|
+
"notFound",
|
|
548
|
+
undefined,
|
|
549
|
+
"Workflow run not found",
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
this.addSubscription(connection, request, "run", runId);
|
|
553
|
+
return clientResponse(request.requestId, "accepted", { subscribed: true });
|
|
554
|
+
}
|
|
555
|
+
case "view.session.watch": {
|
|
556
|
+
const payload = requireRecord(request.payload, "view.session.watch payload");
|
|
557
|
+
this.addSubscription(
|
|
558
|
+
connection,
|
|
559
|
+
request,
|
|
560
|
+
"session",
|
|
561
|
+
requireString(payload.sessionId, "sessionId"),
|
|
562
|
+
);
|
|
563
|
+
return clientResponse(request.requestId, "accepted", { subscribed: true });
|
|
564
|
+
}
|
|
565
|
+
case "view.run.unwatch": {
|
|
566
|
+
const payload = requireRecord(request.payload, "view.run.unwatch payload");
|
|
567
|
+
connection.subscriptions.delete(requireString(payload.subscriptionId, "subscriptionId"));
|
|
568
|
+
return clientResponse(request.requestId, "accepted", { subscribed: false });
|
|
569
|
+
}
|
|
570
|
+
case "view.page": {
|
|
571
|
+
const payload = requireRecord(request.payload, "view.page payload");
|
|
572
|
+
const kind = requireString(payload.kind, "kind");
|
|
573
|
+
if (!WORKFLOW_PAGE_KINDS.includes(kind as WorkflowPageKind)) {
|
|
574
|
+
throw new Error("view.page kind is invalid");
|
|
575
|
+
}
|
|
576
|
+
const view = this.views.page(requireRunId(request), {
|
|
577
|
+
kind: kind as WorkflowPageKind,
|
|
578
|
+
cursor: requireNonNegativeInteger(payload.cursor, "cursor"),
|
|
579
|
+
});
|
|
580
|
+
return view === null
|
|
581
|
+
? clientResponse(request.requestId, "notFound", undefined, "Workflow run not found")
|
|
582
|
+
: clientResponse(
|
|
583
|
+
request.requestId,
|
|
584
|
+
"accepted",
|
|
585
|
+
runPageReceipt(
|
|
586
|
+
view,
|
|
587
|
+
kind as WorkflowPageKind,
|
|
588
|
+
requireNonNegativeInteger(payload.cursor, "cursor"),
|
|
589
|
+
),
|
|
590
|
+
undefined,
|
|
591
|
+
view.revision,
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
case "view.content": {
|
|
595
|
+
const payload = requireRecord(request.payload, "view.content payload");
|
|
596
|
+
const content = this.views.content(
|
|
597
|
+
requireRunId(request),
|
|
598
|
+
requireString(payload.path, "path"),
|
|
599
|
+
requireNonNegativeInteger(payload.offset, "offset"),
|
|
600
|
+
);
|
|
601
|
+
return content === null
|
|
602
|
+
? clientResponse(request.requestId, "notFound", undefined, "Workflow content not found")
|
|
603
|
+
: clientResponse(request.requestId, "accepted", content);
|
|
604
|
+
}
|
|
605
|
+
case "activity.report": {
|
|
606
|
+
this.views.reportActivity(connection.id, parseActivityReport(request.payload));
|
|
607
|
+
return clientResponse(request.requestId, "accepted", { recorded: true });
|
|
608
|
+
}
|
|
609
|
+
case "interaction.submit":
|
|
610
|
+
return await this.submitInteractionAndWait(request);
|
|
611
|
+
case "state.status":
|
|
612
|
+
return clientResponse(request.requestId, "accepted", this.stateStatusReceipt());
|
|
613
|
+
case "state.verify":
|
|
614
|
+
this.state.integrityCheck();
|
|
615
|
+
return clientResponse(request.requestId, "accepted", { valid: true });
|
|
616
|
+
case "state.backup":
|
|
617
|
+
return await this.executeMaintenanceCommand(request, async () => {
|
|
618
|
+
const payload = requireRecord(request.payload, "state.backup payload");
|
|
619
|
+
const destination = requireAbsolutePath(payload.destination, "destination");
|
|
620
|
+
await this.state.backup(destination);
|
|
621
|
+
return { destination };
|
|
622
|
+
});
|
|
623
|
+
case "state.prune":
|
|
624
|
+
return await this.executeMaintenanceCommand(request, async () => {
|
|
625
|
+
const payload = requireRecord(request.payload, "state.prune payload");
|
|
626
|
+
const before = requireString(payload.before, "before");
|
|
627
|
+
const apply = requireBoolean(payload.apply, "apply");
|
|
628
|
+
const backupPath =
|
|
629
|
+
payload.backupPath === undefined
|
|
630
|
+
? undefined
|
|
631
|
+
: requireAbsolutePath(payload.backupPath, "backupPath");
|
|
632
|
+
const report = await pruneState(this.state, this.databasePath, {
|
|
633
|
+
before,
|
|
634
|
+
apply,
|
|
635
|
+
...(backupPath === undefined ? {} : { backupPath }),
|
|
636
|
+
});
|
|
637
|
+
return toJsonValue(report);
|
|
638
|
+
});
|
|
639
|
+
default:
|
|
640
|
+
return this.handleRequest(request);
|
|
641
|
+
}
|
|
642
|
+
} catch (error) {
|
|
643
|
+
return clientResponse(request.requestId, "rejected", undefined, errorMessage(error));
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
private async executeMaintenanceCommand(
|
|
648
|
+
request: ClientRequest,
|
|
649
|
+
operation: () => Promise<JsonValue>,
|
|
650
|
+
): Promise<ClientResponse> {
|
|
651
|
+
const adopted = this.hostState.adoptCommand(request);
|
|
652
|
+
if (adopted !== undefined) return adopted;
|
|
653
|
+
const claim = this.claim;
|
|
654
|
+
if (claim === null || this.stopping) {
|
|
655
|
+
return clientResponse(
|
|
656
|
+
request.requestId,
|
|
657
|
+
"unavailable",
|
|
658
|
+
undefined,
|
|
659
|
+
"Workflow host is stopping",
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
const key = canonicalJson([request.clientId, request.idempotencyKey]);
|
|
663
|
+
const active = this.maintenanceCommands.get(key);
|
|
664
|
+
if (active !== undefined) {
|
|
665
|
+
await active;
|
|
666
|
+
return (
|
|
667
|
+
this.hostState.adoptCommand(request) ??
|
|
668
|
+
clientResponse(
|
|
669
|
+
request.requestId,
|
|
670
|
+
"unavailable",
|
|
671
|
+
undefined,
|
|
672
|
+
"Workflow maintenance command did not complete durably",
|
|
673
|
+
)
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const execution = (async (): Promise<ClientResponse> => {
|
|
678
|
+
let result: Omit<ClientResponse, "schema" | "type" | "requestId">;
|
|
679
|
+
try {
|
|
680
|
+
result = { outcome: "accepted", receipt: await operation() };
|
|
681
|
+
} catch (error) {
|
|
682
|
+
result = { outcome: "rejected", error: errorMessage(error) };
|
|
683
|
+
}
|
|
684
|
+
return this.hostState.executeCommand(request, claim.epoch, () => result);
|
|
685
|
+
})();
|
|
686
|
+
this.maintenanceCommands.set(key, execution);
|
|
687
|
+
try {
|
|
688
|
+
return await execution;
|
|
689
|
+
} finally {
|
|
690
|
+
if (this.maintenanceCommands.get(key) === execution) {
|
|
691
|
+
this.maintenanceCommands.delete(key);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
private addSubscription(
|
|
697
|
+
connection: ClientConnection,
|
|
698
|
+
request: ClientRequest,
|
|
699
|
+
kind: ClientSubscription["kind"],
|
|
700
|
+
target?: string,
|
|
701
|
+
): void {
|
|
702
|
+
const payload = requireRecord(request.payload, `${request.operation} payload`);
|
|
703
|
+
const id = requireString(payload.subscriptionId, "subscriptionId");
|
|
704
|
+
const limit =
|
|
705
|
+
kind === "runs" && payload.limit !== undefined
|
|
706
|
+
? requirePositiveInteger(payload.limit, "limit")
|
|
707
|
+
: undefined;
|
|
708
|
+
connection.subscriptions.set(id, {
|
|
709
|
+
id,
|
|
710
|
+
kind,
|
|
711
|
+
...(target === undefined ? {} : { target }),
|
|
712
|
+
...(limit === undefined ? {} : { limit }),
|
|
713
|
+
revision: 0,
|
|
443
714
|
});
|
|
444
715
|
}
|
|
445
716
|
|
|
446
|
-
private
|
|
717
|
+
private publishViews(): void {
|
|
718
|
+
for (const connection of this.connections.values()) void this.publishConnection(connection);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
private async publishConnection(connection: ClientConnection): Promise<void> {
|
|
722
|
+
if (connection.publishing || connection.socket.destroyed) return;
|
|
723
|
+
connection.publishing = true;
|
|
724
|
+
try {
|
|
725
|
+
for (const subscription of connection.subscriptions.values()) {
|
|
726
|
+
const payload =
|
|
727
|
+
subscription.kind === "runs"
|
|
728
|
+
? toJsonValue(this.views.list(0, subscription.limit))
|
|
729
|
+
: subscription.kind === "run"
|
|
730
|
+
? toJsonValue(this.views.run(subscription.target ?? ""))
|
|
731
|
+
: toJsonValue(this.views.session(subscription.target ?? ""));
|
|
732
|
+
const digest = createHash("sha256").update(canonicalJson(payload)).digest("hex");
|
|
733
|
+
if (subscription.digest === digest) continue;
|
|
734
|
+
subscription.digest = digest;
|
|
735
|
+
subscription.revision += 1;
|
|
736
|
+
const event: ClientEvent = {
|
|
737
|
+
schema: CLIENT_PROTOCOL_SCHEMA,
|
|
738
|
+
type: "event",
|
|
739
|
+
subscriptionId: subscription.id,
|
|
740
|
+
event:
|
|
741
|
+
subscription.kind === "runs"
|
|
742
|
+
? "runs"
|
|
743
|
+
: subscription.kind === "run"
|
|
744
|
+
? "run_snapshot"
|
|
745
|
+
: "session_snapshot",
|
|
746
|
+
revision: subscription.revision,
|
|
747
|
+
...(subscription.kind === "run" && subscription.target !== undefined
|
|
748
|
+
? { runId: subscription.target }
|
|
749
|
+
: {}),
|
|
750
|
+
payload,
|
|
751
|
+
};
|
|
752
|
+
if (!connection.socket.write(encodeProtocolLine(event))) {
|
|
753
|
+
await waitForSocketDrain(connection.socket);
|
|
754
|
+
if (connection.socket.destroyed) return;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
} catch (error) {
|
|
758
|
+
this.log(`client view error: ${errorMessage(error)}`);
|
|
759
|
+
connection.socket.destroy();
|
|
760
|
+
} finally {
|
|
761
|
+
connection.publishing = false;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
private async submitInteractionAndWait(request: ClientRequest): Promise<ClientResponse> {
|
|
766
|
+
const started = this.submitInteraction(request);
|
|
767
|
+
if (started.outcome !== "accepted" && started.outcome !== "adopted") {
|
|
768
|
+
return clientResponse(request.requestId, started.outcome, started.receipt, started.error);
|
|
769
|
+
}
|
|
770
|
+
const payload = requireRecord(request.payload, "interaction payload");
|
|
771
|
+
const requestId = requireString(payload.requestId, "requestId");
|
|
772
|
+
const startedReceipt = requireRecord(started.receipt, "interaction submission receipt");
|
|
773
|
+
const submissionId = requireString(startedReceipt.submissionId, "submissionId");
|
|
774
|
+
for (;;) {
|
|
775
|
+
if (this.stopping) {
|
|
776
|
+
return clientResponse(
|
|
777
|
+
request.requestId,
|
|
778
|
+
"unavailable",
|
|
779
|
+
undefined,
|
|
780
|
+
"Workflow host stopped while validating the submission",
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
const submission = this.hostState.interactionSubmission(requestId, submissionId);
|
|
784
|
+
if (submission?.outcome === "accepted" || submission?.outcome === "adopted") {
|
|
785
|
+
return clientResponse(
|
|
786
|
+
request.requestId,
|
|
787
|
+
started.outcome,
|
|
788
|
+
submission.receipt ?? { requestId, submissionId },
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
if (submission?.outcome === "rejected") {
|
|
792
|
+
const receipt = submission.receipt ?? { requestId, submissionId };
|
|
793
|
+
const detail =
|
|
794
|
+
isObjectRecord(receipt) && typeof receipt.error === "string"
|
|
795
|
+
? receipt.error
|
|
796
|
+
: "Workflow step output failed validation";
|
|
797
|
+
return clientResponse(request.requestId, "rejected", receipt, detail);
|
|
798
|
+
}
|
|
799
|
+
await hostDelay(25);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
private handleRequest(request: ClientRequest): ClientResponse {
|
|
447
804
|
if (this.claim === null || this.stopping) {
|
|
448
805
|
return {
|
|
449
|
-
schema:
|
|
806
|
+
schema: CLIENT_PROTOCOL_SCHEMA,
|
|
807
|
+
type: "response",
|
|
450
808
|
requestId: request.requestId,
|
|
451
809
|
outcome: "unavailable",
|
|
452
810
|
error: "Workflow host is stopping",
|
|
453
811
|
};
|
|
454
812
|
}
|
|
455
813
|
const afterCommit: Array<() => void> = [];
|
|
456
|
-
let response:
|
|
814
|
+
let response: ClientResponse;
|
|
457
815
|
try {
|
|
458
816
|
response = this.hostState.executeCommand(request, this.claim.epoch, () =>
|
|
459
817
|
this.executeOperation(request, afterCommit),
|
|
460
818
|
);
|
|
461
819
|
} catch (error) {
|
|
462
820
|
response = {
|
|
463
|
-
schema:
|
|
821
|
+
schema: CLIENT_PROTOCOL_SCHEMA,
|
|
822
|
+
type: "response",
|
|
464
823
|
requestId: request.requestId,
|
|
465
824
|
outcome: "rejected",
|
|
466
825
|
error: errorMessage(error),
|
|
@@ -471,9 +830,9 @@ export class WorkflowHost {
|
|
|
471
830
|
}
|
|
472
831
|
|
|
473
832
|
private executeOperation(
|
|
474
|
-
request:
|
|
833
|
+
request: ClientRequest,
|
|
475
834
|
afterCommit: Array<() => void>,
|
|
476
|
-
): Omit<
|
|
835
|
+
): Omit<ClientResponse, "schema" | "type" | "requestId"> {
|
|
477
836
|
switch (request.operation) {
|
|
478
837
|
case "host.status":
|
|
479
838
|
return { outcome: "accepted", receipt: this.statusReceipt() };
|
|
@@ -526,14 +885,37 @@ export class WorkflowHost {
|
|
|
526
885
|
case "run.pause": {
|
|
527
886
|
const runId = requireRunId(request);
|
|
528
887
|
const active = this.activeRuns.get(runId);
|
|
529
|
-
if (active
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
888
|
+
if (active !== undefined) {
|
|
889
|
+
if (active.control === "pause") {
|
|
890
|
+
return { outcome: "adopted", receipt: { runId, status: "parked", paused: true } };
|
|
891
|
+
}
|
|
892
|
+
if (active.control === "handoff") {
|
|
893
|
+
const paused = this.queue.pauseParkedWorkflowRun({ runId });
|
|
894
|
+
return paused
|
|
895
|
+
? { outcome: "accepted", receipt: { runId, status: "parked", paused: true } }
|
|
896
|
+
: { outcome: "rejected", error: "Run handoff is not pausable" };
|
|
897
|
+
}
|
|
898
|
+
if (active.control !== undefined) {
|
|
899
|
+
return { outcome: "rejected", error: `Run is already handling ${active.control}` };
|
|
900
|
+
}
|
|
901
|
+
this.commitActivePause(active);
|
|
902
|
+
active.control = "pause";
|
|
903
|
+
afterCommit.push(() => void active.supervisor.stop("cancelled"));
|
|
904
|
+
return { outcome: "accepted", receipt: { runId, status: "parked", paused: true } };
|
|
905
|
+
}
|
|
906
|
+
const paused = this.queue.pauseParkedWorkflowRun({ runId });
|
|
907
|
+
return paused
|
|
908
|
+
? { outcome: "accepted", receipt: { runId, status: "parked", paused: true } }
|
|
909
|
+
: { outcome: "rejected", error: "Run is not pausable" };
|
|
534
910
|
}
|
|
535
911
|
case "run.resume": {
|
|
536
912
|
const runId = requireRunId(request);
|
|
913
|
+
if (this.queue.resumePausedInteraction({ runId })) {
|
|
914
|
+
return {
|
|
915
|
+
outcome: "accepted",
|
|
916
|
+
receipt: { runId, status: "parked", paused: false, waitingForInteraction: true },
|
|
917
|
+
};
|
|
918
|
+
}
|
|
537
919
|
if (this.activeRuns.has(runId) || this.pendingStarts.has(runId)) {
|
|
538
920
|
return { outcome: "adopted", receipt: { runId, active: true } };
|
|
539
921
|
}
|
|
@@ -569,21 +951,49 @@ export class WorkflowHost {
|
|
|
569
951
|
return this.executeControllerOperation(request);
|
|
570
952
|
case "interaction.update": {
|
|
571
953
|
const payload = requireRecord(request.payload, "interaction update payload");
|
|
954
|
+
if (payload.validatePresentation === true) {
|
|
955
|
+
const interaction = this.hostState.getInteraction(
|
|
956
|
+
requireString(payload.requestId, "requestId"),
|
|
957
|
+
);
|
|
958
|
+
const expectedRevision = requireNonNegativeInteger(
|
|
959
|
+
request.expectedRevision,
|
|
960
|
+
"expectedRevision",
|
|
961
|
+
);
|
|
962
|
+
const expiresAt = interaction?.presentationClaimExpiresAt;
|
|
963
|
+
const live =
|
|
964
|
+
interaction !== undefined &&
|
|
965
|
+
interaction.runId === requireRunId(request) &&
|
|
966
|
+
interaction.status === "presenting" &&
|
|
967
|
+
interaction.presenterId === request.clientId &&
|
|
968
|
+
interaction.revision === expectedRevision &&
|
|
969
|
+
interaction.presentationSessionEntryId === null &&
|
|
970
|
+
typeof expiresAt === "string" &&
|
|
971
|
+
Date.parse(expiresAt) > Date.now() &&
|
|
972
|
+
!this.queue.isWorkflowRunPaused(interaction.runId);
|
|
973
|
+
return { outcome: "accepted", receipt: { live } };
|
|
974
|
+
}
|
|
572
975
|
if (payload.claimPresentation === true) {
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
976
|
+
try {
|
|
977
|
+
const interaction = this.hostState.claimInteractionPresentation({
|
|
978
|
+
requestId: requireString(payload.requestId, "requestId"),
|
|
979
|
+
expectedRevision: requireNonNegativeInteger(
|
|
980
|
+
request.expectedRevision,
|
|
981
|
+
"expectedRevision",
|
|
982
|
+
),
|
|
983
|
+
presenterId: request.clientId,
|
|
984
|
+
leaseMs: PRESENTATION_CLAIM_LEASE_MS,
|
|
985
|
+
});
|
|
986
|
+
return {
|
|
987
|
+
outcome: "accepted",
|
|
988
|
+
revision: interaction.revision,
|
|
989
|
+
receipt: interaction as unknown as JsonValue,
|
|
990
|
+
};
|
|
991
|
+
} catch (error) {
|
|
992
|
+
if (errorMessage(error) === "Interactive request presentation claim conflict") {
|
|
993
|
+
return { outcome: "conflict", error: errorMessage(error) };
|
|
994
|
+
}
|
|
995
|
+
throw error;
|
|
996
|
+
}
|
|
587
997
|
}
|
|
588
998
|
if (typeof payload.sessionEntryId === "string") {
|
|
589
999
|
const interaction = this.hostState.markInteractionPresented({
|
|
@@ -606,12 +1016,26 @@ export class WorkflowHost {
|
|
|
606
1016
|
return this.submitInteraction(request);
|
|
607
1017
|
case "decision.answer":
|
|
608
1018
|
return this.answerDecision(request, afterCommit);
|
|
1019
|
+
case "view.runs.watch":
|
|
1020
|
+
case "view.runs.page":
|
|
1021
|
+
case "view.run.get":
|
|
1022
|
+
case "view.run.watch":
|
|
1023
|
+
case "view.run.unwatch":
|
|
1024
|
+
case "view.page":
|
|
1025
|
+
case "view.content":
|
|
1026
|
+
case "view.session.watch":
|
|
1027
|
+
case "activity.report":
|
|
1028
|
+
case "state.status":
|
|
1029
|
+
case "state.verify":
|
|
1030
|
+
case "state.backup":
|
|
1031
|
+
case "state.prune":
|
|
1032
|
+
throw new Error(`${request.operation} must use the live client connection`);
|
|
609
1033
|
}
|
|
610
1034
|
}
|
|
611
1035
|
|
|
612
1036
|
private executeControllerOperation(
|
|
613
|
-
request:
|
|
614
|
-
): Omit<
|
|
1037
|
+
request: ClientRequest,
|
|
1038
|
+
): Omit<ClientResponse, "schema" | "type" | "requestId"> {
|
|
615
1039
|
const payload = requireRecord(request.payload, "controller payload");
|
|
616
1040
|
const projectPath = path.resolve(requireString(payload.projectPath, "projectPath"));
|
|
617
1041
|
if (payload.projectPath !== projectPath) {
|
|
@@ -722,17 +1146,51 @@ export class WorkflowHost {
|
|
|
722
1146
|
};
|
|
723
1147
|
}
|
|
724
1148
|
|
|
725
|
-
private
|
|
1149
|
+
private stateStatusReceipt(): JsonValue {
|
|
1150
|
+
const count = (sql: string, ...params: unknown[]): number => {
|
|
1151
|
+
const row = this.state.connection.prepare(sql).get(...params) as
|
|
1152
|
+
| { count?: unknown }
|
|
1153
|
+
| undefined;
|
|
1154
|
+
return typeof row?.count === "number" ? row.count : 0;
|
|
1155
|
+
};
|
|
1156
|
+
const now = Date.now();
|
|
1157
|
+
return {
|
|
1158
|
+
sizeBytes: fs.statSync(this.databasePath).size,
|
|
1159
|
+
counts: {
|
|
1160
|
+
resources: count("SELECT COUNT(*) AS count FROM resources"),
|
|
1161
|
+
runs: count("SELECT COUNT(*) AS count FROM runs"),
|
|
1162
|
+
controllers: count("SELECT COUNT(*) AS count FROM controller_resources"),
|
|
1163
|
+
decisions: count("SELECT COUNT(*) AS count FROM human_decisions"),
|
|
1164
|
+
settingsScopes: count("SELECT COUNT(*) AS count FROM workflow_settings"),
|
|
1165
|
+
pendingInteractions: count(
|
|
1166
|
+
"SELECT COUNT(*) AS count FROM interactive_requests WHERE status IN ('pending', 'presenting')",
|
|
1167
|
+
),
|
|
1168
|
+
pendingFollowUps: count(
|
|
1169
|
+
"SELECT COUNT(*) AS count FROM workflow_follow_ups WHERE status IN ('queued', 'pending_presentation', 'ready')",
|
|
1170
|
+
),
|
|
1171
|
+
activeLeases: count(
|
|
1172
|
+
"SELECT COUNT(*) AS count FROM leases WHERE owner_id IS NOT NULL AND expires_at > ?",
|
|
1173
|
+
now,
|
|
1174
|
+
),
|
|
1175
|
+
unsettledEffects: count(
|
|
1176
|
+
"SELECT COUNT(*) AS count FROM effects WHERE status IN ('pending', 'applying', 'ambiguous')",
|
|
1177
|
+
),
|
|
1178
|
+
},
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
private rememberDeliveryClaim(options: Omit<DeliveryClaim, "expiresAt">): {
|
|
1183
|
+
claimId: string;
|
|
1184
|
+
claimExpiresAt: string;
|
|
1185
|
+
} {
|
|
726
1186
|
const now = Date.now();
|
|
727
1187
|
for (const [claimId, claim] of this.deliveryClaims) {
|
|
728
1188
|
if (claim.expiresAt <= now) this.deliveryClaims.delete(claimId);
|
|
729
1189
|
}
|
|
730
1190
|
const claimId = randomUUID();
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
});
|
|
735
|
-
return claimId;
|
|
1191
|
+
const expiresAt = now + DELIVERY_CLAIM_LEASE_MS;
|
|
1192
|
+
this.deliveryClaims.set(claimId, { ...options, expiresAt });
|
|
1193
|
+
return { claimId, claimExpiresAt: new Date(expiresAt).toISOString() };
|
|
736
1194
|
}
|
|
737
1195
|
|
|
738
1196
|
private deliveryClaim(
|
|
@@ -757,8 +1215,41 @@ export class WorkflowHost {
|
|
|
757
1215
|
return claim;
|
|
758
1216
|
}
|
|
759
1217
|
|
|
760
|
-
private
|
|
1218
|
+
private validateDelivery(
|
|
1219
|
+
command: ClientRequest,
|
|
1220
|
+
payload: Record<string, unknown>,
|
|
1221
|
+
kind: DeliveryClaim["kind"],
|
|
1222
|
+
): Omit<ClientResponse, "schema" | "type" | "requestId"> {
|
|
1223
|
+
const resourceId = requireString(payload.resourceId, "resourceId");
|
|
1224
|
+
const targetSessionId = requireString(payload.targetSessionId, "targetSessionId");
|
|
1225
|
+
const claimId = requireString(payload.claimId, "claimId");
|
|
1226
|
+
const claim = this.deliveryClaim(claimId, command.clientId, kind, resourceId, targetSessionId);
|
|
1227
|
+
if (claim === undefined) {
|
|
1228
|
+
return { outcome: "accepted", receipt: { claimId, live: false } };
|
|
1229
|
+
}
|
|
1230
|
+
const live =
|
|
1231
|
+
kind === "notification"
|
|
1232
|
+
? this.queue.isWorkflowNotificationClaimLive({
|
|
1233
|
+
notificationId: resourceId,
|
|
1234
|
+
targetSessionId,
|
|
1235
|
+
claimToken: claim.token,
|
|
1236
|
+
})
|
|
1237
|
+
: this.queue.isWorkflowTurnIntentClaimLive({
|
|
1238
|
+
intentId: resourceId,
|
|
1239
|
+
targetSessionId,
|
|
1240
|
+
claimToken: claim.token,
|
|
1241
|
+
});
|
|
1242
|
+
if (!live) this.deliveryClaims.delete(claimId);
|
|
1243
|
+
return { outcome: "accepted", receipt: { claimId, live } };
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
private claimNotification(
|
|
1247
|
+
command: ClientRequest,
|
|
1248
|
+
): Omit<ClientResponse, "schema" | "type" | "requestId"> {
|
|
761
1249
|
const payload = requireRecord(command.payload, "notification claim payload");
|
|
1250
|
+
if (payload.validateClaim === true) {
|
|
1251
|
+
return this.validateDelivery(command, payload, "notification");
|
|
1252
|
+
}
|
|
762
1253
|
const targetSessionId = requireString(payload.targetSessionId, "targetSessionId");
|
|
763
1254
|
const token = randomUUID();
|
|
764
1255
|
const notification = this.queue.claimPendingWorkflowNotifications({
|
|
@@ -770,7 +1261,7 @@ export class WorkflowHost {
|
|
|
770
1261
|
if (notification === undefined) {
|
|
771
1262
|
return { outcome: "accepted", receipt: { notification: null } };
|
|
772
1263
|
}
|
|
773
|
-
const
|
|
1264
|
+
const claim = this.rememberDeliveryClaim({
|
|
774
1265
|
clientId: command.clientId,
|
|
775
1266
|
token,
|
|
776
1267
|
targetSessionId,
|
|
@@ -779,11 +1270,13 @@ export class WorkflowHost {
|
|
|
779
1270
|
});
|
|
780
1271
|
return {
|
|
781
1272
|
outcome: "accepted",
|
|
782
|
-
receipt: {
|
|
1273
|
+
receipt: { ...claim, notification } as unknown as JsonValue,
|
|
783
1274
|
};
|
|
784
1275
|
}
|
|
785
1276
|
|
|
786
|
-
private deliverNotification(
|
|
1277
|
+
private deliverNotification(
|
|
1278
|
+
command: ClientRequest,
|
|
1279
|
+
): Omit<ClientResponse, "schema" | "type" | "requestId"> {
|
|
787
1280
|
const payload = requireRecord(command.payload, "notification delivery payload");
|
|
788
1281
|
const notificationId = requireString(payload.notificationId, "notificationId");
|
|
789
1282
|
const targetSessionId = requireString(payload.targetSessionId, "targetSessionId");
|
|
@@ -809,8 +1302,11 @@ export class WorkflowHost {
|
|
|
809
1302
|
: { outcome: "conflict", error: "Notification delivery claim is stale" };
|
|
810
1303
|
}
|
|
811
1304
|
|
|
812
|
-
private claimTurn(command:
|
|
1305
|
+
private claimTurn(command: ClientRequest): Omit<ClientResponse, "schema" | "type" | "requestId"> {
|
|
813
1306
|
const payload = requireRecord(command.payload, "turn claim payload");
|
|
1307
|
+
if (payload.validateClaim === true) {
|
|
1308
|
+
return this.validateDelivery(command, payload, "turn");
|
|
1309
|
+
}
|
|
814
1310
|
const targetSessionId = requireString(payload.targetSessionId, "targetSessionId");
|
|
815
1311
|
const token = randomUUID();
|
|
816
1312
|
const intent = this.queue.claimEligibleWorkflowTurnIntents({
|
|
@@ -822,7 +1318,7 @@ export class WorkflowHost {
|
|
|
822
1318
|
if (intent === undefined) {
|
|
823
1319
|
return { outcome: "accepted", receipt: { turn: null } };
|
|
824
1320
|
}
|
|
825
|
-
const
|
|
1321
|
+
const claim = this.rememberDeliveryClaim({
|
|
826
1322
|
clientId: command.clientId,
|
|
827
1323
|
token,
|
|
828
1324
|
targetSessionId,
|
|
@@ -831,11 +1327,13 @@ export class WorkflowHost {
|
|
|
831
1327
|
});
|
|
832
1328
|
return {
|
|
833
1329
|
outcome: "accepted",
|
|
834
|
-
receipt: {
|
|
1330
|
+
receipt: { ...claim, turn: intent } as unknown as JsonValue,
|
|
835
1331
|
};
|
|
836
1332
|
}
|
|
837
1333
|
|
|
838
|
-
private resolveTurn(
|
|
1334
|
+
private resolveTurn(
|
|
1335
|
+
command: ClientRequest,
|
|
1336
|
+
): Omit<ClientResponse, "schema" | "type" | "requestId"> {
|
|
839
1337
|
const payload = requireRecord(command.payload, "turn resolution payload");
|
|
840
1338
|
const intentId = requireString(payload.intentId, "intentId");
|
|
841
1339
|
const targetSessionId = requireString(payload.targetSessionId, "targetSessionId");
|
|
@@ -858,9 +1356,9 @@ export class WorkflowHost {
|
|
|
858
1356
|
}
|
|
859
1357
|
|
|
860
1358
|
private answerCheckpoint(
|
|
861
|
-
command:
|
|
1359
|
+
command: ClientRequest,
|
|
862
1360
|
afterCommit: Array<() => void>,
|
|
863
|
-
): Omit<
|
|
1361
|
+
): Omit<ClientResponse, "schema" | "type" | "requestId"> {
|
|
864
1362
|
const parentRunId = requireRunId(command);
|
|
865
1363
|
const payload = requireRecord(command.payload, "checkpoint answer payload");
|
|
866
1364
|
const continuationRunId = requireString(payload.continuationRunId, "continuationRunId");
|
|
@@ -904,15 +1402,18 @@ export class WorkflowHost {
|
|
|
904
1402
|
}
|
|
905
1403
|
|
|
906
1404
|
private answerDecision(
|
|
907
|
-
command:
|
|
1405
|
+
command: ClientRequest,
|
|
908
1406
|
afterCommit: Array<() => void>,
|
|
909
|
-
): Omit<
|
|
1407
|
+
): Omit<ClientResponse, "schema" | "type" | "requestId"> {
|
|
910
1408
|
const payload = requireRecord(command.payload, "decision answer payload");
|
|
911
1409
|
const requestId = requireString(payload.requestId, "requestId");
|
|
912
1410
|
const interaction = this.hostState.getInteraction(requestId);
|
|
913
1411
|
if (interaction === undefined || interaction.kind !== "decision") {
|
|
914
1412
|
return { outcome: "notFound", error: `Decision request not found: ${requestId}` };
|
|
915
1413
|
}
|
|
1414
|
+
if (this.queue.isWorkflowRunPaused(interaction.runId)) {
|
|
1415
|
+
return { outcome: "conflict", error: "Workflow run is paused" };
|
|
1416
|
+
}
|
|
916
1417
|
const request = interaction.contract as unknown as HumanDecisionRequest;
|
|
917
1418
|
const response = payload.response as HumanDecisionResponse;
|
|
918
1419
|
const accepted = this.decisions.acceptSync(request, {
|
|
@@ -1028,9 +1529,9 @@ export class WorkflowHost {
|
|
|
1028
1529
|
}
|
|
1029
1530
|
|
|
1030
1531
|
private startRun(
|
|
1031
|
-
request:
|
|
1532
|
+
request: ClientRequest,
|
|
1032
1533
|
afterCommit: Array<() => void>,
|
|
1033
|
-
): Omit<
|
|
1534
|
+
): Omit<ClientResponse, "schema" | "type" | "requestId"> {
|
|
1034
1535
|
const runId = requireRunId(request);
|
|
1035
1536
|
const payload = requireRecord(request.payload, "run.start payload");
|
|
1036
1537
|
const projectPath = requireAbsolutePath(payload.projectPath, "projectPath");
|
|
@@ -1076,8 +1577,8 @@ export class WorkflowHost {
|
|
|
1076
1577
|
}
|
|
1077
1578
|
|
|
1078
1579
|
private publishInteractionUpdate(
|
|
1079
|
-
request:
|
|
1080
|
-
): Omit<
|
|
1580
|
+
request: ClientRequest,
|
|
1581
|
+
): Omit<ClientResponse, "schema" | "type" | "requestId"> {
|
|
1081
1582
|
const runId = requireRunId(request);
|
|
1082
1583
|
const payload = requireRecord(request.payload, "interaction update payload");
|
|
1083
1584
|
const requestId = requireString(payload.requestId, "requestId");
|
|
@@ -1085,6 +1586,9 @@ export class WorkflowHost {
|
|
|
1085
1586
|
if (interaction === undefined || interaction.runId !== runId) {
|
|
1086
1587
|
return { outcome: "notFound", error: `Interactive request not found: ${requestId}` };
|
|
1087
1588
|
}
|
|
1589
|
+
if (this.queue.isWorkflowRunPaused(runId)) {
|
|
1590
|
+
return { outcome: "conflict", error: "Workflow run is paused" };
|
|
1591
|
+
}
|
|
1088
1592
|
const attemptId = requireString(payload.attempt, "attempt");
|
|
1089
1593
|
const nodeId = requireString(payload.step, "step");
|
|
1090
1594
|
const storedContract = requireRecord(interaction.contract, "interactive contract");
|
|
@@ -1141,13 +1645,18 @@ export class WorkflowHost {
|
|
|
1141
1645
|
}
|
|
1142
1646
|
}
|
|
1143
1647
|
|
|
1144
|
-
private submitInteraction(
|
|
1648
|
+
private submitInteraction(
|
|
1649
|
+
request: ClientRequest,
|
|
1650
|
+
): Omit<ClientResponse, "schema" | "type" | "requestId"> {
|
|
1145
1651
|
const payload = requireRecord(request.payload, "interaction payload");
|
|
1146
1652
|
const requestId = requireString(payload.requestId, "requestId");
|
|
1147
1653
|
const current = this.hostState.getInteraction(requestId);
|
|
1148
1654
|
if (current === undefined || current.runId !== requireRunId(request)) {
|
|
1149
1655
|
return { outcome: "notFound", error: `Interactive request not found: ${requestId}` };
|
|
1150
1656
|
}
|
|
1657
|
+
if (this.queue.isWorkflowRunPaused(current.runId)) {
|
|
1658
|
+
return { outcome: "conflict", error: "Workflow run is paused" };
|
|
1659
|
+
}
|
|
1151
1660
|
const attemptId = requireString(payload.attempt, "attempt");
|
|
1152
1661
|
const nodeId = requireString(payload.step, "step");
|
|
1153
1662
|
const storedContract = requireRecord(current.contract, "interactive contract");
|
|
@@ -1173,6 +1682,9 @@ export class WorkflowHost {
|
|
|
1173
1682
|
},
|
|
1174
1683
|
});
|
|
1175
1684
|
const interaction = submission.interaction;
|
|
1685
|
+
const receipt = isObjectRecord(submission.receipt)
|
|
1686
|
+
? { ...submission.receipt, requestId, submissionId: submission.submissionId }
|
|
1687
|
+
: { requestId, submissionId: submission.submissionId, receipt: submission.receipt };
|
|
1176
1688
|
if (this.activeRuns.has(interaction.runId) || this.activationTasks.has(interaction.runId)) {
|
|
1177
1689
|
this.pendingResumes.add(interaction.runId);
|
|
1178
1690
|
} else {
|
|
@@ -1191,7 +1703,7 @@ export class WorkflowHost {
|
|
|
1191
1703
|
return {
|
|
1192
1704
|
outcome: submission.outcome,
|
|
1193
1705
|
revision: interaction.revision,
|
|
1194
|
-
receipt
|
|
1706
|
+
receipt,
|
|
1195
1707
|
};
|
|
1196
1708
|
}
|
|
1197
1709
|
|
|
@@ -1504,7 +2016,7 @@ export class WorkflowHost {
|
|
|
1504
2016
|
}
|
|
1505
2017
|
const projectPath = this.queue.workflowRunProjectPath(request.runId);
|
|
1506
2018
|
if (projectPath === undefined) throw new Error("Workflow run project is missing");
|
|
1507
|
-
const resolver = new
|
|
2019
|
+
const resolver = new WorkflowClient({
|
|
1508
2020
|
databasePath: this.databasePath,
|
|
1509
2021
|
...(this.options.env === undefined ? {} : { env: this.options.env }),
|
|
1510
2022
|
});
|
|
@@ -1554,7 +2066,7 @@ export class WorkflowHost {
|
|
|
1554
2066
|
): Promise<WorkflowSchedulerResult> {
|
|
1555
2067
|
const existing = this.queue.getWorkflowRun(request.runId);
|
|
1556
2068
|
if (existing !== undefined) return controllerWorkflowResult(existing);
|
|
1557
|
-
const resolver = new
|
|
2069
|
+
const resolver = new WorkflowClient({
|
|
1558
2070
|
databasePath: this.databasePath,
|
|
1559
2071
|
...(this.options.env === undefined ? {} : { env: this.options.env }),
|
|
1560
2072
|
});
|
|
@@ -2850,17 +3362,157 @@ function controllerPathAllowed(
|
|
|
2850
3362
|
);
|
|
2851
3363
|
}
|
|
2852
3364
|
|
|
3365
|
+
function waitForSocketDrain(socket: Socket): Promise<void> {
|
|
3366
|
+
if (socket.destroyed) return Promise.resolve();
|
|
3367
|
+
return new Promise((resolve, reject) => {
|
|
3368
|
+
const cleanup = (): void => {
|
|
3369
|
+
socket.off("drain", onDrain);
|
|
3370
|
+
socket.off("close", onClose);
|
|
3371
|
+
socket.off("error", onError);
|
|
3372
|
+
};
|
|
3373
|
+
const onDrain = (): void => {
|
|
3374
|
+
cleanup();
|
|
3375
|
+
resolve();
|
|
3376
|
+
};
|
|
3377
|
+
const onClose = (): void => {
|
|
3378
|
+
cleanup();
|
|
3379
|
+
resolve();
|
|
3380
|
+
};
|
|
3381
|
+
const onError = (error: Error): void => {
|
|
3382
|
+
cleanup();
|
|
3383
|
+
reject(error);
|
|
3384
|
+
};
|
|
3385
|
+
socket.once("drain", onDrain);
|
|
3386
|
+
socket.once("close", onClose);
|
|
3387
|
+
socket.once("error", onError);
|
|
3388
|
+
if (socket.destroyed) onClose();
|
|
3389
|
+
});
|
|
3390
|
+
}
|
|
3391
|
+
|
|
3392
|
+
function hostDelay(ms: number): Promise<void> {
|
|
3393
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
3394
|
+
}
|
|
3395
|
+
|
|
3396
|
+
function runPageReceipt(view: WorkflowRunView, kind: WorkflowPageKind, cursor: number): JsonValue {
|
|
3397
|
+
const base = {
|
|
3398
|
+
schema: "pi-workflows.run-page.v1",
|
|
3399
|
+
runId: view.runId,
|
|
3400
|
+
revision: view.revision,
|
|
3401
|
+
kind,
|
|
3402
|
+
cursor,
|
|
3403
|
+
};
|
|
3404
|
+
if (kind === "steps") {
|
|
3405
|
+
const state = requireRecord(view.state, "run state view");
|
|
3406
|
+
return {
|
|
3407
|
+
...base,
|
|
3408
|
+
start: view.stepStart,
|
|
3409
|
+
total: view.stepTotal,
|
|
3410
|
+
items: Array.isArray(state.steps) ? state.steps : [],
|
|
3411
|
+
graphSteps: view.graphSteps,
|
|
3412
|
+
graphCursor: view.graphCursor,
|
|
3413
|
+
takenTransitions: view.takenTransitions,
|
|
3414
|
+
};
|
|
3415
|
+
}
|
|
3416
|
+
if (kind === "trace" || kind === "trace_at_step") {
|
|
3417
|
+
return { ...base, ...requireRecord(view.tracePage, "trace page") } as JsonValue;
|
|
3418
|
+
}
|
|
3419
|
+
if (kind === "session_entries" || kind === "session_events") {
|
|
3420
|
+
const session = requireRecord(view.session, "session view");
|
|
3421
|
+
const page = requireRecord(
|
|
3422
|
+
kind === "session_entries" ? session.entryPage : session.eventPage,
|
|
3423
|
+
"session page",
|
|
3424
|
+
);
|
|
3425
|
+
return {
|
|
3426
|
+
...base,
|
|
3427
|
+
...page,
|
|
3428
|
+
...(kind === "session_events" ? { replayCheckpoint: session.replayCheckpoint } : {}),
|
|
3429
|
+
} as JsonValue;
|
|
3430
|
+
}
|
|
3431
|
+
if (kind === "settings") {
|
|
3432
|
+
return {
|
|
3433
|
+
...base,
|
|
3434
|
+
start: view.settingsStart,
|
|
3435
|
+
total: view.settingsTotal,
|
|
3436
|
+
items: view.settingsScopes,
|
|
3437
|
+
};
|
|
3438
|
+
}
|
|
3439
|
+
if (kind === "follow_ups") {
|
|
3440
|
+
const queue = isObjectRecord(view.followUpQueue) ? view.followUpQueue : {};
|
|
3441
|
+
return {
|
|
3442
|
+
...base,
|
|
3443
|
+
start: view.followUpStart,
|
|
3444
|
+
total: view.followUpTotal,
|
|
3445
|
+
items: Array.isArray(queue.items) ? queue.items : [],
|
|
3446
|
+
};
|
|
3447
|
+
}
|
|
3448
|
+
return {
|
|
3449
|
+
...base,
|
|
3450
|
+
start: view.updateStart,
|
|
3451
|
+
total: view.updateTotal,
|
|
3452
|
+
items: view.updates,
|
|
3453
|
+
};
|
|
3454
|
+
}
|
|
3455
|
+
|
|
3456
|
+
function clientResponse(
|
|
3457
|
+
requestId: string,
|
|
3458
|
+
outcome: ClientOutcome,
|
|
3459
|
+
receipt?: JsonValue,
|
|
3460
|
+
error?: string,
|
|
3461
|
+
revision?: number,
|
|
3462
|
+
): ClientResponse {
|
|
3463
|
+
return {
|
|
3464
|
+
schema: CLIENT_PROTOCOL_SCHEMA,
|
|
3465
|
+
type: "response",
|
|
3466
|
+
requestId,
|
|
3467
|
+
outcome,
|
|
3468
|
+
...(revision === undefined ? {} : { revision }),
|
|
3469
|
+
...(receipt === undefined ? {} : { receipt }),
|
|
3470
|
+
...(error === undefined ? {} : { error: boundedClientError(error) }),
|
|
3471
|
+
};
|
|
3472
|
+
}
|
|
3473
|
+
|
|
3474
|
+
function boundedClientError(error: string): string {
|
|
3475
|
+
const singleLine = error.replaceAll(/[\r\n\t]+/gu, " ").trim();
|
|
3476
|
+
if (singleLine.length === 0) return "Workflow request failed";
|
|
3477
|
+
return singleLine.length <= 1_000 ? singleLine : `${singleLine.slice(0, 997)}...`;
|
|
3478
|
+
}
|
|
3479
|
+
|
|
3480
|
+
function parseActivityReport(payload: JsonValue): OriginActivityReport {
|
|
3481
|
+
const value = requireRecord(payload, "activity.report payload");
|
|
3482
|
+
const state = requireString(value.state, "state");
|
|
3483
|
+
if (state !== "started" && state !== "refresh" && state !== "settled") {
|
|
3484
|
+
throw new Error("activity state must be started, refresh, or settled");
|
|
3485
|
+
}
|
|
3486
|
+
return {
|
|
3487
|
+
sessionId: requireString(value.sessionId, "sessionId"),
|
|
3488
|
+
runId: requireString(value.runId, "runId"),
|
|
3489
|
+
requestId: requireString(value.requestId, "requestId"),
|
|
3490
|
+
deliveryId: requireString(value.deliveryId, "deliveryId"),
|
|
3491
|
+
sessionEntryId: requireString(value.sessionEntryId, "sessionEntryId"),
|
|
3492
|
+
sequence: requireNonNegativeInteger(value.sequence, "sequence"),
|
|
3493
|
+
state,
|
|
3494
|
+
};
|
|
3495
|
+
}
|
|
3496
|
+
|
|
3497
|
+
function toJsonValue(value: unknown): JsonValue {
|
|
3498
|
+
return JSON.parse(canonicalJson(value)) as JsonValue;
|
|
3499
|
+
}
|
|
3500
|
+
|
|
2853
3501
|
function payloadLimit(payload: JsonValue): number {
|
|
2854
3502
|
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return 100;
|
|
2855
3503
|
const limit = (payload as Record<string, unknown>).limit;
|
|
2856
3504
|
return typeof limit === "number" && Number.isSafeInteger(limit) && limit > 0 ? limit : 100;
|
|
2857
3505
|
}
|
|
2858
3506
|
|
|
2859
|
-
function requireRunId(request:
|
|
3507
|
+
function requireRunId(request: ClientRequest): string {
|
|
2860
3508
|
if (request.runId === undefined) throw new Error(`${request.operation} requires runId`);
|
|
2861
3509
|
return request.runId;
|
|
2862
3510
|
}
|
|
2863
3511
|
|
|
3512
|
+
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
|
3513
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3514
|
+
}
|
|
3515
|
+
|
|
2864
3516
|
function requireRecord(value: unknown, name: string): Record<string, unknown> {
|
|
2865
3517
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
2866
3518
|
throw new Error(`${name} must be an object`);
|
|
@@ -2881,6 +3533,18 @@ function requireAbsolutePath(value: unknown, name: string): string {
|
|
|
2881
3533
|
return parsed;
|
|
2882
3534
|
}
|
|
2883
3535
|
|
|
3536
|
+
function requireBoolean(value: unknown, name: string): boolean {
|
|
3537
|
+
if (typeof value !== "boolean") throw new Error(`${name} must be a boolean`);
|
|
3538
|
+
return value;
|
|
3539
|
+
}
|
|
3540
|
+
|
|
3541
|
+
function requirePositiveInteger(value: unknown, name: string): number {
|
|
3542
|
+
if (!Number.isSafeInteger(value) || (value as number) <= 0) {
|
|
3543
|
+
throw new Error(`${name} must be a positive integer`);
|
|
3544
|
+
}
|
|
3545
|
+
return value as number;
|
|
3546
|
+
}
|
|
3547
|
+
|
|
2884
3548
|
function requireNonNegativeInteger(value: unknown, name: string): number {
|
|
2885
3549
|
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
|
2886
3550
|
throw new Error(`${name} must be a non-negative integer`);
|
|
@@ -2896,6 +3560,16 @@ function isRevisionRow(value: unknown): value is { revision: number } {
|
|
|
2896
3560
|
);
|
|
2897
3561
|
}
|
|
2898
3562
|
|
|
3563
|
+
function runtimePackageVersion(): string {
|
|
3564
|
+
const parsed = JSON.parse(
|
|
3565
|
+
fs.readFileSync(new URL("../../package.json", import.meta.url), "utf8"),
|
|
3566
|
+
) as { version?: unknown };
|
|
3567
|
+
if (typeof parsed.version !== "string" || parsed.version.length === 0) {
|
|
3568
|
+
throw new Error("Package version is missing");
|
|
3569
|
+
}
|
|
3570
|
+
return parsed.version;
|
|
3571
|
+
}
|
|
3572
|
+
|
|
2899
3573
|
function isLockRecord(
|
|
2900
3574
|
value: unknown,
|
|
2901
3575
|
): value is { schema: string; pid: number; startIdentity: string; hostId: string } {
|