@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.
- package/CHANGELOG.md +58 -1
- package/README.es.md +40 -19
- package/README.fr.md +74 -53
- package/README.hi.md +67 -46
- package/README.it.md +42 -21
- package/README.ja.md +44 -23
- package/README.md +25 -4
- package/README.pt-BR.md +42 -21
- package/README.zh.md +66 -45
- package/bin/sdlab.js +51 -4
- package/lib/adapters/ai-toolkit.js +37 -5
- package/lib/adapters/comfyui-runner.js +114 -27
- 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 +82 -3
- package/lib/critique-render.js +45 -0
- 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 +69 -16
- 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 +394 -0
- package/lib/run-summary.js +12 -6
- package/lib/runtime-runs.js +45 -4
- package/lib/snapshot.js +121 -16
- package/lib/split.js +364 -48
- package/lib/training-manifests.js +114 -21
- package/lib/training-packages.js +147 -15
- package/lib/verifier.js +67 -0
- 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 +48 -8
- package/scripts/curate.js +23 -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 +72 -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 +334 -13
- package/scripts/reingest.js +2 -2
- package/scripts/run-generate.js +3 -0
- 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
|
@@ -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
|
+
}
|
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
|
|