@mcptoolshop/style-dataset-lab 3.3.0 → 3.4.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.
Files changed (92) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.es.md +35 -25
  3. package/README.fr.md +38 -28
  4. package/README.hi.md +36 -26
  5. package/README.it.md +37 -27
  6. package/README.ja.md +40 -30
  7. package/README.md +15 -5
  8. package/README.pt-BR.md +35 -25
  9. package/README.zh.md +33 -23
  10. package/bin/sdlab.js +51 -4
  11. package/lib/adapters/ai-toolkit.js +37 -5
  12. package/lib/adapters/comfyui-runner.js +79 -26
  13. package/lib/adapters/comfyui-workflows.js +10 -2
  14. package/lib/adapters/diffusers-lora.js +37 -5
  15. package/lib/adapters/generic-image-caption.js +43 -5
  16. package/lib/args.js +16 -1
  17. package/lib/batch-modes.js +29 -8
  18. package/lib/batch-runs.js +29 -1
  19. package/lib/batch-sheet-render.js +13 -3
  20. package/lib/brief-compiler.js +19 -0
  21. package/lib/candidate-sheet.js +327 -0
  22. package/lib/canon-build/build.js +146 -93
  23. package/lib/captions.js +57 -0
  24. package/lib/card.js +74 -2
  25. package/lib/comfyui.js +116 -18
  26. package/lib/config.js +116 -12
  27. package/lib/critique-engine.js +36 -2
  28. package/lib/eligibility.js +121 -9
  29. package/lib/eval-pack.js +14 -14
  30. package/lib/eval-runs.js +33 -0
  31. package/lib/export.js +122 -34
  32. package/lib/freeze-gate.js +13 -3
  33. package/lib/freeze-stamp.js +28 -3
  34. package/lib/generated-provenance.js +16 -1
  35. package/lib/implementation-packs.js +95 -17
  36. package/lib/ingest.js +396 -0
  37. package/lib/judgment-provenance.js +220 -0
  38. package/lib/measure.js +422 -0
  39. package/lib/paths.js +196 -7
  40. package/lib/reingest-selected.js +98 -16
  41. package/lib/run-manifest.js +102 -16
  42. package/lib/run-summary.js +12 -6
  43. package/lib/runtime-runs.js +45 -4
  44. package/lib/snapshot.js +114 -15
  45. package/lib/split.js +364 -48
  46. package/lib/training-manifests.js +114 -21
  47. package/lib/training-packages.js +147 -15
  48. package/lib/workflow-profiles.js +24 -3
  49. package/package.json +3 -2
  50. package/runtime/comfyui/txt2img-qwen.json +38 -0
  51. package/runtime/comfyui/txt2img-standard.json +21 -0
  52. package/scripts/_resume.js +62 -0
  53. package/scripts/_shared-args.js +44 -0
  54. package/scripts/brief-compile.js +2 -1
  55. package/scripts/brief-show.js +2 -1
  56. package/scripts/canon-bind.js +2 -2
  57. package/scripts/canon-build.js +27 -20
  58. package/scripts/canon-drift.js +16 -7
  59. package/scripts/canon-freeze-status.js +14 -6
  60. package/scripts/canon-freeze.js +27 -11
  61. package/scripts/canon-unfreeze.js +17 -9
  62. package/scripts/card.js +2 -2
  63. package/scripts/compare.js +2 -2
  64. package/scripts/critique.js +16 -1
  65. package/scripts/curate.js +2 -2
  66. package/scripts/doctor.js +181 -55
  67. package/scripts/eligibility.js +42 -2
  68. package/scripts/eval-pack.js +2 -2
  69. package/scripts/eval-run.js +2 -2
  70. package/scripts/export.js +2 -2
  71. package/scripts/generate-controlnet.js +45 -25
  72. package/scripts/generate-identity.js +53 -23
  73. package/scripts/generate-ipadapter.js +44 -23
  74. package/scripts/generate.js +48 -5
  75. package/scripts/implementation-pack.js +2 -2
  76. package/scripts/ingest.js +145 -0
  77. package/scripts/init.js +73 -13
  78. package/scripts/measure.js +170 -0
  79. package/scripts/measure_image.py +458 -0
  80. package/scripts/migrate-records.js +142 -0
  81. package/scripts/painterly-test.js +4 -3
  82. package/scripts/painterly.js +7 -3
  83. package/scripts/qwen_generate.py +138 -20
  84. package/scripts/reingest.js +2 -2
  85. package/scripts/sheet.js +142 -0
  86. package/scripts/snapshot.js +2 -2
  87. package/scripts/split.js +2 -2
  88. package/scripts/training-manifest.js +2 -2
  89. package/scripts/training-package.js +2 -2
  90. package/scripts/training-profile.js +2 -2
  91. package/scripts/workflow-list.js +2 -1
  92. package/scripts/workflow-show.js +2 -1
@@ -6,7 +6,7 @@
6
6
  * canon-binding flow as everything else. No bypass.
7
7
  */
8
8
 
9
- import { writeFile, mkdir, copyFile } from 'node:fs/promises';
9
+ import { writeFile, mkdir, copyFile, rename, unlink } from 'node:fs/promises';
10
10
  import { join, extname } from 'node:path';
11
11
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
12
12
  import { getRunsDir } from './paths.js';
@@ -16,6 +16,35 @@ import { buildGeneratedProvenance } from './generated-provenance.js';
16
16
  import { buildBaseRecord } from './records.js';
17
17
  import { inputError } from './errors.js';
18
18
 
19
+ // SDL-H7: the only shape a selection id can ever legitimately have,
20
+ // mirroring generateSelectionId() in lib/selections.js (selection_<date>_<seq>,
21
+ // same prefix/date/seq scheme as run/batch ids). Validated BEFORE the id is
22
+ // used anywhere — including before loadSelection(), which does its own
23
+ // unguarded join — so a traversal payload never reaches the filesystem, and
24
+ // before the join at `selectionDir` below, which this module writes
25
+ // provenance.jsonl into. A mismatch reuses loadSelection's own
26
+ // SELECTION_NOT_FOUND shape (not a distinct code) because no real selection
27
+ // can ever have an id outside this shape.
28
+ const SELECTION_ID_RE = /^selection_\d{4}-\d{2}-\d{2}_\d{3}$/;
29
+
30
+ /**
31
+ * B03: atomic write helper — write temp + rename, mirroring the same
32
+ * temp-file-then-rename checkpoint pattern lib/batch-runs.js uses for its
33
+ * per-slot manifest checkpoint. Rename is atomic on a single filesystem, so
34
+ * a crash mid-write can never leave a half-written provenance.jsonl in
35
+ * place of a good one.
36
+ */
37
+ async function atomicWriteText(targetPath, text) {
38
+ const tmpPath = `${targetPath}.tmp-${process.pid}-${Date.now()}`;
39
+ try {
40
+ await writeFile(tmpPath, text);
41
+ await rename(tmpPath, targetPath);
42
+ } catch (err) {
43
+ try { await unlink(tmpPath); } catch { /* ignore */ }
44
+ throw err;
45
+ }
46
+ }
47
+
19
48
  /**
20
49
  * Re-ingest a selection — creates candidate records from chosen outputs.
21
50
  *
@@ -27,6 +56,14 @@ import { inputError } from './errors.js';
27
56
  * @returns {Promise<{created: string[], skipped: string[]}>}
28
57
  */
29
58
  export async function reingestSelection({ projectRoot, projectId, selectionId, dryRun }) {
59
+ if (typeof selectionId !== 'string' || !SELECTION_ID_RE.test(selectionId)) {
60
+ throw inputError(
61
+ 'SELECTION_NOT_FOUND',
62
+ `Selection "${selectionId}" not found`,
63
+ 'Check the selection ID or run: sdlab select --run <id> --approve <files>',
64
+ );
65
+ }
66
+
30
67
  const manifest = loadSelection(projectRoot, selectionId);
31
68
 
32
69
  if (!manifest.reingest_ready) {
@@ -69,7 +106,46 @@ export async function reingestSelection({ projectRoot, projectId, selectionId, d
69
106
 
70
107
  const created = [];
71
108
  const skipped = [];
109
+
110
+ // B03: provenance.jsonl is now built incrementally — checkpointed (atomic
111
+ // full-rewrite, temp+rename) after every item — instead of accumulated in
112
+ // memory and overwritten once at the end. `provenanceLines` seeds from
113
+ // whatever is ALREADY on disk (e.g. left by an interrupted prior run of
114
+ // this same selection) so: (a) a rerun never duplicates a line for a
115
+ // record it already logged, and (b) a partial rerun can never ERASE lines
116
+ // a previous call already committed — the old end-of-run `writeFile`
117
+ // overwrote with only the CURRENT call's accumulator, so a second call
118
+ // touching fewer items than the first could silently wipe out provenance
119
+ // the first call had already written to disk.
120
+ const provenancePath = join(selectionDir, 'provenance.jsonl');
72
121
  const provenanceLines = [];
122
+ const seenProvenanceIds = new Set();
123
+ if (existsSync(provenancePath)) {
124
+ for (const line of readFileSync(provenancePath, 'utf-8').split('\n')) {
125
+ if (!line.trim()) continue;
126
+ provenanceLines.push(line);
127
+ try {
128
+ const parsed = JSON.parse(line);
129
+ if (parsed?.record_id) seenProvenanceIds.add(parsed.record_id);
130
+ } catch {
131
+ // Tolerate a corrupt/partial trailing line from a prior crash —
132
+ // keep it verbatim rather than dropping it, just don't index it.
133
+ }
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Checkpoint one provenance line: skips dryRun, and skips a record_id
139
+ * that's already logged (idempotent — safe to call on both the fresh-write
140
+ * path and the skip/backfill path below).
141
+ */
142
+ async function checkpointProvenance(recordId, prov) {
143
+ if (dryRun) return;
144
+ if (seenProvenanceIds.has(recordId)) return;
145
+ provenanceLines.push(JSON.stringify({ record_id: recordId, ...prov }));
146
+ seenProvenanceIds.add(recordId);
147
+ await atomicWriteText(provenancePath, provenanceLines.join('\n') + '\n');
148
+ }
73
149
 
74
150
  // D-010: use entries() — not indexOf() — to avoid O(n^2) behavior and
75
151
  // silent duplicate-ID bugs when manifest.items contains structurally
@@ -88,12 +164,11 @@ export async function reingestSelection({ projectRoot, projectId, selectionId, d
88
164
  seenIds.add(recordId);
89
165
  const recordPath = join(recordsDir, `${recordId}.json`);
90
166
 
91
- if (existsSync(recordPath)) {
92
- skipped.push(recordId);
93
- continue;
94
- }
95
-
96
- // Resolve the run manifest for this item
167
+ // Resolve the run manifest for this item + build its provenance BEFORE
168
+ // the existsSync check below — B03: a skipped (already-created) item
169
+ // still needs its provenance backfilled if an earlier interrupted run
170
+ // stopped short of appending it, so this can't be gated behind "the
171
+ // record is new".
97
172
  let runManifest;
98
173
  if (manifest.source_type === 'run') {
99
174
  runManifest = getRunManifest(manifest.source_id);
@@ -126,6 +201,16 @@ export async function reingestSelection({ projectRoot, projectId, selectionId, d
126
201
  projectRoot,
127
202
  });
128
203
 
204
+ if (existsSync(recordPath)) {
205
+ // B03: the record already landed on disk (this item was completed by
206
+ // a prior, possibly-interrupted run of this same re-ingest). Backfill
207
+ // its provenance line if that prior run never got to append one —
208
+ // never let a record exist with no corresponding provenance entry.
209
+ await checkpointProvenance(recordId, prov);
210
+ skipped.push(recordId);
211
+ continue;
212
+ }
213
+
129
214
  // Image source in selection/chosen/
130
215
  const srcImage = join(selectionDir, 'chosen', item.filename);
131
216
  const ext = extname(item.filename) || '.png';
@@ -166,18 +251,15 @@ export async function reingestSelection({ projectRoot, projectId, selectionId, d
166
251
  }
167
252
  // Write record
168
253
  await writeFile(recordPath, JSON.stringify(record, null, 2) + '\n');
254
+ // B03: checkpoint immediately after the record write (per-item,
255
+ // matching lib/batch-runs.js's per-slot checkpoint pattern) — NOT
256
+ // batched to the end of the run. A crash on item 120 of 200 must
257
+ // leave provenance for 1-119 durably on disk, not lose it because
258
+ // item 200 never finished.
259
+ await checkpointProvenance(recordId, prov);
169
260
  }
170
261
 
171
262
  created.push(recordId);
172
- provenanceLines.push(JSON.stringify({ record_id: recordId, ...prov }));
173
- }
174
-
175
- // Write provenance log
176
- if (!dryRun && provenanceLines.length > 0) {
177
- await writeFile(
178
- join(selectionDir, 'provenance.jsonl'),
179
- provenanceLines.join('\n') + '\n',
180
- );
181
263
  }
182
264
 
183
265
  return { created, skipped };
@@ -25,6 +25,7 @@
25
25
  import { createHash } from 'node:crypto';
26
26
  import { openSync, readSync, closeSync, statSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
27
27
  import { join } from 'node:path';
28
+ import { warn } from './log.js';
28
29
 
29
30
  /**
30
31
  * Contract version for the `pinning` block itself. Bump when the block's shape
@@ -35,6 +36,16 @@ import { join } from 'node:path';
35
36
  */
36
37
  export const PINNING_VERSION = '1.0.0';
37
38
 
39
+ /**
40
+ * The ONE hash_note value that means "hashing was never attempted because
41
+ * --hash-models wasn't passed" — NOT a failure. buildPinning() (B07) needs
42
+ * to tell this apart from every other hash_note (unresolved/stat
43
+ * failed/read error/no filename), all of which ARE failures worth warning
44
+ * about. Shared as a constant so hashModelFile's own note and buildPinning's
45
+ * filter can never drift apart.
46
+ */
47
+ const HASH_SKIPPED_NOTE = 'hash skipped (pass --hash-models)';
48
+
38
49
  /**
39
50
  * Seed policies — the INTENT behind seed selection, recorded alongside the
40
51
  * concrete per-item seed values so a reader knows how to regenerate.
@@ -144,17 +155,24 @@ const KIND_SUBDIRS = {
144
155
 
145
156
  /**
146
157
  * Resolve a model filename to an absolute path under the models dir. Searches
147
- * the kind-appropriate subdirs first, then every known subdir, then the root.
148
- * Returns null when nothing matches (unresolvable → sha256 null with a note).
158
+ * ONLY the kind-appropriate subdirs (plus the modelsDir root itself, which is
159
+ * kind-agnostic, not another kind's territory) — mirroring ComfyUI's own
160
+ * loaders, which never search across kinds (a UNETLoader never looks in
161
+ * checkpoints/, a VAELoader never looks in loras/, etc).
162
+ *
163
+ * SDL-M8: this used to also fall back to every OTHER kind's subdirs, so a
164
+ * missing unet could silently resolve to a same-named file under
165
+ * checkpoints/ and get pinned as though it were the unet ComfyUI actually
166
+ * loaded — a clean-looking wrong identity. An honest `null` (unresolved →
167
+ * sha256 null + a note, see hashModelFile) is the correct outcome when the
168
+ * kind-specific subdirs don't have the file.
169
+ *
170
+ * Returns null when nothing matches.
149
171
  */
150
172
  export function resolveModelPath(name, kind, modelsDir = defaultModelsDir()) {
151
173
  if (!name || !modelsDir) return null;
174
+ const subdirs = [...(KIND_SUBDIRS[kind] || []), '.'];
152
175
  const tried = new Set();
153
- const subdirs = [
154
- ...(KIND_SUBDIRS[kind] || []),
155
- ...Object.values(KIND_SUBDIRS).flat(),
156
- '.',
157
- ];
158
176
  for (const sub of subdirs) {
159
177
  const candidate = join(modelsDir, sub, name);
160
178
  if (tried.has(candidate)) continue;
@@ -183,12 +201,52 @@ export function sha256File(path) {
183
201
  return 'sha256:' + hash.digest('hex');
184
202
  }
185
203
 
204
+ // SDL-M9: a cheap partial-content fingerprint folded into the cache key
205
+ // alongside name/size/mtime. The key used to be bare name:size:round(mtime)
206
+ // with no content check — on a coarse-mtime mount (network shares, some
207
+ // FAT/exFAT mounts), a same-size in-place replacement (e.g. swapping a
208
+ // LoRA) can round to the SAME mtime as the file it replaced, so the cache
209
+ // would silently serve the sha256 of the OLD content forever. Sampling the
210
+ // first/last few KB — not hashing the whole multi-GB file, which would
211
+ // defeat the point of caching — is enough to catch almost any real
212
+ // replacement while staying cheap on every call (including cache hits).
213
+ const SAMPLE_BYTES = 4096;
214
+
215
+ /**
216
+ * Cheap content fingerprint: sha256 of the first + last SAMPLE_BYTES of the
217
+ * file (or the whole file if smaller). Synchronous, bounded I/O — a few KB
218
+ * regardless of file size, unlike sha256File's full-content streaming read.
219
+ */
220
+ function sampleFingerprint(path, size) {
221
+ const fd = openSync(path, 'r');
222
+ try {
223
+ const hash = createHash('sha256');
224
+ const headLen = Math.min(SAMPLE_BYTES, size);
225
+ if (headLen > 0) {
226
+ const head = Buffer.allocUnsafe(headLen);
227
+ readSync(fd, head, 0, headLen, 0);
228
+ hash.update(head);
229
+ }
230
+ if (size > SAMPLE_BYTES) {
231
+ const tailLen = Math.min(SAMPLE_BYTES, size - headLen);
232
+ const tail = Buffer.allocUnsafe(tailLen);
233
+ readSync(fd, tail, 0, tailLen, size - tailLen);
234
+ hash.update(tail);
235
+ }
236
+ return hash.digest('hex').slice(0, 16);
237
+ } finally {
238
+ closeSync(fd);
239
+ }
240
+ }
241
+
186
242
  /**
187
243
  * Best-effort content identity for one model/LoRA file: {name, size_bytes, sha256}
188
244
  * (+ a short `hash_note` when sha256 can't be produced). size_bytes is always
189
245
  * filled when the file resolves (a cheap stat); sha256 is gated behind
190
- * `doHash` (the --hash-models opt-in) and cached by (name, size, mtime) so
191
- * multi-GB checkpoints aren't re-hashed every wave.
246
+ * `doHash` (the --hash-models opt-in) and cached by (name, size, mtime,
247
+ * partial-content sample) so multi-GB checkpoints aren't re-hashed every
248
+ * wave (SDL-M9: the sample is what keeps a same-size in-place replacement
249
+ * from reusing a stale cache entry).
192
250
  *
193
251
  * @param {string} name — model filename as it appears in the graph
194
252
  * @param {Object} [opts]
@@ -210,8 +268,16 @@ export function hashModelFile(name, { kind, modelsDir = defaultModelsDir(), doHa
210
268
  } catch {
211
269
  return { name, size_bytes: null, sha256: null, hash_note: 'stat failed' };
212
270
  }
213
- if (!doHash) return { name, size_bytes, sha256: null, hash_note: 'hash skipped (pass --hash-models)' };
214
- const key = `${name}:${size_bytes}:${Math.round(mtimeMs)}`;
271
+ if (!doHash) return { name, size_bytes, sha256: null, hash_note: HASH_SKIPPED_NOTE };
272
+ let sample = '';
273
+ try {
274
+ sample = sampleFingerprint(resolved, size_bytes);
275
+ } catch {
276
+ // Best-effort — if sampling fails, fall back to the coarser
277
+ // name:size:mtime key; the sha256File() attempt below will surface any
278
+ // real read error anyway.
279
+ }
280
+ const key = `${name}:${size_bytes}:${Math.round(mtimeMs)}:${sample}`;
215
281
  if (cache?.map?.has(key)) return { name, size_bytes, sha256: cache.map.get(key) };
216
282
  let sha256;
217
283
  try {
@@ -295,14 +361,34 @@ export function flushHashCache(cache) {
295
361
  */
296
362
  export function buildPinning({ graph, models = {}, loras = [], seedPolicy = null, hashModels = false, modelsDir = defaultModelsDir(), cache } = {}) {
297
363
  const opts = { modelsDir, doHash: hashModels, cache };
364
+ const modelHashes = buildModelHashes(models, opts);
365
+ const loraHashes = (loras || []).map((l) => ({
366
+ ...hashModelFile(l.name, { ...opts, kind: 'lora' }),
367
+ weight: l.weight ?? 1.0,
368
+ }));
369
+
370
+ // B07: hash_note silently encoded a hashing FAILURE (unresolved file,
371
+ // stat failed, read error, no filename) with nothing reading it —
372
+ // buildPinning() never aggregated or logged, and this file imported no
373
+ // logger at all. A typo'd SDLAB_MODELS_DIR produced a wave where every
374
+ // model has sha256: null + a note, with zero console signal — PIN_PER_STEP's
375
+ // whole purpose (byte-for-byte replayability) lost silently. Warn ONCE per
376
+ // wave with a count, but never on HASH_SKIPPED_NOTE — that note means
377
+ // "hashing wasn't requested" (the default, --hash-models omitted), which
378
+ // is expected behavior, not a failure, and would make every ordinary run
379
+ // noisy if it warned too.
380
+ const allEntries = [...Object.values(modelHashes), ...loraHashes];
381
+ const failed = allEntries.filter((e) => e && e.hash_note != null && e.hash_note !== HASH_SKIPPED_NOTE);
382
+ if (failed.length > 0) {
383
+ const detail = failed.map((e) => `${e.name ?? '(unnamed)'} (${e.hash_note})`).join(', ');
384
+ warn(`buildPinning: ${failed.length}/${allEntries.length} model(s)/lora(s) failed to hash: ${detail}`);
385
+ }
386
+
298
387
  return {
299
388
  pinning_version: PINNING_VERSION,
300
389
  comfy_workflow_sha: comfyWorkflowSha(graph),
301
390
  seed_policy: seedPolicy,
302
- models: buildModelHashes(models, opts),
303
- loras: (loras || []).map((l) => ({
304
- ...hashModelFile(l.name, { ...opts, kind: 'lora' }),
305
- weight: l.weight ?? 1.0,
306
- })),
391
+ models: modelHashes,
392
+ loras: loraHashes,
307
393
  };
308
394
  }
@@ -12,8 +12,11 @@ import { join } from 'node:path';
12
12
  * Build a summary object from a manifest.
13
13
  */
14
14
  export function buildRunSummary(manifest) {
15
- const ok = manifest.outputs.filter(o => o.status === 'ok');
16
- const failed = manifest.outputs.filter(o => o.status === 'error');
15
+ // B10: defensive default — manifest.errors two lines below already had
16
+ // one; a legacy/truncated manifest with no `outputs` field crashed here
17
+ // with a bare TypeError instead of degrading to an empty results view.
18
+ const ok = (manifest.outputs || []).filter(o => o.status === 'ok');
19
+ const failed = (manifest.outputs || []).filter(o => o.status === 'error');
17
20
  const totalElapsed = ok.reduce((sum, o) => sum + (o.elapsed_ms || 0), 0);
18
21
 
19
22
  return {
@@ -54,7 +57,7 @@ export function renderRunMarkdown(manifest) {
54
57
  lines.push('');
55
58
  lines.push(`| # | Seed | Status | File | Time |`);
56
59
  lines.push(`|---|------|--------|------|------|`);
57
- for (const out of manifest.outputs) {
60
+ for (const out of (manifest.outputs || [])) {
58
61
  const file = out.filename || '—';
59
62
  const time = out.elapsed_ms ? `${(out.elapsed_ms / 1000).toFixed(1)}s` : '—';
60
63
  const status = out.status === 'ok' ? '✓' : out.status === 'dry_run' ? '○' : '✗';
@@ -98,8 +101,11 @@ export function renderRunMarkdown(manifest) {
98
101
  * Render a manifest to terminal text.
99
102
  */
100
103
  export function renderRunText(manifest) {
101
- const ok = manifest.outputs.filter(o => o.status === 'ok' || o.status === 'dry_run');
102
- const failed = manifest.outputs.filter(o => o.status === 'error');
104
+ // B10: same defensive default as buildRunSummary above — not explicitly
105
+ // named by line number in the finding, but the identical defect in the
106
+ // same file (this is the function `sdlab run show` calls in text mode).
107
+ const ok = (manifest.outputs || []).filter(o => o.status === 'ok' || o.status === 'dry_run');
108
+ const failed = (manifest.outputs || []).filter(o => o.status === 'error');
103
109
  const lines = [];
104
110
 
105
111
  lines.push(`Run: ${manifest.run_id}`);
@@ -111,7 +117,7 @@ export function renderRunText(manifest) {
111
117
  if (failed.length > 0) lines.push(`Errors: ${failed.length}`);
112
118
  lines.push('');
113
119
 
114
- for (const out of manifest.outputs) {
120
+ for (const out of (manifest.outputs || [])) {
115
121
  const status = out.status === 'ok' ? '✓' : out.status === 'dry_run' ? '○' : '✗';
116
122
  const time = out.elapsed_ms ? ` (${(out.elapsed_ms / 1000).toFixed(1)}s)` : '';
117
123
  const file = out.filename ? ` → ${out.filename}` : '';
@@ -11,7 +11,7 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs';
11
11
  import { randomInt } from 'node:crypto';
12
12
  import { getRunsDir, getBriefsDir } from './paths.js';
13
13
  import { inputError } from './errors.js';
14
- import { verbose } from './log.js';
14
+ import { verbose, warn } from './log.js';
15
15
 
16
16
  // ─── Run ID generation ───────────────────────────────────────────
17
17
 
@@ -165,10 +165,28 @@ export async function checkpointRunManifest(runDir, manifest) {
165
165
 
166
166
  // ─── Load / List ─────────────────────────────────────────────────
167
167
 
168
+ // SDL-H7: the only shape a run id can ever legitimately have, mirroring the
169
+ // prefix/date/seq scheme generateRunId() builds above. Validated BEFORE the
170
+ // id is joined into a path, so `--resume ../../../../some/dir/leftover`
171
+ // (or any other traversal payload) never reaches existsSync/readFile. A
172
+ // mismatch is reported as the SAME RUN_NOT_FOUND (not a distinct code)
173
+ // because no real run can ever have an id outside this shape — the two
174
+ // cases are indistinguishable to the caller, and this avoids leaking
175
+ // validation internals to whoever is probing.
176
+ const RUN_ID_RE = /^run_\d{4}-\d{2}-\d{2}_\d{3}$/;
177
+
168
178
  /**
169
179
  * Load a run manifest by ID.
170
180
  */
171
181
  export async function loadRun(projectRoot, runId) {
182
+ if (typeof runId !== 'string' || !RUN_ID_RE.test(runId)) {
183
+ throw inputError(
184
+ 'RUN_NOT_FOUND',
185
+ `Run "${runId}" not found`,
186
+ `Check the run ID or run: sdlab run generate --brief <id>`
187
+ );
188
+ }
189
+
172
190
  const runDir = join(getRunsDir(projectRoot), runId);
173
191
  const manifestPath = join(runDir, 'manifest.json');
174
192
 
@@ -185,6 +203,15 @@ export async function loadRun(projectRoot, runId) {
185
203
 
186
204
  /**
187
205
  * List all runs in a project.
206
+ *
207
+ * B01: this used to `continue` past a directory with no readable
208
+ * manifest.json with zero signal — the exact shape a crashed/killed run
209
+ * leaves behind (see checkpointRunManifest() above). A run that ate 90
210
+ * minutes of GPU time before dying became invisible: no id, no `run show`,
211
+ * no resume, and no indication anything was ever skipped. Skipped/unreadable
212
+ * directories are now reported: a one-line warn() with a count always
213
+ * (so a human running `sdlab run list` cannot miss it), and the per-directory
214
+ * detail under --verbose.
188
215
  */
189
216
  export function listRuns(projectRoot) {
190
217
  const runsDir = getRunsDir(projectRoot);
@@ -195,9 +222,13 @@ export function listRuns(projectRoot) {
195
222
  .sort((a, b) => a.name.localeCompare(b.name));
196
223
 
197
224
  const runs = [];
225
+ const skipped = [];
198
226
  for (const entry of entries) {
199
227
  const manifestPath = join(runsDir, entry.name, 'manifest.json');
200
- if (!existsSync(manifestPath)) continue;
228
+ if (!existsSync(manifestPath)) {
229
+ skipped.push({ name: entry.name, reason: 'no manifest.json' });
230
+ continue;
231
+ }
201
232
 
202
233
  try {
203
234
  const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
@@ -209,8 +240,18 @@ export function listRuns(projectRoot) {
209
240
  created_at: manifest.created_at,
210
241
  adapter_target: manifest.adapter_target,
211
242
  });
212
- } catch {
213
- // Skip malformed
243
+ } catch (err) {
244
+ skipped.push({ name: entry.name, reason: `unreadable manifest.json (${err.message})` });
245
+ }
246
+ }
247
+
248
+ if (skipped.length > 0) {
249
+ warn(
250
+ `listRuns: skipped ${skipped.length} run director${skipped.length === 1 ? 'y' : 'ies'} ` +
251
+ `with no readable manifest.json (run with --verbose for details)`
252
+ );
253
+ for (const s of skipped) {
254
+ verbose(` skipped run dir "${s.name}": ${s.reason}`);
214
255
  }
215
256
  }
216
257
 
package/lib/snapshot.js CHANGED
@@ -41,6 +41,96 @@ export function checkManifestVersion(manifest, kind) {
41
41
  }
42
42
  }
43
43
 
44
+ /**
45
+ * SDL-M10: atomically claim a fresh, unused SUBDIRECTORY of `parentDir`.
46
+ *
47
+ * The pattern this replaces — `existsSync(dir)` then throw-if-true,
48
+ * followed later by `mkdir(dir, {recursive:true})` — is check-then-act: a
49
+ * second concurrent process can create the same directory in the window
50
+ * between the check and the mkdir, and recursive mkdir never throws on an
51
+ * already-existing directory, so the race is silently invisible (two
52
+ * builders interleave writes into one snapshot/split/export/etc. dir —
53
+ * Law 1 exposure: "snapshots frozen").
54
+ *
55
+ * Mirrors lib/selections.js's claimSelectionId: a non-recursive `mkdir`
56
+ * is atomic at the filesystem level (POSIX and Windows both guarantee
57
+ * this) — it either creates the directory and returns, or fails with
58
+ * EEXIST if something else claimed that exact name first. On EEXIST we
59
+ * regenerate a fresh id and retry; for the timestamp+random ids used
60
+ * throughout this module, two consecutive real collisions is already
61
+ * vanishingly unlikely.
62
+ *
63
+ * Creating `parentDir` itself (e.g. "snapshots/") is NOT part of the race
64
+ * being closed here — two processes racing to create the same shared
65
+ * parent with {recursive:true} is harmless (both succeed; no caller's
66
+ * data lives directly in the parent, only under their own distinctly
67
+ * named leaf directory).
68
+ *
69
+ * @param {string} parentDir — parent directory, created recursively if missing
70
+ * @param {() => string} generateId — produces a candidate leaf id; called
71
+ * again on every retry so concurrent claimants converge on different ids
72
+ * @param {{code: string, message: string, hint: string, maxAttempts?: number}} onExhausted
73
+ * — error identity thrown if every attempt collides (kept caller-specific
74
+ * so existing codes like SNAPSHOT_ID_COLLISION stay externally stable)
75
+ * @returns {Promise<{id: string, dir: string}>}
76
+ */
77
+ export async function claimIdDir(parentDir, generateId, onExhausted) {
78
+ const maxAttempts = onExhausted.maxAttempts ?? 25;
79
+ if (!existsSync(parentDir)) {
80
+ await mkdir(parentDir, { recursive: true });
81
+ }
82
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
83
+ const id = generateId();
84
+ const dir = join(parentDir, id);
85
+ try {
86
+ await mkdir(dir); // non-recursive — throws EEXIST if already claimed
87
+ return { id, dir };
88
+ } catch (err) {
89
+ if (err.code === 'EEXIST') continue;
90
+ throw err;
91
+ }
92
+ }
93
+ throw inputError(onExhausted.code, onExhausted.message, onExhausted.hint);
94
+ }
95
+
96
+ /**
97
+ * SDL-M10: atomically claim a fresh, unused FILE `<parentDir>/<id>.json`.
98
+ *
99
+ * Same TOCTOU problem as claimIdDir, but for the one artifact in this
100
+ * domain stored as a flat file rather than a directory (training
101
+ * manifests: `training/manifests/<id>.json`). The atomic primitive here
102
+ * is `writeFile(..., {flag: 'wx'})` — exclusive create, which fails with
103
+ * EEXIST rather than silently overwriting — so claim-and-write happen in
104
+ * one step instead of racing a separate existsSync check against it.
105
+ *
106
+ * @param {string} parentDir — parent directory, created recursively if missing
107
+ * @param {() => string} generateId — produces a candidate id; called again
108
+ * on every retry
109
+ * @param {(id: string) => string} buildContent — renders the file content
110
+ * for a given candidate id (called fresh per attempt since the content
111
+ * embeds the id)
112
+ * @param {{code: string, message: string, hint: string, maxAttempts?: number}} onExhausted
113
+ * @returns {Promise<{id: string, filePath: string}>}
114
+ */
115
+ export async function claimIdFile(parentDir, generateId, buildContent, onExhausted) {
116
+ const maxAttempts = onExhausted.maxAttempts ?? 25;
117
+ if (!existsSync(parentDir)) {
118
+ await mkdir(parentDir, { recursive: true });
119
+ }
120
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
121
+ const id = generateId();
122
+ const filePath = join(parentDir, `${id}.json`);
123
+ try {
124
+ await writeFile(filePath, buildContent(id), { flag: 'wx' }); // exclusive create — atomic
125
+ return { id, filePath };
126
+ } catch (err) {
127
+ if (err.code === 'EEXIST') continue;
128
+ throw err;
129
+ }
130
+ }
131
+ throw inputError(onExhausted.code, onExhausted.message, onExhausted.hint);
132
+ }
133
+
44
134
  /**
45
135
  * Derive which rule names were evaluated against a record for a given profile.
46
136
  * Mirrors the order of checks in evaluateEligibility() so the trace is stable.
@@ -101,18 +191,6 @@ export function computeConfigFingerprint(projectRoot) {
101
191
  export async function createSnapshot(projectRoot, profile, options = {}) {
102
192
  const config = loadProjectConfig(projectRoot);
103
193
  const recordsDir = join(projectRoot, 'records');
104
- const snapshotId = generateSnapshotId();
105
- const snapshotDir = join(projectRoot, 'snapshots', snapshotId);
106
-
107
- // D-005: refuse to proceed if the generated ID has collided with an existing
108
- // snapshot dir — silent overwrite would break Law 1 (snapshots are frozen).
109
- if (!options.dryRun && existsSync(snapshotDir)) {
110
- throw inputError(
111
- 'SNAPSHOT_ID_COLLISION',
112
- `Snapshot directory already exists: ${snapshotDir}`,
113
- 'Retry to generate a fresh snapshot ID.',
114
- );
115
- }
116
194
 
117
195
  const configFingerprint = computeConfigFingerprint(projectRoot);
118
196
  const profileId = profile.profile_id || profile.id || 'unknown';
@@ -147,7 +225,7 @@ export async function createSnapshot(projectRoot, profile, options = {}) {
147
225
  continue;
148
226
  }
149
227
 
150
- const { eligible, reasons } = evaluateEligibility(
228
+ const { eligible, reasons, reason_codes: reasonCodes } = evaluateEligibility(
151
229
  record, profile, config.lanes, config.terminology
152
230
  );
153
231
 
@@ -181,7 +259,13 @@ export async function createSnapshot(projectRoot, profile, options = {}) {
181
259
  }
182
260
  }
183
261
  } else {
184
- excluded.push({ record_id: record.id, reasons });
262
+ // F5: forward the stable reason_codes alongside the human-readable
263
+ // reasons — purely additive on excluded.jsonl's on-disk shape (one
264
+ // new key), so old readers/old files that only expect
265
+ // {record_id, reasons} are unaffected. Lets categorizeExclusions
266
+ // (lib/eligibility.js) categorize this snapshot's exclusions by
267
+ // code instead of prose-matching `reasons`.
268
+ excluded.push({ record_id: record.id, reasons, reason_codes: reasonCodes });
185
269
  }
186
270
  }
187
271
 
@@ -197,9 +281,24 @@ export async function createSnapshot(projectRoot, profile, options = {}) {
197
281
 
198
282
  // Write snapshot files (skip if dry run)
199
283
  if (options.dryRun) {
284
+ // Dry run never touches disk, so there is nothing to atomically claim —
285
+ // a fresh preview id is enough to characterize the shape of the result.
286
+ const snapshotId = generateSnapshotId();
200
287
  return { snapshotId, included: included.length, excluded: excluded.length, unreadable: unreadable.length };
201
288
  }
202
- await mkdir(snapshotDir, { recursive: true });
289
+
290
+ // SDL-M10: atomically claim the snapshot id/dir — closes the
291
+ // check-then-act race the old existsSync+recursive-mkdir pattern had
292
+ // (D-005's intent, now actually race-proof; see claimIdDir above).
293
+ const { id: snapshotId, dir: snapshotDir } = await claimIdDir(
294
+ join(projectRoot, 'snapshots'),
295
+ generateSnapshotId,
296
+ {
297
+ code: 'SNAPSHOT_ID_COLLISION',
298
+ message: 'Could not claim a unique snapshot ID — every candidate collided with an existing snapshot directory.',
299
+ hint: 'Retry; if this persists, another sdlab process may be creating snapshots in a tight loop.',
300
+ },
301
+ );
203
302
 
204
303
  const manifest = {
205
304
  // DB-001 / DB-006: stamp manifest schema version (reader checks on load)