@mcptoolshop/style-dataset-lab 3.3.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.es.md +117 -55
  3. package/README.fr.md +125 -65
  4. package/README.hi.md +117 -68
  5. package/README.it.md +119 -58
  6. package/README.ja.md +124 -74
  7. package/README.md +73 -7
  8. package/README.pt-BR.md +128 -63
  9. package/README.zh.md +116 -65
  10. package/bin/sdlab.js +100 -4
  11. package/lib/adapters/ai-toolkit.js +224 -35
  12. package/lib/adapters/comfyui-runner.js +79 -26
  13. package/lib/adapters/comfyui-workflows.js +94 -11
  14. package/lib/adapters/diffusers-lora.js +37 -5
  15. package/lib/adapters/generic-image-caption.js +43 -5
  16. package/lib/args.js +16 -1
  17. package/lib/asset-ingest.js +575 -0
  18. package/lib/asset-masks.js +323 -0
  19. package/lib/asset-source.js +1176 -0
  20. package/lib/batch-modes.js +29 -8
  21. package/lib/batch-runs.js +29 -1
  22. package/lib/batch-sheet-render.js +13 -3
  23. package/lib/brief-compiler.js +19 -0
  24. package/lib/candidate-sheet.js +327 -0
  25. package/lib/canon-author.js +350 -0
  26. package/lib/canon-build/build.js +146 -93
  27. package/lib/canon-review-gate.js +205 -0
  28. package/lib/captions.js +57 -0
  29. package/lib/card.js +74 -2
  30. package/lib/colab-launcher-guards.js +285 -0
  31. package/lib/comfyui.js +116 -18
  32. package/lib/config.js +116 -12
  33. package/lib/critique-engine.js +36 -2
  34. package/lib/eligibility.js +121 -9
  35. package/lib/eval-pack.js +14 -14
  36. package/lib/eval-runs.js +33 -0
  37. package/lib/export.js +122 -34
  38. package/lib/freeze-gate.js +16 -3
  39. package/lib/freeze-stamp.js +28 -3
  40. package/lib/generated-provenance.js +194 -6
  41. package/lib/implementation-packs.js +95 -17
  42. package/lib/ingest.js +396 -0
  43. package/lib/judgment-provenance.js +220 -0
  44. package/lib/lane1-render.js +668 -0
  45. package/lib/measure.js +422 -0
  46. package/lib/memory-store.js +663 -0
  47. package/lib/model-licences.js +305 -0
  48. package/lib/npy-meta.js +130 -0
  49. package/lib/paths.js +196 -7
  50. package/lib/png-meta.js +512 -0
  51. package/lib/records.js +54 -8
  52. package/lib/reingest-selected.js +113 -17
  53. package/lib/reingest.js +27 -3
  54. package/lib/run-manifest.js +159 -25
  55. package/lib/run-summary.js +12 -6
  56. package/lib/runtime-runs.js +45 -4
  57. package/lib/snapshot.js +114 -15
  58. package/lib/split.js +364 -48
  59. package/lib/train-runs.js +214 -0
  60. package/lib/training-manifests.js +114 -21
  61. package/lib/training-packages.js +147 -15
  62. package/lib/verifier.js +3 -0
  63. package/lib/verify-chain.js +367 -0
  64. package/lib/workflow-profiles.js +24 -3
  65. package/package.json +4 -3
  66. package/runtime/comfyui/txt2img-qwen.json +38 -0
  67. package/runtime/comfyui/txt2img-standard.json +21 -0
  68. package/scripts/_resume.js +62 -0
  69. package/scripts/_shared-args.js +44 -0
  70. package/scripts/asset-ingest.js +160 -0
  71. package/scripts/brief-compile.js +2 -1
  72. package/scripts/brief-show.js +2 -1
  73. package/scripts/canon-bind.js +2 -2
  74. package/scripts/canon-build.js +27 -20
  75. package/scripts/canon-draft.js +82 -0
  76. package/scripts/canon-drift.js +16 -7
  77. package/scripts/canon-freeze-status.js +14 -6
  78. package/scripts/canon-freeze.js +31 -11
  79. package/scripts/canon-unfreeze.js +17 -9
  80. package/scripts/card.js +2 -2
  81. package/scripts/colab/REAL_SERVICE_ASSUMPTIONS.md +14 -0
  82. package/scripts/colab/ai-toolkit-override-py315.txt +45 -0
  83. package/scripts/colab/colab_window.sh +587 -0
  84. package/scripts/colab/lane1_render.py +970 -0
  85. package/scripts/colab/launch_guards.py +564 -0
  86. package/scripts/colab/launch_lane1_stub.sh +309 -0
  87. package/scripts/colab/launch_lane3_stub.sh +366 -0
  88. package/scripts/colab/remote_runner.py +59 -0
  89. package/scripts/compare.js +2 -2
  90. package/scripts/critique.js +16 -1
  91. package/scripts/curate.js +2 -2
  92. package/scripts/doctor.js +182 -55
  93. package/scripts/eligibility.js +42 -2
  94. package/scripts/eval-pack.js +2 -2
  95. package/scripts/eval-run.js +2 -2
  96. package/scripts/export.js +2 -2
  97. package/scripts/generate-controlnet.js +45 -25
  98. package/scripts/generate-identity.js +53 -23
  99. package/scripts/generate-ipadapter.js +44 -23
  100. package/scripts/generate.js +86 -17
  101. package/scripts/implementation-pack.js +2 -2
  102. package/scripts/ingest.js +145 -0
  103. package/scripts/init.js +73 -13
  104. package/scripts/measure.js +170 -0
  105. package/scripts/measure_image.py +458 -0
  106. package/scripts/migrate-records.js +142 -0
  107. package/scripts/painterly-test.js +4 -3
  108. package/scripts/painterly.js +7 -3
  109. package/scripts/qwen_generate.py +138 -20
  110. package/scripts/reingest.js +2 -2
  111. package/scripts/sheet.js +142 -0
  112. package/scripts/snapshot.js +2 -2
  113. package/scripts/split.js +2 -2
  114. package/scripts/training-manifest.js +2 -2
  115. package/scripts/training-package.js +2 -2
  116. package/scripts/training-profile.js +2 -2
  117. package/scripts/verify.js +67 -0
  118. package/scripts/workflow-list.js +2 -1
  119. package/scripts/workflow-show.js +2 -1
@@ -0,0 +1,668 @@
1
+ /**
2
+ * Lane 1 (render) kit — VM bootstrap guards, weight manifest staging by sha256
3
+ * + cleared SPDX licence, Qwen LoRA-aware job compiler, durable queue with
4
+ * idempotent resume after kill, periodic copy-back, and PNG tEXt provenance
5
+ * stamping + immediate read-back verification (DESIGN-v3 §3.1, §3.5, §4.4, §7,
6
+ * G1–G3, G8).
7
+ */
8
+
9
+ import { createHash } from 'node:crypto';
10
+ import {
11
+ existsSync,
12
+ mkdirSync,
13
+ readFileSync,
14
+ writeFileSync,
15
+ appendFileSync,
16
+ copyFileSync,
17
+ } from 'node:fs';
18
+ import { dirname, join } from 'node:path';
19
+ import { inputError } from './errors.js';
20
+ import { comfyWorkflowSha } from './run-manifest.js';
21
+ import {
22
+ QWEN_DEFAULTS,
23
+ buildQwenGraph,
24
+ buildStubFixtureGraph,
25
+ } from './adapters/comfyui-workflows.js';
26
+ import { stampPngProvenance, verifyPngProvenance } from './png-meta.js';
27
+
28
+ export const LANE1_PYTHON_VERSION = '3.15.1';
29
+ export const LANE1_UV_PIN = 'uv==0.13.5';
30
+ export const STUDIO_OWNER = 'mcp-tool-shop';
31
+ export const STUDIO_LICENCE = 'CC-BY-4.0';
32
+
33
+ export const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
34
+
35
+ export const CLEARED_LANE1_LICENCES = new Set([
36
+ 'Apache-2.0',
37
+ 'MIT',
38
+ 'BSD-2-Clause',
39
+ 'BSD-3-Clause',
40
+ 'CC0-1.0',
41
+ 'CC-BY-4.0',
42
+ ]);
43
+
44
+ function normalizeSha256(val) {
45
+ if (typeof val !== 'string') return null;
46
+ const trimmed = val.trim().toLowerCase();
47
+ const hex = trimmed.startsWith('sha256:') ? trimmed.slice(7) : trimmed;
48
+ if (!/^[0-9a-f]{64}$/.test(hex)) return null;
49
+ return `sha256:${hex}`;
50
+ }
51
+
52
+ function sha256Buffer(buf) {
53
+ return 'sha256:' + createHash('sha256').update(buf).digest('hex');
54
+ }
55
+
56
+ /**
57
+ * Enforce that ComfyUI is bound to loopback only (§3.1, §6).
58
+ *
59
+ * @param {string} comfyUrl
60
+ * @returns {URL}
61
+ */
62
+ export function assertLoopbackComfyUrl(comfyUrl) {
63
+ let parsed;
64
+ try {
65
+ parsed = new URL(comfyUrl);
66
+ } catch {
67
+ throw inputError(
68
+ 'LANE1_NON_LOOPBACK_BIND',
69
+ `Invalid ComfyUI URL "${comfyUrl}". Must be http://127.0.0.1:<port>.`
70
+ );
71
+ }
72
+ const host = parsed.hostname.toLowerCase();
73
+ if (!LOOPBACK_HOSTS.has(host)) {
74
+ throw inputError(
75
+ 'LANE1_NON_LOOPBACK_BIND',
76
+ `ComfyUI URL "${comfyUrl}" binds to non-loopback host "${host}". Colab terms require loopback-only (127.0.0.1).`
77
+ );
78
+ }
79
+ return parsed;
80
+ }
81
+
82
+ /**
83
+ * Inspect listening sockets and verify that the ComfyUI port (and any listed
84
+ * service port) is bound strictly to loopback (127.0.0.1 / ::1).
85
+ *
86
+ * @param {Array<string|{address: string, port?: number}>} listeningSockets
87
+ * @param {number} [comfyPort=8188]
88
+ * @returns {{ ok: true, sockets: string[] }}
89
+ */
90
+ export function verifyLoopbackSockets(listeningSockets = [], comfyPort = 8188) {
91
+ if (!Array.isArray(listeningSockets) || listeningSockets.length === 0) {
92
+ throw inputError(
93
+ 'LANE1_NON_LOOPBACK_SOCKET',
94
+ 'Listening socket list is empty; window.json must record the verified loopback socket list.'
95
+ );
96
+ }
97
+
98
+ const normalized = [];
99
+ let sawComfyPortLoopback = false;
100
+
101
+ for (const item of listeningSockets) {
102
+ const raw =
103
+ typeof item === 'string'
104
+ ? item.trim()
105
+ : `${item?.address || ''}${item?.port ? `:${item.port}` : ''}`;
106
+ normalized.push(raw);
107
+
108
+ // Match host:port at end of string
109
+ const match = /^(.*):(\d+)$/.exec(raw);
110
+ if (!match) continue;
111
+ const hostPart = match[1].trim().toLowerCase();
112
+ const portNum = Number(match[2]);
113
+
114
+ const isWildcard =
115
+ hostPart === '0.0.0.0' ||
116
+ hostPart === '*' ||
117
+ hostPart === '::' ||
118
+ hostPart === '[::]' ||
119
+ hostPart === '[::0]';
120
+ const isLoopback = LOOPBACK_HOSTS.has(hostPart) || hostPart.startsWith('127.');
121
+
122
+ if (portNum === Number(comfyPort)) {
123
+ if (isWildcard || !isLoopback) {
124
+ throw inputError(
125
+ 'LANE1_NON_LOOPBACK_SOCKET',
126
+ `ComfyUI port ${comfyPort} is listening on non-loopback address "${raw}".`
127
+ );
128
+ }
129
+ sawComfyPortLoopback = true;
130
+ } else if (isWildcard) {
131
+ throw inputError(
132
+ 'LANE1_NON_LOOPBACK_SOCKET',
133
+ `Wildcard listening socket "${raw}" detected on VM.`
134
+ );
135
+ }
136
+ }
137
+
138
+ if (!sawComfyPortLoopback) {
139
+ throw inputError(
140
+ 'LANE1_NON_LOOPBACK_SOCKET',
141
+ `ComfyUI loopback socket 127.0.0.1:${comfyPort} was not found in listening_sockets.`
142
+ );
143
+ }
144
+
145
+ return { ok: true, sockets: normalized };
146
+ }
147
+
148
+ /**
149
+ * Stage and verify model weights by sha256 and cleared SPDX licence from a
150
+ * manifest (§3.1, §5.6, G1, G4).
151
+ *
152
+ * @param {Object} opts
153
+ * @param {Array<Object>} opts.manifest
154
+ * @param {string} [opts.stagingDir]
155
+ * @param {boolean} [opts.verifyFiles=true]
156
+ * @returns {Array<Object>}
157
+ */
158
+ export function stageWeightsFromManifest({ manifest, stagingDir, verifyFiles = true }) {
159
+ if (!Array.isArray(manifest) || manifest.length === 0) {
160
+ throw inputError('LANE1_WEIGHT_MANIFEST_EMPTY', 'Weight manifest must be a non-empty array.');
161
+ }
162
+
163
+ const results = [];
164
+ for (const entry of manifest) {
165
+ const name = entry?.name;
166
+ if (typeof name !== 'string' || name.trim().length === 0) {
167
+ throw inputError('LANE1_WEIGHT_SHA_MISSING', 'Weight entry is missing "name".');
168
+ }
169
+ const expectedSha = normalizeSha256(entry.sha256);
170
+ if (!expectedSha) {
171
+ throw inputError(
172
+ 'LANE1_WEIGHT_SHA_MISSING',
173
+ `Weight "${name}" is missing a valid 64-hex sha256 digest.`
174
+ );
175
+ }
176
+ const licenceId = entry.licence_id;
177
+ if (
178
+ typeof licenceId !== 'string' ||
179
+ !CLEARED_LANE1_LICENCES.has(licenceId) ||
180
+ typeof entry.licence_url !== 'string' ||
181
+ !entry.licence_url.trim() ||
182
+ typeof entry.date_read !== 'string' ||
183
+ !entry.date_read.trim()
184
+ ) {
185
+ throw inputError(
186
+ 'LANE1_WEIGHT_LICENCE_INVALID',
187
+ `Weight "${name}" must carry a cleared SPDX licence_id (${[...CLEARED_LANE1_LICENCES].join(', ')}), licence_url, and date_read (got "${licenceId}").`
188
+ );
189
+ }
190
+
191
+ let verified = false;
192
+ let resolvedPath = entry.local_path || (stagingDir ? join(stagingDir, name) : null);
193
+ if (verifyFiles && resolvedPath) {
194
+ if (!existsSync(resolvedPath)) {
195
+ throw inputError(
196
+ 'LANE1_WEIGHT_FILE_MISSING',
197
+ `Weight "${name}" not found at "${resolvedPath}".`
198
+ );
199
+ }
200
+ const actualSha = sha256Buffer(readFileSync(resolvedPath));
201
+ if (actualSha !== expectedSha) {
202
+ throw inputError(
203
+ 'LANE1_WEIGHT_SHA_MISMATCH',
204
+ `Weight "${name}" sha256 mismatch: got ${actualSha}, expected ${expectedSha}.`
205
+ );
206
+ }
207
+ verified = true;
208
+ }
209
+
210
+ results.push({
211
+ role: entry.role || 'model',
212
+ name,
213
+ repo: entry.repo || null,
214
+ revision: entry.revision || null,
215
+ sha256: expectedSha,
216
+ licence_id: licenceId,
217
+ licence_url: entry.licence_url,
218
+ date_read: entry.date_read,
219
+ local_path: resolvedPath,
220
+ verified,
221
+ });
222
+ }
223
+ return results;
224
+ }
225
+
226
+ /**
227
+ * Build and validate the §7 `window.json` record for a Lane 1 (or Lane 3) window.
228
+ *
229
+ * @param {Object} opts
230
+ * @returns {Object}
231
+ */
232
+ export function buildWindowRecord(opts = {}) {
233
+ const {
234
+ window_id,
235
+ lane = 'render',
236
+ card = 'CPU',
237
+ vm_gate = {},
238
+ system_python = '3.13.16',
239
+ python = LANE1_PYTHON_VERSION,
240
+ python_sha256,
241
+ uv = 'uv 0.13.5',
242
+ comfyui_commit = null,
243
+ comfy_headless_version = '3.1.0',
244
+ comfy_bind = '127.0.0.1:8188',
245
+ listening_sockets = ['127.0.0.1:8188'],
246
+ override_sha256 = null,
247
+ cap = 0.05,
248
+ wall_s = 3000,
249
+ margin = 0.01,
250
+ start_balance = null,
251
+ end_balance = null,
252
+ rate_measured = null,
253
+ opened_at = new Date().toISOString(),
254
+ closed_at = null,
255
+ allowNonPinnedPython = false,
256
+ } = opts;
257
+
258
+ if (!window_id || typeof window_id !== 'string') {
259
+ throw inputError('LANE1_WINDOW_INVALID', 'window.json requires a non-empty "window_id".');
260
+ }
261
+ if (!allowNonPinnedPython && python !== LANE1_PYTHON_VERSION) {
262
+ throw inputError(
263
+ 'LANE1_PYTHON_VERSION_MISMATCH',
264
+ `window.json venv python must be "${LANE1_PYTHON_VERSION}", got "${python}".`
265
+ );
266
+ }
267
+ assertLoopbackComfyUrl(comfy_bind.startsWith('http') ? comfy_bind : `http://${comfy_bind}`);
268
+ const portMatch = /:(\d+)$/.exec(comfy_bind);
269
+ const comfyPort = portMatch ? Number(portMatch[1]) : 8188;
270
+ const checkedSockets = verifyLoopbackSockets(listening_sockets, comfyPort);
271
+
272
+ return {
273
+ window_id,
274
+ lane,
275
+ card,
276
+ vm_gate,
277
+ system_python,
278
+ python,
279
+ python_sha256: normalizeSha256(python_sha256) || python_sha256 || null,
280
+ uv,
281
+ comfyui_commit,
282
+ comfy_headless_version,
283
+ comfy_bind,
284
+ listening_sockets: checkedSockets.sockets,
285
+ override_sha256: normalizeSha256(override_sha256) || override_sha256 || null,
286
+ cap,
287
+ wall_s,
288
+ margin,
289
+ start_balance,
290
+ end_balance,
291
+ rate_measured,
292
+ opened_at,
293
+ closed_at,
294
+ };
295
+ }
296
+
297
+ function validatePinComponent(comp, label) {
298
+ if (!comp || typeof comp !== 'object') {
299
+ throw inputError('LANE1_PIN_INVALID', `Missing model pin for "${label}".`);
300
+ }
301
+ const sha = normalizeSha256(comp.sha256);
302
+ if (!sha) {
303
+ throw inputError('LANE1_PIN_INVALID', `Model pin "${label}" is missing a valid sha256.`);
304
+ }
305
+ if (!comp.licence_id || !CLEARED_LANE1_LICENCES.has(comp.licence_id)) {
306
+ throw inputError(
307
+ 'LANE1_PIN_INVALID',
308
+ `Model pin "${label}" has uncleared licence_id "${comp.licence_id}".`
309
+ );
310
+ }
311
+ if (!comp.licence_url || !comp.date_read) {
312
+ throw inputError(
313
+ 'LANE1_PIN_INVALID',
314
+ `Model pin "${label}" must include licence_url and date_read.`
315
+ );
316
+ }
317
+ return {
318
+ name: comp.name,
319
+ sha256: sha,
320
+ licence_id: comp.licence_id,
321
+ licence_url: comp.licence_url,
322
+ date_read: comp.date_read,
323
+ ...(comp.weight !== undefined ? { weight: Number(comp.weight) } : {}),
324
+ };
325
+ }
326
+
327
+ /**
328
+ * Compile `jobs.jsonl` rows for a Lane 1 render window (§7).
329
+ *
330
+ * @param {Object} opts
331
+ * @returns {{ jobs: Array<Object>, jobs_sha256: string }}
332
+ */
333
+ export function compileLane1Jobs({
334
+ runId,
335
+ canonSha,
336
+ entries,
337
+ modelPins,
338
+ mode = 'qwen',
339
+ width = QWEN_DEFAULTS.width,
340
+ height = QWEN_DEFAULTS.height,
341
+ steps = QWEN_DEFAULTS.steps,
342
+ cfg = QWEN_DEFAULTS.cfg,
343
+ sampler = QWEN_DEFAULTS.sampler,
344
+ scheduler = QWEN_DEFAULTS.scheduler,
345
+ shift = QWEN_DEFAULTS.shift,
346
+ loras = [],
347
+ }) {
348
+ if (!runId || typeof runId !== 'string') {
349
+ throw inputError('LANE1_JOBS_INVALID', 'compileLane1Jobs requires a non-empty runId.');
350
+ }
351
+ const normCanonSha = normalizeSha256(canonSha);
352
+ if (!normCanonSha) {
353
+ throw inputError('LANE1_JOBS_INVALID', 'compileLane1Jobs requires a 64-hex canonSha.');
354
+ }
355
+ if (!Array.isArray(entries) || entries.length === 0) {
356
+ throw inputError('LANE1_JOBS_INVALID', 'compileLane1Jobs requires a non-empty entries array.');
357
+ }
358
+
359
+ const normalizedPins = {
360
+ base: validatePinComponent(modelPins?.base, 'base'),
361
+ text_encoder: validatePinComponent(modelPins?.text_encoder, 'text_encoder'),
362
+ vae: validatePinComponent(modelPins?.vae, 'vae'),
363
+ loras: (modelPins?.loras || loras || []).map((l, i) => validatePinComponent(l, `loras[${i}]`)),
364
+ };
365
+
366
+ const jobs = entries.map((entry, idx) => {
367
+ const jobId = entry.job_id || `job_${String(idx + 1).padStart(3, '0')}`;
368
+ const entryId = entry.entry_id;
369
+ const entrySha = normalizeSha256(entry.entry_sha256);
370
+ if (!entryId || !entrySha) {
371
+ throw inputError(
372
+ 'LANE1_JOBS_INVALID',
373
+ `Entry at index ${idx} is missing entry_id or valid entry_sha256.`
374
+ );
375
+ }
376
+ const seed = Number.isFinite(entry.seed) ? entry.seed : idx + 1;
377
+ const prompt = entry.prompt || '';
378
+ const negative = entry.negative ?? entry.negative_prompt ?? '';
379
+ const jobLoras = entry.loras || normalizedPins.loras;
380
+
381
+ const { nodes: workflow, saveNodeId } =
382
+ mode === 'stub'
383
+ ? buildStubFixtureGraph({
384
+ seed,
385
+ width: entry.width || width || 64,
386
+ height: entry.height || height || 64,
387
+ filenamePrefix: `sdl_${jobId}`,
388
+ })
389
+ : buildQwenGraph({
390
+ prompt,
391
+ negativePrompt: negative,
392
+ seed,
393
+ width: entry.width || width,
394
+ height: entry.height || height,
395
+ steps: entry.steps || steps,
396
+ cfg: entry.cfg ?? cfg,
397
+ sampler: entry.sampler || sampler,
398
+ scheduler: entry.scheduler || scheduler,
399
+ shift: entry.shift ?? shift,
400
+ unet: normalizedPins.base.name,
401
+ clip: normalizedPins.text_encoder.name,
402
+ vae: normalizedPins.vae.name,
403
+ loras: jobLoras,
404
+ filenamePrefix: `sdl_${jobId}`,
405
+ });
406
+
407
+ const workflowSha = comfyWorkflowSha(workflow);
408
+
409
+ return {
410
+ job_id: jobId,
411
+ run_id: runId,
412
+ class: entry.class || 'default',
413
+ prompt,
414
+ negative,
415
+ seed,
416
+ width: entry.width || width,
417
+ height: entry.height || height,
418
+ steps: entry.steps || steps,
419
+ cfg: entry.cfg ?? cfg,
420
+ sampler: entry.sampler || sampler,
421
+ scheduler: entry.scheduler || scheduler,
422
+ shift: entry.shift ?? shift,
423
+ template_id: mode === 'stub' ? 'stub_fixture_64' : 'qwen_txt2img_lora',
424
+ loras: jobLoras,
425
+ save_node_id: saveNodeId,
426
+ workflow_sha: workflowSha,
427
+ workflow,
428
+ canon_sha: normCanonSha,
429
+ entry_id: entryId,
430
+ entry_sha256: entrySha,
431
+ owner: STUDIO_OWNER,
432
+ licence: STUDIO_LICENCE,
433
+ model_pins: normalizedPins,
434
+ };
435
+ });
436
+
437
+ const payload = jobs.map((j) => JSON.stringify(j)).join('\n') + '\n';
438
+ const jobsSha256 = sha256Buffer(Buffer.from(payload, 'utf-8'));
439
+ return { jobs, jobs_sha256: jobsSha256 };
440
+ }
441
+
442
+ /**
443
+ * Write `jobs.jsonl` and its companion `jobs.jsonl.sha256` file.
444
+ *
445
+ * @param {string} jobsPath
446
+ * @param {Array<Object>} jobs
447
+ * @returns {{ jobsPath: string, sha256Path: string, sha256: string }}
448
+ */
449
+ export function writeJobsJsonl(jobsPath, jobs) {
450
+ mkdirSync(dirname(jobsPath), { recursive: true });
451
+ const content = jobs.map((j) => JSON.stringify(j)).join('\n') + '\n';
452
+ const sha = sha256Buffer(Buffer.from(content, 'utf-8'));
453
+ writeFileSync(jobsPath, content, 'utf-8');
454
+ const sha256Path = `${jobsPath}.sha256`;
455
+ writeFileSync(sha256Path, `${sha}\n`, 'utf-8');
456
+ return { jobsPath, sha256Path, sha256: sha };
457
+ }
458
+
459
+ /**
460
+ * Read `jobs.jsonl` and verify its `.sha256` sidecar if present.
461
+ *
462
+ * @param {string} jobsPath
463
+ * @returns {Array<Object>}
464
+ */
465
+ export function readJobsJsonl(jobsPath) {
466
+ const raw = readFileSync(jobsPath, 'utf-8');
467
+ const shaPath = `${jobsPath}.sha256`;
468
+ if (existsSync(shaPath)) {
469
+ const expected = readFileSync(shaPath, 'utf-8').trim();
470
+ const actual = sha256Buffer(Buffer.from(raw, 'utf-8'));
471
+ if (expected && actual !== expected) {
472
+ throw inputError(
473
+ 'LANE1_JOBS_SHA_MISMATCH',
474
+ `jobs.jsonl sha256 mismatch: got ${actual}, expected ${expected}.`
475
+ );
476
+ }
477
+ }
478
+ return raw
479
+ .split('\n')
480
+ .map((line) => line.trim())
481
+ .filter(Boolean)
482
+ .map((line) => JSON.parse(line));
483
+ }
484
+
485
+ function loadCompletedRenders(rendersPath, outDir) {
486
+ const completed = new Map();
487
+ if (!existsSync(rendersPath)) return completed;
488
+ const lines = readFileSync(rendersPath, 'utf-8')
489
+ .split('\n')
490
+ .map((l) => l.trim())
491
+ .filter(Boolean);
492
+ for (const line of lines) {
493
+ const row = JSON.parse(line);
494
+ if (row && row.status === 'ok' && row.job_id && row.output_file) {
495
+ const pngPath = join(outDir, row.output_file);
496
+ if (existsSync(pngPath)) {
497
+ const bytes = readFileSync(pngPath);
498
+ const actualSha = sha256Buffer(bytes);
499
+ if (actualSha === row.png_sha256) {
500
+ try {
501
+ verifyPngProvenance(bytes, {
502
+ owner: row.owner,
503
+ licence: row.licence,
504
+ canon_sha: row.canon_sha,
505
+ entry_id: row.entry_id,
506
+ entry_sha256: row.entry_sha256,
507
+ run_id: row.run_id,
508
+ });
509
+ completed.set(row.job_id, row);
510
+ } catch {
511
+ // If stamp verification fails on disk, re-render the job.
512
+ }
513
+ }
514
+ }
515
+ }
516
+ }
517
+ return completed;
518
+ }
519
+
520
+ function syncCopyBack({ rendersPath, currentJsonPath, outDir, copyBackDir, completedFiles }) {
521
+ mkdirSync(copyBackDir, { recursive: true });
522
+ if (existsSync(rendersPath)) {
523
+ copyFileSync(rendersPath, join(copyBackDir, 'renders.jsonl'));
524
+ }
525
+ if (currentJsonPath && existsSync(currentJsonPath)) {
526
+ copyFileSync(currentJsonPath, join(copyBackDir, 'current.json'));
527
+ }
528
+ for (const file of completedFiles) {
529
+ const src = join(outDir, file);
530
+ if (existsSync(src)) {
531
+ copyFileSync(src, join(copyBackDir, file));
532
+ }
533
+ }
534
+ }
535
+
536
+ /**
537
+ * Execute the durable Lane 1 render queue (`jobs.jsonl` -> `renders.jsonl`)
538
+ * with idempotent resume, PNG tEXt stamping + immediate read-back verification,
539
+ * and periodic copy-back (DESIGN-v3 §3.1, §4.4, §7, G2, G8, H6, H7).
540
+ *
541
+ * @param {Object} opts
542
+ * @returns {Promise<Object>}
543
+ */
544
+ export async function runLane1Queue({
545
+ jobsPath,
546
+ rendersPath,
547
+ outDir,
548
+ copyBackDir = null,
549
+ copyBackEvery = 5,
550
+ windowId = 'win_lane1_local',
551
+ renderFn,
552
+ stopAfterJobs = null,
553
+ stripStampBeforeSave = false,
554
+ }) {
555
+ if (typeof renderFn !== 'function') {
556
+ throw inputError('LANE1_RENDER_FN_REQUIRED', 'runLane1Queue requires a renderFn(job) callback.');
557
+ }
558
+ const jobs = readJobsJsonl(jobsPath);
559
+ mkdirSync(outDir, { recursive: true });
560
+ mkdirSync(dirname(rendersPath), { recursive: true });
561
+
562
+ const currentJsonPath = join(dirname(rendersPath), 'current.json');
563
+ const completedMap = loadCompletedRenders(rendersPath, outDir);
564
+ const completedFiles = new Set([...completedMap.values()].map((r) => r.output_file));
565
+
566
+ let skippedCount = 0;
567
+ let renderedCount = 0;
568
+ let copyBackCount = 0;
569
+ let haltedEarly = false;
570
+
571
+ for (const job of jobs) {
572
+ if (completedMap.has(job.job_id)) {
573
+ skippedCount++;
574
+ continue;
575
+ }
576
+
577
+ const startMs = Date.now();
578
+ const rawPngBuf = await renderFn(job);
579
+ const stampMeta = {
580
+ owner: job.owner || STUDIO_OWNER,
581
+ licence: job.licence || STUDIO_LICENCE,
582
+ canon_sha: job.canon_sha,
583
+ entry_id: job.entry_id,
584
+ entry_sha256: job.entry_sha256,
585
+ run_id: job.run_id,
586
+ workflow_sha: job.workflow_sha,
587
+ window_id: windowId,
588
+ };
589
+
590
+ const outputBytes = stripStampBeforeSave
591
+ ? rawPngBuf
592
+ : stampPngProvenance(rawPngBuf, stampMeta, `${job.job_id}.png`);
593
+
594
+ const outputFile = `${job.job_id}.png`;
595
+ const outputFullPath = join(outDir, outputFile);
596
+ writeFileSync(outputFullPath, outputBytes);
597
+
598
+ // H7: read back the saved PNG from disk immediately and verify its stamp!
599
+ const savedBytes = readFileSync(outputFullPath);
600
+ verifyPngProvenance(savedBytes, stampMeta, outputFile);
601
+
602
+ const pngSha256 = sha256Buffer(savedBytes);
603
+ const wallMs = Date.now() - startMs;
604
+
605
+ const renderRow = {
606
+ job_id: job.job_id,
607
+ run_id: job.run_id,
608
+ status: 'ok',
609
+ output_file: outputFile,
610
+ png_sha256: pngSha256,
611
+ workflow_sha: job.workflow_sha,
612
+ seed: job.seed,
613
+ wall_ms: wallMs,
614
+ window_id: windowId,
615
+ canon_sha: job.canon_sha,
616
+ entry_id: job.entry_id,
617
+ entry_sha256: job.entry_sha256,
618
+ owner: stampMeta.owner,
619
+ licence: stampMeta.licence,
620
+ model_pins: job.model_pins,
621
+ stamp_verified: true,
622
+ };
623
+
624
+ appendFileSync(rendersPath, JSON.stringify(renderRow) + '\n', 'utf-8');
625
+ completedFiles.add(outputFile);
626
+ renderedCount++;
627
+
628
+ writeFileSync(
629
+ currentJsonPath,
630
+ JSON.stringify(
631
+ {
632
+ window_id: windowId,
633
+ run_id: job.run_id,
634
+ last_job_id: job.job_id,
635
+ files: ['renders.jsonl', ...completedFiles],
636
+ },
637
+ null,
638
+ 2
639
+ ),
640
+ 'utf-8'
641
+ );
642
+
643
+ if (copyBackDir && copyBackEvery > 0 && renderedCount % copyBackEvery === 0) {
644
+ syncCopyBack({ rendersPath, currentJsonPath, outDir, copyBackDir, completedFiles });
645
+ copyBackCount++;
646
+ }
647
+
648
+ if (stopAfterJobs !== null && renderedCount >= stopAfterJobs) {
649
+ haltedEarly = true;
650
+ break;
651
+ }
652
+ }
653
+
654
+ if (copyBackDir && !haltedEarly && renderedCount > 0 && renderedCount % copyBackEvery !== 0) {
655
+ syncCopyBack({ rendersPath, currentJsonPath, outDir, copyBackDir, completedFiles });
656
+ copyBackCount++;
657
+ }
658
+
659
+ return {
660
+ totalJobs: jobs.length,
661
+ renderedCount,
662
+ skippedCount,
663
+ copyBackCount,
664
+ haltedEarly,
665
+ rendersPath,
666
+ outDir,
667
+ };
668
+ }