@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.
- package/CHANGELOG.md +33 -0
- package/README.es.md +35 -25
- package/README.fr.md +38 -28
- package/README.hi.md +36 -26
- package/README.it.md +37 -27
- package/README.ja.md +40 -30
- package/README.md +15 -5
- package/README.pt-BR.md +35 -25
- package/README.zh.md +33 -23
- package/bin/sdlab.js +51 -4
- package/lib/adapters/ai-toolkit.js +37 -5
- package/lib/adapters/comfyui-runner.js +79 -26
- package/lib/adapters/comfyui-workflows.js +10 -2
- package/lib/adapters/diffusers-lora.js +37 -5
- package/lib/adapters/generic-image-caption.js +43 -5
- package/lib/args.js +16 -1
- package/lib/batch-modes.js +29 -8
- package/lib/batch-runs.js +29 -1
- package/lib/batch-sheet-render.js +13 -3
- package/lib/brief-compiler.js +19 -0
- package/lib/candidate-sheet.js +327 -0
- package/lib/canon-build/build.js +146 -93
- package/lib/captions.js +57 -0
- package/lib/card.js +74 -2
- package/lib/comfyui.js +116 -18
- package/lib/config.js +116 -12
- package/lib/critique-engine.js +36 -2
- package/lib/eligibility.js +121 -9
- package/lib/eval-pack.js +14 -14
- package/lib/eval-runs.js +33 -0
- package/lib/export.js +122 -34
- package/lib/freeze-gate.js +13 -3
- package/lib/freeze-stamp.js +28 -3
- package/lib/generated-provenance.js +16 -1
- package/lib/implementation-packs.js +95 -17
- package/lib/ingest.js +396 -0
- package/lib/judgment-provenance.js +220 -0
- package/lib/measure.js +422 -0
- package/lib/paths.js +196 -7
- package/lib/reingest-selected.js +98 -16
- package/lib/run-manifest.js +102 -16
- package/lib/run-summary.js +12 -6
- package/lib/runtime-runs.js +45 -4
- package/lib/snapshot.js +114 -15
- package/lib/split.js +364 -48
- package/lib/training-manifests.js +114 -21
- package/lib/training-packages.js +147 -15
- package/lib/workflow-profiles.js +24 -3
- package/package.json +3 -2
- package/runtime/comfyui/txt2img-qwen.json +38 -0
- package/runtime/comfyui/txt2img-standard.json +21 -0
- package/scripts/_resume.js +62 -0
- package/scripts/_shared-args.js +44 -0
- package/scripts/brief-compile.js +2 -1
- package/scripts/brief-show.js +2 -1
- package/scripts/canon-bind.js +2 -2
- package/scripts/canon-build.js +27 -20
- package/scripts/canon-drift.js +16 -7
- package/scripts/canon-freeze-status.js +14 -6
- package/scripts/canon-freeze.js +27 -11
- package/scripts/canon-unfreeze.js +17 -9
- package/scripts/card.js +2 -2
- package/scripts/compare.js +2 -2
- package/scripts/critique.js +16 -1
- package/scripts/curate.js +2 -2
- package/scripts/doctor.js +181 -55
- package/scripts/eligibility.js +42 -2
- package/scripts/eval-pack.js +2 -2
- package/scripts/eval-run.js +2 -2
- package/scripts/export.js +2 -2
- package/scripts/generate-controlnet.js +45 -25
- package/scripts/generate-identity.js +53 -23
- package/scripts/generate-ipadapter.js +44 -23
- package/scripts/generate.js +48 -5
- package/scripts/implementation-pack.js +2 -2
- package/scripts/ingest.js +145 -0
- package/scripts/init.js +73 -13
- package/scripts/measure.js +170 -0
- package/scripts/measure_image.py +458 -0
- package/scripts/migrate-records.js +142 -0
- package/scripts/painterly-test.js +4 -3
- package/scripts/painterly.js +7 -3
- package/scripts/qwen_generate.py +138 -20
- package/scripts/reingest.js +2 -2
- package/scripts/sheet.js +142 -0
- package/scripts/snapshot.js +2 -2
- package/scripts/split.js +2 -2
- package/scripts/training-manifest.js +2 -2
- package/scripts/training-package.js +2 -2
- package/scripts/training-profile.js +2 -2
- package/scripts/workflow-list.js +2 -1
- package/scripts/workflow-show.js +2 -1
package/lib/reingest-selected.js
CHANGED
|
@@ -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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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 };
|
package/lib/run-manifest.js
CHANGED
|
@@ -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
|
|
148
|
-
*
|
|
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
|
|
191
|
-
* multi-GB checkpoints aren't re-hashed every
|
|
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:
|
|
214
|
-
|
|
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:
|
|
303
|
-
loras:
|
|
304
|
-
...hashModelFile(l.name, { ...opts, kind: 'lora' }),
|
|
305
|
-
weight: l.weight ?? 1.0,
|
|
306
|
-
})),
|
|
391
|
+
models: modelHashes,
|
|
392
|
+
loras: loraHashes,
|
|
307
393
|
};
|
|
308
394
|
}
|
package/lib/run-summary.js
CHANGED
|
@@ -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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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}` : '';
|
package/lib/runtime-runs.js
CHANGED
|
@@ -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))
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|