@agentswf/wf 0.0.0-stage → 0.1.0-beta.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/package.json CHANGED
@@ -1,6 +1,35 @@
1
1
  {
2
2
  "name": "@agentswf/wf",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-beta.0",
4
+ "description": "agents.wf: the wf command an agent uses inside a run",
5
+ "license": "MIT",
6
+ "homepage": "https://github.com/romanlv/agentswf",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/romanlv/agentswf.git",
10
+ "directory": "packages/wf"
11
+ },
12
+ "type": "module",
13
+ "engines": {
14
+ "bun": ">=1.4.0"
15
+ },
16
+ "bin": {
17
+ "wf": "./src/cli.ts"
18
+ },
19
+ "exports": {
20
+ "./client": "./src/client.ts",
21
+ "./package.json": "./package.json"
22
+ },
23
+ "files": [
24
+ "src",
25
+ "!src/**/*.test.ts",
26
+ "!src/**/*.eval.ts",
27
+ "!src/**/fixtures"
28
+ ],
29
+ "dependencies": {
30
+ "@agentswf/contract": "0.1.0-beta.0"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ }
35
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,485 @@
1
+ #!/usr/bin/env -S bun --no-env-file
2
+ import {
3
+ type ControlRequest,
4
+ type ControlResponse,
5
+ type MessageRequest,
6
+ validWaitingReason,
7
+ WIRE_VERSION,
8
+ } from "@agentswf/contract/wire";
9
+ import { NotSentError, submitControl } from "./client";
10
+
11
+ /** `wf` is its launcher's full path, as an agent must type it; `wf` alone when it is not known. */
12
+ function usage(wf: string): string {
13
+ return [
14
+ `usage: ${wf} result <call-id> '<json>'`,
15
+ ` ${wf} result <call-id> < result.json`,
16
+ ` ${wf} waiting <call-id> --reason <text> [--timeout <duration>]`,
17
+ ` ${wf} peers [--json]`,
18
+ ...messageUsage(wf, " "),
19
+ "",
20
+ "The call id is the one named in the request. Pass JSON as one argument; with no argument,",
21
+ "JSON is read from standard input.",
22
+ ].join("\n");
23
+ }
24
+
25
+ function messageUsage(wf: string, indent = "usage: "): string[] {
26
+ return [
27
+ `${indent}${wf} send [--await-reply [--timeout <duration>]] <peer> <<'EOF'`,
28
+ " <message>",
29
+ " EOF",
30
+ ` ${wf} reply [--await-reply [--timeout <duration>]] <peer> <<'EOF'`,
31
+ " <answer>",
32
+ " EOF",
33
+ "",
34
+ "Flags go before the message. The message comes from a quoted heredoc, standard input or one",
35
+ "final argument; given an argument, standard input is not read. One that starts with -- goes",
36
+ "in a heredoc.",
37
+ ];
38
+ }
39
+ const MAX_STDIN_BYTES = 1024 * 1024;
40
+ const STDIN_TIMEOUT_MS = 30_000;
41
+
42
+ export type CliOutcome = { exitCode: number; stdout: string; stderr: string };
43
+
44
+ type Submit = (endpoint: string, request: ControlRequest) => Promise<ControlResponse>;
45
+
46
+ export async function runCli(
47
+ argv: readonly string[],
48
+ stdin: string | null,
49
+ submit: Submit = submitControl,
50
+ ): Promise<CliOutcome> {
51
+ // The launcher the engine installs supplies `--at`; the socket is an address, not a secret.
52
+ const {
53
+ endpoint,
54
+ session,
55
+ launcher,
56
+ command: [command, ...args],
57
+ } = launched(argv);
58
+ const wf = launcher ?? "wf";
59
+ if (!endpoint) {
60
+ return usageError("wf must be run through the launcher the workflow engine installed");
61
+ }
62
+ switch (command) {
63
+ case "waiting":
64
+ return runWaiting(endpoint, session, args, submit);
65
+ case "result":
66
+ return runResult(endpoint, session, args, stdin, submit);
67
+ case "peers":
68
+ return runPeers(endpoint, session, args, submit);
69
+ case "send":
70
+ case "reply":
71
+ return runMessage(endpoint, session, command, args, stdin, wf, submit);
72
+ default:
73
+ return usageError(usage(wf));
74
+ }
75
+ }
76
+
77
+ async function runResult(
78
+ endpoint: string,
79
+ session: string | undefined,
80
+ args: readonly string[],
81
+ stdin: string | null,
82
+ submit: Submit,
83
+ ): Promise<CliOutcome> {
84
+ const [operationId, ...rest] = args;
85
+ if (!operationId) return usageError("wf result needs the call id it is answering");
86
+ if (rest.length > 1) {
87
+ return usageError("wf result takes exactly one JSON argument or standard input");
88
+ }
89
+ if (rest.length === 1 && stdin !== null) {
90
+ return usageError("wf result cannot read both an argument and standard input");
91
+ }
92
+ if (rest.length === 0 && stdin === null) {
93
+ return usageError("wf result needs one JSON argument or standard input");
94
+ }
95
+ const raw = rest[0] ?? stdin ?? "";
96
+ if (raw.trim() === "") return usageError("wf result input cannot be empty");
97
+
98
+ let response: ControlResponse;
99
+ try {
100
+ response = await submit(endpoint, {
101
+ version: WIRE_VERSION,
102
+ command: "result",
103
+ operationId,
104
+ raw,
105
+ ...(session ? { session } : {}),
106
+ });
107
+ } catch (error) {
108
+ const reason = error instanceof Error ? error.message : String(error);
109
+ return { exitCode: 1, stdout: "", stderr: `wf: result submission failed.\n${reason}` };
110
+ }
111
+
112
+ if (response.kind === "rejected") {
113
+ return refused(response, `wf: ${rejection(response, operationId)}`);
114
+ }
115
+ if (response.kind !== "accepted")
116
+ return { exitCode: 1, stdout: "", stderr: "wf: unexpected waiting acknowledgement for result" };
117
+ return succeeded(response, "wf: result accepted");
118
+ }
119
+
120
+ async function runWaiting(
121
+ endpoint: string,
122
+ session: string | undefined,
123
+ args: readonly string[],
124
+ submit: Submit,
125
+ ): Promise<CliOutcome> {
126
+ const [operationId, ...flags] = args;
127
+ if (!operationId?.trim() || operationId.startsWith("--"))
128
+ return usageError("wf waiting needs the call id it is waiting on");
129
+ const values = new Map<string, string>();
130
+ for (let i = 0; i < flags.length; i += 2) {
131
+ const flag = flags[i]!;
132
+ const value = flags[i + 1];
133
+ if ((flag !== "--reason" && flag !== "--timeout") || values.has(flag) || value === undefined)
134
+ return usageError("wf waiting accepts --reason once and optional --timeout once");
135
+ values.set(flag, value);
136
+ }
137
+ const reason = values.get("--reason");
138
+ if (!validWaitingReason(reason))
139
+ return usageError("wf waiting --reason must be non-blank and at most 2048 UTF-8 bytes");
140
+ const duration = values.get("--timeout");
141
+ let timeoutMs: number | undefined;
142
+ if (duration !== undefined) {
143
+ timeoutMs = durationOf(duration);
144
+ if (timeoutMs === undefined)
145
+ return usageError("wf waiting --timeout must be a positive integer with ms, s, m or h");
146
+ }
147
+ let response: ControlResponse;
148
+ try {
149
+ response = await submit(endpoint, {
150
+ version: WIRE_VERSION,
151
+ command: "waiting",
152
+ operationId,
153
+ reason,
154
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
155
+ ...(session ? { session } : {}),
156
+ });
157
+ } catch (error) {
158
+ const detail = error instanceof Error ? error.message : String(error);
159
+ return {
160
+ exitCode: 1,
161
+ stdout: "",
162
+ stderr: `wf: waiting acknowledgement is uncertain; the engine may have granted the wait. No retry was sent.\n${detail}`,
163
+ };
164
+ }
165
+ if (response.kind === "rejected")
166
+ return refused(
167
+ response,
168
+ response.code === "owes-reply" || response.code === "no-result"
169
+ ? `wf: waiting refused (${response.code}): ${response.error}`
170
+ : `wf: waiting rejected (${response.code}).\n${response.error}`,
171
+ );
172
+ if (response.kind === "awaiting-reply") return succeeded(response, `wf: ${response.notice}`);
173
+ if (response.kind !== "waiting")
174
+ return {
175
+ exitCode: 1,
176
+ stdout: "",
177
+ stderr: "wf: unexpected acknowledgement for waiting; wait is uncertain",
178
+ };
179
+ return succeeded(
180
+ response,
181
+ `wf: waiting granted until ${response.waitUntil}; operation deadline ${response.deadline} (Unix milliseconds)`,
182
+ );
183
+ }
184
+
185
+ async function runPeers(
186
+ endpoint: string,
187
+ session: string | undefined,
188
+ args: readonly string[],
189
+ submit: Submit,
190
+ ): Promise<CliOutcome> {
191
+ if (args.length > 1 || (args.length === 1 && args[0] !== "--json"))
192
+ return usageError("wf peers takes only --json");
193
+ let response: ControlResponse;
194
+ try {
195
+ response = await submit(endpoint, {
196
+ version: WIRE_VERSION,
197
+ command: "peers",
198
+ ...(session ? { session } : {}),
199
+ });
200
+ } catch (error) {
201
+ const detail = error instanceof Error ? error.message : String(error);
202
+ return { exitCode: 1, stdout: "", stderr: `wf: peers failed; nothing changed.\n${detail}` };
203
+ }
204
+ if (response.kind === "rejected")
205
+ return refused(response, `wf: peers refused (${response.code}): ${response.error}`);
206
+ if (response.kind !== "peers")
207
+ return { exitCode: 1, stdout: "", stderr: "wf: unexpected acknowledgement for peers" };
208
+ // The table says what is owed itself, so the owed-answer line does not lead it.
209
+ if (args[0] === "--json") {
210
+ const { peers, owes, awaits } = response;
211
+ return {
212
+ exitCode: 0,
213
+ stdout: JSON.stringify({ peers, owes, ...(awaits ? { awaits } : {}) }),
214
+ stderr: "",
215
+ };
216
+ }
217
+ return { exitCode: 0, stdout: response.text, stderr: "" };
218
+ }
219
+
220
+ async function runMessage(
221
+ endpoint: string,
222
+ session: string | undefined,
223
+ command: "send" | "reply",
224
+ args: readonly string[],
225
+ stdin: string | null,
226
+ wf: string,
227
+ submit: Submit,
228
+ ): Promise<CliOutcome> {
229
+ const parsed = messageArgs(args);
230
+ const wrong = (problem: string) =>
231
+ usageError(`wf: ${command}: ${problem}\n\n${messageUsage(wf).join("\n")}`);
232
+ if ("error" in parsed) return wrong(parsed.error);
233
+ const { peer, message, awaitReply, timeoutMs } = parsed;
234
+ // A heredoc ends with a newline the agent did not mean as part of the message.
235
+ const text = message ?? stdin?.replace(/\r?\n$/, "") ?? null;
236
+ if (text === null || text.trim() === "") return wrong("it needs a message");
237
+ const request: MessageRequest = {
238
+ version: WIRE_VERSION,
239
+ command,
240
+ peer,
241
+ text,
242
+ ...(awaitReply ? { awaitReply: true as const } : {}),
243
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
244
+ ...(session ? { session } : {}),
245
+ };
246
+ let response: ControlResponse;
247
+ try {
248
+ response = await submit(endpoint, request);
249
+ } catch (error) {
250
+ const detail = error instanceof Error ? error.message : String(error);
251
+ const what = command === "send" ? "message" : "reply";
252
+ // Refused at the connection, nothing reached the engine; after, it may have been admitted.
253
+ if (error instanceof NotSentError) {
254
+ return {
255
+ exitCode: 1,
256
+ stdout: "",
257
+ stderr: `wf: the ${what} was not sent: the engine could not be reached. Try again.\n${detail}`,
258
+ };
259
+ }
260
+ return {
261
+ exitCode: 1,
262
+ stdout: "",
263
+ stderr: `wf: the engine did not confirm this ${what}, so it may have been queued. Nothing was retried: do not send it again.\n${detail}`,
264
+ };
265
+ }
266
+ if (response.kind === "rejected")
267
+ return refused(response, `wf: ${command} refused (${response.code}): ${response.error}`);
268
+ if (response.kind !== "sent")
269
+ return { exitCode: 1, stdout: "", stderr: `wf: unexpected acknowledgement for ${command}` };
270
+ return succeeded(response, `wf: ${response.notice}`);
271
+ }
272
+
273
+ /**
274
+ * Flags, the peer and the message if it is an argument. Flags may come before the peer or right
275
+ * after it, as agents type them; one after the message would otherwise go as its text.
276
+ */
277
+ function messageArgs(
278
+ args: readonly string[],
279
+ ): { peer: string; message?: string; awaitReply: boolean; timeoutMs?: number } | { error: string } {
280
+ let awaitReply = false;
281
+ let timeoutMs: number | undefined;
282
+ const positional: string[] = [];
283
+ for (let i = 0; i < args.length; i += 1) {
284
+ const arg = args[i]!;
285
+ if (!arg.startsWith("--")) {
286
+ positional.push(arg);
287
+ continue;
288
+ }
289
+ if (positional.length > 1) return { error: "flags go before the message" };
290
+ if (arg === "--await-reply" && !awaitReply) awaitReply = true;
291
+ else if (arg === "--timeout" && timeoutMs === undefined) {
292
+ timeoutMs = durationOf(args[i + 1] ?? "");
293
+ if (timeoutMs === undefined)
294
+ return { error: "--timeout takes a positive integer with ms, s, m or h, such as 5m" };
295
+ i += 1;
296
+ } else if (positional.length === 1 && arg !== "--await-reply" && arg !== "--timeout") {
297
+ return { error: `${arg} is not a flag; a message that starts with -- goes in a heredoc` };
298
+ } else return { error: `${arg} is not a flag it takes, or is given twice` };
299
+ }
300
+ if (timeoutMs !== undefined && !awaitReply) return { error: "--timeout needs --await-reply" };
301
+ const [peer, ...rest] = positional;
302
+ if (!peer) return { error: "it needs the peer to send to" };
303
+ if (rest.length > 1)
304
+ return { error: "the message is one argument; quote it, or use a heredoc or standard input" };
305
+ return {
306
+ peer,
307
+ ...(rest[0] === undefined ? {} : { message: rest[0] }),
308
+ awaitReply,
309
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
310
+ };
311
+ }
312
+
313
+ function durationOf(text: string): number | undefined {
314
+ const match = /^([0-9]+)(ms|s|m|h)$/.exec(text);
315
+ if (!match) return undefined;
316
+ const factor = { ms: 1, s: 1000, m: 60_000, h: 3_600_000 }[match[2]!]!;
317
+ const value = Number(match[1]) * factor;
318
+ return Number.isSafeInteger(value) && value > 0 ? value : undefined;
319
+ }
320
+
321
+ /** What is owed leads, so it comes back after a harness compacts; then the acknowledgement. */
322
+ function succeeded(response: { owing?: string }, line: string): CliOutcome {
323
+ return {
324
+ exitCode: 0,
325
+ stdout: response.owing ? `wf: ${response.owing}\n${line}` : line,
326
+ stderr: "",
327
+ };
328
+ }
329
+
330
+ function refused(response: { owing?: string }, line: string): CliOutcome {
331
+ return {
332
+ exitCode: 1,
333
+ stdout: "",
334
+ stderr: response.owing ? `wf: ${response.owing}\n${line}` : line,
335
+ };
336
+ }
337
+
338
+ /**
339
+ * An agent reads this and decides whether to try again, so each case has to say what it can do
340
+ * about it. E5 measured what that is worth: a rejection carrying the reason was corrected in 2.00
341
+ * attempts, a bare refusal in 2.90 to 4.95.
342
+ */
343
+ function rejection(
344
+ response: Extract<ControlResponse, { kind: "rejected" }>,
345
+ operationId: string,
346
+ ): string {
347
+ switch (response.code) {
348
+ case "invalid-result":
349
+ return (
350
+ `result rejected.\n${response.error}\nFix the value and run wf result again. ` +
351
+ "A long value can be written to a file and passed with < file."
352
+ );
353
+ case "wrong-agent":
354
+ return `${operationId} is another agent's call, not yours to answer`;
355
+ case "unknown-operation":
356
+ // Correctable, unlike the two below: the id is wrong, not the call gone. Transposing the id
357
+ // and the JSON lands here, and telling the agent to give up would end a live turn.
358
+ return (
359
+ `no call named ${operationId} is open. ` +
360
+ "Check the id in the request and run wf result again."
361
+ );
362
+ case "expired-operation":
363
+ case "closed-operation":
364
+ return "that call is no longer open; the workflow engine must start a new one";
365
+ // Messaging's: the engine names the peer and the next step.
366
+ case "owes-reply":
367
+ case "no-result":
368
+ return `result refused (${response.code}): ${response.error}`;
369
+ default:
370
+ // Everything else — too large, malformed, wrong version — is only distinguishable by what
371
+ // the engine said, and whether a shorter or different value would be taken.
372
+ return `result submission was rejected.\n${response.error}`;
373
+ }
374
+ }
375
+
376
+ /**
377
+ * The launcher supplies `--at <socket>`, then `--as <its own path>`, then `--session <id>`
378
+ * expanded from the harness's own variable, which is empty when the agent's shell does not set it.
379
+ */
380
+ function launched(argv: readonly string[]): {
381
+ endpoint?: string;
382
+ session?: string;
383
+ launcher?: string;
384
+ command: readonly string[];
385
+ } {
386
+ const [flag, endpoint, ...rest] = argv;
387
+ if (flag !== "--at" || !endpoint) return { command: rest };
388
+ let command = rest;
389
+ let launcher: string | undefined;
390
+ if (command[0] === "--as") {
391
+ launcher = command[1]?.trim() || undefined;
392
+ command = command.slice(2);
393
+ }
394
+ const named = launcher ? { launcher } : {};
395
+ if (command[0] !== "--session") return { endpoint, ...named, command };
396
+ const session = command[1]?.trim();
397
+ return { endpoint, ...named, ...(session ? { session } : {}), command: command.slice(2) };
398
+ }
399
+
400
+ function usageError(stderr: string): CliOutcome {
401
+ return { exitCode: 2, stdout: "", stderr };
402
+ }
403
+
404
+ if (import.meta.main) {
405
+ let stdinText: string | null;
406
+ try {
407
+ stdinText = await readCliStdin(
408
+ process.argv.slice(2),
409
+ process.stdin.isTTY === true,
410
+ Bun.stdin.stream(),
411
+ );
412
+ } catch (error) {
413
+ console.error(`wf: ${error instanceof Error ? error.message : String(error)}`);
414
+ process.exit(2);
415
+ }
416
+ const outcome = await runCli(process.argv.slice(2), stdinText);
417
+ if (outcome.stdout) console.log(outcome.stdout);
418
+ if (outcome.stderr) console.error(outcome.stderr);
419
+ process.exit(outcome.exitCode);
420
+ }
421
+
422
+ /**
423
+ * Standard input, for the commands that take it: `result <call-id>` and nothing more, or `send`
424
+ * and `reply` with no message argument after the peer.
425
+ */
426
+ export async function readCliStdin(
427
+ argv: readonly string[],
428
+ isTTY: boolean,
429
+ stream: ReadableStream<Uint8Array>,
430
+ ): Promise<string | null> {
431
+ if (isTTY) return null;
432
+ const [verb, ...args] = launched(argv).command;
433
+ const parsed = verb === "send" || verb === "reply" ? messageArgs(args) : undefined;
434
+ const reads =
435
+ (verb === "result" && args.length === 1) ||
436
+ (parsed !== undefined && !("error" in parsed) && parsed.message === undefined);
437
+ if (!reads) return null;
438
+ const text = await readBoundedStdin(stream);
439
+ return text === "" ? null : text;
440
+ }
441
+
442
+ /**
443
+ * Bounded in time as well as size. Everything else on this path has a deadline — the client waits
444
+ * 30s, the control plane 30s — and a tool runner that hands the child an inherited pipe instead of
445
+ * `/dev/null` would otherwise stall the agent's whole turn on a read that never ends.
446
+ */
447
+ export async function readBoundedStdin(
448
+ stream: ReadableStream<Uint8Array>,
449
+ maxBytes = MAX_STDIN_BYTES,
450
+ timeoutMs = STDIN_TIMEOUT_MS,
451
+ ): Promise<string> {
452
+ const reader = stream.getReader();
453
+ const chunks: Uint8Array[] = [];
454
+ let bytes = 0;
455
+ let expire!: () => void;
456
+ const expired = new Promise<"expired">((resolve) => {
457
+ expire = () => resolve("expired");
458
+ });
459
+ const expiry = setTimeout(expire, timeoutMs);
460
+ try {
461
+ for (;;) {
462
+ const next = await Promise.race([reader.read(), expired]);
463
+ if (next === "expired") {
464
+ await reader.cancel().catch(() => undefined);
465
+ throw new Error("stdin was not closed in time");
466
+ }
467
+ if (next.done) break;
468
+ bytes += next.value.byteLength;
469
+ if (bytes > maxBytes) {
470
+ await reader.cancel().catch(() => undefined);
471
+ throw new Error("stdin exceeds the size limit");
472
+ }
473
+ chunks.push(next.value);
474
+ }
475
+ } finally {
476
+ clearTimeout(expiry);
477
+ }
478
+ const joined = new Uint8Array(bytes);
479
+ let offset = 0;
480
+ for (const chunk of chunks) {
481
+ joined.set(chunk, offset);
482
+ offset += chunk.byteLength;
483
+ }
484
+ return new TextDecoder().decode(joined);
485
+ }
package/src/client.ts ADDED
@@ -0,0 +1,111 @@
1
+ import { createConnection, type Socket } from "node:net";
2
+ import {
3
+ type ControlRequest,
4
+ decodeResponseFor,
5
+ type ResponseFor,
6
+ type ResultSubmitRequest,
7
+ type ResultSubmitResponse,
8
+ type WaitingRequest,
9
+ type WaitingResponse,
10
+ } from "@agentswf/contract/wire";
11
+
12
+ const MAX_RESPONSE_BYTES = 256 * 1024;
13
+
14
+ /** The connection never opened: nothing was written, so nothing reached the engine. */
15
+ export class NotSentError extends Error {}
16
+
17
+ export function submitResult(
18
+ endpoint: string,
19
+ request: ResultSubmitRequest,
20
+ timeoutSeconds = 30,
21
+ connect: (endpoint: string) => Socket = createConnection,
22
+ ): Promise<ResultSubmitResponse> {
23
+ return submitControl(endpoint, request, timeoutSeconds, connect);
24
+ }
25
+
26
+ export function submitWaiting(
27
+ endpoint: string,
28
+ request: WaitingRequest,
29
+ timeoutSeconds = 30,
30
+ connect: (endpoint: string) => Socket = createConnection,
31
+ ): Promise<WaitingResponse> {
32
+ return submitControl(endpoint, request, timeoutSeconds, connect);
33
+ }
34
+
35
+ /** Sends one request and resolves with a response that answers its command. */
36
+ export function submitControl<R extends ControlRequest>(
37
+ endpoint: string,
38
+ request: R,
39
+ timeoutSeconds = 30,
40
+ connect: (endpoint: string) => Socket = createConnection,
41
+ ): Promise<ResponseFor[R["command"]]> {
42
+ type Response = ResponseFor[R["command"]];
43
+ if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0) {
44
+ return Promise.reject(new Error("control-plane timeout must be positive"));
45
+ }
46
+ const outgoing = Buffer.from(`${JSON.stringify(request)}\n`);
47
+
48
+ return new Promise<Response>((resolve, reject) => {
49
+ const chunks: Buffer[] = [];
50
+ let bytes = 0;
51
+ let settled = false;
52
+ let connected = false;
53
+ const socket = connect(endpoint);
54
+ const lifetime = setTimeout(
55
+ () => fail(new Error("control plane response timed out")),
56
+ timeoutSeconds * 1000,
57
+ );
58
+
59
+ const fail = (error: Error): void => {
60
+ if (settled) return;
61
+ settled = true;
62
+ clearTimeout(lifetime);
63
+ socket.destroy();
64
+ reject(error);
65
+ };
66
+
67
+ socket.once("connect", () => {
68
+ connected = true;
69
+ socket.end(outgoing);
70
+ });
71
+ socket.on("data", (data) => {
72
+ if (settled) return;
73
+ const chunk = typeof data === "string" ? Buffer.from(data) : data;
74
+ bytes += chunk.byteLength;
75
+ if (bytes > MAX_RESPONSE_BYTES) {
76
+ fail(new Error("control-plane response exceeds the size limit"));
77
+ return;
78
+ }
79
+ chunks.push(chunk);
80
+ });
81
+ socket.once("end", () => {
82
+ if (settled) return;
83
+ const frame = Buffer.concat(chunks).toString("utf8");
84
+ if (!frame.endsWith("\n") || frame.slice(0, -1).includes("\n")) {
85
+ fail(new Error("control plane returned an invalid response frame"));
86
+ return;
87
+ }
88
+ let parsed: unknown;
89
+ try {
90
+ parsed = JSON.parse(frame.slice(0, -1));
91
+ } catch {
92
+ fail(new Error("control plane returned invalid JSON"));
93
+ return;
94
+ }
95
+ const decoded = decodeResponseFor<R["command"]>(request.command, parsed);
96
+ if (!decoded.ok) {
97
+ fail(new Error(decoded.error));
98
+ return;
99
+ }
100
+ settled = true;
101
+ clearTimeout(lifetime);
102
+ resolve(decoded.value);
103
+ });
104
+ socket.once("error", (error) =>
105
+ fail(connected ? error : new NotSentError(error.message, { cause: error })),
106
+ );
107
+ socket.once("close", () => {
108
+ if (!settled) fail(new Error("control plane closed without a response"));
109
+ });
110
+ });
111
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.