@mcptoolshop/style-dataset-lab 3.2.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 (95) hide show
  1. package/CHANGELOG.md +58 -1
  2. package/README.es.md +40 -19
  3. package/README.fr.md +74 -53
  4. package/README.hi.md +67 -46
  5. package/README.it.md +42 -21
  6. package/README.ja.md +44 -23
  7. package/README.md +25 -4
  8. package/README.pt-BR.md +42 -21
  9. package/README.zh.md +66 -45
  10. package/bin/sdlab.js +51 -4
  11. package/lib/adapters/ai-toolkit.js +37 -5
  12. package/lib/adapters/comfyui-runner.js +114 -27
  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 +82 -3
  28. package/lib/critique-render.js +45 -0
  29. package/lib/eligibility.js +121 -9
  30. package/lib/eval-pack.js +14 -14
  31. package/lib/eval-runs.js +33 -0
  32. package/lib/export.js +122 -34
  33. package/lib/freeze-gate.js +69 -16
  34. package/lib/freeze-stamp.js +28 -3
  35. package/lib/generated-provenance.js +16 -1
  36. package/lib/implementation-packs.js +95 -17
  37. package/lib/ingest.js +396 -0
  38. package/lib/judgment-provenance.js +220 -0
  39. package/lib/measure.js +422 -0
  40. package/lib/paths.js +196 -7
  41. package/lib/reingest-selected.js +98 -16
  42. package/lib/run-manifest.js +394 -0
  43. package/lib/run-summary.js +12 -6
  44. package/lib/runtime-runs.js +45 -4
  45. package/lib/snapshot.js +121 -16
  46. package/lib/split.js +364 -48
  47. package/lib/training-manifests.js +114 -21
  48. package/lib/training-packages.js +147 -15
  49. package/lib/verifier.js +67 -0
  50. package/lib/workflow-profiles.js +24 -3
  51. package/package.json +3 -2
  52. package/runtime/comfyui/txt2img-qwen.json +38 -0
  53. package/runtime/comfyui/txt2img-standard.json +21 -0
  54. package/scripts/_resume.js +62 -0
  55. package/scripts/_shared-args.js +44 -0
  56. package/scripts/brief-compile.js +2 -1
  57. package/scripts/brief-show.js +2 -1
  58. package/scripts/canon-bind.js +2 -2
  59. package/scripts/canon-build.js +27 -20
  60. package/scripts/canon-drift.js +16 -7
  61. package/scripts/canon-freeze-status.js +14 -6
  62. package/scripts/canon-freeze.js +27 -11
  63. package/scripts/canon-unfreeze.js +17 -9
  64. package/scripts/card.js +2 -2
  65. package/scripts/compare.js +2 -2
  66. package/scripts/critique.js +48 -8
  67. package/scripts/curate.js +23 -2
  68. package/scripts/doctor.js +181 -55
  69. package/scripts/eligibility.js +42 -2
  70. package/scripts/eval-pack.js +2 -2
  71. package/scripts/eval-run.js +2 -2
  72. package/scripts/export.js +2 -2
  73. package/scripts/generate-controlnet.js +45 -25
  74. package/scripts/generate-identity.js +53 -23
  75. package/scripts/generate-ipadapter.js +44 -23
  76. package/scripts/generate.js +72 -5
  77. package/scripts/implementation-pack.js +2 -2
  78. package/scripts/ingest.js +145 -0
  79. package/scripts/init.js +73 -13
  80. package/scripts/measure.js +170 -0
  81. package/scripts/measure_image.py +458 -0
  82. package/scripts/migrate-records.js +142 -0
  83. package/scripts/painterly-test.js +4 -3
  84. package/scripts/painterly.js +7 -3
  85. package/scripts/qwen_generate.py +334 -13
  86. package/scripts/reingest.js +2 -2
  87. package/scripts/run-generate.js +3 -0
  88. package/scripts/sheet.js +142 -0
  89. package/scripts/snapshot.js +2 -2
  90. package/scripts/split.js +2 -2
  91. package/scripts/training-manifest.js +2 -2
  92. package/scripts/training-package.js +2 -2
  93. package/scripts/training-profile.js +2 -2
  94. package/scripts/workflow-list.js +2 -1
  95. package/scripts/workflow-show.js +2 -1
@@ -0,0 +1,394 @@
1
+ /**
2
+ * Run-manifest pinning contract (PIN_PER_STEP).
3
+ *
4
+ * ONE source of truth for the fields that make a generation wave byte-for-byte
5
+ * replayable. Three writers emit provenance today — `scripts/generate.js`
6
+ * (record `provenance`), `lib/adapters/comfyui-runner.js` (run `manifest.json`),
7
+ * and `scripts/qwen_generate.py` (the wave `generation.json` receipt) — and they
8
+ * MUST all pin the same way. This module is the field-name authority; the
9
+ * Python receipt mirrors the identical field contract (see qwen_generate.py).
10
+ *
11
+ * Design (DECOMPOSE_BY_SECRETS, Parnas 1972): the pinning field names + the
12
+ * best-effort model hasher live here so the writers can never drift apart
13
+ * silently. When the model changes, this secret-hiding boundary survives.
14
+ *
15
+ * The pinning block closes three PIN_PER_STEP gaps that the pre-3.3.0 manifests
16
+ * left open (base/sampler/scheduler/shift/seed/steps/cfg were already recorded):
17
+ * 1. comfy_workflow_sha — the exact ComfyUI graph that ran, as a hash.
18
+ * 2. model/LoRA content identity — filenames upgraded to {name,size_bytes,sha256}.
19
+ * 3. seed_policy — HOW seeds were chosen (intent), not just the values.
20
+ *
21
+ * Every field is optional and additive. Legacy manifests with no `pinning`
22
+ * block load fine; readers nullish-coalesce (`m.pinning?.comfy_workflow_sha ?? null`).
23
+ */
24
+
25
+ import { createHash } from 'node:crypto';
26
+ import { openSync, readSync, closeSync, statSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
27
+ import { join } from 'node:path';
28
+ import { warn } from './log.js';
29
+
30
+ /**
31
+ * Contract version for the `pinning` block itself. Bump when the block's shape
32
+ * changes (a new field, a renamed field). Independent of the manifest
33
+ * SCHEMA_VERSION (lib/snapshot.js) — that versions the snapshot/split/export
34
+ * manifest family; this versions the generation-provenance pin. Mirrored as a
35
+ * literal in scripts/qwen_generate.py — keep the two in lockstep.
36
+ */
37
+ export const PINNING_VERSION = '1.0.0';
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
+
49
+ /**
50
+ * Seed policies — the INTENT behind seed selection, recorded alongside the
51
+ * concrete per-item seed values so a reader knows how to regenerate.
52
+ */
53
+ export const SEED_POLICIES = Object.freeze({
54
+ EXPLICIT_PER_ITEM: 'explicit-per-item', // each item's seed is recorded verbatim (qwen_generate.py wave receipt)
55
+ BASE_INCREMENT: 'base+increment', // seed = base_seed + job_index (generate.js / batch path)
56
+ FIXED: 'fixed', // every item shares one seed
57
+ RANDOM: 'random', // first seed fixed, rest sampled
58
+ });
59
+
60
+ /**
61
+ * Map a runtime seed_mode ('increment' | 'fixed' | 'random') to a seed_policy.
62
+ * `increment` is the base+index scheme; unknown modes fall back to base+increment.
63
+ */
64
+ export function seedPolicyFromMode(seedMode) {
65
+ switch (seedMode) {
66
+ case 'fixed': return SEED_POLICIES.FIXED;
67
+ case 'random': return SEED_POLICIES.RANDOM;
68
+ case 'increment':
69
+ default: return SEED_POLICIES.BASE_INCREMENT;
70
+ }
71
+ }
72
+
73
+ // ─── Stable serialization ────────────────────────────────────────
74
+
75
+ /**
76
+ * Deterministic JSON: object keys sorted, arrays kept in order (arrays are
77
+ * meaningful sequences — a LoRA chain, a node's input wiring). Guarantees the
78
+ * same semantic graph hashes identically regardless of key insertion order.
79
+ */
80
+ export function stableStringify(value) {
81
+ if (value === null || value === undefined) return 'null';
82
+ if (typeof value === 'number') return Number.isFinite(value) ? String(value) : 'null';
83
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
84
+ if (typeof value === 'string') return JSON.stringify(value);
85
+ if (Array.isArray(value)) return '[' + value.map(stableStringify).join(',') + ']';
86
+ if (typeof value === 'object') {
87
+ const keys = Object.keys(value).sort();
88
+ const parts = [];
89
+ for (const k of keys) {
90
+ if (value[k] === undefined) continue;
91
+ parts.push(JSON.stringify(k) + ':' + stableStringify(value[k]));
92
+ }
93
+ return '{' + parts.join(',') + '}';
94
+ }
95
+ return 'null';
96
+ }
97
+
98
+ /**
99
+ * Return a copy of a ComfyUI graph with per-ITEM nondeterminism removed, so the
100
+ * whole wave shares ONE comfy_workflow_sha. Normalized out (recorded elsewhere,
101
+ * per item): KSampler seed / noise_seed, CLIPTextEncode prompt text, SaveImage
102
+ * filename_prefix. What SURVIVES the hash is the pipeline skeleton — node
103
+ * topology, model loaders, sampler/scheduler/shift, LoRA chain + weights,
104
+ * dims/steps/cfg — i.e. exactly the part that must match to replay the wave.
105
+ */
106
+ export function normalizeGraphForHash(graph) {
107
+ const out = {};
108
+ for (const [id, node] of Object.entries(graph || {})) {
109
+ const inputs = { ...(node?.inputs || {}) };
110
+ const cls = node?.class_type;
111
+ if (cls === 'KSampler' || cls === 'KSamplerAdvanced') {
112
+ if ('seed' in inputs) inputs.seed = 0;
113
+ if ('noise_seed' in inputs) inputs.noise_seed = 0;
114
+ } else if (cls === 'CLIPTextEncode') {
115
+ if ('text' in inputs) inputs.text = '';
116
+ } else if (cls === 'SaveImage') {
117
+ if ('filename_prefix' in inputs) inputs.filename_prefix = '';
118
+ }
119
+ out[id] = { class_type: cls, inputs };
120
+ }
121
+ return out;
122
+ }
123
+
124
+ /**
125
+ * SHA-256 of the exact ComfyUI graph submitted for the wave (per-item seed +
126
+ * prompt normalized out — see normalizeGraphForHash). Returns a `sha256:`-prefixed
127
+ * digest, or null for an empty/absent graph.
128
+ */
129
+ export function comfyWorkflowSha(graph) {
130
+ if (!graph || Object.keys(graph).length === 0) return null;
131
+ const normalized = normalizeGraphForHash(graph);
132
+ return 'sha256:' + createHash('sha256').update(stableStringify(normalized)).digest('hex');
133
+ }
134
+
135
+ // ─── Model file resolution + hashing (best-effort) ───────────────
136
+
137
+ /**
138
+ * Default ComfyUI models directory on the rig. Override with SDLAB_MODELS_DIR.
139
+ * Hashing is opt-in and best-effort, so a wrong/absent path degrades cleanly to
140
+ * `sha256: null` — it never fails a generation.
141
+ */
142
+ export function defaultModelsDir() {
143
+ return process.env.SDLAB_MODELS_DIR
144
+ || 'E:/AI-Models/ComfyUI_windows_portable/ComfyUI/models';
145
+ }
146
+
147
+ // Kind → candidate subdirectories under the models dir, in search order.
148
+ const KIND_SUBDIRS = {
149
+ unet: ['diffusion_models', 'unet'],
150
+ clip: ['text_encoders', 'clip'],
151
+ vae: ['vae'],
152
+ checkpoint: ['checkpoints'],
153
+ lora: ['loras'],
154
+ };
155
+
156
+ /**
157
+ * Resolve a model filename to an absolute path under the models dir. Searches
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.
171
+ */
172
+ export function resolveModelPath(name, kind, modelsDir = defaultModelsDir()) {
173
+ if (!name || !modelsDir) return null;
174
+ const subdirs = [...(KIND_SUBDIRS[kind] || []), '.'];
175
+ const tried = new Set();
176
+ for (const sub of subdirs) {
177
+ const candidate = join(modelsDir, sub, name);
178
+ if (tried.has(candidate)) continue;
179
+ tried.add(candidate);
180
+ if (existsSync(candidate)) return candidate;
181
+ }
182
+ return null;
183
+ }
184
+
185
+ /**
186
+ * Streaming SHA-256 of a file — 1 MiB chunks, synchronous, never loads the whole
187
+ * (multi-GB) file into memory. Returns a `sha256:`-prefixed digest.
188
+ */
189
+ export function sha256File(path) {
190
+ const hash = createHash('sha256');
191
+ const fd = openSync(path, 'r');
192
+ try {
193
+ const buf = Buffer.allocUnsafe(1 << 20);
194
+ let bytes;
195
+ while ((bytes = readSync(fd, buf, 0, buf.length, null)) > 0) {
196
+ hash.update(bytes === buf.length ? buf : buf.subarray(0, bytes));
197
+ }
198
+ } finally {
199
+ closeSync(fd);
200
+ }
201
+ return 'sha256:' + hash.digest('hex');
202
+ }
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
+
242
+ /**
243
+ * Best-effort content identity for one model/LoRA file: {name, size_bytes, sha256}
244
+ * (+ a short `hash_note` when sha256 can't be produced). size_bytes is always
245
+ * filled when the file resolves (a cheap stat); sha256 is gated behind
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).
250
+ *
251
+ * @param {string} name — model filename as it appears in the graph
252
+ * @param {Object} [opts]
253
+ * @param {'unet'|'clip'|'vae'|'checkpoint'|'lora'} [opts.kind]
254
+ * @param {string} [opts.modelsDir]
255
+ * @param {boolean} [opts.doHash=false] — compute sha256 (else record name+size only)
256
+ * @param {{map: Map, cacheFile?: string, dirty: boolean}} [opts.cache]
257
+ */
258
+ export function hashModelFile(name, { kind, modelsDir = defaultModelsDir(), doHash = false, cache } = {}) {
259
+ if (!name) return { name: name ?? null, size_bytes: null, sha256: null, hash_note: 'no filename' };
260
+ const resolved = resolveModelPath(name, kind, modelsDir);
261
+ if (!resolved) return { name, size_bytes: null, sha256: null, hash_note: `unresolved under ${modelsDir}` };
262
+ let size_bytes = null;
263
+ let mtimeMs = 0;
264
+ try {
265
+ const st = statSync(resolved);
266
+ size_bytes = st.size;
267
+ mtimeMs = st.mtimeMs;
268
+ } catch {
269
+ return { name, size_bytes: null, sha256: null, hash_note: 'stat failed' };
270
+ }
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}`;
281
+ if (cache?.map?.has(key)) return { name, size_bytes, sha256: cache.map.get(key) };
282
+ let sha256;
283
+ try {
284
+ sha256 = sha256File(resolved);
285
+ } catch {
286
+ return { name, size_bytes, sha256: null, hash_note: 'hash failed (read error)' };
287
+ }
288
+ if (cache?.map) {
289
+ cache.map.set(key, sha256);
290
+ cache.dirty = true;
291
+ }
292
+ return { name, size_bytes, sha256 };
293
+ }
294
+
295
+ /**
296
+ * Content-identity block for the model loaders of a wave. `modelSpec` maps a
297
+ * role to a filename — {unet, clip, vae} for the Qwen DiT path, {checkpoint}
298
+ * for the SDXL path. Only provided roles appear in the result.
299
+ */
300
+ export function buildModelHashes(modelSpec = {}, opts = {}) {
301
+ const roleKind = { unet: 'unet', clip: 'clip', vae: 'vae', checkpoint: 'checkpoint' };
302
+ const out = {};
303
+ for (const [role, kind] of Object.entries(roleKind)) {
304
+ const name = modelSpec[role];
305
+ if (!name) continue;
306
+ out[role] = hashModelFile(name, { ...opts, kind });
307
+ }
308
+ return out;
309
+ }
310
+
311
+ // ─── Persistent hash cache (best-effort) ─────────────────────────
312
+
313
+ /**
314
+ * Create a hash cache. Loads the on-disk JSON map (name:size:mtime → sha256) if
315
+ * present, so hashes survive across waves. `cacheFile` defaults to a dotfile in
316
+ * the models dir; override with SDLAB_HASH_CACHE. All I/O is best-effort — a
317
+ * missing/unwritable cache never fails a run.
318
+ */
319
+ export function createHashCache(cacheFile) {
320
+ const file = cacheFile
321
+ || process.env.SDLAB_HASH_CACHE
322
+ || join(defaultModelsDir(), '.sdlab-hash-cache.json');
323
+ const map = new Map();
324
+ try {
325
+ if (existsSync(file)) {
326
+ const obj = JSON.parse(readFileSync(file, 'utf-8'));
327
+ for (const [k, v] of Object.entries(obj)) map.set(k, v);
328
+ }
329
+ } catch { /* best-effort: ignore a corrupt/unreadable cache */ }
330
+ return { map, cacheFile: file, dirty: false };
331
+ }
332
+
333
+ /**
334
+ * Persist a hash cache to disk if it grew. Best-effort — swallows write errors.
335
+ */
336
+ export function flushHashCache(cache) {
337
+ if (!cache || !cache.dirty || !cache.cacheFile) return;
338
+ try {
339
+ const obj = Object.fromEntries(cache.map);
340
+ writeFileSync(cache.cacheFile, JSON.stringify(obj, null, 2));
341
+ cache.dirty = false;
342
+ } catch { /* best-effort: ignore */ }
343
+ }
344
+
345
+ // ─── The pinning block ───────────────────────────────────────────
346
+
347
+ /**
348
+ * Build the pinning block for a wave/item. Identical shape across all three
349
+ * writers (see module header). Every writer calls this so the contract is
350
+ * enforced in ONE place.
351
+ *
352
+ * @param {Object} args
353
+ * @param {Object} [args.graph] — the ComfyUI node graph submitted (for comfy_workflow_sha)
354
+ * @param {Object} [args.models] — {unet?, clip?, vae?, checkpoint?} filenames
355
+ * @param {Array<{name:string, weight?:number}>} [args.loras]
356
+ * @param {string} [args.seedPolicy] — one of SEED_POLICIES
357
+ * @param {boolean} [args.hashModels=false] — opt-in sha256 of model/LoRA files
358
+ * @param {string} [args.modelsDir]
359
+ * @param {{map: Map, cacheFile?: string, dirty: boolean}} [args.cache]
360
+ * @returns {Object} pinning block
361
+ */
362
+ export function buildPinning({ graph, models = {}, loras = [], seedPolicy = null, hashModels = false, modelsDir = defaultModelsDir(), cache } = {}) {
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
+
387
+ return {
388
+ pinning_version: PINNING_VERSION,
389
+ comfy_workflow_sha: comfyWorkflowSha(graph),
390
+ seed_policy: seedPolicy,
391
+ models: modelHashes,
392
+ loras: loraHashes,
393
+ };
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