@ashlr/stack-core 0.3.6 → 0.3.9

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,6 @@
1
1
  {
2
2
  "name": "@ashlr/stack-core",
3
- "version": "0.3.6",
3
+ "version": "0.3.9",
4
4
  "description": "Ashlr Stack core — provider adapters, .stack.toml config, Phantom integration. Shared logic behind the Stack CLI and MCP server.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -3,6 +3,26 @@ import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { __resetPhantomCache } from "../phantom.ts";
5
5
 
6
+ /** Install a source-owned fake executable with an interpreter-appropriate target. */
7
+ export function writeShimmedExecutable(
8
+ dir: string,
9
+ name: string,
10
+ script: string,
11
+ ext = "sh",
12
+ ): string {
13
+ const realPath = join(dir, `${name}.${ext}`);
14
+ const binPath = join(dir, name);
15
+ writeFileSync(realPath, script);
16
+ chmodSync(realPath, 0o700);
17
+ try {
18
+ symlinkSync(realPath, binPath);
19
+ } catch {
20
+ writeFileSync(binPath, script);
21
+ chmodSync(binPath, 0o700);
22
+ }
23
+ return binPath;
24
+ }
25
+
6
26
  /**
7
27
  * Fake `phantom` binary for integration tests. Writes a bash script into a
8
28
  * tmpdir, prepends that tmpdir to PATH, and exposes helpers to inspect the
@@ -163,20 +183,7 @@ case "\${1:-}" in
163
183
  ;;
164
184
  esac
165
185
  `;
166
- const binPath = join(dir, "phantom");
167
- // Sandboxed runners and macOS Gatekeeper's provenance policy refuse to
168
- // exec an extensionless file written by a non-platform process, while a
169
- // `*.sh` target passes the check. Write the shim as `phantom.sh` and link
170
- // `phantom` at it; fall back to the direct file where symlinks fail.
171
- const realPath = join(dir, "phantom.sh");
172
- writeFileSync(realPath, script);
173
- chmodSync(realPath, 0o755);
174
- try {
175
- symlinkSync(realPath, binPath);
176
- } catch {
177
- writeFileSync(binPath, script);
178
- chmodSync(binPath, 0o755);
179
- }
186
+ writeShimmedExecutable(dir, "phantom", script);
180
187
 
181
188
  const previousPath = process.env.PATH ?? "";
182
189
  process.env.PATH = `${dir}:${previousPath}`;
@@ -2,9 +2,9 @@
2
2
  "schema_version": 1,
3
3
  "kind": "ashlr-stack-manifest",
4
4
  "components": [
5
- { "id": "core", "name": "@ashlr/stack-core", "version": "0.3.6", "distribution": "npm" },
6
- { "id": "cli", "name": "@ashlr/stack", "version": "0.3.6", "distribution": "npm" },
7
- { "id": "mcp", "name": "ashlr-stack-mcp", "version": "0.3.6", "distribution": "npm" },
5
+ { "id": "core", "name": "@ashlr/stack-core", "version": "0.3.9", "distribution": "npm" },
6
+ { "id": "cli", "name": "@ashlr/stack", "version": "0.3.9", "distribution": "npm" },
7
+ { "id": "mcp", "name": "ashlr-stack-mcp", "version": "0.3.9", "distribution": "npm" },
8
8
  { "id": "plugin", "name": "stack", "version": "0.1.0", "distribution": "claude-code-plugin" }
9
9
  ]
10
10
  }
@@ -0,0 +1,357 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ type CapabilityDeclaration,
4
+ DEFAULT_RESOURCE_ROLES,
5
+ FLEET_ROLES,
6
+ type FleetResource,
7
+ type FleetResourceQuota,
8
+ type JobRequirement,
9
+ buildFleetResource,
10
+ capacityBound,
11
+ effectiveRoles,
12
+ isFleetRole,
13
+ matchRequirement,
14
+ modelFamily,
15
+ parseCapabilityDocument,
16
+ parseModelMap,
17
+ resolveModel,
18
+ resourceHeadroom,
19
+ } from "../fleet/capability-matrix.ts";
20
+
21
+ const quota = (over: Partial<FleetResourceQuota> = {}): FleetResourceQuota => ({
22
+ target: null,
23
+ ceiling: null,
24
+ hardCap: null,
25
+ held: false,
26
+ running: null,
27
+ queued: null,
28
+ quotaRemaining: null,
29
+ ...over,
30
+ });
31
+
32
+ const resource = (id: string, over: Partial<Omit<FleetResource, "id">> = {}): FleetResource => ({
33
+ id,
34
+ declared: false,
35
+ roles: DEFAULT_RESOURCE_ROLES,
36
+ capabilities: [],
37
+ tiers: null,
38
+ quota: null,
39
+ ...over,
40
+ });
41
+
42
+ const requirement = (over: Partial<JobRequirement> = {}): JobRequirement => ({
43
+ role: "implementer",
44
+ capabilities: [],
45
+ tier: null,
46
+ ...over,
47
+ });
48
+
49
+ describe("parseCapabilityDocument", () => {
50
+ test("parses a minimal valid document", () => {
51
+ const result = parseCapabilityDocument(
52
+ JSON.stringify({
53
+ schema: "fleet.capabilities.v1",
54
+ resources: {
55
+ "codex-cmp": { roles: ["implementer", "reviewer"], capabilities: ["ci-watch"] },
56
+ local: { roles: [], tiers: ["mid", "fast"] },
57
+ },
58
+ }),
59
+ );
60
+ expect(result.status).toBe("ok");
61
+ expect(result.error).toBeNull();
62
+ const doc = result.document!;
63
+ expect(doc.schema).toBe("fleet.capabilities.v1");
64
+ expect(doc.dropped).toBe(0);
65
+ expect(doc.resources.get("codex-cmp")).toEqual({
66
+ roles: ["implementer", "reviewer"],
67
+ capabilities: ["ci-watch"],
68
+ tiers: null,
69
+ });
70
+ expect(doc.resources.get("local")).toEqual({
71
+ roles: [],
72
+ capabilities: [],
73
+ tiers: ["mid", "fast"],
74
+ });
75
+ });
76
+
77
+ test("rejects malformed JSON, non-objects and wrong schema", () => {
78
+ for (const [text, error] of [
79
+ ["not json", "not valid JSON"],
80
+ ["[1,2]", "not a JSON object"],
81
+ ['{"resources":{}}', 'expected schema "fleet.capabilities.v1"'],
82
+ ['{"schema":"fleet.capabilities.v1","resources":[]} ', '"resources" must be an object'],
83
+ ] as const) {
84
+ const result = parseCapabilityDocument(text);
85
+ expect(result.status).toBe("invalid");
86
+ expect(result.document).toBeNull();
87
+ expect(result.error).toBe(error);
88
+ }
89
+ });
90
+
91
+ test("missing resources means an empty document", () => {
92
+ const result = parseCapabilityDocument('{"schema":"fleet.capabilities.v1"}');
93
+ expect(result.status).toBe("ok");
94
+ expect(result.document!.resources.size).toBe(0);
95
+ });
96
+
97
+ test("drops invalid resource entries whole and counts them", () => {
98
+ const result = parseCapabilityDocument(
99
+ JSON.stringify({
100
+ schema: "fleet.capabilities.v1",
101
+ resources: {
102
+ good: { capabilities: ["local-inference"] },
103
+ "bad role": { roles: ["implementer"] }, // invalid resource id
104
+ typo: { roles: ["reviewr"] }, // unknown role
105
+ badcap: { capabilities: ["Local Inference"] }, // invalid tag
106
+ badtier: { tiers: ["FRONTIER"] }, // invalid tier
107
+ wrongtype: { roles: "implementer" }, // roles not an array
108
+ "": {}, // empty id
109
+ "has space": {}, // invalid id
110
+ },
111
+ }),
112
+ );
113
+ expect(result.status).toBe("ok");
114
+ const doc = result.document!;
115
+ expect([...doc.resources.keys()]).toEqual(["good"]);
116
+ expect(doc.dropped).toBe(7);
117
+ });
118
+
119
+ test("ignores unknown keys for forward compatibility", () => {
120
+ const result = parseCapabilityDocument(
121
+ JSON.stringify({
122
+ schema: "fleet.capabilities.v1",
123
+ comment: "operator notes",
124
+ resources: { a: { roles: ["planner"], note: "free text" } },
125
+ }),
126
+ );
127
+ expect(result.status).toBe("ok");
128
+ expect(result.document!.resources.get("a")).toEqual({
129
+ roles: ["planner"],
130
+ capabilities: [],
131
+ tiers: null,
132
+ });
133
+ });
134
+
135
+ test("dedupes repeated tags and roles", () => {
136
+ const result = parseCapabilityDocument(
137
+ JSON.stringify({
138
+ schema: "fleet.capabilities.v1",
139
+ resources: { a: { roles: ["reviewer", "reviewer"], capabilities: ["publish", "publish"] } },
140
+ }),
141
+ );
142
+ expect(result.document!.resources.get("a")).toEqual({
143
+ roles: ["reviewer"],
144
+ capabilities: ["publish"],
145
+ tiers: null,
146
+ });
147
+ });
148
+ });
149
+
150
+ describe("effectiveRoles / buildFleetResource", () => {
151
+ test("undeclared resources default to implementer on all tiers", () => {
152
+ const resource = buildFleetResource("any-lane", undefined, null);
153
+ expect(resource.declared).toBe(false);
154
+ expect(resource.roles).toEqual(["implementer"]);
155
+ expect(resource.capabilities).toEqual([]);
156
+ expect(resource.tiers).toBeNull();
157
+ expect(resource.quota).toBeNull();
158
+ });
159
+
160
+ test("explicit empty roles serve nothing; absent roles keep the default", () => {
161
+ const none: CapabilityDeclaration = { roles: [], capabilities: [], tiers: null };
162
+ const partial: CapabilityDeclaration = { roles: null, capabilities: ["publish"], tiers: null };
163
+ expect(effectiveRoles(none)).toEqual([]);
164
+ expect(effectiveRoles(partial)).toEqual(["implementer"]);
165
+ expect(buildFleetResource("r", none, null).roles).toEqual([]);
166
+ expect(buildFleetResource("r", partial, null).capabilities).toEqual(["publish"]);
167
+ });
168
+ });
169
+
170
+ describe("headroom", () => {
171
+ test("capacity is the smallest known bound; headroom subtracts running", () => {
172
+ expect(capacityBound(quota({ target: 10, ceiling: 32, hardCap: 8 }))).toBe(8);
173
+ expect(capacityBound(quota({ ceiling: 4 }))).toBe(4);
174
+ expect(capacityBound(quota())).toBeNull();
175
+ expect(resourceHeadroom(quota({ target: 6, running: 4 }))).toBe(2);
176
+ expect(resourceHeadroom(quota({ target: 6, running: 9 }))).toBe(0);
177
+ expect(resourceHeadroom(quota({ running: 4 }))).toBeNull();
178
+ expect(resourceHeadroom(null)).toBeNull();
179
+ });
180
+ });
181
+
182
+ describe("matchRequirement", () => {
183
+ const reviewerOnly = resource("reviewer-box", {
184
+ declared: true,
185
+ roles: ["reviewer"],
186
+ capabilities: ["ci-watch"],
187
+ tiers: ["work", "mid"],
188
+ quota: quota({ target: 2, running: 1 }),
189
+ });
190
+ const general = resource("general-lane", { quota: quota({ target: 4, running: 4 }) });
191
+ const local = resource("local-lane", {
192
+ declared: true,
193
+ roles: ["implementer", "planner"],
194
+ capabilities: ["local-inference"],
195
+ tiers: ["mid", "fast"],
196
+ quota: quota({ target: 6, running: 0 }),
197
+ });
198
+ const held = resource("held-lane", { quota: quota({ target: 4, running: 0, held: true }) });
199
+ const unknownQuota = resource("mystery", { declared: true, roles: ["implementer"] });
200
+ const resources = [reviewerOnly, general, local, held, unknownQuota];
201
+
202
+ test("implementer matches every undeclared-default lane with headroom or unknown quota", () => {
203
+ const result = matchRequirement(requirement(), resources);
204
+ // general is at capacity (running 4 >= target 4); held is held; reviewer-box lacks the role.
205
+ expect(result.eligible).toEqual(["local-lane", "mystery"]);
206
+ const byId = new Map(result.matches.map((m) => [m.id, m]));
207
+ expect(byId.get("general-lane")).toMatchObject({
208
+ eligible: false,
209
+ reasons: ["at-capacity"],
210
+ headroom: 0,
211
+ });
212
+ expect(byId.get("held-lane")!.reasons).toContain("held");
213
+ expect(byId.get("reviewer-box")!.reasons).toContain("missing-role:implementer");
214
+ expect(byId.get("mystery")).toMatchObject({ eligible: true, headroom: null });
215
+ });
216
+
217
+ test("reviewer role binds only to the declared reviewer resource", () => {
218
+ const result = matchRequirement(requirement({ role: "reviewer" }), resources);
219
+ expect(result.eligible).toEqual(["reviewer-box"]);
220
+ expect(result.matches.find((m) => m.id === "local-lane")!.reasons).toContain(
221
+ "missing-role:reviewer",
222
+ );
223
+ });
224
+
225
+ test("capability requirements narrow eligibility independently of tier", () => {
226
+ const result = matchRequirement(
227
+ requirement({ capabilities: ["local-inference"], tier: "mid" }),
228
+ resources,
229
+ );
230
+ expect(result.eligible).toEqual(["local-lane"]);
231
+ const byId = new Map(result.matches.map((m) => [m.id, m]));
232
+ // reviewer-box serves mid but lacks local-inference and the implementer role.
233
+ expect(byId.get("reviewer-box")!.reasons).toEqual(
234
+ expect.arrayContaining(["missing-role:implementer", "missing-capability:local-inference"]),
235
+ );
236
+ // mystery has unrestricted tiers, so only the capability tag blocks it.
237
+ expect(byId.get("mystery")!.reasons).toEqual(["missing-capability:local-inference"]);
238
+ // general-lane is also at capacity; held-lane is held.
239
+ expect(byId.get("general-lane")!.reasons).toContain("at-capacity");
240
+ expect(byId.get("held-lane")!.reasons).toContain("held");
241
+ });
242
+
243
+ test("tier check only applies when the resource restricts tiers", () => {
244
+ const open = resource("open-lane", { quota: quota({ target: 4, running: 1 }) });
245
+ const result = matchRequirement(requirement({ tier: "frontier" }), [open, local]);
246
+ const byId = new Map(result.matches.map((m) => [m.id, m]));
247
+ expect(byId.get("open-lane")!.eligible).toBe(true); // tiers null = unrestricted
248
+ expect(byId.get("local-lane")!.reasons).toContain("tier-unsupported:frontier");
249
+ });
250
+
251
+ test("fleet HOLD and fleet hard cap block every resource", () => {
252
+ const held = matchRequirement(requirement(), [general, local], { fleetHold: true });
253
+ expect(held.eligible).toEqual([]);
254
+ expect(held.matches.every((m) => m.reasons.includes("fleet-hold"))).toBe(true);
255
+
256
+ const full = matchRequirement(requirement(), [general, local], {
257
+ fleet: { running: 18, hardCap: 18 },
258
+ });
259
+ expect(full.eligible).toEqual([]);
260
+ expect(full.matches.every((m) => m.reasons.includes("fleet-at-capacity"))).toBe(true);
261
+
262
+ // Below the cap, or unknown running/hardCap, nothing fleet-level blocks.
263
+ const open = matchRequirement(requirement(), [general, local], {
264
+ fleet: { running: 3, hardCap: 18 },
265
+ });
266
+ expect(open.matches.some((m) => m.reasons.includes("fleet-at-capacity"))).toBe(false);
267
+ const unknown = matchRequirement(requirement(), [general, local], {
268
+ fleet: { running: null, hardCap: 18 },
269
+ });
270
+ expect(unknown.matches.some((m) => m.reasons.includes("fleet-at-capacity"))).toBe(false);
271
+ });
272
+
273
+ test("eligible ordering: headroom desc, unknown last, id tie-break; limit truncates", () => {
274
+ const a = resource("b-lane", { quota: quota({ target: 5, running: 1 }) }); // headroom 4
275
+ const b = resource("a-lane", { quota: quota({ target: 9, running: 1 }) }); // headroom 8
276
+ const c = resource("c-lane"); // headroom null
277
+ const result = matchRequirement(requirement(), [a, b, c]);
278
+ expect(result.eligible).toEqual(["a-lane", "b-lane", "c-lane"]);
279
+ const limited = matchRequirement(requirement(), [a, b, c], { limit: 2 });
280
+ expect(limited.matches.map((m) => m.id)).toEqual(["a-lane", "b-lane"]);
281
+ expect(limited.eligible).toEqual(["a-lane", "b-lane"]);
282
+ });
283
+
284
+ test("is provider-name agnostic: identical declarations match identically", () => {
285
+ const decl: CapabilityDeclaration = {
286
+ roles: ["implementer", "reviewer"],
287
+ capabilities: ["publish"],
288
+ tiers: null,
289
+ };
290
+ const one = buildFleetResource("devin", decl, quota({ target: 2, running: 0 }));
291
+ const two = buildFleetResource("acme-runner-7", decl, quota({ target: 2, running: 0 }));
292
+ const result = matchRequirement(requirement({ capabilities: ["publish"] }), [one, two]);
293
+ expect(result.eligible).toEqual(["acme-runner-7", "devin"]);
294
+ });
295
+ });
296
+
297
+ describe("parseModelMap / resolveModel", () => {
298
+ test("parses flat lane[.tier] model entries and drops invalid ones", () => {
299
+ const { map, dropped } = parseModelMap(
300
+ JSON.stringify({
301
+ "codex.work": "gpt-5.3-codex",
302
+ devin: "swe-2-flash-max",
303
+ "codex-local.mid": "qwen3-coder-next",
304
+ "": "bad",
305
+ "bad key": "x",
306
+ num: 3,
307
+ }),
308
+ );
309
+ expect(dropped).toBe(3);
310
+ expect(map.get("codex.work")).toBe("gpt-5.3-codex");
311
+ expect(map.get("devin")).toBe("swe-2-flash-max");
312
+ });
313
+
314
+ test("invalid JSON yields an empty map", () => {
315
+ expect(parseModelMap("nope").map.size).toBe(0);
316
+ expect(parseModelMap("nope").dropped).toBe(1);
317
+ expect(parseModelMap("nope").status).toBe("invalid");
318
+ expect(parseModelMap("[]").status).toBe("invalid");
319
+ expect(parseModelMap("{}").status).toBe("ok");
320
+ });
321
+
322
+ test("resolveModel mirrors dispatchd precedence: lane.tier, lane, family.tier, family", () => {
323
+ const { map } = parseModelMap(
324
+ JSON.stringify({
325
+ "codex-cmp.work": "cmp-work-model",
326
+ "codex.work": "family-work-model",
327
+ codex: "family-model",
328
+ "codex-local": "local-model",
329
+ devin: "devin-model",
330
+ }),
331
+ );
332
+ // codex-cmp is a codex family member but keeps its own lane keys first.
333
+ expect(resolveModel(map, "codex-cmp", "work")).toEqual({
334
+ model: "cmp-work-model",
335
+ via: "codex-cmp.work",
336
+ });
337
+ expect(resolveModel(map, "codex-cmp", "fast")).toEqual({
338
+ model: "family-model",
339
+ via: "codex",
340
+ });
341
+ // codex-local is its own family (dispatchd excludes it from the codex fold).
342
+ expect(modelFamily("codex-local")).toBe("codex-local");
343
+ expect(modelFamily("codex-cmp")).toBe("codex");
344
+ expect(resolveModel(map, "codex-local", "mid")).toEqual({
345
+ model: "local-model",
346
+ via: "codex-local",
347
+ });
348
+ expect(resolveModel(map, "devin", null)).toEqual({ model: "devin-model", via: "devin" });
349
+ expect(resolveModel(map, "ghost", "work")).toBeNull();
350
+ });
351
+ });
352
+
353
+ test("vocabulary sanity", () => {
354
+ expect(FLEET_ROLES).toEqual(["planner", "implementer", "reviewer", "release-verifier"]);
355
+ expect(isFleetRole("implementer")).toBe(true);
356
+ expect(isFleetRole(" Implementer ")).toBe(false);
357
+ });
@@ -0,0 +1,201 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtemp, readFile, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ JOB_RUN_SCHEMA,
7
+ type JobRunRecord,
8
+ appendJobRunRecord,
9
+ runJobStages,
10
+ } from "../fleet/job-run.ts";
11
+
12
+ const tmp = () => mkdtemp(join(tmpdir(), "fleet-job-run-"));
13
+
14
+ /** A clock that advances `step` ms per call — deterministic timings and stamps. */
15
+ const fakeClock = (start = 1_000, step = 25) => {
16
+ let t = start;
17
+ return () => {
18
+ t += step;
19
+ return t;
20
+ };
21
+ };
22
+
23
+ describe("runJobStages", () => {
24
+ test("failed write_sheet_jobs produces a queryable record with stage name and partial counts", async () => {
25
+ // Mirrors production execution 5d2b848b: the sheet write died mid-batch and
26
+ // the run left no structured trace of which stage failed or how far it got.
27
+ const lines: string[] = [];
28
+ const dir = await tmp();
29
+ const tablePath = join(dir, "job-runs.jsonl");
30
+ const record = await runJobStages({
31
+ executionId: "5d2b848b",
32
+ direction: "postgres->sheets",
33
+ tablePath,
34
+ logLine: (line) => lines.push(line),
35
+ now: fakeClock(),
36
+ stages: [
37
+ {
38
+ name: "read_postgres",
39
+ run: (c) => {
40
+ c.rowsOut = 1200;
41
+ },
42
+ },
43
+ {
44
+ name: "transform",
45
+ run: (c) => {
46
+ c.rowsIn = 1200;
47
+ c.rowsOut = 1150;
48
+ },
49
+ },
50
+ {
51
+ name: "write_sheet_jobs",
52
+ run: (c) => {
53
+ c.rowsIn = 1150;
54
+ c.rowsOut = 340; // partial commit before the API rejected the batch
55
+ throw new Error("Sheets API 429: quota exceeded");
56
+ },
57
+ },
58
+ ],
59
+ });
60
+
61
+ expect(record.schema).toBe(JOB_RUN_SCHEMA);
62
+ expect(record.job).toBe("stacksync");
63
+ expect(record.executionId).toBe("5d2b848b");
64
+ expect(record.direction).toBe("postgres->sheets");
65
+ expect(record.status).toBe("error");
66
+ expect(record.lastError).toEqual({
67
+ stage: "write_sheet_jobs",
68
+ message: "Sheets API 429: quota exceeded",
69
+ });
70
+ // Every stage ran, each with a measured duration; the write kept its partial count.
71
+ expect(record.stages).toEqual([
72
+ { stage: "read_postgres", status: "ok", durationMs: 25, rowsIn: 0, rowsOut: 1200 },
73
+ { stage: "transform", status: "ok", durationMs: 25, rowsIn: 1200, rowsOut: 1150 },
74
+ { stage: "write_sheet_jobs", status: "error", durationMs: 25, rowsIn: 1150, rowsOut: 340 },
75
+ ]);
76
+ expect(record.rowsIn).toBe(1200);
77
+ expect(record.rowsOut).toBe(340);
78
+ expect(record.persistError).toBeNull();
79
+
80
+ // The log line and the table row are the same queryable document.
81
+ expect(lines).toHaveLength(1);
82
+ expect(JSON.parse(lines[0]!)).toEqual(record);
83
+ const table = (await readFile(tablePath, "utf8")).trim().split("\n");
84
+ expect(table).toHaveLength(1);
85
+ expect(JSON.parse(table[0]!)).toEqual(record);
86
+ });
87
+
88
+ test("successful run reports ok status, generated id, and ISO bounds", async () => {
89
+ const record = await runJobStages({
90
+ logLine: false,
91
+ stages: [
92
+ {
93
+ name: "read_postgres",
94
+ run: (c) => {
95
+ c.rowsOut = 7;
96
+ },
97
+ },
98
+ {
99
+ name: "transform",
100
+ run: (c) => {
101
+ c.rowsIn = 7;
102
+ c.rowsOut = 7;
103
+ },
104
+ },
105
+ {
106
+ name: "write_sheet_jobs",
107
+ run: (c) => {
108
+ c.rowsIn = 7;
109
+ c.rowsOut = 7;
110
+ },
111
+ },
112
+ ],
113
+ });
114
+ expect(record.status).toBe("ok");
115
+ expect(record.lastError).toBeNull();
116
+ expect(record.executionId).toMatch(/^[0-9a-f]{8}$/);
117
+ expect(record.rowsIn).toBe(7);
118
+ expect(record.rowsOut).toBe(7);
119
+ expect(record.stages.every((s) => s.status === "ok")).toBe(true);
120
+ expect(Date.parse(record.startedAt)).toBeLessThanOrEqual(Date.parse(record.finishedAt));
121
+ expect(record.durationMs).toBeGreaterThanOrEqual(0);
122
+ });
123
+
124
+ test("early failure marks downstream stages skipped and still emits the record", async () => {
125
+ const lines: string[] = [];
126
+ const record = await runJobStages({
127
+ logLine: (line) => lines.push(line),
128
+ stages: [
129
+ {
130
+ name: "read_postgres",
131
+ run: () => {
132
+ throw new Error("connection refused");
133
+ },
134
+ },
135
+ { name: "transform", run: () => {} },
136
+ { name: "write_sheet_jobs", run: () => {} },
137
+ ],
138
+ });
139
+ expect(record.status).toBe("error");
140
+ expect(record.lastError?.stage).toBe("read_postgres");
141
+ expect(record.stages[1]).toEqual({
142
+ stage: "transform",
143
+ status: "skipped",
144
+ durationMs: null,
145
+ rowsIn: 0,
146
+ rowsOut: 0,
147
+ });
148
+ expect(record.stages[2]?.status).toBe("skipped");
149
+ expect(record.rowsIn).toBe(0);
150
+ expect(record.rowsOut).toBe(0);
151
+ expect(lines).toHaveLength(1);
152
+ });
153
+
154
+ test("non-Error throws and oversized messages are flattened and capped", async () => {
155
+ const record = await runJobStages({
156
+ logLine: false,
157
+ stages: [
158
+ {
159
+ name: "transform",
160
+ run: () => {
161
+ throw `line one\nline two ${"x".repeat(1000)}`;
162
+ },
163
+ },
164
+ ],
165
+ });
166
+ expect(record.status).toBe("error");
167
+ expect(record.lastError?.stage).toBe("transform");
168
+ expect(record.lastError?.message.includes("\n")).toBe(false);
169
+ expect(record.lastError?.message.length).toBeLessThanOrEqual(503);
170
+ });
171
+
172
+ test("table append failure is recorded on the record, never thrown", async () => {
173
+ const dir = await tmp();
174
+ const blocker = join(dir, "blocker");
175
+ await writeFile(blocker, "not a directory");
176
+ const record = await runJobStages({
177
+ logLine: false,
178
+ tablePath: join(blocker, "job-runs.jsonl"),
179
+ stages: [{ name: "read_postgres", run: () => {} }],
180
+ });
181
+ expect(record.persistError).not.toBeNull();
182
+ });
183
+ });
184
+
185
+ describe("appendJobRunRecord", () => {
186
+ test("appends records as one JSONL row each, creating parent dirs", async () => {
187
+ const dir = await tmp();
188
+ const path = join(dir, "nested", "runs.jsonl");
189
+ const record = await runJobStages({
190
+ logLine: false,
191
+ stages: [{ name: "read_postgres", run: () => {} }],
192
+ });
193
+ await appendJobRunRecord(path, record);
194
+ await appendJobRunRecord(path, { ...record, status: "error" });
195
+ const lines = (await readFile(path, "utf8")).trim().split("\n");
196
+ expect(lines).toHaveLength(2);
197
+ const rows = lines.map((l) => JSON.parse(l) as JobRunRecord);
198
+ expect(rows[0]?.status).toBe("ok");
199
+ expect(rows[1]?.status).toBe("error");
200
+ });
201
+ });
@@ -111,6 +111,59 @@ describe("fetchWithRetry", () => {
111
111
  expect(calls).toBe(1);
112
112
  });
113
113
 
114
+ test("a POST Request is not retried after a server or network failure", async () => {
115
+ for (const networkFailure of [false, true]) {
116
+ let calls = 0;
117
+ globalThis.fetch = (async () => {
118
+ calls++;
119
+ if (networkFailure) throw new TypeError("fetch failed");
120
+ return new Response(null, { status: 503 });
121
+ }) as unknown as typeof fetch;
122
+ const request = new Request("https://example.com/charge", { method: "POST" });
123
+ const result = fetchWithRetry(request, undefined, { baseDelayMs: 1 });
124
+ if (networkFailure) await expect(result).rejects.toThrow("fetch failed");
125
+ else expect((await result).status).toBe(503);
126
+ expect(calls).toBe(1);
127
+ }
128
+ });
129
+
130
+ test("init method overrides the Request method when deciding whether to retry", async () => {
131
+ let calls = 0;
132
+ globalThis.fetch = (async () => {
133
+ calls++;
134
+ return new Response(null, { status: 503 });
135
+ }) as unknown as typeof fetch;
136
+ await fetchWithRetry(
137
+ new Request("https://example.com"),
138
+ { method: "POST" },
139
+ { baseDelayMs: 1 },
140
+ );
141
+ expect(calls).toBe(1);
142
+ });
143
+
144
+ test("Request and init signals interrupt retry delays, including with an external signal", async () => {
145
+ for (const source of ["request", "init"] as const) {
146
+ const controller = new AbortController();
147
+ const external = new AbortController();
148
+ let calls = 0;
149
+ globalThis.fetch = (async () => {
150
+ calls++;
151
+ setTimeout(() => controller.abort(new Error("cancelled")), 5);
152
+ return new Response(null, { status: 503 });
153
+ }) as unknown as typeof fetch;
154
+ const request = new Request("https://example.com", {
155
+ signal: source === "request" ? controller.signal : undefined,
156
+ });
157
+ await expect(
158
+ fetchWithRetry(request, source === "init" ? { signal: controller.signal } : undefined, {
159
+ signal: external.signal,
160
+ baseDelayMs: 1000,
161
+ }),
162
+ ).rejects.toThrow("cancelled");
163
+ expect(calls).toBe(1);
164
+ }
165
+ });
166
+
114
167
  test("idempotent: true overrides method-based default, letting a POST retry", async () => {
115
168
  let calls = 0;
116
169
  globalThis.fetch = (async () => {