@ashlr/stack-core 0.3.7 → 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 +1 -1
- package/src/__tests__/_harness.ts +21 -14
- package/src/__tests__/fixtures/stack-manifest.v1.json +3 -3
- package/src/__tests__/fleet-capability-matrix.test.ts +357 -0
- package/src/__tests__/fleet-job-run.test.ts +201 -0
- package/src/__tests__/http.test.ts +53 -0
- package/src/ai/phantom-wire-repeated-unknown-placeholder.test.ts +0 -3
- package/src/fleet/capability-matrix.ts +378 -0
- package/src/fleet/job-run.ts +190 -0
- package/src/http.ts +8 -15
- package/src/index.ts +2 -0
- package/src/providers/grafana.ts +0 -1
- package/src/stack-manifest.ts +3 -3
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
{ "id": "cli", "name": "@ashlr/stack", "version": "0.3.
|
|
7
|
-
{ "id": "mcp", "name": "ashlr-stack-mcp", "version": "0.3.
|
|
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 () => {
|
|
@@ -1,7 +1,4 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, setDefaultTimeout } from "bun:test";
|
|
2
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
2
|
import { type Harness, setupFakePhantom } from "../__tests__/_harness.ts";
|
|
6
3
|
import { webhookStubFor } from "./phantom-wire.ts";
|
|
7
4
|
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* capability-matrix.ts — resource-agnostic job→resource matching for the fleet.
|
|
3
|
+
*
|
|
4
|
+
* Today dispatch lanes are provider-named (codex-cmp, claude, devin, codex-local,
|
|
5
|
+
* …) and the dispatcher picks a lane per queued job. The end-state binds fleet
|
|
6
|
+
* roles to whatever resource has the capability, quota and availability — this
|
|
7
|
+
* module is that matching layer. It is pure: callers assemble `FleetResource`
|
|
8
|
+
* rows from fleet state (slots.json, journals, capabilities.json, models.json)
|
|
9
|
+
* and this file answers "which resources can serve this requirement" without
|
|
10
|
+
* ever branching on a provider or lane name.
|
|
11
|
+
*
|
|
12
|
+
* Vocabulary:
|
|
13
|
+
* - Roles (closed set): the four fleet roles a job can require.
|
|
14
|
+
* - Capabilities (open, validated): orthogonal execution properties a job may
|
|
15
|
+
* additionally require; FLEET_CAPABILITIES seeds the known tags.
|
|
16
|
+
* - Tiers: open, validated strings (the dispatcher uses frontier/work/mid/fast).
|
|
17
|
+
*
|
|
18
|
+
* Default capability profile: a resource with no capabilities.json declaration
|
|
19
|
+
* serves the `implementer` role on every tier — the same contract dispatchd
|
|
20
|
+
* applies today, where any lane can launch a coding job. A declaration can
|
|
21
|
+
* narrow roles (`roles: []` serves nothing) or widen them (planner, reviewer,
|
|
22
|
+
* release-verifier need explicit declaration).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export const FLEET_ROLES = ["planner", "implementer", "reviewer", "release-verifier"] as const;
|
|
26
|
+
export type FleetRole = (typeof FLEET_ROLES)[number];
|
|
27
|
+
|
|
28
|
+
/** Seed capability tags; the vocabulary is open — any well-formed tag is valid. */
|
|
29
|
+
export const FLEET_CAPABILITIES = [
|
|
30
|
+
/** Model inference runs on this host; loopback-only serving, no provider quota. */
|
|
31
|
+
"local-inference",
|
|
32
|
+
/** May run the publish path (fork-only pushes through publish.sh). */
|
|
33
|
+
"publish",
|
|
34
|
+
/** Can observe CI and repository state (gh or Locus-scoped reads). */
|
|
35
|
+
"ci-watch",
|
|
36
|
+
] as const;
|
|
37
|
+
|
|
38
|
+
const ROLE_SET: ReadonlySet<string> = new Set(FLEET_ROLES);
|
|
39
|
+
const RESOURCE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
40
|
+
const TAG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
41
|
+
const TIER_RE = /^[a-z0-9][a-z0-9_-]{0,31}$/;
|
|
42
|
+
|
|
43
|
+
export const isFleetRole = (value: unknown): value is FleetRole =>
|
|
44
|
+
typeof value === "string" && ROLE_SET.has(value);
|
|
45
|
+
|
|
46
|
+
export const isResourceId = (value: unknown): value is string =>
|
|
47
|
+
typeof value === "string" && RESOURCE_ID_RE.test(value);
|
|
48
|
+
|
|
49
|
+
const isTag = (value: unknown): value is string => typeof value === "string" && TAG_RE.test(value);
|
|
50
|
+
const isTier = (value: unknown): value is string =>
|
|
51
|
+
typeof value === "string" && TIER_RE.test(value);
|
|
52
|
+
|
|
53
|
+
/** Well-formed capability tag (`local-inference`, `publish`, …); vocabulary is open. */
|
|
54
|
+
export const isFleetCapability = isTag;
|
|
55
|
+
/** Well-formed tier name (`frontier`, `work`, `mid`, `fast`, …); vocabulary is open. */
|
|
56
|
+
export const isFleetTier = isTier;
|
|
57
|
+
|
|
58
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
59
|
+
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
60
|
+
|
|
61
|
+
/** Roles a resource serves when nothing narrows them: implementer only. */
|
|
62
|
+
export const DEFAULT_RESOURCE_ROLES: readonly FleetRole[] = ["implementer"];
|
|
63
|
+
|
|
64
|
+
/** Per-resource quota/availability projection (slots.json + journal counts). */
|
|
65
|
+
export interface FleetResourceQuota {
|
|
66
|
+
/** Operating launch bound (slots.json target); null when unknown. */
|
|
67
|
+
target: number | null;
|
|
68
|
+
ceiling: number | null;
|
|
69
|
+
hardCap: number | null;
|
|
70
|
+
/** Operator or autobackoff hold. */
|
|
71
|
+
held: boolean;
|
|
72
|
+
/** Observed running jobs for this resource; null when journals are unreadable. */
|
|
73
|
+
running: number | null;
|
|
74
|
+
/** Observed queued jobs for this resource; null when journals are unreadable. */
|
|
75
|
+
queued: number | null;
|
|
76
|
+
/** Provider quota reading from slots.json when present; informational only —
|
|
77
|
+
* the dispatcher does not launch-block on it, so neither does matching. */
|
|
78
|
+
quotaRemaining: number | null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** What capabilities.json may declare for one resource. */
|
|
82
|
+
export interface CapabilityDeclaration {
|
|
83
|
+
/** null = default (implementer); [] = serves no role. */
|
|
84
|
+
roles: readonly FleetRole[] | null;
|
|
85
|
+
capabilities: readonly string[];
|
|
86
|
+
/** null = every tier; otherwise the tiers this resource serves. */
|
|
87
|
+
tiers: readonly string[] | null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A schedulable resource after declarations and live quota are merged. */
|
|
91
|
+
export interface FleetResource {
|
|
92
|
+
id: string;
|
|
93
|
+
/** True when capabilities.json declared this resource. */
|
|
94
|
+
declared: boolean;
|
|
95
|
+
/** Effective roles (declaration or the implementer default). */
|
|
96
|
+
roles: readonly FleetRole[];
|
|
97
|
+
capabilities: readonly string[];
|
|
98
|
+
tiers: readonly string[] | null;
|
|
99
|
+
/** null = no slot data; capacity checks then report headroom null, never block. */
|
|
100
|
+
quota: FleetResourceQuota | null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** What a job needs: a role, optional extra capability tags, optional tier. */
|
|
104
|
+
export interface JobRequirement {
|
|
105
|
+
role: FleetRole;
|
|
106
|
+
capabilities: readonly string[];
|
|
107
|
+
tier: string | null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface ResourceMatch {
|
|
111
|
+
id: string;
|
|
112
|
+
eligible: boolean;
|
|
113
|
+
/** Blocking reason codes; empty iff eligible. Codes: `missing-role:<role>`,
|
|
114
|
+
* `missing-capability:<tag>`, `tier-unsupported:<tier>`, `held`,
|
|
115
|
+
* `fleet-hold`, `fleet-at-capacity`, `at-capacity`. */
|
|
116
|
+
reasons: string[];
|
|
117
|
+
/** Spare launch slots (capacity bound minus running); null when either side
|
|
118
|
+
* is unknown — unknown quota never blocks on capacity, it reports null. */
|
|
119
|
+
headroom: number | null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface MatchResult {
|
|
123
|
+
requirement: JobRequirement;
|
|
124
|
+
/** Eligible resources first (headroom desc, id asc), then ineligible by id. */
|
|
125
|
+
matches: ResourceMatch[];
|
|
126
|
+
/** Ids of eligible resources in match order. */
|
|
127
|
+
eligible: string[];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface MatchOptions {
|
|
131
|
+
/** HOLD file present at the queue dir — blocks every resource. */
|
|
132
|
+
fleetHold?: boolean;
|
|
133
|
+
/** Fleet-wide running total and hard cap; blocks all only when certainly full. */
|
|
134
|
+
fleet?: { running: number | null; hardCap: number | null };
|
|
135
|
+
/** Cap on returned matches (eligible ordering preserved). Default unlimited. */
|
|
136
|
+
limit?: number;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface CapabilityDocument {
|
|
140
|
+
schema: "fleet.capabilities.v1";
|
|
141
|
+
resources: ReadonlyMap<string, CapabilityDeclaration>;
|
|
142
|
+
/** Resource entries skipped for invalid shape (they fall back to defaults). */
|
|
143
|
+
dropped: number;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export type CapabilityDocumentResult =
|
|
147
|
+
| { status: "ok"; document: CapabilityDocument; error: null }
|
|
148
|
+
| { status: "invalid"; document: null; error: string };
|
|
149
|
+
|
|
150
|
+
const nonNegInt = (value: unknown): number | null =>
|
|
151
|
+
typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
152
|
+
|
|
153
|
+
function parseStringList(value: unknown, valid: (v: unknown) => boolean): string[] | null {
|
|
154
|
+
if (!Array.isArray(value) || value.some((v) => !valid(v))) return null;
|
|
155
|
+
return [...new Set(value as string[])];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Parse a capabilities.json document. Strict about shape, lenient about scope:
|
|
160
|
+
* unknown keys are ignored, but a resource entry with an invalid roles /
|
|
161
|
+
* capabilities / tiers field is dropped whole and counted in `dropped` — an
|
|
162
|
+
* invalid declaration must never silently narrow or widen a resource.
|
|
163
|
+
*/
|
|
164
|
+
export function parseCapabilityDocument(text: string): CapabilityDocumentResult {
|
|
165
|
+
let root: unknown;
|
|
166
|
+
try {
|
|
167
|
+
root = JSON.parse(text);
|
|
168
|
+
} catch {
|
|
169
|
+
return { status: "invalid", document: null, error: "not valid JSON" };
|
|
170
|
+
}
|
|
171
|
+
if (!isRecord(root)) return { status: "invalid", document: null, error: "not a JSON object" };
|
|
172
|
+
if (root.schema !== "fleet.capabilities.v1")
|
|
173
|
+
return { status: "invalid", document: null, error: 'expected schema "fleet.capabilities.v1"' };
|
|
174
|
+
if (root.resources !== undefined && !isRecord(root.resources))
|
|
175
|
+
return { status: "invalid", document: null, error: '"resources" must be an object' };
|
|
176
|
+
|
|
177
|
+
const resources = new Map<string, CapabilityDeclaration>();
|
|
178
|
+
let dropped = 0;
|
|
179
|
+
for (const [id, value] of Object.entries(isRecord(root.resources) ? root.resources : {})) {
|
|
180
|
+
if (!isResourceId(id) || !isRecord(value)) {
|
|
181
|
+
dropped++;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
let roles: FleetRole[] | null = null;
|
|
185
|
+
if (value.roles !== undefined) {
|
|
186
|
+
const parsed = parseStringList(value.roles, isFleetRole);
|
|
187
|
+
if (parsed === null) {
|
|
188
|
+
dropped++;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
roles = parsed as FleetRole[];
|
|
192
|
+
}
|
|
193
|
+
let capabilities: string[] = [];
|
|
194
|
+
if (value.capabilities !== undefined) {
|
|
195
|
+
const parsed = parseStringList(value.capabilities, isTag);
|
|
196
|
+
if (parsed === null) {
|
|
197
|
+
dropped++;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
capabilities = parsed;
|
|
201
|
+
}
|
|
202
|
+
let tiers: string[] | null = null;
|
|
203
|
+
if (value.tiers !== undefined) {
|
|
204
|
+
const parsed = parseStringList(value.tiers, isTier);
|
|
205
|
+
if (parsed === null) {
|
|
206
|
+
dropped++;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
tiers = parsed;
|
|
210
|
+
}
|
|
211
|
+
resources.set(id, { roles, capabilities, tiers });
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
status: "ok",
|
|
215
|
+
document: { schema: "fleet.capabilities.v1", resources, dropped },
|
|
216
|
+
error: null,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Effective roles for a declaration; absent declaration or absent `roles` → implementer. */
|
|
221
|
+
export function effectiveRoles(
|
|
222
|
+
declaration: CapabilityDeclaration | undefined,
|
|
223
|
+
): readonly FleetRole[] {
|
|
224
|
+
return declaration?.roles ?? DEFAULT_RESOURCE_ROLES;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Merge a declaration with observed quota into a matchable resource. */
|
|
228
|
+
export function buildFleetResource(
|
|
229
|
+
id: string,
|
|
230
|
+
declaration: CapabilityDeclaration | undefined,
|
|
231
|
+
quota: FleetResourceQuota | null,
|
|
232
|
+
): FleetResource {
|
|
233
|
+
return {
|
|
234
|
+
id,
|
|
235
|
+
declared: declaration !== undefined,
|
|
236
|
+
roles: effectiveRoles(declaration),
|
|
237
|
+
capabilities: declaration?.capabilities ?? [],
|
|
238
|
+
tiers: declaration?.tiers ?? null,
|
|
239
|
+
quota,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Smallest known launch bound; null when no quota field is usable. */
|
|
244
|
+
export function capacityBound(quota: FleetResourceQuota): number | null {
|
|
245
|
+
const bounds = [quota.target, quota.ceiling, quota.hardCap].filter(
|
|
246
|
+
(v): v is number => v !== null,
|
|
247
|
+
);
|
|
248
|
+
return bounds.length ? Math.min(...bounds) : null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** capacityBound - running; null when either side is unknown. */
|
|
252
|
+
export function resourceHeadroom(quota: FleetResourceQuota | null): number | null {
|
|
253
|
+
if (quota === null || quota.running === null) return null;
|
|
254
|
+
const bound = capacityBound(quota);
|
|
255
|
+
return bound === null ? null : Math.max(0, bound - quota.running);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Match one job requirement against resources. Provider- and lane-name
|
|
260
|
+
* agnostic: only declared roles, capability tags, tier coverage, holds and
|
|
261
|
+
* observed quota decide eligibility.
|
|
262
|
+
*/
|
|
263
|
+
export function matchRequirement(
|
|
264
|
+
requirement: JobRequirement,
|
|
265
|
+
resources: readonly FleetResource[],
|
|
266
|
+
options: MatchOptions = {},
|
|
267
|
+
): MatchResult {
|
|
268
|
+
const fleetFull =
|
|
269
|
+
options.fleet?.hardCap != null &&
|
|
270
|
+
options.fleet.running != null &&
|
|
271
|
+
options.fleet.running >= options.fleet.hardCap;
|
|
272
|
+
const matches: ResourceMatch[] = resources.map((resource) => {
|
|
273
|
+
const reasons: string[] = [];
|
|
274
|
+
if (!resource.roles.includes(requirement.role))
|
|
275
|
+
reasons.push(`missing-role:${requirement.role}`);
|
|
276
|
+
for (const tag of requirement.capabilities)
|
|
277
|
+
if (!resource.capabilities.includes(tag)) reasons.push(`missing-capability:${tag}`);
|
|
278
|
+
if (
|
|
279
|
+
requirement.tier !== null &&
|
|
280
|
+
resource.tiers !== null &&
|
|
281
|
+
!resource.tiers.includes(requirement.tier)
|
|
282
|
+
)
|
|
283
|
+
reasons.push(`tier-unsupported:${requirement.tier}`);
|
|
284
|
+
if (options.fleetHold === true) reasons.push("fleet-hold");
|
|
285
|
+
if (fleetFull) reasons.push("fleet-at-capacity");
|
|
286
|
+
if (resource.quota?.held === true) reasons.push("held");
|
|
287
|
+
const headroom = resourceHeadroom(resource.quota);
|
|
288
|
+
if (headroom === 0) reasons.push("at-capacity");
|
|
289
|
+
return { id: resource.id, eligible: reasons.length === 0, reasons, headroom };
|
|
290
|
+
});
|
|
291
|
+
matches.sort((a, b) => {
|
|
292
|
+
if (a.eligible !== b.eligible) return a.eligible ? -1 : 1;
|
|
293
|
+
if (a.eligible) {
|
|
294
|
+
// Eligible: most headroom first; unknown headroom sorts last; id breaks ties.
|
|
295
|
+
if (a.headroom !== b.headroom) {
|
|
296
|
+
if (a.headroom === null) return 1;
|
|
297
|
+
if (b.headroom === null) return -1;
|
|
298
|
+
return b.headroom - a.headroom;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return a.id.localeCompare(b.id);
|
|
302
|
+
});
|
|
303
|
+
const limited = options.limit === undefined ? matches : matches.slice(0, options.limit);
|
|
304
|
+
return {
|
|
305
|
+
requirement,
|
|
306
|
+
matches: limited,
|
|
307
|
+
eligible: limited.filter((m) => m.eligible).map((m) => m.id),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
// models.json — flat `lane[.tier]` → model map; resolution mirrors dispatchd.
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
const MODEL_KEY_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
316
|
+
const MODEL_RE = /^[A-Za-z0-9][A-Za-z0-9._:/+-]{0,127}$/;
|
|
317
|
+
|
|
318
|
+
export interface ModelMap {
|
|
319
|
+
map: ReadonlyMap<string, string>;
|
|
320
|
+
dropped: number;
|
|
321
|
+
/** "invalid" when the text is not a JSON object; entries may still be dropped from a valid doc. */
|
|
322
|
+
status: "ok" | "invalid";
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Parse ~/.ashlr-runs/models.json: `{ "<resource>[.<tier>]": "<model>" }`. */
|
|
326
|
+
export function parseModelMap(text: string): ModelMap {
|
|
327
|
+
const map = new Map<string, string>();
|
|
328
|
+
let dropped = 0;
|
|
329
|
+
let root: unknown;
|
|
330
|
+
try {
|
|
331
|
+
root = JSON.parse(text);
|
|
332
|
+
} catch {
|
|
333
|
+
return { map, dropped: 1, status: "invalid" };
|
|
334
|
+
}
|
|
335
|
+
if (!isRecord(root)) return { map, dropped: 1, status: "invalid" };
|
|
336
|
+
for (const [key, value] of Object.entries(root)) {
|
|
337
|
+
const dot = key.indexOf(".");
|
|
338
|
+
const lane = dot < 0 ? key : key.slice(0, dot);
|
|
339
|
+
const tier = dot < 0 ? null : key.slice(dot + 1);
|
|
340
|
+
if (
|
|
341
|
+
!MODEL_KEY_RE.test(lane) ||
|
|
342
|
+
(tier !== null && !isTier(tier)) ||
|
|
343
|
+
typeof value !== "string" ||
|
|
344
|
+
!MODEL_RE.test(value.trim())
|
|
345
|
+
) {
|
|
346
|
+
dropped++;
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
map.set(key, value.trim());
|
|
350
|
+
}
|
|
351
|
+
return { map, dropped, status: "ok" };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** The map family dispatchd uses: `codex` for codex-* lanes except codex-local. */
|
|
355
|
+
export const modelFamily = (resourceId: string): string =>
|
|
356
|
+
resourceId.startsWith("codex") && resourceId !== "codex-local" ? "codex" : resourceId;
|
|
357
|
+
|
|
358
|
+
/** First non-empty of `<id>.<tier>`, `<id>`, `<family>.<tier>`, `<family>`. */
|
|
359
|
+
export function resolveModel(
|
|
360
|
+
map: ReadonlyMap<string, string>,
|
|
361
|
+
resourceId: string,
|
|
362
|
+
tier: string | null,
|
|
363
|
+
): { model: string; via: string } | null {
|
|
364
|
+
const family = modelFamily(resourceId);
|
|
365
|
+
const keys =
|
|
366
|
+
family === resourceId
|
|
367
|
+
? tier === null
|
|
368
|
+
? [resourceId]
|
|
369
|
+
: [`${resourceId}.${tier}`, resourceId]
|
|
370
|
+
: tier === null
|
|
371
|
+
? [resourceId, family]
|
|
372
|
+
: [`${resourceId}.${tier}`, resourceId, `${family}.${tier}`, family];
|
|
373
|
+
for (const key of keys) {
|
|
374
|
+
const model = map.get(key);
|
|
375
|
+
if (model) return { model, via: key };
|
|
376
|
+
}
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { appendFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* job-run.ts — per-execution structured result records for fleet jobs.
|
|
6
|
+
*
|
|
7
|
+
* Motivation: stacksync execution 5d2b848b died inside `write_sheet_jobs` and
|
|
8
|
+
* the only artifact was an unstructured error line — remotely we could not
|
|
9
|
+
* tell which stage failed, how long each stage took, or how many rows were
|
|
10
|
+
* pulled vs. committed. Every staged job run now ends with one JobRunRecord:
|
|
11
|
+
* per-stage timings, rows in/out, and the last error tagged with its stage.
|
|
12
|
+
*
|
|
13
|
+
* The record is emitted as a single-line JSON log (greppable in journald or
|
|
14
|
+
* any log drain) and, when `tablePath` is set, appended as one row to a JSONL
|
|
15
|
+
* table that `readFleetJsonl` / `jq` can query. Records carry counts and a
|
|
16
|
+
* bounded, flattened error message — never row payloads.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const JOB_RUN_SCHEMA = "fleet.job-run.v1";
|
|
20
|
+
|
|
21
|
+
/** Stacksync's fixed pipeline order: source read → row transform → sheet write. */
|
|
22
|
+
export const SYNC_STAGES = ["read_postgres", "transform", "write_sheet_jobs"] as const;
|
|
23
|
+
export type SyncStageName = (typeof SYNC_STAGES)[number];
|
|
24
|
+
|
|
25
|
+
export type StageStatus = "ok" | "error" | "skipped";
|
|
26
|
+
|
|
27
|
+
export interface StageTiming {
|
|
28
|
+
stage: string;
|
|
29
|
+
status: StageStatus;
|
|
30
|
+
/** Wall time of the stage; null when the stage never ran (skipped). */
|
|
31
|
+
durationMs: number | null;
|
|
32
|
+
/** Rows the stage consumed. */
|
|
33
|
+
rowsIn: number;
|
|
34
|
+
/** Rows the stage produced; on a failed write this is the partial commit count. */
|
|
35
|
+
rowsOut: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface JobRunError {
|
|
39
|
+
stage: string;
|
|
40
|
+
message: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface JobRunRecord {
|
|
44
|
+
schema: typeof JOB_RUN_SCHEMA;
|
|
45
|
+
job: string;
|
|
46
|
+
executionId: string;
|
|
47
|
+
/** Sync direction for two-way jobs, e.g. "postgres->sheets"; null when unset. */
|
|
48
|
+
direction: string | null;
|
|
49
|
+
startedAt: string;
|
|
50
|
+
finishedAt: string;
|
|
51
|
+
durationMs: number;
|
|
52
|
+
status: "ok" | "error";
|
|
53
|
+
/** Rows that entered the pipeline: the first stage's input, or its output when
|
|
54
|
+
* it is a source stage (read_postgres reports only rowsOut). */
|
|
55
|
+
rowsIn: number;
|
|
56
|
+
/** Rows the pipeline emitted: the last attempted stage's output — partial on failure. */
|
|
57
|
+
rowsOut: number;
|
|
58
|
+
stages: StageTiming[];
|
|
59
|
+
lastError: JobRunError | null;
|
|
60
|
+
/** Set when the JSONL table append failed; the log line is then the only copy. */
|
|
61
|
+
persistError: string | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Mutable counters handed to a stage; partial values survive a throw. */
|
|
65
|
+
export interface StageCounts {
|
|
66
|
+
rowsIn: number;
|
|
67
|
+
rowsOut: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface JobStage {
|
|
71
|
+
name: string;
|
|
72
|
+
run: (counts: StageCounts) => void | Promise<void>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface RunJobOptions {
|
|
76
|
+
stages: readonly JobStage[];
|
|
77
|
+
/** Job name stamped on the record. Default "stacksync". */
|
|
78
|
+
job?: string;
|
|
79
|
+
/** Caller-assigned execution id (e.g. "5d2b848b"); defaults to a random 8-hex id. */
|
|
80
|
+
executionId?: string;
|
|
81
|
+
/** Sync direction label, e.g. "postgres->sheets". */
|
|
82
|
+
direction?: string;
|
|
83
|
+
/** JSONL table path; when set the record is appended as one line. */
|
|
84
|
+
tablePath?: string;
|
|
85
|
+
/** Log-line sink; default writes one JSON line to stdout. Pass `false` to silence. */
|
|
86
|
+
logLine?: ((line: string) => void) | false;
|
|
87
|
+
/** Wall clock for ISO stamps and durations (ms); default Date.now. */
|
|
88
|
+
now?: () => number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Cap on recorded error text — provider error bodies can be kilobytes. */
|
|
92
|
+
const MAX_ERROR_MESSAGE = 500;
|
|
93
|
+
|
|
94
|
+
function errorMessage(error: unknown): string {
|
|
95
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
96
|
+
const flat = raw.replace(/\s+/g, " ").trim();
|
|
97
|
+
return flat.length > MAX_ERROR_MESSAGE ? `${flat.slice(0, MAX_ERROR_MESSAGE)}...` : flat;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Junk counts (negative, non-finite) record as 0 — a record must never carry NaN. */
|
|
101
|
+
const rowCount = (value: number): number =>
|
|
102
|
+
Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
|
|
103
|
+
|
|
104
|
+
const defaultLogLine = (line: string): void => console.log(line);
|
|
105
|
+
|
|
106
|
+
/** Append one record to a JSONL table, creating the parent directory as needed. */
|
|
107
|
+
export async function appendJobRunRecord(path: string, record: JobRunRecord): Promise<void> {
|
|
108
|
+
await mkdir(dirname(path), { recursive: true });
|
|
109
|
+
await appendFile(path, `${JSON.stringify(record)}\n`, "utf8");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Run `stages` in order and return the execution record. The record — not a
|
|
114
|
+
* throw — is the outcome: callers branch on `record.status` /
|
|
115
|
+
* `record.lastError`. A stage that throws ends the run; the failing stage's
|
|
116
|
+
* timing and partial counts are kept, later stages are marked "skipped", and
|
|
117
|
+
* the record is still emitted so a remote run leaves evidence.
|
|
118
|
+
*/
|
|
119
|
+
export async function runJobStages(options: RunJobOptions): Promise<JobRunRecord> {
|
|
120
|
+
const now = options.now ?? Date.now;
|
|
121
|
+
const startMs = now();
|
|
122
|
+
const timings: StageTiming[] = [];
|
|
123
|
+
let lastError: JobRunError | null = null;
|
|
124
|
+
|
|
125
|
+
for (const stage of options.stages) {
|
|
126
|
+
if (lastError !== null) {
|
|
127
|
+
timings.push({
|
|
128
|
+
stage: stage.name,
|
|
129
|
+
status: "skipped",
|
|
130
|
+
durationMs: null,
|
|
131
|
+
rowsIn: 0,
|
|
132
|
+
rowsOut: 0,
|
|
133
|
+
});
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const counts: StageCounts = { rowsIn: 0, rowsOut: 0 };
|
|
137
|
+
const stageStart = now();
|
|
138
|
+
try {
|
|
139
|
+
await stage.run(counts);
|
|
140
|
+
timings.push({
|
|
141
|
+
stage: stage.name,
|
|
142
|
+
status: "ok",
|
|
143
|
+
durationMs: Math.max(0, Math.round(now() - stageStart)),
|
|
144
|
+
rowsIn: rowCount(counts.rowsIn),
|
|
145
|
+
rowsOut: rowCount(counts.rowsOut),
|
|
146
|
+
});
|
|
147
|
+
} catch (error) {
|
|
148
|
+
timings.push({
|
|
149
|
+
stage: stage.name,
|
|
150
|
+
status: "error",
|
|
151
|
+
durationMs: Math.max(0, Math.round(now() - stageStart)),
|
|
152
|
+
rowsIn: rowCount(counts.rowsIn),
|
|
153
|
+
rowsOut: rowCount(counts.rowsOut),
|
|
154
|
+
});
|
|
155
|
+
lastError = { stage: stage.name, message: errorMessage(error) };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const endMs = now();
|
|
160
|
+
const attempted = timings.filter((t) => t.status !== "skipped");
|
|
161
|
+
const first = attempted[0];
|
|
162
|
+
const last = attempted[attempted.length - 1];
|
|
163
|
+
const record: JobRunRecord = {
|
|
164
|
+
schema: JOB_RUN_SCHEMA,
|
|
165
|
+
job: options.job ?? "stacksync",
|
|
166
|
+
executionId: options.executionId ?? crypto.randomUUID().slice(0, 8),
|
|
167
|
+
direction: options.direction ?? null,
|
|
168
|
+
startedAt: new Date(startMs).toISOString(),
|
|
169
|
+
finishedAt: new Date(endMs).toISOString(),
|
|
170
|
+
durationMs: Math.max(0, Math.round(endMs - startMs)),
|
|
171
|
+
status: lastError === null ? "ok" : "error",
|
|
172
|
+
rowsIn: first ? first.rowsIn || first.rowsOut : 0,
|
|
173
|
+
rowsOut: last?.rowsOut ?? 0,
|
|
174
|
+
stages: timings,
|
|
175
|
+
lastError,
|
|
176
|
+
persistError: null,
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
if (options.tablePath) {
|
|
180
|
+
try {
|
|
181
|
+
await appendJobRunRecord(options.tablePath, record);
|
|
182
|
+
} catch (error) {
|
|
183
|
+
record.persistError = errorMessage(error);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const sink = options.logLine === undefined ? defaultLogLine : options.logLine;
|
|
188
|
+
if (sink) sink(JSON.stringify(record));
|
|
189
|
+
return record;
|
|
190
|
+
}
|
package/src/http.ts
CHANGED
|
@@ -56,23 +56,21 @@ export async function fetchWithRetry(
|
|
|
56
56
|
const baseDelayMs = opts.baseDelayMs ?? 400;
|
|
57
57
|
const maxDelayMs = opts.maxDelayMs ?? 8000;
|
|
58
58
|
const retryOn = opts.retryOn ?? defaultRetryOn;
|
|
59
|
-
const idempotent =
|
|
60
|
-
|
|
59
|
+
const idempotent =
|
|
60
|
+
opts.idempotent ??
|
|
61
|
+
isIdempotent(init?.method ?? (input instanceof Request ? input.method : undefined));
|
|
62
|
+
const requestSignal = init?.signal ?? (input instanceof Request ? input.signal : undefined);
|
|
63
|
+
const mergedInit = mergeSignal({ ...init, signal: requestSignal }, opts.signal);
|
|
64
|
+
const signal = mergedInit?.signal ?? undefined;
|
|
61
65
|
|
|
62
66
|
let attempt = 0;
|
|
63
|
-
// Track the last non-ok response so a final failure returns something the
|
|
64
|
-
// caller can inspect (status / body) rather than a synthetic error.
|
|
65
|
-
let lastResponse: Response | undefined;
|
|
66
|
-
let lastError: unknown;
|
|
67
|
-
|
|
68
67
|
while (true) {
|
|
69
68
|
if (signal?.aborted) throw abortError(signal);
|
|
70
69
|
|
|
71
70
|
try {
|
|
72
|
-
const res = await fetch(input,
|
|
71
|
+
const res = await fetch(input, mergedInit);
|
|
73
72
|
if (res.ok || !idempotent || !retryOn(res)) return res;
|
|
74
73
|
|
|
75
|
-
lastResponse = res;
|
|
76
74
|
if (attempt >= retries) return res;
|
|
77
75
|
|
|
78
76
|
const waitMs = computeDelay(res, attempt, baseDelayMs, maxDelayMs);
|
|
@@ -82,19 +80,14 @@ export async function fetchWithRetry(
|
|
|
82
80
|
// Network-level failure (DNS, connection reset, fetch threw). Only retry
|
|
83
81
|
// for idempotent requests — a POST that "failed" may have actually been
|
|
84
82
|
// accepted by the remote before the connection dropped.
|
|
83
|
+
if (signal?.aborted) throw abortError(signal);
|
|
85
84
|
if (isAbortError(err)) throw err;
|
|
86
|
-
lastError = err;
|
|
87
85
|
if (!idempotent || attempt >= retries) throw err;
|
|
88
86
|
const waitMs = computeDelay(undefined, attempt, baseDelayMs, maxDelayMs);
|
|
89
87
|
await sleep(waitMs, signal);
|
|
90
88
|
attempt++;
|
|
91
89
|
}
|
|
92
90
|
}
|
|
93
|
-
|
|
94
|
-
// Unreachable — both branches above either return or throw. The assignment
|
|
95
|
-
// to lastResponse/lastError keeps TS happy if the loop ever changes shape.
|
|
96
|
-
void lastResponse;
|
|
97
|
-
void lastError;
|
|
98
91
|
}
|
|
99
92
|
|
|
100
93
|
// ---------------------------------------------------------------------------
|
package/src/index.ts
CHANGED
package/src/providers/grafana.ts
CHANGED
package/src/stack-manifest.ts
CHANGED
|
@@ -26,9 +26,9 @@ export const STACK_MANIFEST = Object.freeze({
|
|
|
26
26
|
schema_version: 1,
|
|
27
27
|
kind: "ashlr-stack-manifest",
|
|
28
28
|
components: Object.freeze([
|
|
29
|
-
Object.freeze({ id: "core", name: "@ashlr/stack-core", version: "0.3.
|
|
30
|
-
Object.freeze({ id: "cli", name: "@ashlr/stack", version: "0.3.
|
|
31
|
-
Object.freeze({ id: "mcp", name: "ashlr-stack-mcp", version: "0.3.
|
|
29
|
+
Object.freeze({ id: "core", name: "@ashlr/stack-core", version: "0.3.9", distribution: "npm" }),
|
|
30
|
+
Object.freeze({ id: "cli", name: "@ashlr/stack", version: "0.3.9", distribution: "npm" }),
|
|
31
|
+
Object.freeze({ id: "mcp", name: "ashlr-stack-mcp", version: "0.3.9", distribution: "npm" }),
|
|
32
32
|
Object.freeze({
|
|
33
33
|
id: "plugin",
|
|
34
34
|
name: "stack",
|