unitbob 0.6.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +5 -4
- package/dist/files/guardrails.js +1 -1
- package/dist/files/packets.js +319 -0
- package/dist/files/suiteBuild.js +34 -2
- package/dist/files/suiteBuildUpload.js +1 -1
- package/dist/files/workerPlan.js +79 -5
- package/dist/runner/failureDigest.js +98 -16
- package/dist/runner/provision.js +174 -9
- package/dist/runner/pytest.js +1 -1
- package/dist/runner/pytestBddPlugin.js +5 -0
- package/dist/runner/rspec.js +1 -1
- package/dist/runner/vitest.js +1 -1
- package/dist/surfaces/graph.js +33 -0
- package/dist/surfaces/routeInventory.js +2 -33
- package/dist/verbs/acceptWorkerPlan.js +0 -0
- package/dist/verbs/putSuiteBuild.js +1 -1
- package/dist/verbs/run.js +2 -2
- package/dist/verbs/runLocal.js +66 -2
- package/dist/verbs/suitePrepare.js +78 -2
- package/dist/wire.js +1 -1
- package/package.json +1 -1
- package/plugin/codex/agents/suite-repair-worker.toml +1 -1
- package/plugin/codex/agents/suite-worker.toml +13 -5
- package/dist/verbs/validateWorkerPlan.js +0 -9
package/dist/cli.js
CHANGED
|
@@ -27,7 +27,7 @@ import { validateBuild } from "./verbs/validateBuild.js";
|
|
|
27
27
|
import { fixPrepare } from "./verbs/fixPrepare.js";
|
|
28
28
|
import { contractPrompt } from "./verbs/contractPrompt.js";
|
|
29
29
|
import { suiteReviewPrepare } from "./verbs/suiteReviewPrepare.js";
|
|
30
|
-
import {
|
|
30
|
+
import { acceptWorkerPlan } from "./verbs/acceptWorkerPlan.js";
|
|
31
31
|
import { validateWorkerCheckpoints } from "./verbs/validateWorkerCheckpoints.js";
|
|
32
32
|
import { installCodexAgents } from "./verbs/codexInstall.js";
|
|
33
33
|
const USAGE = `unitbob — thin local hands for the Unitbob server.
|
|
@@ -53,7 +53,8 @@ Verbs:
|
|
|
53
53
|
suite-review-prepare Internal: bind an independent BDD quality review to the built behavioral candidate.
|
|
54
54
|
validate-build Internal: check the host's suite answer against the request, locally, before
|
|
55
55
|
uploading. Reports every problem at once; put-suite-build runs it too.
|
|
56
|
-
|
|
56
|
+
accept-worker-plan Internal: check the exact request-bound worker plan and seed a checkpoint for
|
|
57
|
+
every slice it names, before fan-out.
|
|
57
58
|
validate-worker-checkpoints
|
|
58
59
|
Internal: validate every worker checkpoint before assembly or repair.
|
|
59
60
|
put-suite-build Internal: upload the host-built guardrail suite (whole spec file + test_metadata),
|
|
@@ -120,8 +121,8 @@ export async function main(argv, deps = { ensureLinked }) {
|
|
|
120
121
|
case 'validate-build':
|
|
121
122
|
await validateBuild(await linked(), args);
|
|
122
123
|
return 0;
|
|
123
|
-
case '
|
|
124
|
-
await
|
|
124
|
+
case 'accept-worker-plan':
|
|
125
|
+
await acceptWorkerPlan(await linked(), args);
|
|
125
126
|
return 0;
|
|
126
127
|
case 'validate-worker-checkpoints':
|
|
127
128
|
await validateWorkerCheckpoints(await linked(), args);
|
package/dist/files/guardrails.js
CHANGED
|
@@ -67,7 +67,7 @@ abort 'unitbob_helper: refusing to run against a non-test environment' unless Ra
|
|
|
67
67
|
// runs need no connector-written support files here (the runtime pytest.ini
|
|
68
68
|
// lives outside this directory and is written by the pytest runner).
|
|
69
69
|
//
|
|
70
|
-
// Every file, not just the main one (spec
|
|
70
|
+
// Every file, not just the main one (spec one-place-per-rule, §6.4). The directory is wiped
|
|
71
71
|
// first and only the main file was written back, so a published suite of four
|
|
72
72
|
// files came back as one and the run that followed it silently protected a
|
|
73
73
|
// quarter of what the map claimed.
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
3
|
+
import { graphNodes, methodNameOf } from "../surfaces/graph.js";
|
|
4
|
+
import { assertUnitbobPath } from "./artifactPath.js";
|
|
5
|
+
import { surfacesPath } from "./mapBuild.js";
|
|
6
|
+
// Spec 37-1. A worker used to receive names — `User#get_token`, `POST /api/tokens` —
|
|
7
|
+
// and spend its first two thirds finding the code behind them. On microblog,
|
|
8
|
+
// 2026-08-23, that was 474 of 674 worker turns and 63% of their input, once per
|
|
9
|
+
// worker, for an application whose entire source is 92 KB.
|
|
10
|
+
//
|
|
11
|
+
// Resolving a name to a file is a dictionary lookup against two artifacts that
|
|
12
|
+
// are already on this machine, so it costs no tokens and asks no model. What it
|
|
13
|
+
// finds is copied whole: the packet carries exactly what the worker would have
|
|
14
|
+
// opened anyway, which is why it cannot inflate a run.
|
|
15
|
+
//
|
|
16
|
+
// Nothing here goes up the wire. Spec 22 keeps the source on the vibecoder's
|
|
17
|
+
// machine, and a packet is a local file the worker opens by path.
|
|
18
|
+
export const PACKETS_DIR = '.unitbob/suite-build/packets';
|
|
19
|
+
// The fuse, not a setting. It exists for the five-thousand-line controller
|
|
20
|
+
// whose file is not "what a worker would have read anyway" — there the packet
|
|
21
|
+
// carries the path and says so, and the worker opens the part it needs. There
|
|
22
|
+
// is nowhere for this number to live except this source file: the size of a
|
|
23
|
+
// packet is not a fact about the project, it is a fact about what fits.
|
|
24
|
+
export const MAX_PACKET_BYTES = 200_000;
|
|
25
|
+
export function packetsDir(projectRoot) {
|
|
26
|
+
return join(projectRoot, '.unitbob', 'suite-build', 'packets');
|
|
27
|
+
}
|
|
28
|
+
export function packetIndexPath(projectRoot) {
|
|
29
|
+
return join(packetsDir(projectRoot), 'index.json');
|
|
30
|
+
}
|
|
31
|
+
export function readPacketIndex(projectRoot) {
|
|
32
|
+
const path = packetIndexPath(projectRoot);
|
|
33
|
+
if (!existsSync(path))
|
|
34
|
+
return null;
|
|
35
|
+
try {
|
|
36
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
37
|
+
if (!Array.isArray(parsed?.targets))
|
|
38
|
+
return null;
|
|
39
|
+
// Entries, not just the array: a hand-edited index must not throw out of a
|
|
40
|
+
// verb whose whole policy is that a bad index costs the packets and nothing
|
|
41
|
+
// else.
|
|
42
|
+
return { targets: parsed.targets.filter((target) => !!asRecord(target) && asString(target?.id) !== undefined) };
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// A packet index we cannot read is the same as none: the worker searches,
|
|
46
|
+
// as it did before this spec. It is never a reason to fail a build.
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// Build every packet for this request. Called right after `request.json` is
|
|
51
|
+
// written, and before any plan exists: the entrypoints are known from the
|
|
52
|
+
// request, the workers are not, so a packet belongs to an entrypoint and two
|
|
53
|
+
// entrypoints in one file share one packet.
|
|
54
|
+
export function writeSuitePackets(projectRoot, request) {
|
|
55
|
+
const targets = targetsOf(request);
|
|
56
|
+
const lookup = buildLookup(projectRoot);
|
|
57
|
+
const notes = new Map();
|
|
58
|
+
// Regenerated whole, every run. A packet left over from a previous assignment
|
|
59
|
+
// sits under an authoritative name with nothing on it saying how old it is.
|
|
60
|
+
const dir = packetsDir(projectRoot);
|
|
61
|
+
rmSync(dir, { recursive: true, force: true });
|
|
62
|
+
mkdirSync(dir, { recursive: true });
|
|
63
|
+
const written = new Map();
|
|
64
|
+
const refused = new Map();
|
|
65
|
+
for (const target of targets) {
|
|
66
|
+
const sourceFile = lookup(target.entrypoint);
|
|
67
|
+
if (!sourceFile) {
|
|
68
|
+
target.note = 'no single file in graph.json or surfaces.json answers to this name — find it yourself';
|
|
69
|
+
count(notes, 'did not resolve to one file');
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
// The path travels even when the contents do not: a worker told which file
|
|
73
|
+
// to open has still been saved the search.
|
|
74
|
+
target.source_file = sourceFile;
|
|
75
|
+
// Two entrypoints in one file get one packet, written once, referenced twice.
|
|
76
|
+
if (!written.has(sourceFile) && !refused.has(sourceFile)) {
|
|
77
|
+
const copied = copyPacket(projectRoot, sourceFile);
|
|
78
|
+
if (typeof copied === 'number')
|
|
79
|
+
written.set(sourceFile, copied);
|
|
80
|
+
else
|
|
81
|
+
refused.set(sourceFile, copied);
|
|
82
|
+
}
|
|
83
|
+
const bytes = written.get(sourceFile);
|
|
84
|
+
if (bytes === undefined) {
|
|
85
|
+
const refusal = refused.get(sourceFile);
|
|
86
|
+
target.note = refusal.note;
|
|
87
|
+
count(notes, refusal.kind);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
target.packet = `${PACKETS_DIR}/${sourceFile}`;
|
|
91
|
+
target.bytes = bytes;
|
|
92
|
+
}
|
|
93
|
+
writeFileSync(packetIndexPath(projectRoot), `${JSON.stringify({ targets }, null, 2)}\n`);
|
|
94
|
+
return {
|
|
95
|
+
targets: targets.length,
|
|
96
|
+
located: targets.filter((target) => target.source_file).length,
|
|
97
|
+
resolved: targets.filter((target) => target.packet).length,
|
|
98
|
+
files: written.size,
|
|
99
|
+
bytes: [...written.values()].reduce((sum, bytes) => sum + bytes, 0),
|
|
100
|
+
notes: [...notes].map(([note, count]) => `${count} × ${note}`),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
// The two names a request carries. Structural interfaces name methods
|
|
104
|
+
// (`User#get_token`); behavioral capabilities name addresses (`POST /api/tokens`).
|
|
105
|
+
// Both are entrypoints, both resolve to a file, and both produce the same kind
|
|
106
|
+
// of packet — a worker on either branch opens the same thing the same way.
|
|
107
|
+
function targetsOf(request) {
|
|
108
|
+
const targets = [];
|
|
109
|
+
for (const branch of request.branches) {
|
|
110
|
+
const assignment = branch.assignment;
|
|
111
|
+
for (const block of asArray(assignment?.blocks)) {
|
|
112
|
+
for (const iface of asArray(asRecord(block)?.interfaces)) {
|
|
113
|
+
const record = asRecord(iface);
|
|
114
|
+
const id = asString(record?.interface_id);
|
|
115
|
+
if (!id)
|
|
116
|
+
continue;
|
|
117
|
+
for (const entrypoint of asArray(record?.entrypoints)) {
|
|
118
|
+
const name = asString(entrypoint);
|
|
119
|
+
if (name)
|
|
120
|
+
targets.push({ branch: branch.suite_kind, id, entrypoint: name });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
for (const capability of asArray(assignment?.capabilities)) {
|
|
125
|
+
const record = asRecord(capability);
|
|
126
|
+
const id = asString(record?.capability_id);
|
|
127
|
+
if (!id)
|
|
128
|
+
continue;
|
|
129
|
+
for (const surface of asArray(record?.surfaces)) {
|
|
130
|
+
const name = asString(surface);
|
|
131
|
+
if (name)
|
|
132
|
+
targets.push({ branch: branch.suite_kind, id, entrypoint: name });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return targets;
|
|
137
|
+
}
|
|
138
|
+
// One name in, at most one file out. Two local artifacts answer, both of them
|
|
139
|
+
// built from the graph rather than from anybody's spelling rule:
|
|
140
|
+
//
|
|
141
|
+
// `surfaces.json` — the addresses the router declared, each already carrying
|
|
142
|
+
// the file that serves it. It answers a behavioral surface (`POST /api/tokens`)
|
|
143
|
+
// by its id and a structural entrypoint (`MainRoutes.export_posts`) by the
|
|
144
|
+
// handler label, which is the router's own wording, not a name we built.
|
|
145
|
+
//
|
|
146
|
+
// `graph.json` — every symbol graphify saw, matched by method name. A name
|
|
147
|
+
// alone is not enough: `User#get_token` and `ApiTokens.get_token` are two
|
|
148
|
+
// different entrypoints and graphify saw only one `get_token()`. So the owner
|
|
149
|
+
// the entrypoint named has to agree with the file before it is believed.
|
|
150
|
+
//
|
|
151
|
+
// Ambiguity ends in silence, never in a first match, and neither does a name
|
|
152
|
+
// that resolves to a file its owner has nothing to do with. A packet holding
|
|
153
|
+
// the wrong file is worse than no packet: the worker reads it, believes it, and
|
|
154
|
+
// writes a test about code that does not serve this entrypoint. On microblog,
|
|
155
|
+
// 2026-08-23, matching on the bare name alone sent both `ApiTokens` entrypoints
|
|
156
|
+
// to `app/models.py`, whose `get_token` belongs to `User`.
|
|
157
|
+
function buildLookup(projectRoot) {
|
|
158
|
+
const byAddress = new Map();
|
|
159
|
+
for (const surface of readSurfaces(projectRoot)) {
|
|
160
|
+
const record = asRecord(surface);
|
|
161
|
+
const file = normalisePath(asString(record?.source_file));
|
|
162
|
+
if (!file)
|
|
163
|
+
continue;
|
|
164
|
+
for (const key of [asString(record?.id), asString(record?.handler_label)]) {
|
|
165
|
+
if (key)
|
|
166
|
+
remember(byAddress, key, file);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const byMethod = new Map();
|
|
170
|
+
const byLabel = new Map();
|
|
171
|
+
for (const node of graphNodes(projectRoot)) {
|
|
172
|
+
const file = normalisePath(typeof node.source_file === 'string' ? node.source_file : undefined);
|
|
173
|
+
if (typeof node.label !== 'string' || !file)
|
|
174
|
+
continue;
|
|
175
|
+
remember(byMethod, methodNameOf(node.label), file);
|
|
176
|
+
remember(byLabel, node.label, file);
|
|
177
|
+
}
|
|
178
|
+
// The owner and the file agree if graphify put something of the owner's name
|
|
179
|
+
// in that file, or if the owner is the file — `ApiAuth` and `app/api/auth.py`
|
|
180
|
+
// are the same thing said twice, once in the map's words and once in the
|
|
181
|
+
// filesystem's. This confirms a file we already found; it never builds a path
|
|
182
|
+
// out of a name, which would be us maintaining somebody else's naming rule.
|
|
183
|
+
const agrees = (owner, file) => byLabel.get(owner)?.has(file) === true ||
|
|
184
|
+
byMethod.get(owner)?.has(file) === true ||
|
|
185
|
+
squash(file).includes(squash(owner));
|
|
186
|
+
return (entrypoint) => {
|
|
187
|
+
const declared = only(byAddress.get(entrypoint));
|
|
188
|
+
if (declared)
|
|
189
|
+
return declared;
|
|
190
|
+
// An address is the router's word, not a symbol, and must not fall through
|
|
191
|
+
// to symbol matching: `GET /users` would resolve by the name `users` to
|
|
192
|
+
// whatever single thing answers to it — a helper, a model, anything.
|
|
193
|
+
if (/\s/.test(entrypoint))
|
|
194
|
+
return undefined;
|
|
195
|
+
const named = byMethod.get(methodNameOf(entrypoint));
|
|
196
|
+
if (!named)
|
|
197
|
+
return undefined;
|
|
198
|
+
const owner = ownerNameOf(entrypoint);
|
|
199
|
+
if (!owner)
|
|
200
|
+
return only(named);
|
|
201
|
+
return only(new Set([...named].filter((file) => agrees(owner, file))));
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
// The name the entrypoint hangs its method on: `User` in `User#get_token`,
|
|
205
|
+
// `Invoice` in `Billing::Invoice#total`. Split exactly as `methodNameOf` splits,
|
|
206
|
+
// so the two never disagree about where a name ends.
|
|
207
|
+
function ownerNameOf(entrypoint) {
|
|
208
|
+
const parts = entrypoint.replace(/\(.*\)\s*$/, '').split(/::|[#./]/).filter(Boolean);
|
|
209
|
+
return parts.length > 1 ? parts[parts.length - 2] : undefined;
|
|
210
|
+
}
|
|
211
|
+
function squash(value) {
|
|
212
|
+
return value.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
213
|
+
}
|
|
214
|
+
// graphify writes a path the way its host does. `app\models.rb` and
|
|
215
|
+
// `./app/models.rb` are the same file as `app/models.rb`, and treating them as
|
|
216
|
+
// three keys makes one file look like three and one method look ambiguous.
|
|
217
|
+
function normalisePath(value) {
|
|
218
|
+
const normalised = value?.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
219
|
+
return normalised || undefined;
|
|
220
|
+
}
|
|
221
|
+
function remember(into, key, file) {
|
|
222
|
+
const seen = into.get(key) ?? new Set();
|
|
223
|
+
seen.add(file);
|
|
224
|
+
into.set(key, seen);
|
|
225
|
+
}
|
|
226
|
+
function count(notes, note) {
|
|
227
|
+
notes.set(note, (notes.get(note) ?? 0) + 1);
|
|
228
|
+
}
|
|
229
|
+
function only(files) {
|
|
230
|
+
return files && files.size === 1 ? [...files][0] : undefined;
|
|
231
|
+
}
|
|
232
|
+
function readSurfaces(projectRoot) {
|
|
233
|
+
const path = surfacesPath(projectRoot);
|
|
234
|
+
if (!existsSync(path))
|
|
235
|
+
return [];
|
|
236
|
+
try {
|
|
237
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
238
|
+
return asArray(parsed?.surfaces);
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
return [];
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// Copy one source file into the packets folder, keeping the path it has in the
|
|
245
|
+
// checkout so the worker recognises it. Returns the byte count, or why there is
|
|
246
|
+
// no copy to make. It never throws: one unreadable file out of a hundred is one
|
|
247
|
+
// worker searching, not a run without packets.
|
|
248
|
+
function copyPacket(projectRoot, sourceFile) {
|
|
249
|
+
const relative = `${PACKETS_DIR}/${sourceFile}`;
|
|
250
|
+
try {
|
|
251
|
+
assertUnitbobPath(relative, PACKETS_DIR);
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
return {
|
|
255
|
+
note: `graph.json names a path we will not write (${sourceFile}) — find the file yourself`,
|
|
256
|
+
kind: 'named a path we will not write',
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
try {
|
|
260
|
+
// The path came from graphify's output, not from us. A symlink or an
|
|
261
|
+
// absolute path there must not turn "copy a project file" into "copy
|
|
262
|
+
// anything on this machine", so the file is required to really live inside
|
|
263
|
+
// the checkout. `realpathSync` resolves every component, so a symlinked
|
|
264
|
+
// directory in the middle of the path is caught the same way.
|
|
265
|
+
const root = realpathSync(projectRoot);
|
|
266
|
+
let onDisk;
|
|
267
|
+
try {
|
|
268
|
+
onDisk = realpathSync(resolve(projectRoot, sourceFile));
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
return {
|
|
272
|
+
note: `${sourceFile} is named in graph.json but is not on disk — find the file yourself`,
|
|
273
|
+
kind: 'named a file that is not on disk',
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
if (onDisk !== root && !onDisk.startsWith(root + sep)) {
|
|
277
|
+
return {
|
|
278
|
+
note: `${sourceFile} resolves outside the project — find the file yourself`,
|
|
279
|
+
kind: 'resolved outside the project',
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
const stat = statSync(onDisk);
|
|
283
|
+
// A directory passes the size check comfortably and then throws EISDIR on
|
|
284
|
+
// read. Said here rather than caught below, so the words name the cause.
|
|
285
|
+
if (!stat.isFile()) {
|
|
286
|
+
return {
|
|
287
|
+
note: `${sourceFile} is not a file — find the code yourself`,
|
|
288
|
+
kind: 'named something that is not a file',
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
if (stat.size > MAX_PACKET_BYTES) {
|
|
292
|
+
return {
|
|
293
|
+
note: `${sourceFile} is ${stat.size.toLocaleString('en-US')} bytes, over the ${MAX_PACKET_BYTES.toLocaleString('en-US')}-byte packet fuse — open it at that path instead`,
|
|
294
|
+
kind: 'over the packet fuse — the path travels instead',
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
// The bytes we actually wrote, not the size we saw a moment ago.
|
|
298
|
+
const body = readFileSync(onDisk);
|
|
299
|
+
const destination = join(projectRoot, relative);
|
|
300
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
301
|
+
writeFileSync(destination, body);
|
|
302
|
+
return body.length;
|
|
303
|
+
}
|
|
304
|
+
catch (err) {
|
|
305
|
+
return {
|
|
306
|
+
note: `${sourceFile} could not be copied (${err.message}) — find the file yourself`,
|
|
307
|
+
kind: 'could not be copied',
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
function asArray(value) {
|
|
312
|
+
return Array.isArray(value) ? value : [];
|
|
313
|
+
}
|
|
314
|
+
function asRecord(value) {
|
|
315
|
+
return value && typeof value === 'object' ? value : undefined;
|
|
316
|
+
}
|
|
317
|
+
function asString(value) {
|
|
318
|
+
return typeof value === 'string' && value.trim() !== '' ? value : undefined;
|
|
319
|
+
}
|
package/dist/files/suiteBuild.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import { dirname, join, sep } from 'node:path';
|
|
4
4
|
import { assertUnitbobPath } from "./artifactPath.js";
|
|
@@ -18,6 +18,38 @@ export function reviewRequestPath(projectRoot) {
|
|
|
18
18
|
export function candidateRunPath(projectRoot) {
|
|
19
19
|
return join(projectRoot, '.unitbob', 'suite-build', 'candidate-run.json');
|
|
20
20
|
}
|
|
21
|
+
// Spec 37-2, criterion 3. What a new build leaves behind, in the order a reader
|
|
22
|
+
// meets it: the plan, the checkpoints written against that plan, and the answer
|
|
23
|
+
// assembled from them. All three are bound to the `request.json` this run is
|
|
24
|
+
// about to overwrite, so from the next line on they are a previous run's papers
|
|
25
|
+
// wearing this run's filenames — which is exactly the confusion the coordinator
|
|
26
|
+
// used to spend a turn untangling before fan-out.
|
|
27
|
+
const PREVIOUS_RUN_ARTIFACTS = ['worker-plan.json', 'checkpoints', 'suite_output.json'];
|
|
28
|
+
const PREVIOUS_DIR = 'previous';
|
|
29
|
+
// Moved, never removed. A run costs hours and real money, and one interrupt plus
|
|
30
|
+
// one restart must not be able to spend that twice — `previous/` is one line
|
|
31
|
+
// more than `rmSync` and it is the line that makes a restart survivable.
|
|
32
|
+
//
|
|
33
|
+
// Replaced one artifact at a time rather than by clearing `previous/` first. The
|
|
34
|
+
// tidier version — wipe, then move whatever exists — loses a complete previous
|
|
35
|
+
// run to a partial one: a build interrupted between writing the plan and seeding
|
|
36
|
+
// its checkpoints displaces only `worker-plan.json`, and clearing would take the
|
|
37
|
+
// finished checkpoints and answer of the run before it with no way back. A
|
|
38
|
+
// `previous/` holding pieces of two runs is worth strictly more than an empty
|
|
39
|
+
// one, and every piece in it is named by the file it kept.
|
|
40
|
+
export function movePreviousRunAside(projectRoot) {
|
|
41
|
+
const buildDir = join(projectRoot, '.unitbob', 'suite-build');
|
|
42
|
+
const found = PREVIOUS_RUN_ARTIFACTS.filter((name) => existsSync(join(buildDir, name)));
|
|
43
|
+
if (found.length === 0)
|
|
44
|
+
return [];
|
|
45
|
+
const previous = join(buildDir, PREVIOUS_DIR);
|
|
46
|
+
mkdirSync(previous, { recursive: true });
|
|
47
|
+
for (const name of found) {
|
|
48
|
+
rmSync(join(previous, name), { recursive: true, force: true });
|
|
49
|
+
renameSync(join(buildDir, name), join(previous, name));
|
|
50
|
+
}
|
|
51
|
+
return found;
|
|
52
|
+
}
|
|
21
53
|
export function writeBehavioralReviewRequest(projectRoot, output, candidateRun, knownDefectContext = { status: 'not_supplied' }, fixedCandidateRun) {
|
|
22
54
|
const metadata = output.test_metadata;
|
|
23
55
|
const candidateDigest = suiteCandidateDigest(output);
|
|
@@ -83,7 +115,7 @@ export function branchRunner(output) {
|
|
|
83
115
|
return runner;
|
|
84
116
|
}
|
|
85
117
|
// What the reviewer actually read: the suite files, and the manifest that runs
|
|
86
|
-
// them. Nothing else (spec
|
|
118
|
+
// them. Nothing else (spec one-place-per-rule, §4).
|
|
87
119
|
//
|
|
88
120
|
// `test_metadata` used to be in here, and the server's copy of this formula
|
|
89
121
|
// stripped the review's own keys back out to match — two lists that had to stay
|
|
@@ -2,7 +2,7 @@ import { readBehavioralReview } from "./suiteBuild.js";
|
|
|
2
2
|
// What travels to the server, and what "published" means when it answers. One
|
|
3
3
|
// module, because two commands ask those questions: `put-suite-build` sends the
|
|
4
4
|
// batch, and `validate-build` sends the same batch as a dry run so the server's
|
|
5
|
-
// verdict is about the exact bytes the publish will carry (spec
|
|
5
|
+
// verdict is about the exact bytes the publish will carry (spec one-place-per-rule, §3).
|
|
6
6
|
//
|
|
7
7
|
// A second assembly would be a second answer to "what are we uploading", and the
|
|
8
8
|
// dry run would then be checking something the publish does not send — which is
|
package/dist/files/workerPlan.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
-
import { join } from 'node:path';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
4
|
import { detectStructuralRunner } from "../runner/precheck.js";
|
|
5
5
|
import { assertUnitbobPath } from "./artifactPath.js";
|
|
6
6
|
export function workerPlanPath(projectRoot) {
|
|
@@ -34,6 +34,75 @@ export function readWorkerPlan(projectRoot) {
|
|
|
34
34
|
}
|
|
35
35
|
return parsed;
|
|
36
36
|
}
|
|
37
|
+
export const SUPERSEDED_DIR = 'superseded';
|
|
38
|
+
export function seedWorkerCheckpoints(projectRoot) {
|
|
39
|
+
const plan = readWorkerPlan(projectRoot);
|
|
40
|
+
const request_digest = requestDigest(projectRoot);
|
|
41
|
+
const plan_digest = workerPlanDigest(projectRoot);
|
|
42
|
+
const written = [];
|
|
43
|
+
const kept = [];
|
|
44
|
+
const superseded = [];
|
|
45
|
+
for (const item of plan.workers) {
|
|
46
|
+
const path = checkpointPath(projectRoot, item);
|
|
47
|
+
const label = `${item.branch}:${item.worker_id}`;
|
|
48
|
+
// Left alone when it already belongs to this plan: the coordinator's facts
|
|
49
|
+
// and a worker's finished slice both live in this file, and a run costs
|
|
50
|
+
// hours.
|
|
51
|
+
if (belongsToPlan(path, plan_digest)) {
|
|
52
|
+
kept.push(label);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
// Everything else needs a fresh seed — but the file being replaced is not
|
|
56
|
+
// necessarily worthless. `plan_digest` is a digest of the whole
|
|
57
|
+
// `worker-plan.json`, so editing one slice invalidates every checkpoint at
|
|
58
|
+
// once, including finished ones; and replanning after the first slice comes
|
|
59
|
+
// back is a documented, ordinary move. Overwriting in place would have made
|
|
60
|
+
// this verb the one thing in the build that destroys hours of work, on the
|
|
61
|
+
// most ordinary path there is. Moved for the same reason `suite-prepare`
|
|
62
|
+
// moves the previous run rather than deleting it.
|
|
63
|
+
if (existsSync(path)) {
|
|
64
|
+
const aside = join(dirname(path), SUPERSEDED_DIR, `${item.branch}-${item.worker_id}.json`);
|
|
65
|
+
mkdirSync(dirname(aside), { recursive: true });
|
|
66
|
+
rmSync(aside, { force: true });
|
|
67
|
+
renameSync(path, aside);
|
|
68
|
+
superseded.push(label);
|
|
69
|
+
}
|
|
70
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
71
|
+
writeFileSync(path, `${JSON.stringify(seedFor(item, request_digest, plan_digest), null, 2)}\n`);
|
|
72
|
+
written.push(label);
|
|
73
|
+
}
|
|
74
|
+
return { written, kept, superseded };
|
|
75
|
+
}
|
|
76
|
+
function belongsToPlan(path, planDigest) {
|
|
77
|
+
if (!existsSync(path))
|
|
78
|
+
return false;
|
|
79
|
+
try {
|
|
80
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
81
|
+
return parsed?.plan_digest === planDigest;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function seedFor(item, request_digest, plan_digest) {
|
|
88
|
+
return {
|
|
89
|
+
request_digest,
|
|
90
|
+
plan_digest,
|
|
91
|
+
branch: item.branch,
|
|
92
|
+
worker_id: item.worker_id,
|
|
93
|
+
// Every promise starts unresolved: the slice has not been worked yet, and
|
|
94
|
+
// the gate wants each one accounted for exactly once.
|
|
95
|
+
unresolved_promises: [...item.promises],
|
|
96
|
+
completed_promises: [],
|
|
97
|
+
written_paths: [],
|
|
98
|
+
decisions: [],
|
|
99
|
+
known_problems: [],
|
|
100
|
+
// Behavioral only, and absent rather than empty elsewhere — it joins Gherkin
|
|
101
|
+
// Scenarios to addresses, and the structural branch has no Scenarios.
|
|
102
|
+
...(item.branch === 'behavioral' ? { surface_coverage: [] } : {}),
|
|
103
|
+
facts: [],
|
|
104
|
+
};
|
|
105
|
+
}
|
|
37
106
|
const RUBY_HARNESS = {
|
|
38
107
|
behavioral: '.unitbob/behavioral/step_definitions/00_unitbob_world.rb',
|
|
39
108
|
structural: '.unitbob/structural/unitbob_helper.rb',
|
|
@@ -70,6 +139,14 @@ export function validateWorkerPlanFiles(projectRoot) {
|
|
|
70
139
|
const label = workerLabel(item, index);
|
|
71
140
|
if (!isNonEmptyString(item?.branch) || !expectedByBranch.has(item.branch))
|
|
72
141
|
errors.push(`${label}: branch is not in the request`);
|
|
142
|
+
// Both halves of the checkpoint filename, held to the same rule. `worker_id`
|
|
143
|
+
// has always been checked; `branch` never was, because it only ever came
|
|
144
|
+
// from `request.json` and was only ever read. Since spec 37-2 the pair is
|
|
145
|
+
// also a path this connector *writes*, and `request.json` is a file on the
|
|
146
|
+
// vibecoder's disk — so a `suite_kind` of `../../..` would have put a
|
|
147
|
+
// seeded checkpoint outside the project.
|
|
148
|
+
else if (!/^[a-zA-Z0-9_-]+$/.test(item.branch))
|
|
149
|
+
errors.push(`${label}: branch must be a filename-safe name`);
|
|
73
150
|
if (!isNonEmptyString(item?.worker_id) || !/^[a-zA-Z0-9_-]+$/.test(item.worker_id))
|
|
74
151
|
errors.push(`${label}: worker_id must be a stable filename-safe id`);
|
|
75
152
|
else if (seenWorkers.has(item.worker_id))
|
|
@@ -109,9 +186,6 @@ export function validateWorkerPlanFiles(projectRoot) {
|
|
|
109
186
|
if (!expectedHarness && isNonEmptyString(item?.harness_path) && !item.harness_path.startsWith('.unitbob/')) {
|
|
110
187
|
errors.push(`${label}: harness_path must be a connector-owned path under .unitbob/ (got "${item.harness_path}")`);
|
|
111
188
|
}
|
|
112
|
-
if (!item?.limits || item.limits.planned_cases !== item.planned_cases?.length) {
|
|
113
|
-
errors.push(`${label}: limits.planned_cases must equal planned_cases.length`);
|
|
114
|
-
}
|
|
115
189
|
for (const ownedPath of Array.isArray(item?.owned_paths) ? item.owned_paths : []) {
|
|
116
190
|
if (!isNonEmptyString(ownedPath)) {
|
|
117
191
|
errors.push(`${label}: owned path must be a non-empty string`);
|