@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
package/lib/eval-pack.js
CHANGED
|
@@ -10,14 +10,13 @@
|
|
|
10
10
|
* 4. Subject continuity — same-subject records for identity testing
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { readdir, readFile, writeFile
|
|
13
|
+
import { readdir, readFile, writeFile } from 'node:fs/promises';
|
|
14
14
|
import { join } from 'node:path';
|
|
15
15
|
import { existsSync } from 'node:fs';
|
|
16
16
|
import { randomBytes } from 'node:crypto';
|
|
17
17
|
import { loadProjectConfig, detectLane, detectGroup } from './config.js';
|
|
18
18
|
import { loadRecord } from './records.js';
|
|
19
|
-
import { computeConfigFingerprint, SCHEMA_VERSION, checkManifestVersion } from './snapshot.js';
|
|
20
|
-
import { inputError } from './errors.js';
|
|
19
|
+
import { computeConfigFingerprint, SCHEMA_VERSION, checkManifestVersion, claimIdDir } from './snapshot.js';
|
|
21
20
|
|
|
22
21
|
/**
|
|
23
22
|
* Generate an eval-pack ID: eval-YYYYMMDD-HHMMSS-XXXX
|
|
@@ -73,17 +72,18 @@ export async function buildEvalPack(projectRoot, options = {}) {
|
|
|
73
72
|
const subjectContinuity = buildSubjectContinuity(records, maxContinuity);
|
|
74
73
|
|
|
75
74
|
// Write eval pack
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
// D-005
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
75
|
+
// SDL-M10: atomically claim the eval-pack id/dir — closes the
|
|
76
|
+
// check-then-act race the old existsSync+recursive-mkdir pattern had
|
|
77
|
+
// (D-005's intent, now actually race-proof; see claimIdDir in snapshot.js).
|
|
78
|
+
const { id: evalId, dir: evalDir } = await claimIdDir(
|
|
79
|
+
join(projectRoot, 'eval-packs'),
|
|
80
|
+
generateEvalId,
|
|
81
|
+
{
|
|
82
|
+
code: 'EVAL_PACK_ID_COLLISION',
|
|
83
|
+
message: 'Could not claim a unique eval-pack ID — every candidate collided with an existing eval-pack directory.',
|
|
84
|
+
hint: 'Retry; if this persists, another sdlab process may be creating eval packs in a tight loop.',
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
87
|
|
|
88
88
|
// DB-014: compute `records_available` so the manifest reports sampled vs.
|
|
89
89
|
// available. Operators can see when they're leaving records on the table.
|
package/lib/eval-runs.js
CHANGED
|
@@ -14,6 +14,7 @@ import { loadTrainingManifest } from './training-manifests.js';
|
|
|
14
14
|
import { loadEvalPack } from './eval-pack.js';
|
|
15
15
|
import { loadProjectConfig, detectLane, detectGroup } from './config.js';
|
|
16
16
|
import { SCHEMA_VERSION, checkManifestVersion } from './snapshot.js';
|
|
17
|
+
import { inputError } from './errors.js';
|
|
17
18
|
|
|
18
19
|
// DB-008: cap sample record_ids per failure bucket so scorecards stay small
|
|
19
20
|
// but the operator has enough detail to start investigation.
|
|
@@ -91,6 +92,18 @@ export async function scoreEvalRun(projectRoot, evalRunId, outputsPath) {
|
|
|
91
92
|
|
|
92
93
|
// Load eval pack tasks
|
|
93
94
|
const evalPackDir = join(projectRoot, 'eval-packs', runManifest.eval_pack_id);
|
|
95
|
+
// F2: createEvalRun only ever stores the eval_pack_id string — it never
|
|
96
|
+
// re-verifies at scoring time that the pack directory still exists. If
|
|
97
|
+
// the pack was deleted (or moved) between run creation and scoring, this
|
|
98
|
+
// used to fall straight through to the all-four-skip / vacuous-pass path
|
|
99
|
+
// below. Fail loudly here with the exact missing path instead.
|
|
100
|
+
if (!existsSync(evalPackDir)) {
|
|
101
|
+
throw inputError(
|
|
102
|
+
'EVAL_PACK_DIR_MISSING',
|
|
103
|
+
`Eval pack directory not found: ${evalPackDir} (eval run "${evalRunId}" references eval_pack_id "${runManifest.eval_pack_id}").`,
|
|
104
|
+
'The eval pack this run was created against may have been deleted or moved. Rebuild the eval pack, or create a fresh eval run against one that still exists.',
|
|
105
|
+
);
|
|
106
|
+
}
|
|
94
107
|
const taskFiles = {
|
|
95
108
|
'lane-coverage': 'lane-coverage.jsonl',
|
|
96
109
|
'forbidden-drift': 'forbidden-drift.jsonl',
|
|
@@ -102,10 +115,18 @@ export async function scoreEvalRun(projectRoot, evalRunId, outputsPath) {
|
|
|
102
115
|
|
|
103
116
|
// Score each task type
|
|
104
117
|
const scores = {};
|
|
118
|
+
// F2: count task files that actually resolved. Object.values({}).every(...)
|
|
119
|
+
// on an EMPTY scores object is vacuously true in JS — with zero task files
|
|
120
|
+
// found, `scores` stays `{}` and the verdict below silently computed
|
|
121
|
+
// overall_verdict:'pass', overall_score:0.000 for a model that was never
|
|
122
|
+
// evaluated against anything. resolvedTaskFiles turns that into a named,
|
|
123
|
+
// structured failure instead of a green rubber stamp.
|
|
124
|
+
let resolvedTaskFiles = 0;
|
|
105
125
|
|
|
106
126
|
for (const [taskName, fileName] of Object.entries(taskFiles)) {
|
|
107
127
|
const taskPath = join(evalPackDir, fileName);
|
|
108
128
|
if (!existsSync(taskPath)) continue;
|
|
129
|
+
resolvedTaskFiles++;
|
|
109
130
|
|
|
110
131
|
const taskEntries = (await readFile(taskPath, 'utf-8'))
|
|
111
132
|
.trim().split('\n').filter(Boolean).map(l => JSON.parse(l));
|
|
@@ -121,6 +142,18 @@ export async function scoreEvalRun(projectRoot, evalRunId, outputsPath) {
|
|
|
121
142
|
}
|
|
122
143
|
}
|
|
123
144
|
|
|
145
|
+
// F2: belt-and-suspenders — even with the directory-existence guard above,
|
|
146
|
+
// a pack directory that exists but has had every individual task file
|
|
147
|
+
// deleted out from under it must hit the same guard, not the vacuous
|
|
148
|
+
// Object.values({}).every(...) === true path.
|
|
149
|
+
if (resolvedTaskFiles === 0) {
|
|
150
|
+
throw inputError(
|
|
151
|
+
'EVAL_PACK_NO_TASK_FILES',
|
|
152
|
+
`Eval pack "${runManifest.eval_pack_id}" at ${evalPackDir} has no resolvable task files — nothing was scored for eval run "${evalRunId}".`,
|
|
153
|
+
`Expected at least one of: ${Object.values(taskFiles).join(', ')}. Verify the eval pack was built correctly and its directory was not partially deleted.`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
124
157
|
// Compute overall verdict
|
|
125
158
|
const allPassed = Object.values(scores).every(s => s.passed);
|
|
126
159
|
const overallScore = Object.values(scores).reduce((sum, s) => sum + (s.score || 0), 0) /
|
package/lib/export.js
CHANGED
|
@@ -11,7 +11,7 @@ import { existsSync } from 'node:fs';
|
|
|
11
11
|
import { platform } from 'node:os';
|
|
12
12
|
import { createHash, randomBytes } from 'node:crypto';
|
|
13
13
|
import { loadProjectConfig, loadExportProfile, detectLane, detectGroup } from './config.js';
|
|
14
|
-
import { loadSnapshot, loadSnapshotIncluded, computeConfigFingerprint, SCHEMA_VERSION, checkManifestVersion } from './snapshot.js';
|
|
14
|
+
import { loadSnapshot, loadSnapshotIncluded, computeConfigFingerprint, SCHEMA_VERSION, checkManifestVersion, claimIdDir } from './snapshot.js';
|
|
15
15
|
import { loadSplit, loadSplitPartition } from './split.js';
|
|
16
16
|
import { loadRecord } from './records.js';
|
|
17
17
|
import { generateCard } from './card.js';
|
|
@@ -36,6 +36,63 @@ async function sha256File(filePath) {
|
|
|
36
36
|
return createHash('sha256').update(data).digest('hex');
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* SDL-H6: validate that a record-relative asset_path does not escape
|
|
41
|
+
* projectRoot. Rejects absolute paths outright (ASSET_PATH_INVALID) and
|
|
42
|
+
* rejects any relative path that resolves outside projectRoot
|
|
43
|
+
* (ASSET_PATH_TRAVERSAL — e.g. "../../../../Users/victim/.ssh/id_rsa").
|
|
44
|
+
* Returns the resolved absolute path on success.
|
|
45
|
+
*
|
|
46
|
+
* This was previously inlined here only (D-007) and never ported to the
|
|
47
|
+
* training-package adapters, which checked only `existsSync` — a
|
|
48
|
+
* traversal path satisfies existsSync just as well as a legitimate one.
|
|
49
|
+
* lib/training-packages.js and the adapters import this so the exact same
|
|
50
|
+
* guard (same error codes) applies on every path that turns asset_path
|
|
51
|
+
* into a filesystem read.
|
|
52
|
+
*/
|
|
53
|
+
export function assertSafeAssetPath(assetPath, projectRoot, recordId) {
|
|
54
|
+
if (isAbsolute(assetPath)) {
|
|
55
|
+
throw inputError(
|
|
56
|
+
'ASSET_PATH_INVALID',
|
|
57
|
+
`Record "${recordId}" has absolute asset_path: ${assetPath}`,
|
|
58
|
+
'asset_path must be relative to the project root.',
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
const projectRootResolved = resolve(projectRoot);
|
|
62
|
+
const resolvedSource = resolve(projectRootResolved, assetPath);
|
|
63
|
+
if (resolvedSource !== projectRootResolved &&
|
|
64
|
+
!resolvedSource.startsWith(projectRootResolved + sep)) {
|
|
65
|
+
throw inputError(
|
|
66
|
+
'ASSET_PATH_TRAVERSAL',
|
|
67
|
+
`Record "${recordId}" asset_path escapes projectRoot: ${assetPath}`,
|
|
68
|
+
'Reject path traversal segments (e.g. "../") in asset_path.',
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return resolvedSource;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* SDL-H5: allowlist model for an id destined to become a filename or path
|
|
76
|
+
* segment (letters, digits, dot, underscore, hyphen only — no `/`, `\`,
|
|
77
|
+
* or `..`, so no traversal is expressible). Mirrors the character class
|
|
78
|
+
* lib/selections.js's SAFE_FILENAME_RE uses for approved-output filenames;
|
|
79
|
+
* that constant isn't exported and additionally requires an image
|
|
80
|
+
* extension suffix that doesn't apply to a bare record/entity id, so this
|
|
81
|
+
* is a parallel definition over the same allowlist model rather than a
|
|
82
|
+
* copy of a differently-shaped regex.
|
|
83
|
+
*/
|
|
84
|
+
export const SAFE_ID_RE = /^[A-Za-z0-9._-]+$/;
|
|
85
|
+
|
|
86
|
+
export function assertSafeId(id, context) {
|
|
87
|
+
if (typeof id !== 'string' || !SAFE_ID_RE.test(id)) {
|
|
88
|
+
throw inputError(
|
|
89
|
+
'UNSAFE_RECORD_ID',
|
|
90
|
+
`Invalid id "${id}" in ${context}`,
|
|
91
|
+
'ids must match [A-Za-z0-9._-]+ — letters, digits, dot, underscore, hyphen only.',
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
39
96
|
/**
|
|
40
97
|
* Build an export package.
|
|
41
98
|
*
|
|
@@ -55,22 +112,12 @@ export async function buildExport(projectRoot, snapshotId, splitId, options = {}
|
|
|
55
112
|
const included = await loadSnapshotIncluded(projectRoot, snapshotId);
|
|
56
113
|
const recordsDir = join(projectRoot, 'records');
|
|
57
114
|
|
|
58
|
-
const exportId = generateExportId();
|
|
59
|
-
const exportDir = join(projectRoot, 'exports', exportId);
|
|
60
|
-
|
|
61
|
-
// D-005: existsSync guard prevents silent overwrite on ID collision
|
|
62
|
-
if (existsSync(exportDir)) {
|
|
63
|
-
throw inputError(
|
|
64
|
-
'EXPORT_ID_COLLISION',
|
|
65
|
-
`Export directory already exists: ${exportDir}`,
|
|
66
|
-
'Retry to generate a fresh export ID, or delete the existing directory if this was intentional.',
|
|
67
|
-
);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
115
|
// D-001: Verify fingerprint has not drifted since snapshot creation.
|
|
71
116
|
// We inherit the snapshot's fingerprint in the export manifest; if a caller
|
|
72
117
|
// has edited project config mid-flight we surface that loudly rather than
|
|
73
118
|
// silently embedding a new fingerprint that masks reproducibility loss.
|
|
119
|
+
// (Checked BEFORE claiming an export id/dir below — no point reserving
|
|
120
|
+
// one if we're about to abort.)
|
|
74
121
|
const currentFingerprint = computeConfigFingerprint(projectRoot);
|
|
75
122
|
if (snapshot.config_fingerprint && currentFingerprint !== snapshot.config_fingerprint) {
|
|
76
123
|
throw inputError(
|
|
@@ -80,18 +127,44 @@ export async function buildExport(projectRoot, snapshotId, splitId, options = {}
|
|
|
80
127
|
);
|
|
81
128
|
}
|
|
82
129
|
|
|
83
|
-
|
|
130
|
+
// SDL-M10: atomically claim the export id/dir — closes the
|
|
131
|
+
// check-then-act race the old existsSync+recursive-mkdir pattern had
|
|
132
|
+
// (D-005's intent, now actually race-proof; see claimIdDir in snapshot.js).
|
|
133
|
+
const { id: exportId, dir: exportDir } = await claimIdDir(
|
|
134
|
+
join(projectRoot, 'exports'),
|
|
135
|
+
generateExportId,
|
|
136
|
+
{
|
|
137
|
+
code: 'EXPORT_ID_COLLISION',
|
|
138
|
+
message: 'Could not claim a unique export ID — every candidate collided with an existing export directory.',
|
|
139
|
+
hint: 'Retry; if this persists, another sdlab process may be creating exports in a tight loop.',
|
|
140
|
+
},
|
|
141
|
+
);
|
|
84
142
|
|
|
85
143
|
// Build metadata + collect image paths
|
|
86
144
|
const metadata = [];
|
|
87
145
|
const imagePaths = [];
|
|
88
146
|
const checksumEntries = [];
|
|
147
|
+
// F3: a record the (frozen, Law-1) snapshot included whose file was
|
|
148
|
+
// later deleted from records/ must never silently drop the export's
|
|
149
|
+
// record count below snapshot.counts.included. loadRecord() returns
|
|
150
|
+
// null on ENOENT (lib/records.js) — previously that null was a silent
|
|
151
|
+
// `continue` with no counter, no warning, no id recorded, so an export
|
|
152
|
+
// built from a snapshot with stale/deleted records would just quietly
|
|
153
|
+
// contain fewer rows than snapshot.counts.included promised. Accumulated
|
|
154
|
+
// across the whole loop (not fail-fast on the first) so the operator
|
|
155
|
+
// sees every missing id in one error instead of fixing them one at a
|
|
156
|
+
// time; hard-failed below rather than warned — see the comment at the
|
|
157
|
+
// throw site for why export specifically earns that escalation.
|
|
158
|
+
const missingRecords = [];
|
|
89
159
|
|
|
90
160
|
const projectRootResolved = resolve(projectRoot);
|
|
91
161
|
|
|
92
162
|
for (const entry of included) {
|
|
93
163
|
const record = await loadRecord(recordsDir, entry.record_id);
|
|
94
|
-
if (!record)
|
|
164
|
+
if (!record) {
|
|
165
|
+
missingRecords.push({ record_id: entry.record_id, stage: 'export' });
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
95
168
|
|
|
96
169
|
const prompt = record.provenance?.prompt || '';
|
|
97
170
|
const lane = detectLane(record.id, prompt, config.lanes);
|
|
@@ -115,28 +188,20 @@ export async function buildExport(projectRoot, snapshotId, splitId, options = {}
|
|
|
115
188
|
|
|
116
189
|
// Track image for linking
|
|
117
190
|
if (profile.include_images && record.asset_path) {
|
|
118
|
-
// D-007: validate asset_path does not escape projectRoot
|
|
119
|
-
|
|
120
|
-
// (D-009) to avoid collisions on shared basenames.
|
|
121
|
-
if (isAbsolute(record.asset_path)) {
|
|
122
|
-
throw inputError(
|
|
123
|
-
'ASSET_PATH_INVALID',
|
|
124
|
-
`Record "${record.id}" has absolute asset_path: ${record.asset_path}`,
|
|
125
|
-
'asset_path must be relative to the project root.',
|
|
126
|
-
);
|
|
127
|
-
}
|
|
128
|
-
const resolvedSource = resolve(projectRootResolved, record.asset_path);
|
|
129
|
-
if (resolvedSource !== projectRootResolved &&
|
|
130
|
-
!resolvedSource.startsWith(projectRootResolved + sep)) {
|
|
131
|
-
throw inputError(
|
|
132
|
-
'ASSET_PATH_TRAVERSAL',
|
|
133
|
-
`Record "${record.id}" asset_path escapes projectRoot: ${record.asset_path}`,
|
|
134
|
-
'Reject path traversal segments (e.g. "../") in asset_path.',
|
|
135
|
-
);
|
|
136
|
-
}
|
|
191
|
+
// D-007 / SDL-H6: validate asset_path does not escape projectRoot.
|
|
192
|
+
const resolvedSource = assertSafeAssetPath(record.asset_path, projectRootResolved, record.id);
|
|
137
193
|
const sourceBasename = basename(record.asset_path);
|
|
138
194
|
const extMatch = sourceBasename.match(/\.[^.]+$/);
|
|
139
195
|
const ext = extMatch ? extMatch[0] : '';
|
|
196
|
+
// SDL-H5: the block above only validated the SOURCE asset_path —
|
|
197
|
+
// record.id becomes part of the DESTINATION filename below and was
|
|
198
|
+
// completely unvalidated despite the variable being named
|
|
199
|
+
// "safeFilename". A record id of "../../../evil" would escape the
|
|
200
|
+
// export dir on write. Reachable without hand-authoring: reingest
|
|
201
|
+
// derives ids from generated-output filenames.
|
|
202
|
+
assertSafeId(record.id, `record.id for export image filename (record "${record.id}")`);
|
|
203
|
+
// D-009: name output files by record.id to avoid collisions on
|
|
204
|
+
// shared basenames.
|
|
140
205
|
const safeFilename = `${record.id}${ext}`;
|
|
141
206
|
imagePaths.push({
|
|
142
207
|
record_id: record.id,
|
|
@@ -146,6 +211,29 @@ export async function buildExport(projectRoot, snapshotId, splitId, options = {}
|
|
|
146
211
|
}
|
|
147
212
|
}
|
|
148
213
|
|
|
214
|
+
// F3: hard-fail on any record the snapshot promised but records/ no
|
|
215
|
+
// longer has. Nothing has been written to exportDir yet (metadata.jsonl,
|
|
216
|
+
// images/, manifest.json, etc. are all written below this point) — an
|
|
217
|
+
// aborted export leaves only the empty claimed directory behind, no
|
|
218
|
+
// partial/misleading artifacts. Export (and training-packages.js one
|
|
219
|
+
// layer further downstream) is where Law 4 ("exports are reproducible —
|
|
220
|
+
// rebuildable from snapshot + split + profile") makes completeness an
|
|
221
|
+
// actual contract rather than a courtesy, which is why this escalates to
|
|
222
|
+
// a thrown error instead of the warn()-and-continue ledger pattern used
|
|
223
|
+
// in split.js and implementation-packs.js, where the artifact produced
|
|
224
|
+
// is not itself the reproducibility-guaranteeing one.
|
|
225
|
+
if (missingRecords.length > 0) {
|
|
226
|
+
const sample = missingRecords.slice(0, 10).map(m => m.record_id);
|
|
227
|
+
throw inputError(
|
|
228
|
+
'EXPORT_MISSING_RECORDS',
|
|
229
|
+
`${missingRecords.length} of ${included.length} record(s) referenced by snapshot "${snapshotId}" could not be loaded from ${recordsDir} — this export would silently contain fewer records than the snapshot's included count.` +
|
|
230
|
+
(missingRecords.length > 10
|
|
231
|
+
? ` First 10: ${sample.join(', ')}, ...`
|
|
232
|
+
: ` Missing: ${sample.join(', ')}.`),
|
|
233
|
+
'Record file(s) were likely deleted after the snapshot was created (a snapshot freezes the SELECTION, not the underlying record files). Restore the missing file(s), or create a fresh snapshot that reflects current on-disk records before exporting.',
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
149
237
|
// Write metadata.jsonl
|
|
150
238
|
const metadataPath = join(exportDir, 'metadata.jsonl');
|
|
151
239
|
await writeFile(metadataPath, metadata.map(r => JSON.stringify(r)).join('\n') + '\n');
|
package/lib/freeze-gate.js
CHANGED
|
@@ -24,8 +24,43 @@ import { existsSync } from 'node:fs';
|
|
|
24
24
|
import { loadBuildConfig } from './canon-build/load-config.js';
|
|
25
25
|
import { loadCanonEntriesInDir, entryId } from './canon-build/load-entry.js';
|
|
26
26
|
import { readFreezeStatus } from './freeze-stamp.js';
|
|
27
|
-
import { appendEvent } from './freeze-events.js';
|
|
27
|
+
import { appendEvent, readEventsFor } from './freeze-events.js';
|
|
28
28
|
import { inputError } from './errors.js';
|
|
29
|
+
import { warn } from './log.js';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Best-effort freeze date for an entry: the `at` of its most recent freeze /
|
|
33
|
+
* override event. Returns null when the log is absent/unreadable (the freeze
|
|
34
|
+
* block itself carries no timestamp). Used only to enrich the contrastive
|
|
35
|
+
* refuse message — never load-bearing for the gate decision.
|
|
36
|
+
*/
|
|
37
|
+
async function latestFreezeAt(projectRoot, id) {
|
|
38
|
+
try {
|
|
39
|
+
const events = await readEventsFor(projectRoot, id);
|
|
40
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
41
|
+
if (events[i].type === 'freeze' || events[i].type === 'override') return events[i].at;
|
|
42
|
+
}
|
|
43
|
+
} catch { /* no log / unreadable → no date */ }
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Contrastive lead (Horvitz CHI 1999): state the DEFAULT action and the system's
|
|
49
|
+
* reasoning — who froze it, when, and why — before the override path. This is the
|
|
50
|
+
* "you probably expected to just regenerate; I'm refusing, here's why" framing.
|
|
51
|
+
*/
|
|
52
|
+
function contrastiveLead(id, status, freeze, frozenAt) {
|
|
53
|
+
const by = freeze?.frozen_by || 'unknown';
|
|
54
|
+
const reason = freeze?.frozen_reason || '(no reason recorded)';
|
|
55
|
+
const when = frozenAt ? ` on ${String(frozenAt).slice(0, 10)}` : '';
|
|
56
|
+
return `Default: REFUSE — "${id}" is ${status} frozen by ${by}${when}, reason: "${reason}".`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Render the watched-field list for the contrastive override condition. */
|
|
60
|
+
function watchList(freeze) {
|
|
61
|
+
const w = freeze?.watch_fields || [];
|
|
62
|
+
return w.length ? `[${w.join(', ')}]` : '[no watched fields]';
|
|
63
|
+
}
|
|
29
64
|
|
|
30
65
|
/**
|
|
31
66
|
* Load every canon entry in a project, keyed by schema.
|
|
@@ -40,9 +75,18 @@ export async function loadAllCanonEntries(projectRoot) {
|
|
|
40
75
|
let config;
|
|
41
76
|
try {
|
|
42
77
|
config = await loadBuildConfig(configPath, projectRoot);
|
|
43
|
-
} catch {
|
|
44
|
-
// Invalid config → degrade to "no gate" rather than blocking every write
|
|
45
|
-
//
|
|
78
|
+
} catch (err) {
|
|
79
|
+
// Invalid config → degrade to "no gate" rather than blocking every write
|
|
80
|
+
// (a config typo shouldn't turn into "no one can generate/curate/paint
|
|
81
|
+
// anything in this project"). But that degrade-open MUST be audible —
|
|
82
|
+
// silently disabling freeze protection project-wide with zero operator
|
|
83
|
+
// signal is its own defect. Warn loudly to stderr; the operator will
|
|
84
|
+
// also hit the same config error surfaced properly on `sdlab canon build`.
|
|
85
|
+
warn(
|
|
86
|
+
`freeze gate disabled for this project: canon-build/config.json is invalid ` +
|
|
87
|
+
`(${err.message}). Every entry is being treated as not canon-bound — freeze/soft-advisory ` +
|
|
88
|
+
`protection is OFF until the config is fixed. Run "sdlab canon build" to see the full error.`,
|
|
89
|
+
);
|
|
46
90
|
return [];
|
|
47
91
|
}
|
|
48
92
|
const entries = [];
|
|
@@ -109,27 +153,36 @@ export async function assertNotFrozen({
|
|
|
109
153
|
const status = readFreezeStatus(fm);
|
|
110
154
|
const id = entryId(resolved.entry);
|
|
111
155
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
);
|
|
118
|
-
|
|
156
|
+
// Contrastive refuse messaging (Horvitz CHI 1999): lead with the default
|
|
157
|
+
// action + the system's reasoning (who/when/why) pulled from the freeze
|
|
158
|
+
// stamp, then state the override path and its condition (don't touch the
|
|
159
|
+
// watched fields). frozenAt is best-effort from the event log.
|
|
160
|
+
if (status === 'frozen' || status === 'soft-advisory') {
|
|
161
|
+
const frozenAt = await latestFreezeAt(projectRoot, id);
|
|
162
|
+
const lead = contrastiveLead(id, status, fm.freeze, frozenAt);
|
|
163
|
+
const watched = watchList(fm.freeze);
|
|
164
|
+
|
|
165
|
+
if (status === 'frozen') {
|
|
166
|
+
throw inputError(
|
|
167
|
+
'CANON_ENTRY_FROZEN',
|
|
168
|
+
`${lead} A hard freeze has no inline bypass, so "${action}" will not proceed.`,
|
|
169
|
+
`Unfreeze with: sdlab canon unfreeze ${id} --reason "<why>" (appended to canon-build/freeze-events.jsonl as an audit record). This freeze watches ${watched}.`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
119
172
|
|
|
120
|
-
|
|
173
|
+
// soft-advisory
|
|
121
174
|
if (!allowSoftAdvisoryBypass) {
|
|
122
175
|
throw inputError(
|
|
123
176
|
'CANON_ENTRY_FROZEN',
|
|
124
|
-
|
|
125
|
-
`
|
|
177
|
+
`${lead} "${action}" has no --i-know bypass.`,
|
|
178
|
+
`Run: sdlab canon unfreeze ${id} --reason "<why>" for a proper unfreeze, or pick a command that accepts --i-know. Watched fields: ${watched}.`,
|
|
126
179
|
);
|
|
127
180
|
}
|
|
128
181
|
if (!bypassReason) {
|
|
129
182
|
throw inputError(
|
|
130
183
|
'CANON_ENTRY_FROZEN',
|
|
131
|
-
|
|
132
|
-
`Re-run with
|
|
184
|
+
`${lead} Override requires --i-know AND --reason.`,
|
|
185
|
+
`Re-run with --i-know --reason "<why>" ONLY if your change does not touch the watched fields ${watched}. The bypass is logged to canon-build/freeze-events.jsonl.`,
|
|
133
186
|
);
|
|
134
187
|
}
|
|
135
188
|
// Bypass allowed — log and proceed.
|
package/lib/freeze-stamp.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { createHash } from 'node:crypto';
|
|
12
12
|
import { getByPath } from './canon-build/load-config.js';
|
|
13
|
+
import { inputError } from './errors.js';
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* The four legal freeze statuses. Entries without a `freeze` block behave
|
|
@@ -19,13 +20,37 @@ export const FREEZE_STATUSES = Object.freeze(['auto', 'frozen', 'soft-advisory',
|
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* Read the normalized freeze status for an entry frontmatter.
|
|
22
|
-
*
|
|
23
|
+
*
|
|
24
|
+
* A missing `freeze` block, or a `freeze` block with no `status` key at all,
|
|
25
|
+
* collapses to `auto` — that permissive default is deliberate (D8): an entry
|
|
26
|
+
* that has never opted into freeze protection should never block a write.
|
|
27
|
+
*
|
|
28
|
+
* But a `freeze` block that IS present with a `status` value that is a
|
|
29
|
+
* non-empty string outside `FREEZE_STATUSES` is not "absent" — it is
|
|
30
|
+
* malformed. Coercing that case to `auto` too would silently disable hard
|
|
31
|
+
* freeze protection on a hand-edited typo (e.g. `status: Frozen` against the
|
|
32
|
+
* lowercase-only enum) with no error, no warning, and no bypass event, since
|
|
33
|
+
* the bypass path is never entered. Fail loud instead: throw so the operator
|
|
34
|
+
* fixes the typo rather than unknowingly shipping an unprotected entry.
|
|
35
|
+
*
|
|
36
|
+
* @throws {SdlabError} CANON_FREEZE_STATUS_INVALID when `freeze` is present
|
|
37
|
+
* and `status` is a non-empty string that isn't a legal FREEZE_STATUSES value.
|
|
23
38
|
*/
|
|
24
39
|
export function readFreezeStatus(frontmatter) {
|
|
25
40
|
const status = frontmatter?.freeze?.status;
|
|
26
41
|
if (typeof status !== 'string') return 'auto';
|
|
27
|
-
if (
|
|
28
|
-
|
|
42
|
+
if (FREEZE_STATUSES.includes(status)) return status;
|
|
43
|
+
if (status.length > 0) {
|
|
44
|
+
throw inputError(
|
|
45
|
+
'CANON_FREEZE_STATUS_INVALID',
|
|
46
|
+
`Invalid freeze.status "${status}"${frontmatter?.id ? ` on entry "${frontmatter.id}"` : ''}: ` +
|
|
47
|
+
`not one of [${FREEZE_STATUSES.join(', ')}].`,
|
|
48
|
+
`Fix the freeze.status value (check for a case typo — statuses are lowercase-only). ` +
|
|
49
|
+
`A malformed status is treated as an error, not as "auto", so a typo can never ` +
|
|
50
|
+
`silently disable freeze protection.`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
return 'auto';
|
|
29
54
|
}
|
|
30
55
|
|
|
31
56
|
/**
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { readFileSync, existsSync } from 'node:fs';
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
import { createHash } from 'node:crypto';
|
|
12
|
+
import { warn } from './log.js';
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Build a generation provenance payload for a selected output.
|
|
@@ -68,7 +69,21 @@ export function buildGeneratedProvenance({
|
|
|
68
69
|
const brief = JSON.parse(readFileSync(briefPath, 'utf-8'));
|
|
69
70
|
if (brief.subject_id) prov.subject_id = brief.subject_id;
|
|
70
71
|
if (brief.parent_brief_id) prov.parent_brief_id = brief.parent_brief_id;
|
|
71
|
-
} catch {
|
|
72
|
+
} catch (err) {
|
|
73
|
+
// B08: the brief exists but couldn't be read/parsed (e.g. a
|
|
74
|
+
// concurrent mid-write) — lineage is genuinely UNKNOWN here, not
|
|
75
|
+
// merely absent. Surface it instead of silently shipping
|
|
76
|
+
// provenance with no lineage and no signal that anything failed.
|
|
77
|
+
warn(`generated-provenance: brief ${briefPath} could not be read (${err.message}) — lineage_incomplete`);
|
|
78
|
+
prov.lineage_incomplete = true;
|
|
79
|
+
}
|
|
80
|
+
} else {
|
|
81
|
+
// B08: no brief on disk at all for this run — the same "genuinely
|
|
82
|
+
// unknown" lineage state as an unreadable brief, not a normal
|
|
83
|
+
// "this brief simply has no subject_id" case (that one is NOT
|
|
84
|
+
// flagged — see the brief-read-fine-but-no-subject_id branch above).
|
|
85
|
+
warn(`generated-provenance: brief not found at ${briefPath} — lineage_incomplete`);
|
|
86
|
+
prov.lineage_incomplete = true;
|
|
72
87
|
}
|
|
73
88
|
}
|
|
74
89
|
|