@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/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() };
|
|
@@ -592,6 +951,27 @@ export class WorkflowHost {
|
|
|
592
951
|
return this.executeControllerOperation(request);
|
|
593
952
|
case "interaction.update": {
|
|
594
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
|
+
}
|
|
595
975
|
if (payload.claimPresentation === true) {
|
|
596
976
|
try {
|
|
597
977
|
const interaction = this.hostState.claimInteractionPresentation({
|
|
@@ -636,12 +1016,26 @@ export class WorkflowHost {
|
|
|
636
1016
|
return this.submitInteraction(request);
|
|
637
1017
|
case "decision.answer":
|
|
638
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`);
|
|
639
1033
|
}
|
|
640
1034
|
}
|
|
641
1035
|
|
|
642
1036
|
private executeControllerOperation(
|
|
643
|
-
request:
|
|
644
|
-
): Omit<
|
|
1037
|
+
request: ClientRequest,
|
|
1038
|
+
): Omit<ClientResponse, "schema" | "type" | "requestId"> {
|
|
645
1039
|
const payload = requireRecord(request.payload, "controller payload");
|
|
646
1040
|
const projectPath = path.resolve(requireString(payload.projectPath, "projectPath"));
|
|
647
1041
|
if (payload.projectPath !== projectPath) {
|
|
@@ -752,6 +1146,39 @@ export class WorkflowHost {
|
|
|
752
1146
|
};
|
|
753
1147
|
}
|
|
754
1148
|
|
|
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
|
+
|
|
755
1182
|
private rememberDeliveryClaim(options: Omit<DeliveryClaim, "expiresAt">): {
|
|
756
1183
|
claimId: string;
|
|
757
1184
|
claimExpiresAt: string;
|
|
@@ -789,10 +1216,10 @@ export class WorkflowHost {
|
|
|
789
1216
|
}
|
|
790
1217
|
|
|
791
1218
|
private validateDelivery(
|
|
792
|
-
command:
|
|
1219
|
+
command: ClientRequest,
|
|
793
1220
|
payload: Record<string, unknown>,
|
|
794
1221
|
kind: DeliveryClaim["kind"],
|
|
795
|
-
): Omit<
|
|
1222
|
+
): Omit<ClientResponse, "schema" | "type" | "requestId"> {
|
|
796
1223
|
const resourceId = requireString(payload.resourceId, "resourceId");
|
|
797
1224
|
const targetSessionId = requireString(payload.targetSessionId, "targetSessionId");
|
|
798
1225
|
const claimId = requireString(payload.claimId, "claimId");
|
|
@@ -816,7 +1243,9 @@ export class WorkflowHost {
|
|
|
816
1243
|
return { outcome: "accepted", receipt: { claimId, live } };
|
|
817
1244
|
}
|
|
818
1245
|
|
|
819
|
-
private claimNotification(
|
|
1246
|
+
private claimNotification(
|
|
1247
|
+
command: ClientRequest,
|
|
1248
|
+
): Omit<ClientResponse, "schema" | "type" | "requestId"> {
|
|
820
1249
|
const payload = requireRecord(command.payload, "notification claim payload");
|
|
821
1250
|
if (payload.validateClaim === true) {
|
|
822
1251
|
return this.validateDelivery(command, payload, "notification");
|
|
@@ -845,7 +1274,9 @@ export class WorkflowHost {
|
|
|
845
1274
|
};
|
|
846
1275
|
}
|
|
847
1276
|
|
|
848
|
-
private deliverNotification(
|
|
1277
|
+
private deliverNotification(
|
|
1278
|
+
command: ClientRequest,
|
|
1279
|
+
): Omit<ClientResponse, "schema" | "type" | "requestId"> {
|
|
849
1280
|
const payload = requireRecord(command.payload, "notification delivery payload");
|
|
850
1281
|
const notificationId = requireString(payload.notificationId, "notificationId");
|
|
851
1282
|
const targetSessionId = requireString(payload.targetSessionId, "targetSessionId");
|
|
@@ -871,7 +1302,7 @@ export class WorkflowHost {
|
|
|
871
1302
|
: { outcome: "conflict", error: "Notification delivery claim is stale" };
|
|
872
1303
|
}
|
|
873
1304
|
|
|
874
|
-
private claimTurn(command:
|
|
1305
|
+
private claimTurn(command: ClientRequest): Omit<ClientResponse, "schema" | "type" | "requestId"> {
|
|
875
1306
|
const payload = requireRecord(command.payload, "turn claim payload");
|
|
876
1307
|
if (payload.validateClaim === true) {
|
|
877
1308
|
return this.validateDelivery(command, payload, "turn");
|
|
@@ -900,7 +1331,9 @@ export class WorkflowHost {
|
|
|
900
1331
|
};
|
|
901
1332
|
}
|
|
902
1333
|
|
|
903
|
-
private resolveTurn(
|
|
1334
|
+
private resolveTurn(
|
|
1335
|
+
command: ClientRequest,
|
|
1336
|
+
): Omit<ClientResponse, "schema" | "type" | "requestId"> {
|
|
904
1337
|
const payload = requireRecord(command.payload, "turn resolution payload");
|
|
905
1338
|
const intentId = requireString(payload.intentId, "intentId");
|
|
906
1339
|
const targetSessionId = requireString(payload.targetSessionId, "targetSessionId");
|
|
@@ -923,9 +1356,9 @@ export class WorkflowHost {
|
|
|
923
1356
|
}
|
|
924
1357
|
|
|
925
1358
|
private answerCheckpoint(
|
|
926
|
-
command:
|
|
1359
|
+
command: ClientRequest,
|
|
927
1360
|
afterCommit: Array<() => void>,
|
|
928
|
-
): Omit<
|
|
1361
|
+
): Omit<ClientResponse, "schema" | "type" | "requestId"> {
|
|
929
1362
|
const parentRunId = requireRunId(command);
|
|
930
1363
|
const payload = requireRecord(command.payload, "checkpoint answer payload");
|
|
931
1364
|
const continuationRunId = requireString(payload.continuationRunId, "continuationRunId");
|
|
@@ -969,9 +1402,9 @@ export class WorkflowHost {
|
|
|
969
1402
|
}
|
|
970
1403
|
|
|
971
1404
|
private answerDecision(
|
|
972
|
-
command:
|
|
1405
|
+
command: ClientRequest,
|
|
973
1406
|
afterCommit: Array<() => void>,
|
|
974
|
-
): Omit<
|
|
1407
|
+
): Omit<ClientResponse, "schema" | "type" | "requestId"> {
|
|
975
1408
|
const payload = requireRecord(command.payload, "decision answer payload");
|
|
976
1409
|
const requestId = requireString(payload.requestId, "requestId");
|
|
977
1410
|
const interaction = this.hostState.getInteraction(requestId);
|
|
@@ -1096,9 +1529,9 @@ export class WorkflowHost {
|
|
|
1096
1529
|
}
|
|
1097
1530
|
|
|
1098
1531
|
private startRun(
|
|
1099
|
-
request:
|
|
1532
|
+
request: ClientRequest,
|
|
1100
1533
|
afterCommit: Array<() => void>,
|
|
1101
|
-
): Omit<
|
|
1534
|
+
): Omit<ClientResponse, "schema" | "type" | "requestId"> {
|
|
1102
1535
|
const runId = requireRunId(request);
|
|
1103
1536
|
const payload = requireRecord(request.payload, "run.start payload");
|
|
1104
1537
|
const projectPath = requireAbsolutePath(payload.projectPath, "projectPath");
|
|
@@ -1144,8 +1577,8 @@ export class WorkflowHost {
|
|
|
1144
1577
|
}
|
|
1145
1578
|
|
|
1146
1579
|
private publishInteractionUpdate(
|
|
1147
|
-
request:
|
|
1148
|
-
): Omit<
|
|
1580
|
+
request: ClientRequest,
|
|
1581
|
+
): Omit<ClientResponse, "schema" | "type" | "requestId"> {
|
|
1149
1582
|
const runId = requireRunId(request);
|
|
1150
1583
|
const payload = requireRecord(request.payload, "interaction update payload");
|
|
1151
1584
|
const requestId = requireString(payload.requestId, "requestId");
|
|
@@ -1212,7 +1645,9 @@ export class WorkflowHost {
|
|
|
1212
1645
|
}
|
|
1213
1646
|
}
|
|
1214
1647
|
|
|
1215
|
-
private submitInteraction(
|
|
1648
|
+
private submitInteraction(
|
|
1649
|
+
request: ClientRequest,
|
|
1650
|
+
): Omit<ClientResponse, "schema" | "type" | "requestId"> {
|
|
1216
1651
|
const payload = requireRecord(request.payload, "interaction payload");
|
|
1217
1652
|
const requestId = requireString(payload.requestId, "requestId");
|
|
1218
1653
|
const current = this.hostState.getInteraction(requestId);
|
|
@@ -1247,6 +1682,9 @@ export class WorkflowHost {
|
|
|
1247
1682
|
},
|
|
1248
1683
|
});
|
|
1249
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 };
|
|
1250
1688
|
if (this.activeRuns.has(interaction.runId) || this.activationTasks.has(interaction.runId)) {
|
|
1251
1689
|
this.pendingResumes.add(interaction.runId);
|
|
1252
1690
|
} else {
|
|
@@ -1265,7 +1703,7 @@ export class WorkflowHost {
|
|
|
1265
1703
|
return {
|
|
1266
1704
|
outcome: submission.outcome,
|
|
1267
1705
|
revision: interaction.revision,
|
|
1268
|
-
receipt
|
|
1706
|
+
receipt,
|
|
1269
1707
|
};
|
|
1270
1708
|
}
|
|
1271
1709
|
|
|
@@ -1578,7 +2016,7 @@ export class WorkflowHost {
|
|
|
1578
2016
|
}
|
|
1579
2017
|
const projectPath = this.queue.workflowRunProjectPath(request.runId);
|
|
1580
2018
|
if (projectPath === undefined) throw new Error("Workflow run project is missing");
|
|
1581
|
-
const resolver = new
|
|
2019
|
+
const resolver = new WorkflowClient({
|
|
1582
2020
|
databasePath: this.databasePath,
|
|
1583
2021
|
...(this.options.env === undefined ? {} : { env: this.options.env }),
|
|
1584
2022
|
});
|
|
@@ -1628,7 +2066,7 @@ export class WorkflowHost {
|
|
|
1628
2066
|
): Promise<WorkflowSchedulerResult> {
|
|
1629
2067
|
const existing = this.queue.getWorkflowRun(request.runId);
|
|
1630
2068
|
if (existing !== undefined) return controllerWorkflowResult(existing);
|
|
1631
|
-
const resolver = new
|
|
2069
|
+
const resolver = new WorkflowClient({
|
|
1632
2070
|
databasePath: this.databasePath,
|
|
1633
2071
|
...(this.options.env === undefined ? {} : { env: this.options.env }),
|
|
1634
2072
|
});
|
|
@@ -2924,17 +3362,157 @@ function controllerPathAllowed(
|
|
|
2924
3362
|
);
|
|
2925
3363
|
}
|
|
2926
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
|
+
|
|
2927
3501
|
function payloadLimit(payload: JsonValue): number {
|
|
2928
3502
|
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return 100;
|
|
2929
3503
|
const limit = (payload as Record<string, unknown>).limit;
|
|
2930
3504
|
return typeof limit === "number" && Number.isSafeInteger(limit) && limit > 0 ? limit : 100;
|
|
2931
3505
|
}
|
|
2932
3506
|
|
|
2933
|
-
function requireRunId(request:
|
|
3507
|
+
function requireRunId(request: ClientRequest): string {
|
|
2934
3508
|
if (request.runId === undefined) throw new Error(`${request.operation} requires runId`);
|
|
2935
3509
|
return request.runId;
|
|
2936
3510
|
}
|
|
2937
3511
|
|
|
3512
|
+
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
|
3513
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3514
|
+
}
|
|
3515
|
+
|
|
2938
3516
|
function requireRecord(value: unknown, name: string): Record<string, unknown> {
|
|
2939
3517
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
2940
3518
|
throw new Error(`${name} must be an object`);
|
|
@@ -2955,6 +3533,18 @@ function requireAbsolutePath(value: unknown, name: string): string {
|
|
|
2955
3533
|
return parsed;
|
|
2956
3534
|
}
|
|
2957
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
|
+
|
|
2958
3548
|
function requireNonNegativeInteger(value: unknown, name: string): number {
|
|
2959
3549
|
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
|
2960
3550
|
throw new Error(`${name} must be a non-negative integer`);
|
|
@@ -2970,6 +3560,16 @@ function isRevisionRow(value: unknown): value is { revision: number } {
|
|
|
2970
3560
|
);
|
|
2971
3561
|
}
|
|
2972
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
|
+
|
|
2973
3573
|
function isLockRecord(
|
|
2974
3574
|
value: unknown,
|
|
2975
3575
|
): value is { schema: string; pid: number; startIdentity: string; hostId: string } {
|