@osolmaz/pi-workflows 0.13.4 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +146 -160
- package/dist/builtins/autoimplement.workflow.js +3 -1
- package/dist/builtins/autoimplement.workflow.js.map +1 -1
- package/dist/builtins/change-verification.workflow.js +7 -2
- package/dist/builtins/change-verification.workflow.js.map +1 -1
- package/dist/builtins/metadata.d.ts +22 -0
- package/dist/builtins/metadata.js +10 -0
- package/dist/builtins/metadata.js.map +1 -0
- package/dist/builtins/monitor.workflow.js +4 -1
- package/dist/builtins/monitor.workflow.js.map +1 -1
- package/dist/builtins/sanity-check.workflow.js +4 -1
- package/dist/builtins/sanity-check.workflow.js.map +1 -1
- package/dist/builtins/workspace-preparation.workflow.js +3 -1
- package/dist/builtins/workspace-preparation.workflow.js.map +1 -1
- package/dist/controllers/index.d.ts +1 -2
- package/dist/controllers/index.js +0 -1
- package/dist/controllers/index.js.map +1 -1
- package/dist/controllers/sqlite.d.ts +85 -31
- package/dist/controllers/sqlite.js +541 -126
- package/dist/controllers/sqlite.js.map +1 -1
- package/dist/controllers/store.d.ts +1 -0
- package/dist/extension/controller-command.d.ts +22 -0
- package/dist/extension/controller-command.js +32 -0
- package/dist/extension/controller-command.js.map +1 -0
- package/dist/extension/index.d.ts +2 -9
- package/dist/extension/index.js +876 -2928
- package/dist/extension/index.js.map +1 -1
- package/dist/extension/message-card.d.ts +13 -0
- package/dist/extension/message-card.js +46 -0
- package/dist/extension/message-card.js.map +1 -0
- package/dist/extension/step-message.d.ts +5 -6
- package/dist/extension/step-message.js +41 -52
- package/dist/extension/step-message.js.map +1 -1
- package/dist/host/child-worker-supervisor.d.ts +50 -0
- package/dist/host/child-worker-supervisor.js +159 -0
- package/dist/host/child-worker-supervisor.js.map +1 -0
- package/dist/host/client.d.ts +48 -0
- package/dist/host/client.js +216 -0
- package/dist/host/client.js.map +1 -0
- package/dist/host/controller-worker-entry.d.ts +2 -0
- package/dist/host/controller-worker-entry.js +244 -0
- package/dist/host/controller-worker-entry.js.map +1 -0
- package/dist/host/controller-worker-protocol.d.ts +36 -0
- package/dist/host/controller-worker-protocol.js +49 -0
- package/dist/host/controller-worker-protocol.js.map +1 -0
- package/dist/host/controller-worker-supervisor.d.ts +21 -0
- package/dist/host/controller-worker-supervisor.js +54 -0
- package/dist/host/controller-worker-supervisor.js.map +1 -0
- package/dist/host/host-entry.d.ts +2 -0
- package/dist/host/host-entry.js +23 -0
- package/dist/host/host-entry.js.map +1 -0
- package/dist/host/processes.d.ts +17 -12
- package/dist/host/processes.js +154 -50
- package/dist/host/processes.js.map +1 -1
- package/dist/host/protocol.d.ts +38 -0
- package/dist/host/protocol.js +156 -0
- package/dist/host/protocol.js.map +1 -0
- package/dist/host/resolver-entry.d.ts +49 -0
- package/dist/host/resolver-entry.js +149 -0
- package/dist/host/resolver-entry.js.map +1 -0
- package/dist/host/rpc-executor.d.ts +14 -3
- package/dist/host/rpc-executor.js +63 -30
- package/dist/host/rpc-executor.js.map +1 -1
- package/dist/host/runner.d.ts +95 -29
- package/dist/host/runner.js +2311 -348
- package/dist/host/runner.js.map +1 -1
- package/dist/host/state.d.ts +174 -0
- package/dist/host/state.js +689 -0
- package/dist/host/state.js.map +1 -0
- package/dist/host/worker-entry.d.ts +10 -0
- package/dist/host/worker-entry.js +446 -0
- package/dist/host/worker-entry.js.map +1 -0
- package/dist/host/worker-protocol.d.ts +31 -0
- package/dist/host/worker-protocol.js +122 -0
- package/dist/host/worker-protocol.js.map +1 -0
- package/dist/host/worker-store.d.ts +80 -0
- package/dist/host/worker-store.js +91 -0
- package/dist/host/worker-store.js.map +1 -0
- package/dist/host/worker-supervisor.d.ts +22 -0
- package/dist/host/worker-supervisor.js +54 -0
- package/dist/host/worker-supervisor.js.map +1 -0
- package/dist/render/canvas.d.ts +2 -1
- package/dist/render/canvas.js +40 -15
- package/dist/render/canvas.js.map +1 -1
- package/dist/render/graph-render.d.ts +7 -3
- package/dist/render/graph-render.js +125 -74
- package/dist/render/graph-render.js.map +1 -1
- package/dist/state/database.js +3 -1
- package/dist/state/database.js.map +1 -1
- package/dist/state/index.d.ts +1 -0
- package/dist/state/index.js +1 -0
- package/dist/state/index.js.map +1 -1
- package/dist/state/prune.js +36 -10
- package/dist/state/prune.js.map +1 -1
- package/dist/state/schema.d.ts +1 -1
- package/dist/state/schema.js +171 -3
- package/dist/state/schema.js.map +1 -1
- package/dist/state/viewer.d.ts +46 -0
- package/dist/state/viewer.js +249 -0
- package/dist/state/viewer.js.map +1 -0
- package/dist/viewer/cli.d.ts +1 -1
- package/dist/viewer/cli.js +47 -18
- package/dist/viewer/cli.js.map +1 -1
- package/dist/workflows/composition.js +25 -2
- package/dist/workflows/composition.js.map +1 -1
- package/dist/workflows/definition.d.ts +3 -1
- package/dist/workflows/definition.js +25 -0
- package/dist/workflows/definition.js.map +1 -1
- package/dist/workflows/engine.d.ts +1 -0
- package/dist/workflows/engine.js +157 -42
- package/dist/workflows/engine.js.map +1 -1
- package/dist/workflows/errors.d.ts +3 -1
- package/dist/workflows/errors.js +4 -7
- package/dist/workflows/errors.js.map +1 -1
- package/dist/workflows/human-decision.d.ts +3 -0
- package/dist/workflows/human-decision.js +31 -0
- package/dist/workflows/human-decision.js.map +1 -1
- package/dist/workflows/index.d.ts +1 -1
- package/dist/workflows/index.js +1 -1
- package/dist/workflows/index.js.map +1 -1
- package/dist/workflows/schema.js +17 -6
- package/dist/workflows/schema.js.map +1 -1
- package/dist/{viewer → workflows}/session-reducer.d.ts +3 -1
- package/dist/{viewer → workflows}/session-reducer.js +4 -0
- package/dist/workflows/session-reducer.js.map +1 -0
- package/dist/workflows/store.d.ts +87 -1
- package/dist/workflows/store.js +892 -93
- package/dist/workflows/store.js.map +1 -1
- package/dist/workflows/tool-input.d.ts +0 -22
- package/dist/workflows/tool-input.js +1 -44
- package/dist/workflows/tool-input.js.map +1 -1
- package/dist/workflows/types.d.ts +36 -4
- package/docs/2026-08-25-workflow-follow-ups.md +8 -6
- package/docs/2026-08-30-out-of-process-workflow-host-plan.md +353 -0
- package/docs/CONTROLLERS.md +11 -11
- package/docs/DEFERRED_TURNS.md +61 -27
- package/docs/HUMAN_DECISIONS.md +12 -4
- package/docs/SQLITE_STATE.md +42 -8
- package/docs/WORKFLOW_HOST.md +452 -0
- package/docs/development.md +46 -30
- package/docs/live-replay-protocol.md +129 -100
- package/docs/plans/2026-08-19-human-decision-gates-plan.md +34 -8
- package/docs/plans/2026-08-27-workflow-terminal-restart-plan.md +399 -0
- package/docs/plans/2026-08-28-piw-incremental-viewer-plan.md +293 -0
- package/docs/plans/piw-viewer-experience-implementation-plan.md +11 -1
- package/docs/plans/tui-viewer-implementation-plan.md +5 -0
- package/docs/tui-viewer.md +18 -3
- package/docs/workflows.md +164 -156
- package/examples/workflows/command-batch.workflow.ts +2 -0
- package/examples/workflows/shell.workflow.ts +2 -1
- package/herdr-plugin.toml +1 -1
- package/package.json +1 -1
- package/skills/autodoc/SKILL.md +1 -1
- package/skills/autoimplement/SKILL.md +1 -1
- package/skills/autoplan/SKILL.md +1 -1
- package/skills/pi-workflows/SKILL.md +2 -0
- package/src/builtins/autoimplement.workflow.ts +3 -0
- package/src/builtins/change-verification.workflow.ts +7 -2
- package/src/builtins/metadata.ts +9 -0
- package/src/builtins/monitor.workflow.ts +4 -0
- package/src/builtins/sanity-check.workflow.ts +4 -0
- package/src/builtins/workspace-preparation.workflow.ts +3 -1
- package/src/controllers/index.ts +3 -5
- package/src/controllers/sqlite.ts +929 -225
- package/src/controllers/store.ts +1 -0
- package/src/extension/controller-command.ts +45 -0
- package/src/extension/index.ts +948 -3506
- package/src/extension/message-card.ts +61 -0
- package/src/extension/step-message.ts +58 -63
- package/src/host/child-worker-supervisor.ts +183 -0
- package/src/host/client.ts +293 -0
- package/src/host/controller-worker-entry.ts +311 -0
- package/src/host/controller-worker-protocol.ts +104 -0
- package/src/host/controller-worker-supervisor.ts +79 -0
- package/src/host/host-entry.ts +23 -0
- package/src/host/processes.ts +171 -54
- package/src/host/protocol.ts +196 -0
- package/src/host/resolver-entry.ts +241 -0
- package/src/host/rpc-executor.ts +76 -34
- package/src/host/runner.ts +2813 -422
- package/src/host/state.ts +1160 -0
- package/src/host/worker-entry.ts +533 -0
- package/src/host/worker-protocol.ts +165 -0
- package/src/host/worker-store.ts +229 -0
- package/src/host/worker-supervisor.ts +74 -0
- package/src/render/canvas.ts +44 -10
- package/src/render/graph-render.ts +145 -90
- package/src/state/database.ts +2 -1
- package/src/state/index.ts +14 -0
- package/src/state/prune.ts +35 -9
- package/src/state/schema.ts +171 -3
- package/src/state/viewer.ts +356 -0
- package/src/viewer/cli.ts +49 -17
- package/src/workflows/composition.ts +36 -2
- package/src/workflows/definition.ts +32 -0
- package/src/workflows/engine.ts +157 -54
- package/src/workflows/errors.ts +11 -2
- package/src/workflows/human-decision.ts +49 -0
- package/src/workflows/index.ts +2 -0
- package/src/workflows/schema.ts +19 -6
- package/src/{viewer → workflows}/session-reducer.ts +11 -1
- package/src/workflows/store.ts +1316 -108
- package/src/workflows/tool-input.ts +2 -60
- package/src/workflows/types.ts +32 -4
- package/dist/controllers/workflow-engine-scheduler.d.ts +0 -29
- package/dist/controllers/workflow-engine-scheduler.js +0 -175
- package/dist/controllers/workflow-engine-scheduler.js.map +0 -1
- package/dist/extension/controller-host.d.ts +0 -48
- package/dist/extension/controller-host.js +0 -110
- package/dist/extension/controller-host.js.map +0 -1
- package/dist/extension/deferred-turn-coordinator.d.ts +0 -32
- package/dist/extension/deferred-turn-coordinator.js +0 -143
- package/dist/extension/deferred-turn-coordinator.js.map +0 -1
- package/dist/extension/deferred-turn.d.ts +0 -44
- package/dist/extension/deferred-turn.js +0 -110
- package/dist/extension/deferred-turn.js.map +0 -1
- package/dist/extension/executor.d.ts +0 -86
- package/dist/extension/executor.js +0 -311
- package/dist/extension/executor.js.map +0 -1
- package/dist/extension/follow-up-coordinator.d.ts +0 -27
- package/dist/extension/follow-up-coordinator.js +0 -131
- package/dist/extension/follow-up-coordinator.js.map +0 -1
- package/dist/extension/recorder.d.ts +0 -84
- package/dist/extension/recorder.js +0 -528
- package/dist/extension/recorder.js.map +0 -1
- package/dist/extension/session-events.d.ts +0 -133
- package/dist/extension/session-events.js +0 -61
- package/dist/extension/session-events.js.map +0 -1
- package/dist/viewer/session-reducer.js.map +0 -1
- package/src/controllers/workflow-engine-scheduler.ts +0 -246
- package/src/extension/controller-host.ts +0 -167
- package/src/extension/deferred-turn-coordinator.ts +0 -171
- package/src/extension/deferred-turn.ts +0 -166
- package/src/extension/executor.ts +0 -411
- package/src/extension/follow-up-coordinator.ts +0 -151
- package/src/extension/recorder.ts +0 -655
- package/src/extension/session-events.ts +0 -121
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { once } from "node:events";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import net from "node:net";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { workflowStatePath } from "../state/database.js";
|
|
9
|
+
import { canonicalJson, parseJson, type JsonValue } from "../state/json.js";
|
|
10
|
+
import {
|
|
11
|
+
encodeProtocolLine,
|
|
12
|
+
hostSocketPath,
|
|
13
|
+
NdjsonFrameDecoder,
|
|
14
|
+
parseHostResponse,
|
|
15
|
+
type HostOperation,
|
|
16
|
+
type HostRequest,
|
|
17
|
+
type HostResponse,
|
|
18
|
+
} from "./protocol.js";
|
|
19
|
+
import type {
|
|
20
|
+
ResolvedControllerInitialization,
|
|
21
|
+
ResolvedSettingsChange,
|
|
22
|
+
ResolvedWorkflowLaunch,
|
|
23
|
+
} from "./resolver-entry.js";
|
|
24
|
+
|
|
25
|
+
const CONNECT_TIMEOUT_MS = 2_000;
|
|
26
|
+
const START_TIMEOUT_MS = 10_000;
|
|
27
|
+
|
|
28
|
+
export class WorkflowHostClient {
|
|
29
|
+
readonly clientId: string;
|
|
30
|
+
readonly databasePath: string;
|
|
31
|
+
readonly endpoint: string;
|
|
32
|
+
|
|
33
|
+
constructor(
|
|
34
|
+
options: {
|
|
35
|
+
clientId?: string;
|
|
36
|
+
databasePath?: string;
|
|
37
|
+
hostEntryPath?: string;
|
|
38
|
+
env?: Record<string, string>;
|
|
39
|
+
} = {},
|
|
40
|
+
) {
|
|
41
|
+
this.clientId = options.clientId ?? `client-${randomUUID()}`;
|
|
42
|
+
this.databasePath = options.databasePath ?? workflowStatePath();
|
|
43
|
+
this.endpoint = hostSocketPath(this.databasePath);
|
|
44
|
+
this.hostEntryPath = options.hostEntryPath;
|
|
45
|
+
this.env = options.env;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private readonly hostEntryPath: string | undefined;
|
|
49
|
+
private readonly env: Record<string, string> | undefined;
|
|
50
|
+
|
|
51
|
+
async request(options: {
|
|
52
|
+
operation: HostOperation;
|
|
53
|
+
requestId?: string;
|
|
54
|
+
idempotencyKey?: string;
|
|
55
|
+
runId?: string;
|
|
56
|
+
expectedRevision?: number;
|
|
57
|
+
payload?: JsonValue;
|
|
58
|
+
}): Promise<HostResponse> {
|
|
59
|
+
const request: HostRequest = {
|
|
60
|
+
schema: "pi-workflows.host-request.v1",
|
|
61
|
+
requestId: options.requestId ?? randomUUID(),
|
|
62
|
+
clientId: this.clientId,
|
|
63
|
+
operation: options.operation,
|
|
64
|
+
idempotencyKey: options.idempotencyKey ?? randomUUID(),
|
|
65
|
+
...(options.runId === undefined ? {} : { runId: options.runId }),
|
|
66
|
+
...(options.expectedRevision === undefined
|
|
67
|
+
? {}
|
|
68
|
+
: { expectedRevision: options.expectedRevision }),
|
|
69
|
+
payload: options.payload ?? {},
|
|
70
|
+
};
|
|
71
|
+
return await sendRequest(this.endpoint, request);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async resolveWorkflow(options: {
|
|
75
|
+
cwd: string;
|
|
76
|
+
workflowRef: string;
|
|
77
|
+
timeoutMs?: number;
|
|
78
|
+
}): Promise<ResolvedWorkflowLaunch> {
|
|
79
|
+
return (await this.runResolver(
|
|
80
|
+
{
|
|
81
|
+
schema: "pi-workflows.resolve-request.v1",
|
|
82
|
+
cwd: options.cwd,
|
|
83
|
+
workflowRef: options.workflowRef,
|
|
84
|
+
},
|
|
85
|
+
options.cwd,
|
|
86
|
+
"pi-workflows.resolved-launch.v1",
|
|
87
|
+
options.timeoutMs,
|
|
88
|
+
)) as unknown as ResolvedWorkflowLaunch;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async resolveControllerInitialization(options: {
|
|
92
|
+
cwd: string;
|
|
93
|
+
controllerName: string;
|
|
94
|
+
spec: JsonValue;
|
|
95
|
+
timeoutMs?: number;
|
|
96
|
+
}): Promise<ResolvedControllerInitialization> {
|
|
97
|
+
return (await this.runResolver(
|
|
98
|
+
{
|
|
99
|
+
schema: "pi-workflows.controller-initialization-request.v1",
|
|
100
|
+
cwd: options.cwd,
|
|
101
|
+
controllerName: options.controllerName,
|
|
102
|
+
spec: options.spec,
|
|
103
|
+
},
|
|
104
|
+
options.cwd,
|
|
105
|
+
"pi-workflows.resolved-controller-initialization.v1",
|
|
106
|
+
options.timeoutMs,
|
|
107
|
+
)) as unknown as ResolvedControllerInitialization;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async resolveSettingsChange(options: {
|
|
111
|
+
cwd: string;
|
|
112
|
+
workflowRef: string;
|
|
113
|
+
definitionDigest: string;
|
|
114
|
+
mountPath: string;
|
|
115
|
+
current: JsonValue;
|
|
116
|
+
patch: JsonValue;
|
|
117
|
+
actorId: string;
|
|
118
|
+
timeoutMs?: number;
|
|
119
|
+
}): Promise<ResolvedSettingsChange> {
|
|
120
|
+
return (await this.runResolver(
|
|
121
|
+
{
|
|
122
|
+
schema: "pi-workflows.settings-validation-request.v1",
|
|
123
|
+
cwd: options.cwd,
|
|
124
|
+
workflowRef: options.workflowRef,
|
|
125
|
+
definitionDigest: options.definitionDigest,
|
|
126
|
+
mountPath: options.mountPath,
|
|
127
|
+
current: options.current,
|
|
128
|
+
patch: options.patch,
|
|
129
|
+
actorId: options.actorId,
|
|
130
|
+
},
|
|
131
|
+
options.cwd,
|
|
132
|
+
"pi-workflows.resolved-settings-change.v1",
|
|
133
|
+
options.timeoutMs,
|
|
134
|
+
)) as unknown as ResolvedSettingsChange;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private async runResolver(
|
|
138
|
+
request: JsonValue,
|
|
139
|
+
cwd: string,
|
|
140
|
+
expectedSchema: string,
|
|
141
|
+
timeoutMs = START_TIMEOUT_MS,
|
|
142
|
+
): Promise<JsonValue> {
|
|
143
|
+
const builtEntry = fileURLToPath(new URL("./resolver-entry.js", import.meta.url));
|
|
144
|
+
const sourceEntry = fileURLToPath(new URL("./resolver-entry.ts", import.meta.url));
|
|
145
|
+
const args = fs.existsSync(builtEntry)
|
|
146
|
+
? [builtEntry]
|
|
147
|
+
: ["--import", createRequire(import.meta.url).resolve("tsx"), sourceEntry];
|
|
148
|
+
const child = spawn(process.execPath, args, {
|
|
149
|
+
cwd,
|
|
150
|
+
detached: process.platform !== "win32",
|
|
151
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
152
|
+
env: { ...process.env, ...this.env },
|
|
153
|
+
});
|
|
154
|
+
let stdout: Buffer = Buffer.alloc(0);
|
|
155
|
+
let stderr: Buffer = Buffer.alloc(0);
|
|
156
|
+
let outputError: Error | undefined;
|
|
157
|
+
const append = (current: Buffer, chunk: Buffer): Buffer => {
|
|
158
|
+
const next = Buffer.concat([current, chunk]);
|
|
159
|
+
if (next.byteLength > 1024 * 1024) {
|
|
160
|
+
outputError = new Error("Workflow resolver output exceeds 1 MiB");
|
|
161
|
+
stopProcessGroup(child.pid);
|
|
162
|
+
return current;
|
|
163
|
+
}
|
|
164
|
+
return next;
|
|
165
|
+
};
|
|
166
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
167
|
+
stdout = append(stdout, chunk);
|
|
168
|
+
});
|
|
169
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
170
|
+
stderr = append(stderr, chunk);
|
|
171
|
+
});
|
|
172
|
+
child.stdin.end(canonicalJson(request));
|
|
173
|
+
const timeout = setTimeout(() => stopProcessGroup(child.pid), timeoutMs);
|
|
174
|
+
timeout.unref?.();
|
|
175
|
+
const [code, signal] = (await once(child, "exit")) as [number | null, NodeJS.Signals | null];
|
|
176
|
+
clearTimeout(timeout);
|
|
177
|
+
if (outputError !== undefined) throw outputError;
|
|
178
|
+
if (code !== 0) {
|
|
179
|
+
const detail = stderr.toString("utf8").trim().slice(0, 2_000);
|
|
180
|
+
throw new Error(
|
|
181
|
+
detail || `Workflow resolver exited before completion (code ${code}, signal ${signal})`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
const value = parseJson(stdout.toString("utf8").trimEnd());
|
|
185
|
+
if (!isRecord(value) || value.schema !== expectedSchema) {
|
|
186
|
+
throw new Error("Workflow resolver returned an invalid result envelope");
|
|
187
|
+
}
|
|
188
|
+
return value as JsonValue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async ensureRunning(): Promise<HostResponse> {
|
|
192
|
+
try {
|
|
193
|
+
return await this.request({
|
|
194
|
+
operation: "host.status",
|
|
195
|
+
requestId: `host-status-${randomUUID()}`,
|
|
196
|
+
idempotencyKey: `host-status-${randomUUID()}`,
|
|
197
|
+
});
|
|
198
|
+
} catch {
|
|
199
|
+
this.startDetached();
|
|
200
|
+
}
|
|
201
|
+
const deadline = Date.now() + START_TIMEOUT_MS;
|
|
202
|
+
let lastError: unknown;
|
|
203
|
+
while (Date.now() < deadline) {
|
|
204
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
205
|
+
try {
|
|
206
|
+
return await this.request({
|
|
207
|
+
operation: "host.status",
|
|
208
|
+
requestId: `host-status-${randomUUID()}`,
|
|
209
|
+
idempotencyKey: `host-status-${randomUUID()}`,
|
|
210
|
+
});
|
|
211
|
+
} catch (error) {
|
|
212
|
+
lastError = error;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
throw new Error(
|
|
216
|
+
`Workflow host did not become ready: ${lastError instanceof Error ? lastError.message : String(lastError)}`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private startDetached(): void {
|
|
221
|
+
const builtEntry = fileURLToPath(new URL("./host-entry.js", import.meta.url));
|
|
222
|
+
const sourceEntry = fileURLToPath(new URL("./host-entry.ts", import.meta.url));
|
|
223
|
+
const entry = this.hostEntryPath ?? builtEntry;
|
|
224
|
+
const args =
|
|
225
|
+
this.hostEntryPath === undefined && !fs.existsSync(builtEntry)
|
|
226
|
+
? ["--import", createRequire(import.meta.url).resolve("tsx"), sourceEntry]
|
|
227
|
+
: [entry];
|
|
228
|
+
const child = spawn(process.execPath, [...args, "--database", this.databasePath], {
|
|
229
|
+
detached: true,
|
|
230
|
+
stdio: "ignore",
|
|
231
|
+
env: { ...process.env, ...this.env },
|
|
232
|
+
});
|
|
233
|
+
child.unref();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function stopProcessGroup(pid: number | undefined): void {
|
|
238
|
+
if (pid === undefined) return;
|
|
239
|
+
try {
|
|
240
|
+
if (process.platform !== "win32") process.kill(-pid, "SIGKILL");
|
|
241
|
+
else process.kill(pid, "SIGKILL");
|
|
242
|
+
} catch {
|
|
243
|
+
// The resolver has already exited.
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
248
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function sendRequest(endpoint: string, request: HostRequest): Promise<HostResponse> {
|
|
252
|
+
const socket = net.createConnection(endpoint);
|
|
253
|
+
const decoder = new NdjsonFrameDecoder();
|
|
254
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
255
|
+
try {
|
|
256
|
+
await Promise.race([
|
|
257
|
+
once(socket, "connect"),
|
|
258
|
+
new Promise<never>((_, reject) => {
|
|
259
|
+
timer = setTimeout(
|
|
260
|
+
() => reject(new Error("Workflow host connection timed out")),
|
|
261
|
+
CONNECT_TIMEOUT_MS,
|
|
262
|
+
);
|
|
263
|
+
timer.unref?.();
|
|
264
|
+
}),
|
|
265
|
+
once(socket, "error").then(([error]) => Promise.reject(error)),
|
|
266
|
+
]);
|
|
267
|
+
if (!socket.write(encodeProtocolLine(request))) await once(socket, "drain");
|
|
268
|
+
const response = await new Promise<HostResponse>((resolve, reject) => {
|
|
269
|
+
const timeout = setTimeout(
|
|
270
|
+
() => reject(new Error("Workflow host response timed out")),
|
|
271
|
+
CONNECT_TIMEOUT_MS,
|
|
272
|
+
);
|
|
273
|
+
timeout.unref?.();
|
|
274
|
+
socket.on("data", (chunk: Buffer) => {
|
|
275
|
+
try {
|
|
276
|
+
const frame = decoder.push(chunk)[0];
|
|
277
|
+
if (frame === undefined) return;
|
|
278
|
+
clearTimeout(timeout);
|
|
279
|
+
resolve(parseHostResponse(frame));
|
|
280
|
+
} catch (error) {
|
|
281
|
+
clearTimeout(timeout);
|
|
282
|
+
reject(error);
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
socket.once("error", reject);
|
|
286
|
+
socket.once("close", () => reject(new Error("Workflow host closed before responding")));
|
|
287
|
+
});
|
|
288
|
+
return response;
|
|
289
|
+
} finally {
|
|
290
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
291
|
+
socket.destroy();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { once } from "node:events";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { jsonFingerprint } from "../controllers/json.js";
|
|
6
|
+
import { loadControllerFile } from "../controllers/loader.js";
|
|
7
|
+
import { createResultHelpers } from "../controllers/results.js";
|
|
8
|
+
import type {
|
|
9
|
+
ChildWorkflowRecord,
|
|
10
|
+
ControllerEffects,
|
|
11
|
+
ControllerFollowUpResult,
|
|
12
|
+
ControllerQueueFollowUpRequest,
|
|
13
|
+
ControllerRemoveFollowUpRequest,
|
|
14
|
+
ControllerSettingsChangeRequest,
|
|
15
|
+
ControllerSettingsChangeResult,
|
|
16
|
+
ControllerWorkflows,
|
|
17
|
+
EffectApplication,
|
|
18
|
+
EffectDefinition,
|
|
19
|
+
EffectObservation,
|
|
20
|
+
EffectRecord,
|
|
21
|
+
ReconcileContext,
|
|
22
|
+
ReconcileResult,
|
|
23
|
+
} from "../controllers/types.js";
|
|
24
|
+
import { parseJson, type JsonValue } from "../state/json.js";
|
|
25
|
+
import { errorMessage } from "../workflows/errors.js";
|
|
26
|
+
import {
|
|
27
|
+
CONTROLLER_WORKER_LAUNCH_SCHEMA,
|
|
28
|
+
encodeControllerWorkerLine,
|
|
29
|
+
parseControllerWorkerResponse,
|
|
30
|
+
type ControllerWorkerLaunchEnvelope,
|
|
31
|
+
type ControllerWorkerMessage,
|
|
32
|
+
type ControllerWorkerOperation,
|
|
33
|
+
type ControllerWorkerResponse,
|
|
34
|
+
} from "./controller-worker-protocol.js";
|
|
35
|
+
|
|
36
|
+
const STARTUP_ENV = "PI_WORKFLOWS_CONTROLLER_WORKER_LAUNCH";
|
|
37
|
+
const MAX_FRAME_BYTES = 1024 * 1024;
|
|
38
|
+
|
|
39
|
+
class ControllerWorkerTransport {
|
|
40
|
+
private readonly pending = new Map<
|
|
41
|
+
string,
|
|
42
|
+
{ resolve: (response: ControllerWorkerResponse) => void; reject: (error: Error) => void }
|
|
43
|
+
>();
|
|
44
|
+
private buffered = Buffer.alloc(0);
|
|
45
|
+
|
|
46
|
+
constructor(private readonly launch: ControllerWorkerLaunchEnvelope) {
|
|
47
|
+
process.stdin.on("data", (chunk: Buffer) => this.onData(chunk));
|
|
48
|
+
process.stdin.on("error", (error) => this.failAll(error));
|
|
49
|
+
process.stdin.resume();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async request<T>(operation: ControllerWorkerOperation, payload: JsonValue): Promise<T> {
|
|
53
|
+
const message: ControllerWorkerMessage = {
|
|
54
|
+
schema: "pi-workflows.controller-worker-message.v1",
|
|
55
|
+
launchSchema: this.launch.schema,
|
|
56
|
+
messageId: randomUUID(),
|
|
57
|
+
workerEpoch: this.launch.workerEpoch,
|
|
58
|
+
generation: this.launch.generation,
|
|
59
|
+
operation,
|
|
60
|
+
payload,
|
|
61
|
+
};
|
|
62
|
+
const response = await this.send(message);
|
|
63
|
+
if (response.outcome !== "accepted") {
|
|
64
|
+
throw new Error(response.error ?? `Controller worker ${response.outcome}`);
|
|
65
|
+
}
|
|
66
|
+
return response.result as T;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
close(): void {
|
|
70
|
+
process.stdin.pause();
|
|
71
|
+
process.stdin.removeAllListeners("data");
|
|
72
|
+
process.stdin.removeAllListeners("error");
|
|
73
|
+
this.failAll(new Error("Controller worker transport closed"));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private async send(message: ControllerWorkerMessage): Promise<ControllerWorkerResponse> {
|
|
77
|
+
const response = new Promise<ControllerWorkerResponse>((resolve, reject) => {
|
|
78
|
+
this.pending.set(message.messageId, { resolve, reject });
|
|
79
|
+
});
|
|
80
|
+
if (!process.stdout.write(encodeControllerWorkerLine(message))) {
|
|
81
|
+
await once(process.stdout, "drain");
|
|
82
|
+
}
|
|
83
|
+
return await response;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private onData(chunk: Buffer): void {
|
|
87
|
+
this.buffered = Buffer.concat([this.buffered, chunk]);
|
|
88
|
+
if (this.buffered.byteLength > MAX_FRAME_BYTES && !this.buffered.includes(0x0a)) {
|
|
89
|
+
this.failAll(new Error("Controller worker response exceeds 1 MiB"));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
for (;;) {
|
|
93
|
+
const newline = this.buffered.indexOf(0x0a);
|
|
94
|
+
if (newline < 0) return;
|
|
95
|
+
const frame = this.buffered.subarray(0, newline);
|
|
96
|
+
this.buffered = this.buffered.subarray(newline + 1);
|
|
97
|
+
if (frame.byteLength === 0) continue;
|
|
98
|
+
try {
|
|
99
|
+
const response = parseControllerWorkerResponse(frame);
|
|
100
|
+
const pending = this.pending.get(response.messageId);
|
|
101
|
+
if (pending === undefined) throw new Error("Controller worker response is unexpected");
|
|
102
|
+
this.pending.delete(response.messageId);
|
|
103
|
+
pending.resolve(response);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
this.failAll(error instanceof Error ? error : new Error(String(error)));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private failAll(error: Error): void {
|
|
111
|
+
for (const pending of this.pending.values()) pending.reject(error);
|
|
112
|
+
this.pending.clear();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
class HostedControllerEffects implements ControllerEffects {
|
|
117
|
+
private used = false;
|
|
118
|
+
|
|
119
|
+
constructor(
|
|
120
|
+
private readonly transport: ControllerWorkerTransport,
|
|
121
|
+
private readonly signal: AbortSignal,
|
|
122
|
+
) {}
|
|
123
|
+
|
|
124
|
+
async ensure<TRequest>(definition: EffectDefinition<TRequest>): Promise<EffectRecord> {
|
|
125
|
+
if (this.used) throw new Error("A reconciliation pass may ensure only one external effect");
|
|
126
|
+
this.used = true;
|
|
127
|
+
const reservation = await this.transport.request<{ record: EffectRecord; created: boolean }>(
|
|
128
|
+
"effect.reserve",
|
|
129
|
+
{
|
|
130
|
+
key: definition.key,
|
|
131
|
+
kind: definition.kind,
|
|
132
|
+
requestFingerprint: jsonFingerprint(definition.request),
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
if (["applied", "rejected", "indeterminate"].includes(reservation.record.state)) {
|
|
136
|
+
return reservation.record;
|
|
137
|
+
}
|
|
138
|
+
if (!reservation.created) {
|
|
139
|
+
const observation = await definition.observe(this.signal);
|
|
140
|
+
if (observation.state !== "not_applied") {
|
|
141
|
+
return await this.settleObservation(definition.key, observation);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
let applied: EffectApplication;
|
|
145
|
+
try {
|
|
146
|
+
applied = await definition.apply(this.signal);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
applied = { state: "indeterminate", error: boundedError(error) };
|
|
149
|
+
}
|
|
150
|
+
return await this.transport.request<EffectRecord>("effect.settle", {
|
|
151
|
+
key: definition.key,
|
|
152
|
+
state: applied.state,
|
|
153
|
+
...(applied.state === "applied" && applied.externalRef !== undefined
|
|
154
|
+
? { externalRef: applied.externalRef }
|
|
155
|
+
: {}),
|
|
156
|
+
...(applied.state !== "applied" && applied.error !== undefined
|
|
157
|
+
? { error: applied.error }
|
|
158
|
+
: {}),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private async settleObservation(
|
|
163
|
+
key: string,
|
|
164
|
+
observation: EffectObservation,
|
|
165
|
+
): Promise<EffectRecord> {
|
|
166
|
+
return await this.transport.request<EffectRecord>("effect.settle", {
|
|
167
|
+
key,
|
|
168
|
+
state: observation.state === "applied" ? "applied" : "indeterminate",
|
|
169
|
+
...(observation.state === "applied" && observation.externalRef !== undefined
|
|
170
|
+
? { externalRef: observation.externalRef }
|
|
171
|
+
: {}),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
class HostedControllerWorkflows implements ControllerWorkflows {
|
|
177
|
+
constructor(private readonly transport: ControllerWorkerTransport) {}
|
|
178
|
+
|
|
179
|
+
async ensure(request: {
|
|
180
|
+
requestKey: string;
|
|
181
|
+
workflow: string;
|
|
182
|
+
input: unknown;
|
|
183
|
+
}): Promise<ChildWorkflowRecord> {
|
|
184
|
+
return await this.transport.request<ChildWorkflowRecord>(
|
|
185
|
+
"workflow.ensure",
|
|
186
|
+
request as JsonValue,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async changeSettings(
|
|
191
|
+
request: ControllerSettingsChangeRequest,
|
|
192
|
+
): Promise<ControllerSettingsChangeResult> {
|
|
193
|
+
return await this.transport.request<ControllerSettingsChangeResult>(
|
|
194
|
+
"workflow.changeSettings",
|
|
195
|
+
request as unknown as JsonValue,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async queueFollowUp(request: ControllerQueueFollowUpRequest): Promise<ControllerFollowUpResult> {
|
|
200
|
+
return await this.transport.request<ControllerFollowUpResult>(
|
|
201
|
+
"workflow.queueFollowUp",
|
|
202
|
+
request as unknown as JsonValue,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async removeFollowUp(
|
|
207
|
+
request: ControllerRemoveFollowUpRequest,
|
|
208
|
+
): Promise<ControllerFollowUpResult> {
|
|
209
|
+
return await this.transport.request<ControllerFollowUpResult>(
|
|
210
|
+
"workflow.removeFollowUp",
|
|
211
|
+
request as unknown as JsonValue,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function runControllerWorker(): Promise<number> {
|
|
217
|
+
const launch = readLaunchEnvelope();
|
|
218
|
+
const transport = new ControllerWorkerTransport(launch);
|
|
219
|
+
try {
|
|
220
|
+
const definition = await loadControllerFile(launch.definitionPath);
|
|
221
|
+
if (definition.name !== launch.controllerName) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
`Controller source name changed: expected ${launch.controllerName}, got ${definition.name}`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
await transport.request("worker.ready", {});
|
|
227
|
+
const abort = new AbortController();
|
|
228
|
+
const timeoutMs = definition.timeoutMs ?? launch.timeoutMs;
|
|
229
|
+
const timer = setTimeout(
|
|
230
|
+
() => abort.abort(new Error(`Reconciliation timed out after ${timeoutMs}ms`)),
|
|
231
|
+
timeoutMs,
|
|
232
|
+
);
|
|
233
|
+
try {
|
|
234
|
+
const context: ReconcileContext<unknown> = {
|
|
235
|
+
signal: abort.signal,
|
|
236
|
+
effects: new HostedControllerEffects(transport, abort.signal),
|
|
237
|
+
workflows: new HostedControllerWorkflows(transport),
|
|
238
|
+
...createResultHelpers<unknown>(),
|
|
239
|
+
};
|
|
240
|
+
const result = await raceWithAbort(
|
|
241
|
+
Promise.resolve(definition.reconcile(context, launch.resource)),
|
|
242
|
+
abort.signal,
|
|
243
|
+
);
|
|
244
|
+
validateResult(result);
|
|
245
|
+
await transport.request("worker.finished", result as unknown as JsonValue);
|
|
246
|
+
} catch (error) {
|
|
247
|
+
await transport.request("worker.failed", { error: boundedError(error) });
|
|
248
|
+
} finally {
|
|
249
|
+
clearTimeout(timer);
|
|
250
|
+
}
|
|
251
|
+
return 0;
|
|
252
|
+
} finally {
|
|
253
|
+
transport.close();
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function readLaunchEnvelope(): ControllerWorkerLaunchEnvelope {
|
|
258
|
+
const encoded = process.env[STARTUP_ENV];
|
|
259
|
+
if (encoded === undefined) throw new Error("Controller worker launch envelope is missing");
|
|
260
|
+
const value = parseJson(Buffer.from(encoded, "base64url").toString("utf8"));
|
|
261
|
+
if (
|
|
262
|
+
typeof value !== "object" ||
|
|
263
|
+
value === null ||
|
|
264
|
+
Array.isArray(value) ||
|
|
265
|
+
(value as { schema?: unknown }).schema !== CONTROLLER_WORKER_LAUNCH_SCHEMA
|
|
266
|
+
) {
|
|
267
|
+
throw new Error("Controller worker launch envelope is invalid");
|
|
268
|
+
}
|
|
269
|
+
return value as unknown as ControllerWorkerLaunchEnvelope;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function validateResult(value: unknown): asserts value is ReconcileResult<unknown> {
|
|
273
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
274
|
+
throw new Error("Controller reconcile result must be an object");
|
|
275
|
+
}
|
|
276
|
+
const result = value as { kind?: unknown; afterMs?: unknown };
|
|
277
|
+
if (result.kind !== "settled" && result.kind !== "requeue") {
|
|
278
|
+
throw new Error("Controller reconcile result kind is invalid");
|
|
279
|
+
}
|
|
280
|
+
if (
|
|
281
|
+
result.afterMs !== undefined &&
|
|
282
|
+
(!Number.isSafeInteger(result.afterMs) || (result.afterMs as number) < 0)
|
|
283
|
+
) {
|
|
284
|
+
throw new Error("Controller reconcile afterMs must be a non-negative safe integer");
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function raceWithAbort<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
289
|
+
return new Promise<T>((resolve, reject) => {
|
|
290
|
+
const onAbort = () => reject(signal.reason ?? new Error("Reconciliation aborted"));
|
|
291
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
292
|
+
operation.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
|
|
293
|
+
if (signal.aborted) onAbort();
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function boundedError(error: unknown): string {
|
|
298
|
+
const message = errorMessage(error);
|
|
299
|
+
return message.length <= 8_192 ? message : `${message.slice(0, 8_192)}…`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function main(): Promise<void> {
|
|
303
|
+
try {
|
|
304
|
+
process.exitCode = await runControllerWorker();
|
|
305
|
+
} catch (error) {
|
|
306
|
+
process.stderr.write(`${errorMessage(error)}\n`);
|
|
307
|
+
process.exitCode = 1;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) void main();
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { ControllerResource } from "../controllers/types.js";
|
|
2
|
+
import { canonicalJson, parseJson, type JsonValue } from "../state/json.js";
|
|
3
|
+
|
|
4
|
+
export const CONTROLLER_WORKER_LAUNCH_SCHEMA = "pi-workflows.controller-worker-launch.v1" as const;
|
|
5
|
+
export const CONTROLLER_WORKER_MESSAGE_SCHEMA =
|
|
6
|
+
"pi-workflows.controller-worker-message.v1" as const;
|
|
7
|
+
export const CONTROLLER_WORKER_RESPONSE_SCHEMA =
|
|
8
|
+
"pi-workflows.controller-worker-response.v1" as const;
|
|
9
|
+
|
|
10
|
+
export type ControllerWorkerLaunchEnvelope = {
|
|
11
|
+
schema: typeof CONTROLLER_WORKER_LAUNCH_SCHEMA;
|
|
12
|
+
workerEpoch: string;
|
|
13
|
+
hostEpoch: number;
|
|
14
|
+
generation: number;
|
|
15
|
+
projectPath: string;
|
|
16
|
+
definitionPath: string;
|
|
17
|
+
controllerName: string;
|
|
18
|
+
resource: ControllerResource;
|
|
19
|
+
timeoutMs: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type ControllerWorkerOperation =
|
|
23
|
+
| "worker.ready"
|
|
24
|
+
| "effect.reserve"
|
|
25
|
+
| "effect.settle"
|
|
26
|
+
| "workflow.ensure"
|
|
27
|
+
| "workflow.changeSettings"
|
|
28
|
+
| "workflow.queueFollowUp"
|
|
29
|
+
| "workflow.removeFollowUp"
|
|
30
|
+
| "worker.finished"
|
|
31
|
+
| "worker.failed";
|
|
32
|
+
|
|
33
|
+
export type ControllerWorkerMessage = {
|
|
34
|
+
schema: typeof CONTROLLER_WORKER_MESSAGE_SCHEMA;
|
|
35
|
+
launchSchema: typeof CONTROLLER_WORKER_LAUNCH_SCHEMA;
|
|
36
|
+
messageId: string;
|
|
37
|
+
workerEpoch: string;
|
|
38
|
+
generation: number;
|
|
39
|
+
operation: ControllerWorkerOperation;
|
|
40
|
+
payload: JsonValue;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type ControllerWorkerResponse = {
|
|
44
|
+
schema: typeof CONTROLLER_WORKER_RESPONSE_SCHEMA;
|
|
45
|
+
messageId: string;
|
|
46
|
+
outcome: "accepted" | "rejected" | "claimLost";
|
|
47
|
+
result?: JsonValue;
|
|
48
|
+
error?: string;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export function encodeControllerWorkerLine(
|
|
52
|
+
value: ControllerWorkerMessage | ControllerWorkerResponse,
|
|
53
|
+
): Buffer {
|
|
54
|
+
return Buffer.from(`${canonicalJson(value)}\n`, "utf8");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function parseControllerWorkerMessage(frame: Buffer): ControllerWorkerMessage {
|
|
58
|
+
const value = parseJson(frame.toString("utf8"));
|
|
59
|
+
if (!isRecord(value) || value.schema !== CONTROLLER_WORKER_MESSAGE_SCHEMA) {
|
|
60
|
+
throw new Error("Invalid controller worker message schema");
|
|
61
|
+
}
|
|
62
|
+
if (
|
|
63
|
+
value.launchSchema !== CONTROLLER_WORKER_LAUNCH_SCHEMA ||
|
|
64
|
+
typeof value.messageId !== "string" ||
|
|
65
|
+
typeof value.workerEpoch !== "string" ||
|
|
66
|
+
!Number.isSafeInteger(value.generation) ||
|
|
67
|
+
typeof value.operation !== "string" ||
|
|
68
|
+
!OPERATIONS.has(value.operation as ControllerWorkerOperation) ||
|
|
69
|
+
!Object.hasOwn(value, "payload")
|
|
70
|
+
) {
|
|
71
|
+
throw new Error("Invalid controller worker message");
|
|
72
|
+
}
|
|
73
|
+
return value as ControllerWorkerMessage;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function parseControllerWorkerResponse(frame: Buffer): ControllerWorkerResponse {
|
|
77
|
+
const value = parseJson(frame.toString("utf8"));
|
|
78
|
+
if (!isRecord(value) || value.schema !== CONTROLLER_WORKER_RESPONSE_SCHEMA) {
|
|
79
|
+
throw new Error("Invalid controller worker response schema");
|
|
80
|
+
}
|
|
81
|
+
if (
|
|
82
|
+
typeof value.messageId !== "string" ||
|
|
83
|
+
!["accepted", "rejected", "claimLost"].includes(String(value.outcome))
|
|
84
|
+
) {
|
|
85
|
+
throw new Error("Invalid controller worker response");
|
|
86
|
+
}
|
|
87
|
+
return value as ControllerWorkerResponse;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const OPERATIONS = new Set<ControllerWorkerOperation>([
|
|
91
|
+
"worker.ready",
|
|
92
|
+
"effect.reserve",
|
|
93
|
+
"effect.settle",
|
|
94
|
+
"workflow.ensure",
|
|
95
|
+
"workflow.changeSettings",
|
|
96
|
+
"workflow.queueFollowUp",
|
|
97
|
+
"workflow.removeFollowUp",
|
|
98
|
+
"worker.finished",
|
|
99
|
+
"worker.failed",
|
|
100
|
+
]);
|
|
101
|
+
|
|
102
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
103
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
104
|
+
}
|