@flow-engineer/flow 0.0.1 → 0.2.1

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.
Files changed (104) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +611 -0
  3. package/coding-agents/skill.md +353 -0
  4. package/coding-agents/test-skill.md +223 -0
  5. package/coding-agents/tools-skill.md +89 -0
  6. package/dist/flow-messaging/src/client.d.ts +40 -0
  7. package/dist/flow-messaging/src/client.js +142 -0
  8. package/dist/flow-messaging/src/index.d.ts +72 -0
  9. package/dist/flow-messaging/src/index.js +605 -0
  10. package/dist/simulators/src/index.d.ts +2 -0
  11. package/dist/simulators/src/index.js +2 -0
  12. package/dist/simulators/src/page.html +1245 -0
  13. package/dist/simulators/src/person.html +100 -0
  14. package/dist/simulators/src/platforms.d.ts +29 -0
  15. package/dist/simulators/src/platforms.js +72 -0
  16. package/dist/simulators/src/server.d.ts +201 -0
  17. package/dist/simulators/src/server.js +759 -0
  18. package/dist/src/agent.d.ts +504 -0
  19. package/dist/src/agent.js +818 -0
  20. package/dist/src/channels.d.ts +178 -0
  21. package/dist/src/channels.js +243 -0
  22. package/dist/src/conversation.d.ts +361 -0
  23. package/dist/src/conversation.js +1487 -0
  24. package/dist/src/db.d.ts +18 -0
  25. package/dist/src/db.js +57 -0
  26. package/dist/src/events.d.ts +17 -0
  27. package/dist/src/events.js +20 -0
  28. package/dist/src/http.d.ts +45 -0
  29. package/dist/src/http.js +269 -0
  30. package/dist/src/index.d.ts +14 -0
  31. package/dist/src/index.js +11 -0
  32. package/dist/src/model.d.ts +21 -0
  33. package/dist/src/model.js +96 -0
  34. package/dist/src/names.d.ts +22 -0
  35. package/dist/src/names.js +43 -0
  36. package/dist/src/nudges.d.ts +23 -0
  37. package/dist/src/nudges.js +117 -0
  38. package/dist/src/postgres.d.ts +20 -0
  39. package/dist/src/postgres.js +62 -0
  40. package/dist/src/schedules.d.ts +60 -0
  41. package/dist/src/schedules.js +249 -0
  42. package/dist/src/skills.d.ts +12 -0
  43. package/dist/src/skills.js +69 -0
  44. package/dist/src/store.d.ts +214 -0
  45. package/dist/src/store.js +448 -0
  46. package/dist/src/talk.d.ts +57 -0
  47. package/dist/src/talk.js +166 -0
  48. package/dist/src/time.d.ts +71 -0
  49. package/dist/src/time.js +170 -0
  50. package/dist/src/tools.d.ts +43 -0
  51. package/dist/src/tools.js +26 -0
  52. package/dist/src/traces.d.ts +31 -0
  53. package/dist/src/traces.js +303 -0
  54. package/dist/src/types.d.ts +260 -0
  55. package/dist/src/types.js +2 -0
  56. package/dist/testing/src/account.d.ts +4 -0
  57. package/dist/testing/src/account.js +580 -0
  58. package/dist/testing/src/chat.d.ts +3 -0
  59. package/dist/testing/src/chat.js +117 -0
  60. package/dist/testing/src/check-endpoint.d.ts +31 -0
  61. package/dist/testing/src/check-endpoint.js +158 -0
  62. package/dist/testing/src/chinwag.d.ts +2 -0
  63. package/dist/testing/src/chinwag.js +6 -0
  64. package/dist/testing/src/cli.d.ts +2 -0
  65. package/dist/testing/src/cli.js +592 -0
  66. package/dist/testing/src/client.d.ts +117 -0
  67. package/dist/testing/src/client.js +956 -0
  68. package/dist/testing/src/flow-login.d.ts +143 -0
  69. package/dist/testing/src/flow-login.js +249 -0
  70. package/dist/testing/src/http-agent.d.ts +47 -0
  71. package/dist/testing/src/http-agent.js +155 -0
  72. package/dist/testing/src/index.d.ts +3 -0
  73. package/dist/testing/src/index.js +2 -0
  74. package/dist/testing/src/new.d.ts +13 -0
  75. package/dist/testing/src/new.js +147 -0
  76. package/dist/testing/src/print-run.d.ts +3 -0
  77. package/dist/testing/src/print-run.js +22 -0
  78. package/dist/testing/src/protocol.d.ts +159 -0
  79. package/dist/testing/src/protocol.js +28 -0
  80. package/dist/testing/src/report.d.ts +74 -0
  81. package/dist/testing/src/report.js +90 -0
  82. package/dist/testing/src/show.d.ts +36 -0
  83. package/dist/testing/src/show.js +280 -0
  84. package/dist/testing/src/suite.d.ts +87 -0
  85. package/dist/testing/src/suite.js +198 -0
  86. package/docs/agents.md +85 -0
  87. package/examples/cafe/agent/AGENTS.md +10 -0
  88. package/examples/cafe/agent/skills/allergens/SKILL.md +7 -0
  89. package/examples/cafe/src/agent.ts +43 -0
  90. package/examples/cafe/src/channels.ts +20 -0
  91. package/examples/cafe/src/main.ts +30 -0
  92. package/examples/cafe/src/tools.ts +62 -0
  93. package/examples/cafe/tests/scenarios/latte-correction.yaml +30 -0
  94. package/examples/cafe/tests/scenarios/order-fails.yaml +26 -0
  95. package/examples/cafe/tests/tools/get_menu.yaml +14 -0
  96. package/examples/cafe/tests/tools/place_order.yaml +29 -0
  97. package/examples/http-agent/server.mjs +32 -0
  98. package/examples/http-agent/tests/scenarios/opening-hours.yaml +16 -0
  99. package/flow-messaging/README.md +191 -0
  100. package/package.json +63 -8
  101. package/spec/agent-endpoint.openapi.yaml +148 -0
  102. package/spec/nudges.openapi.yaml +167 -0
  103. package/spec/request.schema.json +121 -0
  104. package/spec/response.schema.json +284 -0
@@ -0,0 +1,956 @@
1
+ // The developer's side of the tunnel: connects a local agent to the test cloud. The agent keeps its
2
+ // own model and key; its tools are replaced by calls the cloud simulates, its replies go to the
3
+ // cloud's platform simulators, and the person's messages arrive over one stream.
4
+ import { createHash, randomUUID } from "node:crypto";
5
+ import { readFile } from "node:fs/promises";
6
+ import { Agent as HttpAgent, request as httpRequest } from "node:http";
7
+ import { Agent as HttpsAgent, request as httpsRequest } from "node:https";
8
+ import { basename, relative, resolve } from "node:path";
9
+ import { toSpec } from "../../src/index.js";
10
+ import { PROFILE_TOOL, profileTool } from "../../src/conversation.js";
11
+ import { STAND_IN } from "../../src/tools.js";
12
+ import { mimeOf, parseScenario, parseToolFile, suiteHash } from "./suite.js";
13
+ /** The session opened, then the cloud ended it with a `failed` command. `code` is the reason's leading
14
+ * code (`capacity: ...` gives capacity), when it starts with one. */
15
+ export class CloudEnded extends Error {
16
+ code;
17
+ constructor(error) {
18
+ super(`the cloud stopped the session: ${error}`);
19
+ this.name = "CloudEnded";
20
+ this.code = /^([a-z_]+):/.exec(error)?.[1];
21
+ }
22
+ }
23
+ /** The tunnel, the stream, or the connection failed after the session opened. `runs` are the ones already received. The CLI prints them and exits 2. */
24
+ export class SessionStopped extends Error {
25
+ runs;
26
+ dashboard;
27
+ constructor(reason, runs, dashboard) {
28
+ super(reason);
29
+ this.name = "SessionStopped";
30
+ this.runs = runs;
31
+ this.dashboard = dashboard;
32
+ }
33
+ }
34
+ /** The cloud said session_gone, and its open said it can re-open: runInCloud plays the unfinished runs once more. */
35
+ class SessionGone extends SessionStopped {
36
+ sessionId;
37
+ constructor(reason, runs, dashboard, sessionId) {
38
+ super(reason, runs, dashboard);
39
+ this.sessionId = sessionId;
40
+ }
41
+ }
42
+ /** A POST that sat until its own deadline. It fails that one call. Only a cloud that stores keys sends it again. The session abort is not one of these. */
43
+ export class TimeoutError extends Error {
44
+ constructor() {
45
+ super("timed out");
46
+ this.name = "TimeoutError";
47
+ }
48
+ }
49
+ /** The cloud answered a tool call `ok: false`. Its message is the cloud's code: a tool file's own error,
50
+ * or one of {@link CLOUD_TOOL_ERRORS} when the cloud could not play the call. */
51
+ class ToolFailed extends Error {
52
+ }
53
+ /** A tool call's codes for the cloud's own failure, not the tool's: its simulator failed, or the run had already ended. */
54
+ const CLOUD_TOOL_ERRORS = new Set(["simulator_failed", "run_finished"]);
55
+ /** The stream stopped carrying bytes: the idle watch fired, or the cloud closed it before finished. */
56
+ export class StreamLost extends Error {
57
+ constructor(message, options) {
58
+ super(message, options);
59
+ this.name = "StreamLost";
60
+ }
61
+ }
62
+ const POST_TIMEOUT_MS = 30_000;
63
+ const STREAM_IDLE_MS = 45_000;
64
+ const RESUME_WINDOW_MS = 120_000;
65
+ const STREAM_REFRESH_MS = 50 * 60_000;
66
+ /** How long one call keeps trying after its first failure. The first try's own wait does not count, so a timed-out try is still sent again. */
67
+ export const RETRY_BUDGET_MS = 30_000;
68
+ /** The cloud abandons one model attempt at 90s and tries a timeout once more, then this POST has its 30s. */
69
+ export const CLOUD_MODEL_BUDGET_MS = 90_000 * 2 + POST_TIMEOUT_MS;
70
+ /** How long a tool call may take: the longest latency its suite file declares, plus {@link CLOUD_MODEL_BUDGET_MS} (210s).
71
+ * A tool with no file gets the simulator's own cap (60s) plus that 210s.
72
+ *
73
+ * The cloud asks its model before the declared latency, and the model's own time counts toward it.
74
+ * 210s is two 90s attempts plus the 30s POST budget, so a stuck model can finish before this POST gives up.
75
+ * When it runs out, the agent sees an error while the cloud may still record the call in the world log once the model returns. */
76
+ export function toolTimeoutMs(files, tool, budgetMs = CLOUD_MODEL_BUDGET_MS) {
77
+ let declared = 0;
78
+ let known = false;
79
+ for (const file of files) {
80
+ if (!file.path.startsWith("tools/") || !file.path.endsWith(".yaml"))
81
+ continue;
82
+ try {
83
+ const parsed = parseToolFile(file);
84
+ if (parsed.tool !== tool)
85
+ continue;
86
+ known = true;
87
+ for (const response of parsed.responses)
88
+ declared = Math.max(declared, response.latency_ms);
89
+ }
90
+ catch { /* the cloud refuses the file; the call still waits the simulator's cap */ }
91
+ }
92
+ return budgetMs + (known ? declared : 60_000);
93
+ }
94
+ /** The base budget plus 1s per 100KB, so a 5MB event batch or file upload is not cut off at 30s. */
95
+ export function postBudgetMs(baseMs, bytes) {
96
+ return baseMs + Math.floor(Math.max(0, bytes) / 100_000) * 1_000;
97
+ }
98
+ /** The open's headers. A cloud off this machine needs the CI key and the synthetic attestation;
99
+ * a local one needs neither, and gets them when they are given. */
100
+ export function cloudHeaders(access) {
101
+ const local = isLoopback(access.cloud);
102
+ if (!local && !access.ciKey)
103
+ throw new Error("A hosted cloud needs the agent's CI key: set FLOW_FRAMEWORK_CLOUD_KEY.");
104
+ if ((!local || access.ciKey) && !access.synthetic) {
105
+ throw new Error("A hosted cloud runs synthetic tests only: pass --synthetic to attest that your test files hold no real customer or member data.");
106
+ }
107
+ if (access.environment && access.environment !== "staging" && access.environment !== "production") {
108
+ throw new Error("--environment must be staging or production.");
109
+ }
110
+ const headers = {};
111
+ if (access.ciKey)
112
+ headers.authorization = `Bearer ${access.ciKey}`;
113
+ if (access.synthetic)
114
+ headers["x-chinwag-synthetic"] = "true";
115
+ const optional = [
116
+ ["x-chinwag-agent-version", "--agent-version", access.agentVersion],
117
+ ["x-chinwag-ref", "--ref", access.ref],
118
+ ["x-chinwag-environment", "--environment", access.environment],
119
+ ];
120
+ for (const [header, flag, value] of optional) {
121
+ if (!value)
122
+ continue;
123
+ if (!/^[\x20-\x7e]{1,100}$/.test(value))
124
+ throw new Error(`${flag} must be 1 to 100 printable ASCII characters.`);
125
+ headers[header] = value;
126
+ }
127
+ return headers;
128
+ }
129
+ /** Where the session is played: Opened.tunnel when the cloud answers one, else the open's own address. */
130
+ export function tunnelBase(cloud, opened) {
131
+ if (!opened.tunnel)
132
+ return cloud.replace(/\/$/, "");
133
+ let url = null;
134
+ try {
135
+ url = new URL(opened.tunnel);
136
+ }
137
+ catch { }
138
+ if (!url || (url.protocol !== "https:" && url.protocol !== "http:") || url.username || url.password) {
139
+ throw new Error(`the cloud answered a tunnel that is not an http(s) URL: ${opened.tunnel}`);
140
+ }
141
+ return opened.tunnel.replace(/\/$/, "");
142
+ }
143
+ function isLoopback(cloud) {
144
+ const host = new URL(cloud).hostname;
145
+ return host === "localhost" || host === "[::1]" || /^127(\.\d{1,3}){3}$/.test(host);
146
+ }
147
+ /** Plays the suite in the cloud. If the cloud loses the session (session_gone, on a cloud whose open said
148
+ * reopen), one new session plays only the runs that have no result yet, and the two reports are merged. */
149
+ export async function runInCloud(options) {
150
+ try {
151
+ return await playSession(options);
152
+ }
153
+ catch (error) {
154
+ if (!(error instanceof SessionGone))
155
+ throw error;
156
+ const log = options.log ?? ((line) => console.log(line));
157
+ const unfinished = unfinishedRuns(options.files, options.runs ?? 1, error.runs);
158
+ if (!unfinished.length)
159
+ throw new SessionStopped(error.message, error.runs, error.dashboard);
160
+ const which = unfinished.map((r) => `${r.scenario} on ${r.channel} #${r.run}`).join(", ");
161
+ log(`The cloud lost session ${error.sessionId} (${error.message}). Opening a new session to play the ${unfinished.length} run${unfinished.length === 1 ? "" : "s"} with no result, at the cloud's cost again: ${which}`);
162
+ let second;
163
+ try {
164
+ // With nothing finished, the whole suite plays again without `only`: the cloud takes `only` just
165
+ // for files it already stored, and the first session may have been lost before its uploads.
166
+ second = await playSession(options, error.runs.length ? unfinished : undefined);
167
+ }
168
+ catch (again) {
169
+ // Never a third session: a second loss stops with what both sessions finished.
170
+ if (again instanceof SessionStopped)
171
+ throw new SessionStopped(again.message, [...error.runs, ...again.runs], again.dashboard);
172
+ throw new SessionStopped(`the cloud lost the session, and a new one did not open: ${again instanceof Error ? again.message : String(again)}`, error.runs, error.dashboard);
173
+ }
174
+ return { ...second, runs: [...error.runs, ...second.runs], lost: { sessionId: error.sessionId, dashboard: error.dashboard } };
175
+ }
176
+ }
177
+ /** The runs the suite plays (each scenario on each of its channels, `runs` times) that have no summary in `done`. */
178
+ function unfinishedRuns(files, runs, done) {
179
+ const have = new Set(done.map((r) => `${r.scenario}\n${r.channel}\n${r.run}`));
180
+ const out = [];
181
+ for (const file of files) {
182
+ if (!file.path.startsWith("scenarios/"))
183
+ continue;
184
+ let scenario;
185
+ try {
186
+ scenario = parseScenario(file);
187
+ }
188
+ catch {
189
+ continue; /* the cloud would have refused the session */
190
+ }
191
+ for (const channel of scenario.channels) {
192
+ for (let run = 1; run <= runs; run++)
193
+ if (!have.has(`${scenario.id}\n${channel}\n${run}`))
194
+ out.push({ scenario: scenario.id, channel, run });
195
+ }
196
+ }
197
+ return out;
198
+ }
199
+ /** One session, start to finish. `only`: the runs a re-open plays. */
200
+ async function playSession(options, only) {
201
+ const log = options.log ?? ((line) => console.log(line));
202
+ const postTimeoutMs = options.postTimeoutMs ?? POST_TIMEOUT_MS;
203
+ const streamIdleMs = options.streamIdleMs ?? STREAM_IDLE_MS;
204
+ const retryBudgetMs = options.retryBudgetMs ?? RETRY_BUDGET_MS;
205
+ const openRetryCapMs = options.openRetryCapMs ?? OPEN_RETRY_CAP_MS;
206
+ const resumeWindowMs = options.resumeWindowMs ?? RESUME_WINDOW_MS;
207
+ const now = options.now ?? Date.now;
208
+ const streamRefreshMs = options.streamRefreshMs ?? STREAM_REFRESH_MS;
209
+ let idempotent = false;
210
+ const base = options.cloud.replace(/\/$/, "");
211
+ // The session's address is known once it opens; tools and events only use it after that.
212
+ let session = "";
213
+ let auth = {};
214
+ const controller = new AbortController();
215
+ let stream;
216
+ let stopped = false;
217
+ let failure;
218
+ let dashboard = "";
219
+ let openedId = "";
220
+ let reopenable = false;
221
+ const runs = [];
222
+ let idle;
223
+ let refresh;
224
+ const fail = (context, error) => {
225
+ if (stopped || failure)
226
+ return;
227
+ failure = new Error(`${context}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
228
+ stream?.destroy(failure);
229
+ controller.abort();
230
+ };
231
+ const pending = new Set();
232
+ const watch = (work, context, allowRefusal = false) => {
233
+ const handled = work.then(() => { }, (error) => {
234
+ if (allowRefusal && error instanceof PostError && error.status === 409 && error.code === "platform_refused")
235
+ return;
236
+ // Retried already. The send fails for this reply; the stream stays up for the other runs.
237
+ if (allowRefusal && unavailable(error))
238
+ return;
239
+ fail(context, error);
240
+ });
241
+ pending.add(handled);
242
+ void handled.then(() => pending.delete(handled));
243
+ };
244
+ // Tools: same name, description and input; running one asks the cloud, which holds the request
245
+ // until its simulator has answered and the chosen latency has passed.
246
+ // 210s already includes the default POST budget. A session that sets its own uses that instead, so a test can still watch a timeout.
247
+ const toolBudget = options.postTimeoutMs == null ? CLOUD_MODEL_BUDGET_MS : postTimeoutMs;
248
+ // The built-in get_user_profile is played like the agent's own tools, so a test never reads a real
249
+ // profile: its stand-in is given to every agent, and counts only for one with a `profile`.
250
+ const ownProfile = options.tools.some((t) => t.name === PROFILE_TOOL);
251
+ const standIn = ownProfile ? [] : [Object.assign(profileTool(() => null), { [STAND_IN]: true })];
252
+ const played = [...options.tools, ...standIn];
253
+ const toolWait = new Map(played.map((t) => [t.name, toolTimeoutMs(options.files, t.name, toolBudget)]));
254
+ const tools = played.map((t) => ({
255
+ ...t,
256
+ run: (input, context) => {
257
+ const call = (async () => {
258
+ const request = { conversationId: context.conversationId, tool: t.name, input };
259
+ const result = await postRetry(`${session}/tool-calls`, request, auth, controller.signal, log, toolWait.get(t.name) ?? postTimeoutMs, {
260
+ mode: idempotent ? "idempotent" : "never", key: `tool:${randomUUID()}`, budgetMs: retryBudgetMs, what: "tool call",
261
+ });
262
+ if (!result.ok)
263
+ throw new ToolFailed(result.error);
264
+ return result.output;
265
+ })();
266
+ // The agent's own code may start a tool and never wait for it (an intercept's hand-off). A call
267
+ // the cloud failed is then that run's error, never an unhandled rejection that ends the session;
268
+ // whoever awaits the call still gets the failure.
269
+ call.catch((error) => {
270
+ if (stopped || (error instanceof ToolFailed && !CLOUD_TOOL_ERRORS.has(error.message)))
271
+ return;
272
+ const why = error instanceof PostError && error.code ? error.code : error instanceof Error ? error.message : String(error);
273
+ log(`${context.conversationId}: the cloud failed the tool call ${t.name} (${why}): an infrastructure error for this run, not the agent's; the session plays on.`);
274
+ });
275
+ return call;
276
+ },
277
+ }));
278
+ // Events go up in small batches, so the cloud can show the trace and time the agent.
279
+ const queued = [];
280
+ let flushing = null;
281
+ let timer;
282
+ let eventSeq = 0;
283
+ const flush = async () => {
284
+ while (queued.length) {
285
+ const seq = ++eventSeq;
286
+ let bytes = Buffer.byteLength(`{"seq":${seq},"events":[]}`);
287
+ let count = 0;
288
+ while (count < queued.length && count < 500) {
289
+ const next = queued[count].bytes + (count ? 1 : 0);
290
+ if (bytes + next > 5_000_000)
291
+ break;
292
+ bytes += next;
293
+ count++;
294
+ }
295
+ if (!count)
296
+ throw new Error("one event exceeds the 5MB upload limit");
297
+ const batch = queued.slice(0, count);
298
+ const started = Date.now();
299
+ const wait = started - batch[0].at;
300
+ let failed = false;
301
+ try {
302
+ await postRetry(`${session}/events`, undefined, auth, controller.signal, log, postTimeoutMs, {
303
+ mode: idempotent ? "idempotent" : "never",
304
+ key: `events:${seq}`,
305
+ serialized: `{"seq":${seq},"events":[${batch.map((e) => e.json).join(",")}]}`,
306
+ budgetMs: retryBudgetMs,
307
+ what: "event upload",
308
+ });
309
+ queued.splice(0, count);
310
+ }
311
+ catch (error) {
312
+ const closed = error instanceof PostError && error.status === 409 && error.code === "session_closed";
313
+ const aborted = stopped && error instanceof Error && error.name === "AbortError";
314
+ if (closed || aborted) {
315
+ log(`session already finished, dropped ${count} events`);
316
+ queued.splice(0, count);
317
+ continue;
318
+ }
319
+ failed = true;
320
+ // An older cloud does not store the key, so a retry might apply the batch twice.
321
+ const again = idempotent ? "retried" : "not retried";
322
+ throw new Error(`event upload failed; acceptance unknown, ${again}`, { cause: error });
323
+ }
324
+ finally {
325
+ const roundtrip = Date.now() - started;
326
+ if (failed || (options.verbose && (roundtrip >= 1_000 || wait >= 1_000))) {
327
+ log(`event upload ${failed ? "failed" : "slow"}: roundtrip_ms=${roundtrip} count=${count} bytes=${bytes} queue_wait_ms=${wait}`);
328
+ }
329
+ }
330
+ }
331
+ };
332
+ const startFlush = () => {
333
+ if (flushing || stopped || failure || !session)
334
+ return;
335
+ flushing = flush().catch((error) => fail("events", error)).finally(() => { flushing = null; });
336
+ };
337
+ const events = (event) => {
338
+ if (stopped || failure)
339
+ return;
340
+ try {
341
+ const json = JSON.stringify(event);
342
+ queued.push({ json, bytes: Buffer.byteLength(json), at: Date.now() });
343
+ if (!flushing && !timer)
344
+ timer = setTimeout(() => { timer = undefined; startFlush(); }, 100);
345
+ }
346
+ catch (error) {
347
+ fail("event serialization", error);
348
+ }
349
+ };
350
+ // The test cloud judges from this. Other sinks do not get it unless they ask.
351
+ events.context = true;
352
+ try {
353
+ const agent = options.build({ tools, events: [events] });
354
+ const open = {
355
+ agent: { name: options.name, title: options.title, tools: [...options.tools, ...(agent.hasProfile?.() ? standIn : [])].map(toSpec), channels: agent.channels() },
356
+ files: options.files.map(({ path, hash }) => ({ path, hash })),
357
+ runs: options.runs ?? 1,
358
+ deliveryReceipts: true,
359
+ retries: true,
360
+ resume: true,
361
+ reopen: true,
362
+ ...(only ? { only } : {}),
363
+ };
364
+ const opened = await openOnce(`${base}/api/sessions`, open, options.openHeaders, controller.signal, postTimeoutMs, openRetryCapMs, log);
365
+ idempotent = opened.idempotency === true;
366
+ openedId = opened.sessionId;
367
+ reopenable = opened.reopen === true;
368
+ const tunnel = tunnelBase(base, opened);
369
+ auth = { authorization: `Bearer ${opened.token}` };
370
+ const missing = new Set(opened.missing);
371
+ const uploads = options.files.filter((f) => missing.has(f.hash));
372
+ if (uploads.length) {
373
+ await postRetry(`${tunnel}/api/files`, { files: uploads }, auth, controller.signal, log, postTimeoutMs, {
374
+ mode: idempotent ? "idempotent" : "never",
375
+ key: `files:${createHash("sha256").update(uploads.map((file) => file.hash).join("\n")).digest("hex")}`,
376
+ budgetMs: retryBudgetMs,
377
+ what: "file upload",
378
+ });
379
+ }
380
+ log(`Session ${opened.sessionId}: suite ${suiteHash(options.files).slice(0, 12)}, ${options.files.length} files (${uploads.length} new to the cloud, the rest it already had).`);
381
+ dashboard = opened.dashboard;
382
+ options.onDashboard?.(dashboard, opened.sessionId, typeof opened.agent === "string" && opened.agent ? opened.agent : undefined);
383
+ log(`Watch it: ${opened.dashboard}`);
384
+ session = `${tunnel}/api/sessions/${opened.sessionId}`;
385
+ if (failure)
386
+ throw failure;
387
+ startFlush();
388
+ const streamUrl = `${session}/stream`;
389
+ stream = await openStream(streamUrl, auth, controller.signal);
390
+ if (failure)
391
+ throw failure;
392
+ // A cloud that resumes numbers its commands: a lost stream is opened again after the last one
393
+ // handled, and a command the cloud sends again is skipped.
394
+ const resume = opened.resume === true;
395
+ let lastId = 0;
396
+ let refreshing = false;
397
+ // The cloud writes ": ping" every 15s. Three missed pings and the stream is dead.
398
+ const arm = () => {
399
+ if (idle)
400
+ clearTimeout(idle);
401
+ idle = setTimeout(() => {
402
+ const lost = new StreamLost(`no bytes for ${streamIdleMs / 1000}s`);
403
+ if (resume)
404
+ stream?.destroy(lost);
405
+ else
406
+ fail("the stream is dead", lost);
407
+ }, streamIdleMs);
408
+ };
409
+ // The tunnel cuts a request at an hour, so a resumable stream is replaced before then.
410
+ const scheduleRefresh = () => {
411
+ if (refresh)
412
+ clearTimeout(refresh);
413
+ if (resume)
414
+ refresh = setTimeout(() => { refreshing = true; stream?.destroy(); }, streamRefreshMs);
415
+ };
416
+ arm();
417
+ scheduleRefresh();
418
+ let draining = false;
419
+ let drained = false;
420
+ let receiveFails = 0;
421
+ // Drain and the drained POST wait on deliveries that can outlast the idle window. Pings
422
+ // still arrive, and sit unread until that wait ends, so the timer must be down for it.
423
+ const pauseIdle = () => {
424
+ if (idle)
425
+ clearTimeout(idle);
426
+ idle = undefined;
427
+ };
428
+ const drainWork = async () => {
429
+ // Delivery completion may enqueue recovery events or another outbound, so keep
430
+ // draining until both lanes are empty. Event batches remain strictly serial.
431
+ do {
432
+ while (pending.size)
433
+ await Promise.all(pending);
434
+ if (failure)
435
+ throw failure;
436
+ if (timer) {
437
+ clearTimeout(timer);
438
+ timer = undefined;
439
+ }
440
+ startFlush();
441
+ await flushing;
442
+ if (failure)
443
+ throw failure;
444
+ } while (pending.size || queued.length);
445
+ };
446
+ /** One command from the stream. Returns the summary once the session has finished. */
447
+ const handle = async (command) => {
448
+ if (draining && (command.type === "message" || command.type === "reaction"))
449
+ throw new Error("the cloud sent a command after drain");
450
+ if (command.type === "message") {
451
+ const deliveries = [];
452
+ // Not async: register the promise during receive(), before its fire-and-forget
453
+ // interceptor returns null. Each command owns its deliveries, even in a burst.
454
+ let outboundIndex = 0;
455
+ const deliver = (outbound) => {
456
+ const index = outboundIndex++;
457
+ const delivery = postRetry(`${session}/outbound`, { conversationId: command.conversationId, outbound }, auth, controller.signal, log, postTimeoutMs, {
458
+ mode: idempotent ? "idempotent" : "outbound",
459
+ key: `outbound:${command.conversationId}:${command.messageId}:${index}`,
460
+ budgetMs: retryBudgetMs,
461
+ what: "outbound delivery",
462
+ }).then(() => { });
463
+ deliveries.push(delivery);
464
+ // Keep the rejection for the normal conversation's delivery recovery, but
465
+ // attach a handler immediately for fire-and-forget interceptor sends too.
466
+ // A tunnel blip (no JSON from the cloud) is not a reason to end the session.
467
+ watch(delivery, "outbound delivery", true);
468
+ return delivery;
469
+ };
470
+ let taken;
471
+ try {
472
+ taken = agent.receive(command.conversationId, withFiles(command.incoming, options.testsDir, log), deliver, {
473
+ channel: command.channel,
474
+ channelName: command.channelName,
475
+ messageId: command.messageId,
476
+ });
477
+ }
478
+ catch (error) {
479
+ const detail = error instanceof Error ? error.message : String(error);
480
+ receiveFails++;
481
+ // One broken message can time out. An agent that throws on every message would
482
+ // wait out the whole suite, one run at a time.
483
+ if (receiveFails >= 3)
484
+ throw new Error(`the agent throws on every message: ${detail}`);
485
+ log(`receive failed: ${detail}`);
486
+ return;
487
+ }
488
+ receiveFails = 0;
489
+ const receipt = { conversationId: command.conversationId, messageId: command.messageId, status: taken === null ? "intercepted" : "taken" };
490
+ const sendReceipt = () => postRetry(`${session}/receipts`, receipt, auth, controller.signal, log, postTimeoutMs, {
491
+ mode: idempotent ? "idempotent" : "never",
492
+ key: `receipt:${receipt.conversationId}:${receipt.messageId}`,
493
+ budgetMs: retryBudgetMs,
494
+ what: "delivery receipt",
495
+ });
496
+ // An intercepted reply waits until its outbound bodies land. A tunnel blip on one of them
497
+ // is not a receipt failure: this run can time out, and the later runs still play. A real
498
+ // refusal still ends the session, the same as a receipt the tunnel rejected.
499
+ const receiptWork = taken === null
500
+ ? Promise.all(deliveries).then(sendReceipt, (error) => { if (!unavailable(error))
501
+ throw error; })
502
+ : sendReceipt();
503
+ watch(receiptWork, "delivery receipt");
504
+ }
505
+ else if (command.type === "reaction") {
506
+ agent.reacted(command.conversationId, command.emoji, command.toText);
507
+ }
508
+ else if (command.type === "run") {
509
+ runs.push(command.run);
510
+ options.onRun?.(command.run);
511
+ }
512
+ else if (command.type === "drain") {
513
+ if (draining)
514
+ throw new Error("the cloud sent drain twice");
515
+ draining = true;
516
+ pauseIdle();
517
+ await drainWork();
518
+ await postRetry(`${session}/drained`, {}, auth, controller.signal, log, postTimeoutMs, {
519
+ mode: idempotent ? "idempotent" : "never",
520
+ key: `drained:${opened.sessionId}`,
521
+ budgetMs: retryBudgetMs,
522
+ what: "drained",
523
+ });
524
+ drained = true;
525
+ arm();
526
+ }
527
+ else if (command.type === "finished") {
528
+ pauseIdle();
529
+ if (!drained)
530
+ await drainWork();
531
+ return command.summary;
532
+ }
533
+ else if (command.type === "failed") {
534
+ throw new CloudEnded(command.error);
535
+ }
536
+ };
537
+ /** The stream opened again after the last command handled, with backoff, until the resume window closes. */
538
+ const reconnect = async (why) => {
539
+ log(`stream ${why}; resuming after command ${lastId}`);
540
+ const deadline = now() + resumeWindowMs;
541
+ let last;
542
+ for (let attempt = 0;; attempt++) {
543
+ const backoff = attempt === 0 ? 0 : Math.min(10_000, 250 * 2 ** attempt) * (0.8 + Math.random() * 0.4);
544
+ const refused = last instanceof Error && last.cause instanceof PostError ? last.cause : undefined;
545
+ const wait = Math.max(backoff, refused?.retryAfterMs ?? 0);
546
+ if (now() + wait >= deadline) {
547
+ const detail = last instanceof Error ? `; last try: ${last.message}` : "";
548
+ throw new StreamLost(`the stream did not resume within ${resumeWindowMs / 1000}s (${why})${detail}`);
549
+ }
550
+ await pause(wait, controller.signal);
551
+ if (failure)
552
+ throw failure;
553
+ try {
554
+ return await openStream(streamUrl, { ...auth, "last-event-id": String(lastId) }, controller.signal, Math.max(1, Math.min(postTimeoutMs, deadline - now())));
555
+ }
556
+ catch (error) {
557
+ if (failure)
558
+ throw failure;
559
+ const cause = error instanceof Error && error.cause instanceof PostError ? error.cause : error;
560
+ const code = cause instanceof PostError && cause.code ? ` ${cause.code}` : "";
561
+ if (!retryable(cause, "idempotent"))
562
+ throw new StreamLost(`the stream could not resume: ${error instanceof Error ? error.message : String(error)}${code}`, { cause: error });
563
+ last = error;
564
+ }
565
+ }
566
+ };
567
+ for (;;) {
568
+ const decoder = new TextDecoder();
569
+ const chunks = stream[Symbol.asyncIterator]();
570
+ let buffer = "";
571
+ let lost;
572
+ for (;;) {
573
+ let next;
574
+ try {
575
+ next = await chunks.next();
576
+ }
577
+ catch (error) {
578
+ lost = error;
579
+ break;
580
+ }
581
+ if (next.done)
582
+ break;
583
+ arm();
584
+ buffer += decoder.decode(next.value, { stream: true });
585
+ let end;
586
+ while ((end = buffer.indexOf("\n\n")) >= 0) {
587
+ const frame = buffer.slice(0, end).split("\n");
588
+ buffer = buffer.slice(end + 2);
589
+ const data = frame.filter((l) => l.startsWith("data: ")).map((l) => l.slice(6)).join("\n");
590
+ if (!data)
591
+ continue;
592
+ const idLine = frame.find((l) => l.startsWith("id:"));
593
+ const id = idLine === undefined ? NaN : Number(idLine.slice(3).trim());
594
+ if (Number.isSafeInteger(id) && id > 0) {
595
+ // Sent again after a resume: it was handled on the stream before.
596
+ if (id <= lastId)
597
+ continue;
598
+ lastId = id;
599
+ }
600
+ let command;
601
+ try {
602
+ command = JSON.parse(data);
603
+ }
604
+ catch {
605
+ log("a stream frame was not valid JSON");
606
+ continue;
607
+ }
608
+ const summary = await handle(command);
609
+ if (summary)
610
+ return summary;
611
+ }
612
+ }
613
+ if (failure)
614
+ throw failure;
615
+ const closed = new StreamLost("the cloud closed the stream before the session finished");
616
+ if (!resume)
617
+ throw lost ?? closed;
618
+ pauseIdle();
619
+ const why = refreshing ? "refreshed before the tunnel's hour limit" : `lost (${lost instanceof Error ? lost.message : "the cloud closed it"})`;
620
+ refreshing = false;
621
+ stream.destroy();
622
+ stream = await reconnect(why);
623
+ arm();
624
+ scheduleRefresh();
625
+ }
626
+ }
627
+ catch (error) {
628
+ if (error instanceof SessionStopped)
629
+ throw error;
630
+ const reason = failure ?? (error instanceof Error ? error : new Error(String(error)));
631
+ // Exit 2 is a transport failure after the session exists. A refusal, or anything
632
+ // before the open answers, is the error itself (the CLI prints it alone and exits by its kind, report.ts).
633
+ if (dashboard && isTransportFailure(reason)) {
634
+ if (reopenable && saysGone(reason))
635
+ throw new SessionGone(reason.message, runs, dashboard, openedId);
636
+ throw new SessionStopped(reason.message, runs, dashboard);
637
+ }
638
+ throw reason;
639
+ }
640
+ finally {
641
+ stopped = true;
642
+ if (timer)
643
+ clearTimeout(timer);
644
+ if (idle)
645
+ clearTimeout(idle);
646
+ if (refresh)
647
+ clearTimeout(refresh);
648
+ stream?.destroy();
649
+ controller.abort();
650
+ await Promise.all(pending);
651
+ await flushing;
652
+ }
653
+ }
654
+ /** The stream on a connection of its own. fetch shares one HTTP/2 connection per origin, and over
655
+ * HTTPS a request behind another open one on it waits until that one finishes: a tool call the
656
+ * cloud has not answered held every later event and reply, and the run ended agent_quiet.
657
+ * A refusal's status and JSON code are its cause, a PostError. A resume waits at most `timeoutMs` for the answer. */
658
+ function openStream(url, headers, signal, timeoutMs) {
659
+ const request = url.startsWith("https:") ? httpsRequest : httpRequest;
660
+ return new Promise((resolve, reject) => {
661
+ const req = request(url, { headers, agent: false, signal }, (response) => {
662
+ if (timer)
663
+ clearTimeout(timer);
664
+ if (response.statusCode === 200)
665
+ return resolve(response);
666
+ const status = response.statusCode ?? 0;
667
+ let text = "";
668
+ response.setEncoding("utf8");
669
+ response.on("data", (chunk) => { if (text.length < 4_096)
670
+ text += chunk; });
671
+ response.on("error", () => { });
672
+ response.on("end", () => {
673
+ const cause = new PostError(url, status, text, retryAfterOf(flatHeaders(response.headers)));
674
+ reject(new Error(`could not open the stream (${status})`, { cause }));
675
+ });
676
+ });
677
+ const timer = timeoutMs === undefined ? undefined : setTimeout(() => req.destroy(new TimeoutError()), timeoutMs);
678
+ req.on("error", (error) => { if (timer)
679
+ clearTimeout(timer); reject(error); }).end();
680
+ });
681
+ }
682
+ /** The cloud answered a call with a status that is not 2xx. `code` is a JSON `error` string (the tunnel's
683
+ * refusals, which retries go by); `type` is a JSON `error` object's type (a hosted cloud's front door). */
684
+ export class PostError extends Error {
685
+ status;
686
+ code;
687
+ type;
688
+ retryAfterMs;
689
+ constructor(url, status, text, retryAfterMs) {
690
+ super(`${url}: ${status} ${shown(text)}`);
691
+ this.name = "PostError";
692
+ this.status = status;
693
+ let error;
694
+ try {
695
+ error = JSON.parse(text).error;
696
+ }
697
+ catch { /* the body was not JSON */ }
698
+ if (typeof error === "string")
699
+ this.code = error;
700
+ else if (error && typeof error === "object" && typeof error.type === "string")
701
+ this.type = error.type;
702
+ this.retryAfterMs = retryAfterMs;
703
+ }
704
+ }
705
+ const OPEN_RETRY_CAP_MS = 60_000;
706
+ /** The open, sent once more when the cloud is busy (a 429 that is not the spend cap, or a 503 capacity)
707
+ * and its Retry-After is at most `capMs`. */
708
+ async function openOnce(url, body, headers, signal, timeoutMs, capMs, log) {
709
+ try {
710
+ return await post(url, body, headers, signal, undefined, timeoutMs);
711
+ }
712
+ catch (error) {
713
+ if (!(error instanceof PostError) || error.retryAfterMs === undefined || error.retryAfterMs > capMs)
714
+ throw error;
715
+ const why = error.type ?? error.code;
716
+ const busy = (error.status === 429 && why !== "spend_cap") || (error.status === 503 && why === "capacity");
717
+ if (!busy)
718
+ throw error;
719
+ log(`The cloud is busy (${error.status}${why ? ` ${why}` : ""}); trying once more in ${Math.ceil(error.retryAfterMs / 1000)} s.`);
720
+ await pause(error.retryAfterMs, signal);
721
+ return await post(url, body, headers, signal, undefined, timeoutMs);
722
+ }
723
+ }
724
+ /** This call can be dropped and the session continues: a frontend page with no JSON, a
725
+ * timeout, or a connection that died. */
726
+ function unavailable(error) {
727
+ if (error instanceof TimeoutError)
728
+ return true;
729
+ if (error instanceof PostError)
730
+ return !error.code && (error.status === 502 || error.status === 503 || error.status === 504);
731
+ if (!(error instanceof Error) || error.name === "AbortError")
732
+ return false;
733
+ const code = error.code;
734
+ return code === "ECONNRESET" || code === "ECONNREFUSED" || code === "EPIPE" || code === "ETIMEDOUT" || code === "EAI_AGAIN" || error.message === "socket hang up";
735
+ }
736
+ /** The tunnel, the stream, or the connection, as opposed to a refusal the cloud wrote as JSON. */
737
+ export function isTransportFailure(error) {
738
+ let current = error;
739
+ for (let guard = 0; current instanceof Error && guard < 6; guard++) {
740
+ if (current instanceof TimeoutError || current instanceof StreamLost)
741
+ return true;
742
+ if (current instanceof PostError)
743
+ return current.code === "session_gone" || (!current.code && (current.status === 502 || current.status === 503 || current.status === 504));
744
+ if (/^could not open the stream \((502|503|504)\)$/.test(current.message))
745
+ return true;
746
+ const code = current.code;
747
+ if (code === "ECONNRESET" || code === "ECONNREFUSED" || code === "EPIPE" || code === "ETIMEDOUT" || code === "EAI_AGAIN" || code === "ENOTFOUND" || current.message === "socket hang up")
748
+ return true;
749
+ current = current.cause;
750
+ }
751
+ return false;
752
+ }
753
+ /** The cloud no longer has the session: a tunnel call or the stream answered session_gone. */
754
+ function saysGone(error) {
755
+ let current = error;
756
+ for (let guard = 0; current instanceof Error && guard < 6; guard++) {
757
+ if (current instanceof PostError && current.code === "session_gone")
758
+ return true;
759
+ current = current.cause;
760
+ }
761
+ return false;
762
+ }
763
+ /** Safe to send again, until the cloud stores an idempotency key. A 502/503/504 with no JSON
764
+ * never reached the container. A failure before the connection was made did not either.
765
+ * A timeout or a reset may mean the reply was already delivered. */
766
+ function retryableOutbound(error) {
767
+ if (error instanceof PostError)
768
+ return !error.code && (error.status === 502 || error.status === 503 || error.status === 504);
769
+ if (!(error instanceof Error) || error.name === "AbortError")
770
+ return false;
771
+ const code = error.code;
772
+ return code === "ECONNREFUSED" || code === "EAI_AGAIN" || code === "ENOTFOUND";
773
+ }
774
+ function shown(text) {
775
+ const plain = text.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
776
+ return plain.slice(0, 180);
777
+ }
778
+ function pause(ms, signal) {
779
+ if (signal?.aborted)
780
+ return Promise.reject(signal.reason);
781
+ return new Promise((resolve, reject) => {
782
+ const timer = setTimeout(() => { signal?.removeEventListener("abort", stop); resolve(); }, ms);
783
+ const stop = () => { clearTimeout(timer); reject(signal?.reason); };
784
+ signal?.addEventListener("abort", stop, { once: true });
785
+ });
786
+ }
787
+ /** Whether this failure may be sent again. A JSON refusal never is. An older cloud retries an
788
+ * outbound only when the body cannot have arrived. A cloud that stores keys also retries a
789
+ * timeout, a reset, a 429, and its own 503 unavailable. */
790
+ function retryable(error, mode) {
791
+ if (mode === "never")
792
+ return false;
793
+ if (mode === "outbound")
794
+ return retryableOutbound(error);
795
+ if (error instanceof TimeoutError)
796
+ return true;
797
+ if (error instanceof PostError) {
798
+ if (error.status === 429)
799
+ return true;
800
+ if (error.status === 503 && error.code === "unavailable")
801
+ return true;
802
+ return !error.code && (error.status === 502 || error.status === 503 || error.status === 504);
803
+ }
804
+ if (!(error instanceof Error) || error.name === "AbortError")
805
+ return false;
806
+ const code = error.code;
807
+ return code === "ECONNRESET" || code === "ECONNREFUSED" || code === "EPIPE" || code === "ETIMEDOUT" || code === "EAI_AGAIN" || code === "ENOTFOUND" || error.message === "socket hang up";
808
+ }
809
+ function retryStatus(error) {
810
+ if (error instanceof PostError)
811
+ return String(error.status);
812
+ if (error instanceof TimeoutError)
813
+ return "timed out";
814
+ return "no response";
815
+ }
816
+ /** Wait between tries: Retry-After when the cloud set one, otherwise exponential with jitter. */
817
+ function retryWaitMs(attempt, error) {
818
+ if (error instanceof PostError && error.retryAfterMs !== undefined)
819
+ return error.retryAfterMs;
820
+ const base = 200 * 2 ** attempt;
821
+ return base + Math.random() * base * 0.2;
822
+ }
823
+ /** One call, sent again when that is safe, until the budget has passed since its first failure.
824
+ * The same Idempotency-Key goes on every try. */
825
+ async function postRetry(url, body, headers, signal, log, timeoutMs, options) {
826
+ let firstFailure;
827
+ let attempt = 0;
828
+ const headersWithKey = { ...headers, "idempotency-key": options.key };
829
+ for (;;) {
830
+ try {
831
+ return await post(url, body, headersWithKey, signal, options.serialized, timeoutMs);
832
+ }
833
+ catch (error) {
834
+ const again = retryable(error, options.mode);
835
+ firstFailure ??= Date.now();
836
+ const elapsed = Date.now() - firstFailure;
837
+ const wait = again ? retryWaitMs(attempt, error) : 0;
838
+ if (!again || elapsed + wait > options.budgetMs) {
839
+ if (again)
840
+ log(`${options.what} failed after ${attempt + 1} tries`);
841
+ else if (options.mode !== "never" && unavailable(error))
842
+ log(`${options.what} failed (${retryStatus(error)})`);
843
+ throw error;
844
+ }
845
+ log(`${options.what} unavailable (${retryStatus(error)}), trying again`);
846
+ await pause(wait, signal);
847
+ attempt++;
848
+ }
849
+ }
850
+ }
851
+ /** POSTs share a keep-alive agent and speak HTTP/1.1, so each request still in flight has its own
852
+ * socket. A tool call the cloud has not answered cannot hold the later ones, and a finished
853
+ * connection is reused. The stream stays on a connection of its own (openStream). */
854
+ const httpPosts = new HttpAgent({ keepAlive: true });
855
+ const httpsPosts = new HttpsAgent({ keepAlive: true });
856
+ function requestOnce(url, payload, headers, signal, timeoutMs) {
857
+ const secure = url.startsWith("https:");
858
+ const request = secure ? httpsRequest : httpRequest;
859
+ return new Promise((resolve, reject) => {
860
+ let settled = false;
861
+ let timer;
862
+ let onAbort;
863
+ const finish = (error, result) => {
864
+ if (settled)
865
+ return;
866
+ settled = true;
867
+ if (timer)
868
+ clearTimeout(timer);
869
+ if (onAbort)
870
+ signal?.removeEventListener("abort", onAbort);
871
+ if (error)
872
+ reject(error);
873
+ else
874
+ resolve(result);
875
+ };
876
+ const req = request(url, {
877
+ method: "POST",
878
+ headers: { "content-type": "application/json", ...headers, "content-length": Buffer.byteLength(payload) },
879
+ agent: secure ? httpsPosts : httpPosts,
880
+ }, (response) => {
881
+ const chunks = [];
882
+ response.on("error", finish);
883
+ response.on("aborted", () => finish(new Error("The request was aborted")));
884
+ response.on("data", (chunk) => chunks.push(chunk));
885
+ response.on("end", () => finish(undefined, { status: response.statusCode ?? 0, text: Buffer.concat(chunks).toString("utf8"), headers: flatHeaders(response.headers) }));
886
+ });
887
+ timer = setTimeout(() => { finish(new TimeoutError()); req.destroy(); }, timeoutMs);
888
+ if (signal) {
889
+ onAbort = () => { finish(abortError(signal)); req.destroy(); };
890
+ signal.addEventListener("abort", onAbort, { once: true });
891
+ }
892
+ req.on("error", (error) => finish(signal?.aborted ? abortError(signal) : error));
893
+ req.end(payload);
894
+ });
895
+ }
896
+ function abortError(signal) {
897
+ const reason = signal.reason;
898
+ return reason instanceof Error ? reason : new DOMException("The operation was aborted", "AbortError");
899
+ }
900
+ function flatHeaders(headers) {
901
+ const out = {};
902
+ for (const [name, value] of Object.entries(headers)) {
903
+ if (typeof value === "string")
904
+ out[name] = value;
905
+ else if (Array.isArray(value))
906
+ out[name] = value.join(", ");
907
+ }
908
+ return out;
909
+ }
910
+ function retryAfterOf(headers) {
911
+ const value = headers["retry-after"];
912
+ if (!value)
913
+ return undefined;
914
+ const seconds = Number(value);
915
+ if (Number.isFinite(seconds) && seconds >= 0)
916
+ return Math.round(seconds * 1000);
917
+ const date = Date.parse(value);
918
+ if (Number.isNaN(date))
919
+ return undefined;
920
+ return Math.max(0, date - Date.now());
921
+ }
922
+ async function post(url, body, headers = {}, signal, serialized, timeoutMs = POST_TIMEOUT_MS) {
923
+ const payload = serialized ?? JSON.stringify(body ?? {});
924
+ const { status, text, headers: responseHeaders } = await requestOnce(url, payload, headers, signal, postBudgetMs(timeoutMs, Buffer.byteLength(payload)));
925
+ if (status < 200 || status >= 300) {
926
+ throw new PostError(url, status, text, retryAfterOf(responseHeaders));
927
+ }
928
+ return (text ? JSON.parse(text) : {});
929
+ }
930
+ /** The person's message with the files it names read from the tests folder, and only from there. */
931
+ function withFiles(incoming, testsDir, log) {
932
+ const { attachments, ...rest } = incoming;
933
+ if (!attachments?.length)
934
+ return rest;
935
+ return {
936
+ ...rest,
937
+ attachments: attachments.map((a) => ({
938
+ kind: a.kind,
939
+ mime: mimeOf(a.file),
940
+ name: basename(a.file),
941
+ data: () => {
942
+ const reading = readAttachment(testsDir, a.file);
943
+ // A caller that never awaits still must not become an uncaught exception.
944
+ reading.catch((error) => log(`attachment could not be read: ${a.file}: ${error instanceof Error ? error.message : String(error)}`));
945
+ return reading;
946
+ },
947
+ })),
948
+ };
949
+ }
950
+ function readAttachment(testsDir, file) {
951
+ const path = testsDir ? resolve(testsDir, file) : "";
952
+ const inside = testsDir && !relative(resolve(testsDir), path).startsWith("..") && path !== resolve(testsDir);
953
+ if (!inside)
954
+ return Promise.reject(new Error(`${file} is not a file in the tests folder`));
955
+ return readFile(path).then((bytes) => new Uint8Array(bytes));
956
+ }