@mcptoolshop/style-dataset-lab 2.3.0 → 3.0.1
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 +178 -0
- package/CODE_OF_CONDUCT.md +9 -0
- package/CONTRIBUTING.md +71 -0
- package/README.md +82 -9
- package/SECURITY.md +6 -5
- package/bin/sdlab.js +359 -11
- package/lib/adapters/comfyui-runner.js +170 -0
- package/lib/adapters/comfyui-workflows.js +163 -0
- package/lib/adapters/diffusers-lora.js +7 -2
- package/lib/adapters/generic-image-caption.js +6 -1
- package/lib/args.js +200 -13
- package/lib/batch-compiler.js +122 -0
- package/lib/batch-modes.js +111 -0
- package/lib/batch-runs.js +237 -0
- package/lib/batch-sheet-render.js +209 -0
- package/lib/brief-compiler.js +625 -0
- package/lib/brief-render.js +205 -0
- package/lib/card.js +11 -4
- package/lib/comfyui.js +100 -6
- package/lib/config.js +68 -3
- package/lib/critique-engine.js +413 -0
- package/lib/critique-render.js +171 -0
- package/lib/deprecation.js +1 -1
- package/lib/eval-pack.js +94 -4
- package/lib/eval-runs.js +42 -3
- package/lib/export.js +144 -34
- package/lib/generated-provenance.js +87 -0
- package/lib/implementation-packs.js +15 -1
- package/lib/log.js +41 -4
- package/lib/paths.js +72 -3
- package/lib/refine-briefs.js +336 -0
- package/lib/reingest-selected.js +184 -0
- package/lib/reingest.js +15 -1
- package/lib/run-summary.js +132 -0
- package/lib/runtime-runs.js +218 -0
- package/lib/selections.js +375 -0
- package/lib/snapshot.js +100 -7
- package/lib/split.js +175 -17
- package/lib/training-manifests.js +17 -3
- package/lib/training-packages.js +28 -14
- package/lib/workflow-profiles.js +161 -0
- package/package.json +13 -4
- package/scripts/batch-generate.js +176 -0
- package/scripts/batch-sheet.js +46 -0
- package/scripts/batch-show.js +86 -0
- package/scripts/brief-compile.js +134 -0
- package/scripts/brief-show.js +53 -0
- package/scripts/canon-bind.js +7 -9
- package/scripts/card.js +22 -25
- package/scripts/compare.js +38 -33
- package/scripts/critique-show.js +50 -0
- package/scripts/critique.js +70 -0
- package/scripts/curate.js +91 -46
- package/scripts/doctor.js +172 -6
- package/scripts/eligibility.js +17 -14
- package/scripts/eval-pack.js +13 -11
- package/scripts/eval-run.js +30 -29
- package/scripts/export.js +27 -31
- package/scripts/generate-controlnet.js +31 -17
- package/scripts/generate-identity.js +37 -20
- package/scripts/generate-ipadapter.js +22 -16
- package/scripts/generate.js +68 -13
- package/scripts/implementation-pack.js +19 -14
- package/scripts/init.js +137 -17
- package/scripts/painterly.js +8 -10
- package/scripts/refine.js +119 -0
- package/scripts/reingest-selected.js +78 -0
- package/scripts/reingest.js +37 -18
- package/scripts/run-generate.js +76 -0
- package/scripts/run-list.js +58 -0
- package/scripts/run-show.js +63 -0
- package/scripts/select.js +119 -0
- package/scripts/selection-show.js +88 -0
- package/scripts/snapshot.js +22 -24
- package/scripts/split.js +26 -31
- package/scripts/training-manifest.js +29 -24
- package/scripts/training-package.js +24 -20
- package/scripts/training-profile.js +13 -8
- package/scripts/workflow-list.js +61 -0
- package/scripts/workflow-show.js +97 -0
- package/templates/domains/architecture/inputs/prompts/example-wave.json +32 -0
- package/templates/domains/architecture/terminology.json +10 -8
- package/templates/domains/architecture/workflows/profiles/environment-moodboard.json +62 -0
- package/templates/domains/architecture/workflows/profiles/interior-variant-pack.json +60 -0
- package/templates/domains/character-design/inputs/prompts/example-wave.json +32 -0
- package/templates/domains/character-design/project.json +4 -2
- package/templates/domains/character-design/workflows/profiles/character-portrait-set.json +61 -0
- package/templates/domains/character-design/workflows/profiles/expression-sheet.json +59 -0
- package/templates/domains/creature-design/inputs/prompts/example-wave.json +32 -0
- package/templates/domains/creature-design/project.json +4 -2
- package/templates/domains/creature-design/terminology.json +1 -0
- package/templates/domains/creature-design/workflows/profiles/creature-portrait-set.json +61 -0
- package/templates/domains/creature-design/workflows/profiles/silhouette-exploration.json +58 -0
- package/templates/domains/game-art/inputs/prompts/example-wave.json +37 -0
- package/templates/domains/game-art/project.json +4 -2
- package/templates/domains/game-art/workflows/profiles/character-portrait-set.json +62 -0
- package/templates/domains/game-art/workflows/profiles/environment-moodboard.json +61 -0
- package/templates/domains/vehicle-mech/inputs/prompts/example-wave.json +37 -0
- package/templates/domains/vehicle-mech/project.json +4 -2
- package/templates/domains/vehicle-mech/workflows/profiles/variant-pack.json +60 -0
- package/templates/domains/vehicle-mech/workflows/profiles/vehicle-silhouette-pack.json +59 -0
- package/scripts/bulk-curate-wave2-5.js +0 -138
- package/scripts/bulk-curate-waves11-18.js +0 -413
- package/scripts/curate-wave25.js +0 -209
- package/scripts/migrate-records.js +0 -141
- package/templates/inputs/prompts/example-wave.json +0 -27
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Selection engine — approve outputs from runs or batches into
|
|
3
|
+
* durable selection artifacts.
|
|
4
|
+
*
|
|
5
|
+
* A selection is a creative decision: what was chosen, why, and
|
|
6
|
+
* where it came from. Re-ingest consumes selections; this module
|
|
7
|
+
* only builds and stores the artifact.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync, existsSync, readdirSync, mkdirSync, copyFileSync } from 'node:fs';
|
|
11
|
+
import { writeFile, mkdir, readFile } from 'node:fs/promises';
|
|
12
|
+
import { join, basename } from 'node:path';
|
|
13
|
+
import { getRunsDir } from './paths.js';
|
|
14
|
+
import { getBatchesDir } from './batch-runs.js';
|
|
15
|
+
import { inputError } from './errors.js';
|
|
16
|
+
|
|
17
|
+
// ─── Filename validation ─────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
const SAFE_FILENAME_RE = /^[A-Za-z0-9._-]+\.(png|webp|jpg|jpeg)$/i;
|
|
20
|
+
|
|
21
|
+
function assertSafeFilename(filename, context) {
|
|
22
|
+
if (typeof filename !== 'string' || !SAFE_FILENAME_RE.test(filename)) {
|
|
23
|
+
throw inputError(
|
|
24
|
+
'UNSAFE_FILENAME',
|
|
25
|
+
`Invalid filename "${filename}" in ${context}`,
|
|
26
|
+
`Filenames must match letters/digits/dot/underscore/hyphen and end in .png/.webp/.jpg/.jpeg`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ─── Selection ID generation ─────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
export function getSelectionsDir(projectRoot) {
|
|
34
|
+
return join(projectRoot, 'selections');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// PB-008: tighten regex so partial matches (e.g. 'selection_<date>_001_wip')
|
|
38
|
+
// don't inflate the sequence. We require exactly 3 digits.
|
|
39
|
+
const SELECTION_SEQ_RE = /^(\d{3})$/;
|
|
40
|
+
|
|
41
|
+
export function generateSelectionId(selectionsDir) {
|
|
42
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
43
|
+
const prefix = `selection_${today}_`;
|
|
44
|
+
|
|
45
|
+
let maxSeq = 0;
|
|
46
|
+
if (existsSync(selectionsDir)) {
|
|
47
|
+
const existing = readdirSync(selectionsDir, { withFileTypes: true })
|
|
48
|
+
.filter(e => e.isDirectory() && e.name.startsWith(prefix));
|
|
49
|
+
for (const e of existing) {
|
|
50
|
+
const seqStr = e.name.slice(prefix.length);
|
|
51
|
+
const match = SELECTION_SEQ_RE.exec(seqStr);
|
|
52
|
+
if (!match) continue;
|
|
53
|
+
const seq = parseInt(match[1], 10);
|
|
54
|
+
if (seq > maxSeq) maxSeq = seq;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return `${prefix}${String(maxSeq + 1).padStart(3, '0')}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* PB-008: atomically claim the next selection ID by mkdir retry-on-EEXIST.
|
|
63
|
+
* Races safely against a concurrent sdlab process — each one will land on
|
|
64
|
+
* a unique sequence. Returns { selectionId, selectionDir }.
|
|
65
|
+
*
|
|
66
|
+
* Callers that already create the directory themselves can keep using
|
|
67
|
+
* generateSelectionId; the atomic path is opt-in.
|
|
68
|
+
*/
|
|
69
|
+
export async function claimSelectionId(selectionsDir) {
|
|
70
|
+
if (!existsSync(selectionsDir)) {
|
|
71
|
+
mkdirSync(selectionsDir, { recursive: true });
|
|
72
|
+
}
|
|
73
|
+
for (let attempt = 0; attempt < 25; attempt++) {
|
|
74
|
+
const candidate = generateSelectionId(selectionsDir);
|
|
75
|
+
const dir = join(selectionsDir, candidate);
|
|
76
|
+
try {
|
|
77
|
+
mkdirSync(dir); // throws EEXIST if a concurrent process already claimed it
|
|
78
|
+
return { selectionId: candidate, selectionDir: dir };
|
|
79
|
+
} catch (err) {
|
|
80
|
+
if (err.code === 'EEXIST') continue;
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
throw inputError(
|
|
85
|
+
'SELECTION_ID_CLAIM_FAILED',
|
|
86
|
+
'Could not claim a unique selection ID after 25 attempts',
|
|
87
|
+
'Another sdlab process may be writing selections in a tight loop. Try again.'
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ─── Run selection ───────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Create a selection from a single run.
|
|
95
|
+
*
|
|
96
|
+
* @param {Object} opts
|
|
97
|
+
* @param {string} opts.projectRoot
|
|
98
|
+
* @param {string} opts.projectId
|
|
99
|
+
* @param {string} opts.runId
|
|
100
|
+
* @param {string[]} opts.approvedFiles — filenames to approve (e.g. ['001.png', '003.png'])
|
|
101
|
+
* @param {string} [opts.reason]
|
|
102
|
+
* @param {string[]} [opts.tags]
|
|
103
|
+
* @returns {Promise<Object>} selection manifest
|
|
104
|
+
*/
|
|
105
|
+
export async function createSelectionFromRun({
|
|
106
|
+
projectRoot, projectId, runId, approvedFiles, reason, tags,
|
|
107
|
+
}) {
|
|
108
|
+
const runsDir = getRunsDir(projectRoot);
|
|
109
|
+
const runDir = join(runsDir, runId);
|
|
110
|
+
const manifestPath = join(runDir, 'manifest.json');
|
|
111
|
+
|
|
112
|
+
if (!existsSync(manifestPath)) {
|
|
113
|
+
throw inputError('RUN_NOT_FOUND', `Run "${runId}" not found`, 'Check the run ID.');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const runManifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
117
|
+
const outputsDir = join(runDir, 'outputs');
|
|
118
|
+
|
|
119
|
+
// Validate all approved files exist
|
|
120
|
+
for (const file of approvedFiles) {
|
|
121
|
+
assertSafeFilename(file, `approvedFiles for run ${runId}`);
|
|
122
|
+
if (!existsSync(join(outputsDir, file))) {
|
|
123
|
+
throw inputError(
|
|
124
|
+
'OUTPUT_NOT_FOUND',
|
|
125
|
+
`Output "${file}" not found in ${runId}/outputs/`,
|
|
126
|
+
`Available: ${readdirSync(outputsDir).filter(f => f.endsWith('.png')).join(', ') || '(none)'}`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Create selection directory
|
|
132
|
+
const selectionsDir = getSelectionsDir(projectRoot);
|
|
133
|
+
const selectionId = generateSelectionId(selectionsDir);
|
|
134
|
+
const selectionDir = join(selectionsDir, selectionId);
|
|
135
|
+
await mkdir(join(selectionDir, 'chosen'), { recursive: true });
|
|
136
|
+
|
|
137
|
+
// Copy chosen files
|
|
138
|
+
const items = [];
|
|
139
|
+
for (let i = 0; i < approvedFiles.length; i++) {
|
|
140
|
+
const file = approvedFiles[i];
|
|
141
|
+
const destName = `sel_${String(i + 1).padStart(3, '0')}.png`;
|
|
142
|
+
copyFileSync(join(outputsDir, file), join(selectionDir, 'chosen', destName));
|
|
143
|
+
|
|
144
|
+
// Find seed from run manifest
|
|
145
|
+
const outputEntry = runManifest.outputs?.find(o => o.filename === file);
|
|
146
|
+
items.push({
|
|
147
|
+
slot_or_output: file,
|
|
148
|
+
filename: destName,
|
|
149
|
+
seed: outputEntry?.seed,
|
|
150
|
+
reason: reason || 'selected',
|
|
151
|
+
tags: tags || [],
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Build selection manifest
|
|
156
|
+
const manifest = {
|
|
157
|
+
selection_id: selectionId,
|
|
158
|
+
project_id: projectId,
|
|
159
|
+
source_type: 'run',
|
|
160
|
+
source_id: runId,
|
|
161
|
+
workflow_id: runManifest.workflow_template_id,
|
|
162
|
+
created_at: new Date().toISOString(),
|
|
163
|
+
items,
|
|
164
|
+
reingest_ready: true,
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// Attach subject from brief if available
|
|
168
|
+
const briefPath = join(runDir, 'brief.json');
|
|
169
|
+
if (existsSync(briefPath)) {
|
|
170
|
+
try {
|
|
171
|
+
const brief = JSON.parse(readFileSync(briefPath, 'utf-8'));
|
|
172
|
+
if (brief.subject_id) manifest.subject_id = brief.subject_id;
|
|
173
|
+
} catch { /* skip */ }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return { selectionId, selectionDir, manifest, runManifest };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ─── Batch selection ─────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Create a selection from a batch.
|
|
183
|
+
*
|
|
184
|
+
* @param {Object} opts
|
|
185
|
+
* @param {string} opts.projectRoot
|
|
186
|
+
* @param {string} opts.projectId
|
|
187
|
+
* @param {string} opts.batchId
|
|
188
|
+
* @param {Array<{slotId: string, filename: string}>} opts.approvedSlots
|
|
189
|
+
* @param {string} [opts.reason]
|
|
190
|
+
* @param {string[]} [opts.tags]
|
|
191
|
+
* @returns {Promise<Object>} selection manifest
|
|
192
|
+
*/
|
|
193
|
+
export async function createSelectionFromBatch({
|
|
194
|
+
projectRoot, projectId, batchId, approvedSlots, reason, tags,
|
|
195
|
+
}) {
|
|
196
|
+
const batchesDir = getBatchesDir(projectRoot);
|
|
197
|
+
const batchDir = join(batchesDir, batchId);
|
|
198
|
+
const batchManifestPath = join(batchDir, 'manifest.json');
|
|
199
|
+
|
|
200
|
+
if (!existsSync(batchManifestPath)) {
|
|
201
|
+
throw inputError('BATCH_NOT_FOUND', `Batch "${batchId}" not found`, 'Check the batch ID.');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const batchManifest = JSON.parse(readFileSync(batchManifestPath, 'utf-8'));
|
|
205
|
+
const runsDir = getRunsDir(projectRoot);
|
|
206
|
+
|
|
207
|
+
// Build slot → run lookup
|
|
208
|
+
const slotRunMap = new Map();
|
|
209
|
+
for (const slot of batchManifest.slots || []) {
|
|
210
|
+
slotRunMap.set(slot.slot_id, slot);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Validate each approved slot
|
|
214
|
+
const runManifests = new Map();
|
|
215
|
+
for (const { slotId, filename } of approvedSlots) {
|
|
216
|
+
assertSafeFilename(filename, `approvedSlots for batch ${batchId}`);
|
|
217
|
+
const slot = slotRunMap.get(slotId);
|
|
218
|
+
if (!slot) {
|
|
219
|
+
throw inputError(
|
|
220
|
+
'SLOT_NOT_FOUND',
|
|
221
|
+
`Slot "${slotId}" not found in batch ${batchId}`,
|
|
222
|
+
`Available: ${[...slotRunMap.keys()].join(', ')}`,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const runDir = join(runsDir, slot.run_id);
|
|
227
|
+
const outputPath = join(runDir, 'outputs', filename);
|
|
228
|
+
if (!existsSync(outputPath)) {
|
|
229
|
+
throw inputError(
|
|
230
|
+
'OUTPUT_NOT_FOUND',
|
|
231
|
+
`Output "${filename}" not found in ${slot.run_id}/outputs/`,
|
|
232
|
+
`Check the filename.`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Cache run manifests
|
|
237
|
+
if (!runManifests.has(slot.run_id)) {
|
|
238
|
+
const rmPath = join(runDir, 'manifest.json');
|
|
239
|
+
if (existsSync(rmPath)) {
|
|
240
|
+
runManifests.set(slot.run_id, JSON.parse(readFileSync(rmPath, 'utf-8')));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Create selection directory
|
|
246
|
+
const selectionsDir = getSelectionsDir(projectRoot);
|
|
247
|
+
const selectionId = generateSelectionId(selectionsDir);
|
|
248
|
+
const selectionDir = join(selectionsDir, selectionId);
|
|
249
|
+
await mkdir(join(selectionDir, 'chosen'), { recursive: true });
|
|
250
|
+
|
|
251
|
+
// Copy chosen files and build items
|
|
252
|
+
const items = [];
|
|
253
|
+
for (let i = 0; i < approvedSlots.length; i++) {
|
|
254
|
+
const { slotId, filename } = approvedSlots[i];
|
|
255
|
+
const slot = slotRunMap.get(slotId);
|
|
256
|
+
const runDir = join(runsDir, slot.run_id);
|
|
257
|
+
const destName = `sel_${String(i + 1).padStart(3, '0')}.png`;
|
|
258
|
+
|
|
259
|
+
copyFileSync(join(runDir, 'outputs', filename), join(selectionDir, 'chosen', destName));
|
|
260
|
+
|
|
261
|
+
const rm = runManifests.get(slot.run_id);
|
|
262
|
+
const outputEntry = rm?.outputs?.find(o => o.filename === filename);
|
|
263
|
+
|
|
264
|
+
items.push({
|
|
265
|
+
slot_or_output: `${slotId}:${filename}`,
|
|
266
|
+
filename: destName,
|
|
267
|
+
seed: outputEntry?.seed,
|
|
268
|
+
reason: reason || `selected from slot ${slot.label || slotId}`,
|
|
269
|
+
tags: tags || [],
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const manifest = {
|
|
274
|
+
selection_id: selectionId,
|
|
275
|
+
project_id: projectId,
|
|
276
|
+
source_type: 'batch',
|
|
277
|
+
source_id: batchId,
|
|
278
|
+
workflow_id: batchManifest.mode_id,
|
|
279
|
+
created_at: new Date().toISOString(),
|
|
280
|
+
items,
|
|
281
|
+
reingest_ready: true,
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
if (batchManifest.subject_id) manifest.subject_id = batchManifest.subject_id;
|
|
285
|
+
|
|
286
|
+
return { selectionId, selectionDir, manifest, batchManifest, runManifests };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ─── Save / Load / List ──────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Save a selection manifest + summary to disk.
|
|
293
|
+
*/
|
|
294
|
+
export async function saveSelection(selectionDir, manifest) {
|
|
295
|
+
await writeFile(
|
|
296
|
+
join(selectionDir, 'manifest.json'),
|
|
297
|
+
JSON.stringify(manifest, null, 2) + '\n',
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
// Summary JSON
|
|
301
|
+
await writeFile(
|
|
302
|
+
join(selectionDir, 'summary.json'),
|
|
303
|
+
JSON.stringify({
|
|
304
|
+
selection_id: manifest.selection_id,
|
|
305
|
+
source_type: manifest.source_type,
|
|
306
|
+
source_id: manifest.source_id,
|
|
307
|
+
workflow_id: manifest.workflow_id,
|
|
308
|
+
subject_id: manifest.subject_id,
|
|
309
|
+
items: manifest.items.length,
|
|
310
|
+
reingest_ready: manifest.reingest_ready,
|
|
311
|
+
created_at: manifest.created_at,
|
|
312
|
+
}, null, 2) + '\n',
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
// Summary markdown
|
|
316
|
+
const lines = [
|
|
317
|
+
`# Selection: ${manifest.selection_id}`,
|
|
318
|
+
'',
|
|
319
|
+
`**Source:** ${manifest.source_type} \`${manifest.source_id}\``,
|
|
320
|
+
`**Workflow:** ${manifest.workflow_id || '—'}`,
|
|
321
|
+
];
|
|
322
|
+
if (manifest.subject_id) lines.push(`**Subject:** ${manifest.subject_id}`);
|
|
323
|
+
lines.push(`**Created:** ${manifest.created_at}`);
|
|
324
|
+
lines.push(`**Re-ingest ready:** ${manifest.reingest_ready ? 'yes' : 'no'}`);
|
|
325
|
+
lines.push('');
|
|
326
|
+
lines.push('## Chosen outputs');
|
|
327
|
+
lines.push('');
|
|
328
|
+
lines.push('| # | Source | File | Seed | Reason |');
|
|
329
|
+
lines.push('|---|--------|------|------|--------|');
|
|
330
|
+
for (let i = 0; i < manifest.items.length; i++) {
|
|
331
|
+
const item = manifest.items[i];
|
|
332
|
+
lines.push(
|
|
333
|
+
`| ${i + 1} | ${item.slot_or_output} | ${item.filename} | ${item.seed ?? '—'} | ${item.reason} |`,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
lines.push('');
|
|
337
|
+
if (manifest.items.some(it => it.tags?.length)) {
|
|
338
|
+
lines.push('## Tags');
|
|
339
|
+
for (const item of manifest.items) {
|
|
340
|
+
if (item.tags?.length) {
|
|
341
|
+
lines.push(`- ${item.filename}: ${item.tags.join(', ')}`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
lines.push('');
|
|
345
|
+
}
|
|
346
|
+
await writeFile(join(selectionDir, 'summary.md'), lines.join('\n'));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Load a selection manifest by ID.
|
|
351
|
+
*/
|
|
352
|
+
export function loadSelection(projectRoot, selectionId) {
|
|
353
|
+
const selectionDir = join(getSelectionsDir(projectRoot), selectionId);
|
|
354
|
+
const manifestPath = join(selectionDir, 'manifest.json');
|
|
355
|
+
if (!existsSync(manifestPath)) {
|
|
356
|
+
throw inputError(
|
|
357
|
+
'SELECTION_NOT_FOUND',
|
|
358
|
+
`Selection "${selectionId}" not found`,
|
|
359
|
+
'Check the selection ID or run: sdlab select --run <id> --approve <files>',
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
return JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* List all selections in a project.
|
|
367
|
+
*/
|
|
368
|
+
export function listSelections(projectRoot) {
|
|
369
|
+
const dir = getSelectionsDir(projectRoot);
|
|
370
|
+
if (!existsSync(dir)) return [];
|
|
371
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
372
|
+
.filter(e => e.isDirectory() && e.name.startsWith('selection_'))
|
|
373
|
+
.map(e => e.name)
|
|
374
|
+
.sort();
|
|
375
|
+
}
|
package/lib/snapshot.js
CHANGED
|
@@ -11,6 +11,45 @@ import { join } from 'node:path';
|
|
|
11
11
|
import { createHash, randomBytes } from 'node:crypto';
|
|
12
12
|
import { loadProjectConfig, loadSelectionProfile, detectLane, detectGroup } from './config.js';
|
|
13
13
|
import { evaluateEligibility } from './eligibility.js';
|
|
14
|
+
import { inputError } from './errors.js';
|
|
15
|
+
import { warn } from './log.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* DB-001 / DB-006: Manifest schema version. Every manifest.json written by
|
|
19
|
+
* snapshot/split/export/eval-pack/eval-run/training-manifest/training-package/
|
|
20
|
+
* impl-pack stamps this version; readers log a WARNING on version mismatch
|
|
21
|
+
* (best-effort compatibility).
|
|
22
|
+
*/
|
|
23
|
+
export const SCHEMA_VERSION = '2.2.0';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Warn if a loaded manifest's schema_version does not match the reader.
|
|
27
|
+
* Non-fatal — continues with best-effort compatibility. No warning when
|
|
28
|
+
* manifest is missing the field (legacy unstamped artifacts).
|
|
29
|
+
*/
|
|
30
|
+
export function checkManifestVersion(manifest, kind) {
|
|
31
|
+
if (manifest?.schema_version && manifest.schema_version !== SCHEMA_VERSION) {
|
|
32
|
+
warn(
|
|
33
|
+
`${kind} schema v${manifest.schema_version} loaded by v${SCHEMA_VERSION} reader — continuing with best-effort compatibility`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Derive which rule names were evaluated against a record for a given profile.
|
|
40
|
+
* Mirrors the order of checks in evaluateEligibility() so the trace is stable.
|
|
41
|
+
*/
|
|
42
|
+
function rulesCheckedForProfile(profile) {
|
|
43
|
+
const rules = [];
|
|
44
|
+
if (profile.require_judgment) rules.push('require_judgment');
|
|
45
|
+
if (profile.require_status?.length > 0) rules.push('require_status');
|
|
46
|
+
if (profile.require_canon_bound) rules.push('require_canon_bound');
|
|
47
|
+
if (profile.minimum_pass_ratio != null) rules.push('minimum_pass_ratio');
|
|
48
|
+
if (profile.exclude_lanes?.length > 0) rules.push('exclude_lanes');
|
|
49
|
+
if (profile.rights_filter != null) rules.push('rights_filter');
|
|
50
|
+
if (profile.exclude_tags?.length > 0) rules.push('exclude_tags');
|
|
51
|
+
return rules;
|
|
52
|
+
}
|
|
14
53
|
|
|
15
54
|
/**
|
|
16
55
|
* Generate a snapshot ID: snap-YYYYMMDD-HHMMSS-XXXX
|
|
@@ -59,6 +98,20 @@ export async function createSnapshot(projectRoot, profile, options = {}) {
|
|
|
59
98
|
const snapshotId = generateSnapshotId();
|
|
60
99
|
const snapshotDir = join(projectRoot, 'snapshots', snapshotId);
|
|
61
100
|
|
|
101
|
+
// D-005: refuse to proceed if the generated ID has collided with an existing
|
|
102
|
+
// snapshot dir — silent overwrite would break Law 1 (snapshots are frozen).
|
|
103
|
+
if (!options.dryRun && existsSync(snapshotDir)) {
|
|
104
|
+
throw inputError(
|
|
105
|
+
'SNAPSHOT_ID_COLLISION',
|
|
106
|
+
`Snapshot directory already exists: ${snapshotDir}`,
|
|
107
|
+
'Retry to generate a fresh snapshot ID.',
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const configFingerprint = computeConfigFingerprint(projectRoot);
|
|
112
|
+
const profileId = profile.profile_id || profile.id || 'unknown';
|
|
113
|
+
const rulesChecked = rulesCheckedForProfile(profile);
|
|
114
|
+
|
|
62
115
|
// Load all records (sorted for deterministic output)
|
|
63
116
|
const files = (await readdir(recordsDir))
|
|
64
117
|
.filter(f => f.endsWith('.json'))
|
|
@@ -66,13 +119,27 @@ export async function createSnapshot(projectRoot, profile, options = {}) {
|
|
|
66
119
|
|
|
67
120
|
const included = [];
|
|
68
121
|
const excluded = [];
|
|
122
|
+
const unreadable = []; // DB-003: collect per-record parse errors, continue
|
|
69
123
|
const laneDistribution = {};
|
|
70
124
|
const factionDistribution = {};
|
|
71
125
|
const verdictDistribution = { pass: 0, fail: 0, partial: 0 };
|
|
72
126
|
|
|
73
127
|
for (const file of files) {
|
|
74
|
-
|
|
75
|
-
|
|
128
|
+
// DB-003: wrap per-record JSON.parse in try/catch so one bad file does
|
|
129
|
+
// not abort the entire snapshot. The operator sees an `unreadable` list
|
|
130
|
+
// in the manifest + a WARNING count on stderr.
|
|
131
|
+
let record;
|
|
132
|
+
try {
|
|
133
|
+
const raw = await readFile(join(recordsDir, file), 'utf-8');
|
|
134
|
+
record = JSON.parse(raw);
|
|
135
|
+
} catch (err) {
|
|
136
|
+
unreadable.push({
|
|
137
|
+
record_id: file.replace(/\.json$/, ''),
|
|
138
|
+
file,
|
|
139
|
+
error: err.message,
|
|
140
|
+
});
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
76
143
|
|
|
77
144
|
const { eligible, reasons } = evaluateEligibility(
|
|
78
145
|
record, profile, config.lanes, config.terminology
|
|
@@ -89,7 +156,15 @@ export async function createSnapshot(projectRoot, profile, options = {}) {
|
|
|
89
156
|
: 'n/a';
|
|
90
157
|
const reason = `${record.judgment.status}, canon-bound, pass_ratio=${passRatio}, lane=${lane}`;
|
|
91
158
|
|
|
92
|
-
|
|
159
|
+
// D-006: record the rule-trace + profile context so inclusion is
|
|
160
|
+
// replayable without re-running evaluateEligibility.
|
|
161
|
+
included.push({
|
|
162
|
+
record_id: record.id,
|
|
163
|
+
reason,
|
|
164
|
+
rules_checked: rulesChecked,
|
|
165
|
+
profile_id: profileId,
|
|
166
|
+
config_fingerprint: configFingerprint,
|
|
167
|
+
});
|
|
93
168
|
|
|
94
169
|
// Track distributions
|
|
95
170
|
laneDistribution[lane] = (laneDistribution[lane] || 0) + 1;
|
|
@@ -104,30 +179,46 @@ export async function createSnapshot(projectRoot, profile, options = {}) {
|
|
|
104
179
|
}
|
|
105
180
|
}
|
|
106
181
|
|
|
182
|
+
// DB-003: surface unreadable records loudly so partial parse failures
|
|
183
|
+
// are not silent. Manifest also carries the full list for drill-down.
|
|
184
|
+
if (unreadable.length > 0) {
|
|
185
|
+
const sample = unreadable.slice(0, 5).map(u => u.file).join(', ');
|
|
186
|
+
warn(
|
|
187
|
+
`snapshot: ${unreadable.length} record file(s) were unreadable and skipped` +
|
|
188
|
+
(unreadable.length > 5 ? ` (first 5: ${sample}, ...)` : ` (${sample})`),
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
107
192
|
// Write snapshot files (skip if dry run)
|
|
108
193
|
if (options.dryRun) {
|
|
109
|
-
return { snapshotId, included: included.length, excluded: excluded.length };
|
|
194
|
+
return { snapshotId, included: included.length, excluded: excluded.length, unreadable: unreadable.length };
|
|
110
195
|
}
|
|
111
196
|
await mkdir(snapshotDir, { recursive: true });
|
|
112
197
|
|
|
113
198
|
const manifest = {
|
|
199
|
+
// DB-001 / DB-006: stamp manifest schema version (reader checks on load)
|
|
200
|
+
schema_version: SCHEMA_VERSION,
|
|
114
201
|
snapshot_id: snapshotId,
|
|
115
202
|
created_at: new Date().toISOString(),
|
|
116
203
|
created_by: 'sdlab-snapshot-v1',
|
|
117
204
|
project_name: config.meta.name,
|
|
118
205
|
frozen: true,
|
|
119
206
|
selection_profile: profile,
|
|
120
|
-
config_fingerprint:
|
|
207
|
+
config_fingerprint: configFingerprint,
|
|
121
208
|
config_versions: {
|
|
122
209
|
constitution: config.constitution.version || '1.0.0',
|
|
123
210
|
project: config.meta.version || '0.0.0',
|
|
124
211
|
},
|
|
125
212
|
counts: {
|
|
126
213
|
total_records: files.length,
|
|
127
|
-
|
|
214
|
+
// DB-003: evaluated reflects records actually parsed (excludes unreadable)
|
|
215
|
+
evaluated: files.length - unreadable.length,
|
|
128
216
|
included: included.length,
|
|
129
217
|
excluded: excluded.length,
|
|
218
|
+
unreadable: unreadable.length,
|
|
130
219
|
},
|
|
220
|
+
// DB-003: preserve the unreadable list so operators can investigate
|
|
221
|
+
errors: unreadable,
|
|
131
222
|
};
|
|
132
223
|
|
|
133
224
|
const summary = {
|
|
@@ -153,7 +244,9 @@ export async function loadSnapshot(projectRoot, snapshotId) {
|
|
|
153
244
|
if (!existsSync(path)) {
|
|
154
245
|
throw new Error(`Snapshot "${snapshotId}" not found at ${path}`);
|
|
155
246
|
}
|
|
156
|
-
|
|
247
|
+
const manifest = JSON.parse(await readFile(path, 'utf-8'));
|
|
248
|
+
checkManifestVersion(manifest, 'snapshot');
|
|
249
|
+
return manifest;
|
|
157
250
|
}
|
|
158
251
|
|
|
159
252
|
/**
|