@mcptoolshop/style-dataset-lab 2.3.0 → 3.0.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 +125 -0
- package/README.md +36 -8
- package/bin/sdlab.js +129 -0
- package/lib/adapters/comfyui-runner.js +170 -0
- package/lib/adapters/comfyui-workflows.js +163 -0
- package/lib/batch-compiler.js +118 -0
- package/lib/batch-modes.js +111 -0
- package/lib/batch-runs.js +168 -0
- package/lib/batch-sheet-render.js +206 -0
- package/lib/brief-compiler.js +598 -0
- package/lib/brief-render.js +205 -0
- package/lib/critique-engine.js +413 -0
- package/lib/critique-render.js +171 -0
- package/lib/generated-provenance.js +87 -0
- package/lib/paths.js +31 -0
- package/lib/refine-briefs.js +329 -0
- package/lib/reingest-selected.js +173 -0
- package/lib/run-summary.js +131 -0
- package/lib/runtime-runs.js +168 -0
- package/lib/selections.js +323 -0
- package/lib/workflow-profiles.js +161 -0
- package/package.json +6 -3
- package/scripts/batch-generate.js +167 -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 +52 -0
- package/scripts/critique-show.js +50 -0
- package/scripts/critique.js +70 -0
- package/scripts/doctor.js +169 -1
- package/scripts/init.js +22 -0
- package/scripts/refine.js +119 -0
- package/scripts/reingest-selected.js +78 -0
- package/scripts/reingest.js +7 -1
- 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 +118 -0
- package/scripts/selection-show.js +88 -0
- package/scripts/workflow-list.js +61 -0
- package/scripts/workflow-show.js +96 -0
- 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/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/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/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/workflows/profiles/variant-pack.json +60 -0
- package/templates/domains/vehicle-mech/workflows/profiles/vehicle-silhouette-pack.json +59 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-ingest selected outputs as new candidate records.
|
|
3
|
+
*
|
|
4
|
+
* Selected outputs re-enter the project as generated candidates
|
|
5
|
+
* with full provenance. They go through the same review and
|
|
6
|
+
* canon-binding flow as everything else. No bypass.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { writeFile, mkdir, copyFile } from 'node:fs/promises';
|
|
10
|
+
import { join, extname } from 'node:path';
|
|
11
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
12
|
+
import { getRunsDir } from './paths.js';
|
|
13
|
+
import { getBatchesDir } from './batch-runs.js';
|
|
14
|
+
import { getSelectionsDir, loadSelection } from './selections.js';
|
|
15
|
+
import { buildGeneratedProvenance } from './generated-provenance.js';
|
|
16
|
+
import { buildBaseRecord } from './records.js';
|
|
17
|
+
import { inputError } from './errors.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Re-ingest a selection — creates candidate records from chosen outputs.
|
|
21
|
+
*
|
|
22
|
+
* @param {Object} opts
|
|
23
|
+
* @param {string} opts.projectRoot
|
|
24
|
+
* @param {string} opts.projectId
|
|
25
|
+
* @param {string} opts.selectionId
|
|
26
|
+
* @param {boolean} [opts.dryRun]
|
|
27
|
+
* @returns {Promise<{created: string[], skipped: string[]}>}
|
|
28
|
+
*/
|
|
29
|
+
export async function reingestSelection({ projectRoot, projectId, selectionId, dryRun }) {
|
|
30
|
+
const manifest = loadSelection(projectRoot, selectionId);
|
|
31
|
+
|
|
32
|
+
if (!manifest.reingest_ready) {
|
|
33
|
+
throw inputError(
|
|
34
|
+
'SELECTION_NOT_READY',
|
|
35
|
+
`Selection "${selectionId}" is not marked reingest_ready`,
|
|
36
|
+
'Set reingest_ready: true in the selection manifest.',
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const selectionDir = join(getSelectionsDir(projectRoot), selectionId);
|
|
41
|
+
const recordsDir = join(projectRoot, 'records');
|
|
42
|
+
const inboxDir = join(projectRoot, 'inbox', 'generated');
|
|
43
|
+
|
|
44
|
+
if (!dryRun) {
|
|
45
|
+
await mkdir(recordsDir, { recursive: true });
|
|
46
|
+
await mkdir(inboxDir, { recursive: true });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Load source manifests for provenance
|
|
50
|
+
const runsDir = getRunsDir(projectRoot);
|
|
51
|
+
const runManifestCache = new Map();
|
|
52
|
+
|
|
53
|
+
function getRunManifest(runId) {
|
|
54
|
+
if (runManifestCache.has(runId)) return runManifestCache.get(runId);
|
|
55
|
+
const mPath = join(runsDir, runId, 'manifest.json');
|
|
56
|
+
if (!existsSync(mPath)) return null;
|
|
57
|
+
const m = JSON.parse(readFileSync(mPath, 'utf-8'));
|
|
58
|
+
runManifestCache.set(runId, m);
|
|
59
|
+
return m;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let batchManifest = null;
|
|
63
|
+
if (manifest.source_type === 'batch') {
|
|
64
|
+
const bmPath = join(getBatchesDir(projectRoot), manifest.source_id, 'manifest.json');
|
|
65
|
+
if (existsSync(bmPath)) {
|
|
66
|
+
batchManifest = JSON.parse(readFileSync(bmPath, 'utf-8'));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const created = [];
|
|
71
|
+
const skipped = [];
|
|
72
|
+
const provenanceLines = [];
|
|
73
|
+
|
|
74
|
+
for (const item of manifest.items) {
|
|
75
|
+
// Determine record ID from selection + item index
|
|
76
|
+
const idx = String(manifest.items.indexOf(item) + 1).padStart(3, '0');
|
|
77
|
+
const recordId = `gen_${selectionId}_${idx}`;
|
|
78
|
+
const recordPath = join(recordsDir, `${recordId}.json`);
|
|
79
|
+
|
|
80
|
+
if (existsSync(recordPath)) {
|
|
81
|
+
skipped.push(recordId);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Resolve the run manifest for this item
|
|
86
|
+
let runManifest;
|
|
87
|
+
if (manifest.source_type === 'run') {
|
|
88
|
+
runManifest = getRunManifest(manifest.source_id);
|
|
89
|
+
} else if (manifest.source_type === 'batch' && batchManifest) {
|
|
90
|
+
// Find which run this slot belongs to
|
|
91
|
+
const slotId = item.slot_or_output.split(':')[0];
|
|
92
|
+
const slot = batchManifest.slots?.find(s => s.slot_id === slotId);
|
|
93
|
+
if (slot) {
|
|
94
|
+
runManifest = getRunManifest(slot.run_id);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!runManifest) {
|
|
99
|
+
// Fallback: minimal provenance
|
|
100
|
+
runManifest = {
|
|
101
|
+
run_id: manifest.source_id,
|
|
102
|
+
brief_id: 'unknown',
|
|
103
|
+
workflow_template_id: manifest.workflow_id || 'unknown',
|
|
104
|
+
created_at: manifest.created_at,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Build provenance
|
|
109
|
+
const prov = buildGeneratedProvenance({
|
|
110
|
+
sourceType: manifest.source_type,
|
|
111
|
+
sourceId: manifest.source_id,
|
|
112
|
+
runManifest,
|
|
113
|
+
batchManifest,
|
|
114
|
+
item,
|
|
115
|
+
projectRoot,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Image source in selection/chosen/
|
|
119
|
+
const srcImage = join(selectionDir, 'chosen', item.filename);
|
|
120
|
+
const ext = extname(item.filename) || '.png';
|
|
121
|
+
const destImage = `${recordId}${ext}`;
|
|
122
|
+
const inboxPath = join(inboxDir, destImage);
|
|
123
|
+
const assetPath = `inbox/generated/${destImage}`;
|
|
124
|
+
|
|
125
|
+
// Get file size
|
|
126
|
+
let fileBytes = 0;
|
|
127
|
+
if (existsSync(srcImage)) {
|
|
128
|
+
fileBytes = statSync(srcImage).size;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Build record
|
|
132
|
+
const record = buildBaseRecord(recordId, assetPath, {
|
|
133
|
+
width: null,
|
|
134
|
+
height: null,
|
|
135
|
+
bytes: fileBytes,
|
|
136
|
+
}, {
|
|
137
|
+
source: 'generated',
|
|
138
|
+
selection_id: selectionId,
|
|
139
|
+
generation_provenance: prov,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
record.schema_version = '2.2.0';
|
|
143
|
+
|
|
144
|
+
// Tags
|
|
145
|
+
const recordTags = ['generated', 'selected'];
|
|
146
|
+
if (manifest.workflow_id) recordTags.push(manifest.workflow_id);
|
|
147
|
+
if (manifest.subject_id) recordTags.push(manifest.subject_id);
|
|
148
|
+
if (item.tags?.length) recordTags.push(...item.tags);
|
|
149
|
+
record.tags = [...new Set(recordTags)];
|
|
150
|
+
|
|
151
|
+
if (!dryRun) {
|
|
152
|
+
// Copy image into inbox
|
|
153
|
+
if (existsSync(srcImage)) {
|
|
154
|
+
await copyFile(srcImage, inboxPath);
|
|
155
|
+
}
|
|
156
|
+
// Write record
|
|
157
|
+
await writeFile(recordPath, JSON.stringify(record, null, 2) + '\n');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
created.push(recordId);
|
|
161
|
+
provenanceLines.push(JSON.stringify({ record_id: recordId, ...prov }));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Write provenance log
|
|
165
|
+
if (!dryRun && provenanceLines.length > 0) {
|
|
166
|
+
await writeFile(
|
|
167
|
+
join(selectionDir, 'provenance.jsonl'),
|
|
168
|
+
provenanceLines.join('\n') + '\n',
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { created, skipped };
|
|
173
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run summary — render manifest to human-readable summary files.
|
|
3
|
+
*
|
|
4
|
+
* Writes summary.md + summary.json to the run directory.
|
|
5
|
+
* Contact sheet generation is separate (requires image processing).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { writeFile } from 'node:fs/promises';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Build a summary object from a manifest.
|
|
13
|
+
*/
|
|
14
|
+
export function buildRunSummary(manifest) {
|
|
15
|
+
const ok = manifest.outputs.filter(o => o.status === 'ok');
|
|
16
|
+
const failed = manifest.outputs.filter(o => o.status === 'error');
|
|
17
|
+
const totalElapsed = ok.reduce((sum, o) => sum + (o.elapsed_ms || 0), 0);
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
run_id: manifest.run_id,
|
|
21
|
+
brief_id: manifest.brief_id,
|
|
22
|
+
project_id: manifest.project_id,
|
|
23
|
+
adapter_target: manifest.adapter_target,
|
|
24
|
+
output_mode: manifest.output_mode,
|
|
25
|
+
created_at: manifest.created_at,
|
|
26
|
+
dry_run: manifest.dry_run,
|
|
27
|
+
success_count: manifest.success_count,
|
|
28
|
+
error_count: manifest.error_count,
|
|
29
|
+
total_outputs: manifest.output_count,
|
|
30
|
+
total_elapsed_ms: totalElapsed,
|
|
31
|
+
avg_elapsed_ms: ok.length > 0 ? Math.round(totalElapsed / ok.length) : 0,
|
|
32
|
+
seed_plan: manifest.seed_plan,
|
|
33
|
+
checkpoint: manifest.checkpoint,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Render a manifest to Markdown summary.
|
|
39
|
+
*/
|
|
40
|
+
export function renderRunMarkdown(manifest) {
|
|
41
|
+
const lines = [];
|
|
42
|
+
lines.push(`# Run: ${manifest.run_id}`);
|
|
43
|
+
lines.push('');
|
|
44
|
+
lines.push(`**Brief:** ${manifest.brief_id}`);
|
|
45
|
+
lines.push(`**Project:** ${manifest.project_id}`);
|
|
46
|
+
lines.push(`**Created:** ${manifest.created_at}`);
|
|
47
|
+
lines.push(`**Adapter:** ${manifest.adapter_target}`);
|
|
48
|
+
lines.push(`**Mode:** ${manifest.output_mode}`);
|
|
49
|
+
if (manifest.dry_run) lines.push('**⚠ DRY RUN — no images generated**');
|
|
50
|
+
lines.push('');
|
|
51
|
+
|
|
52
|
+
// Results
|
|
53
|
+
lines.push('## Results');
|
|
54
|
+
lines.push('');
|
|
55
|
+
lines.push(`| # | Seed | Status | File | Time |`);
|
|
56
|
+
lines.push(`|---|------|--------|------|------|`);
|
|
57
|
+
for (const out of manifest.outputs) {
|
|
58
|
+
const file = out.filename || '—';
|
|
59
|
+
const time = out.elapsed_ms ? `${(out.elapsed_ms / 1000).toFixed(1)}s` : '—';
|
|
60
|
+
const status = out.status === 'ok' ? '✓' : out.status === 'dry_run' ? '○' : '✗';
|
|
61
|
+
lines.push(`| ${out.index + 1} | ${out.seed} | ${status} | ${file} | ${time} |`);
|
|
62
|
+
}
|
|
63
|
+
lines.push('');
|
|
64
|
+
|
|
65
|
+
// Errors
|
|
66
|
+
if (manifest.errors.length > 0) {
|
|
67
|
+
lines.push('## Errors');
|
|
68
|
+
lines.push('');
|
|
69
|
+
for (const err of manifest.errors) {
|
|
70
|
+
lines.push(`- **Image ${err.index + 1}** (seed ${err.seed}): ${err.error}`);
|
|
71
|
+
}
|
|
72
|
+
lines.push('');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Runtime
|
|
76
|
+
lines.push('## Runtime');
|
|
77
|
+
lines.push('');
|
|
78
|
+
lines.push(`| Parameter | Value |`);
|
|
79
|
+
lines.push(`|-----------|-------|`);
|
|
80
|
+
lines.push(`| Checkpoint | ${manifest.checkpoint || '—'} |`);
|
|
81
|
+
lines.push(`| LoRAs | ${(manifest.loras || []).map(l => l.name).join(', ') || '—'} |`);
|
|
82
|
+
lines.push(`| ComfyUI | ${manifest.comfy_url || '—'} |`);
|
|
83
|
+
|
|
84
|
+
const rp = manifest.runtime_plan || {};
|
|
85
|
+
lines.push(`| Size | ${rp.width || '?'}×${rp.height || '?'} |`);
|
|
86
|
+
lines.push(`| Steps | ${rp.steps || '?'} |`);
|
|
87
|
+
lines.push(`| CFG | ${rp.cfg || '?'} |`);
|
|
88
|
+
lines.push(`| Sampler | ${rp.sampler || '?'} |`);
|
|
89
|
+
lines.push(`| Seed mode | ${rp.seed_mode || '?'} |`);
|
|
90
|
+
lines.push(`| Base seed | ${manifest.seed_plan?.base_seed || '?'} |`);
|
|
91
|
+
lines.push('');
|
|
92
|
+
|
|
93
|
+
return lines.join('\n');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Render a manifest to terminal text.
|
|
98
|
+
*/
|
|
99
|
+
export function renderRunText(manifest) {
|
|
100
|
+
const ok = manifest.outputs.filter(o => o.status === 'ok' || o.status === 'dry_run');
|
|
101
|
+
const failed = manifest.outputs.filter(o => o.status === 'error');
|
|
102
|
+
const lines = [];
|
|
103
|
+
|
|
104
|
+
lines.push(`Run: ${manifest.run_id}`);
|
|
105
|
+
lines.push(`Brief: ${manifest.brief_id}`);
|
|
106
|
+
lines.push(`Project: ${manifest.project_id}`);
|
|
107
|
+
lines.push(`Mode: ${manifest.output_mode} (${manifest.adapter_target})`);
|
|
108
|
+
if (manifest.dry_run) lines.push('⚠ DRY RUN');
|
|
109
|
+
lines.push(`Results: ${ok.length}/${manifest.output_count} succeeded`);
|
|
110
|
+
if (failed.length > 0) lines.push(`Errors: ${failed.length}`);
|
|
111
|
+
lines.push('');
|
|
112
|
+
|
|
113
|
+
for (const out of manifest.outputs) {
|
|
114
|
+
const status = out.status === 'ok' ? '✓' : out.status === 'dry_run' ? '○' : '✗';
|
|
115
|
+
const time = out.elapsed_ms ? ` (${(out.elapsed_ms / 1000).toFixed(1)}s)` : '';
|
|
116
|
+
const file = out.filename ? ` → ${out.filename}` : '';
|
|
117
|
+
const err = out.error ? ` — ${out.error}` : '';
|
|
118
|
+
lines.push(` ${status} [${out.index + 1}] seed ${out.seed}${file}${time}${err}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return lines.join('\n');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Save summary files into the run directory.
|
|
126
|
+
*/
|
|
127
|
+
export async function saveRunSummary(runDir, manifest) {
|
|
128
|
+
const summary = buildRunSummary(manifest);
|
|
129
|
+
await writeFile(join(runDir, 'summary.json'), JSON.stringify(summary, null, 2) + '\n');
|
|
130
|
+
await writeFile(join(runDir, 'summary.md'), renderRunMarkdown(manifest) + '\n');
|
|
131
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime run management — create, load, list run artifacts.
|
|
3
|
+
*
|
|
4
|
+
* A run is a frozen execution artifact: brief + seeds + outputs + manifest.
|
|
5
|
+
* The run folder IS the review surface — humans open it directly.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFile, writeFile, mkdir, readdir, copyFile } from 'node:fs/promises';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
11
|
+
import { getRunsDir, getBriefsDir } from './paths.js';
|
|
12
|
+
import { inputError } from './errors.js';
|
|
13
|
+
|
|
14
|
+
// ─── Run ID generation ───────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Generate a run ID: run_YYYY-MM-DD_NNN
|
|
18
|
+
*/
|
|
19
|
+
export function generateRunId(runsDir) {
|
|
20
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
21
|
+
const prefix = `run_${today}_`;
|
|
22
|
+
|
|
23
|
+
let maxSeq = 0;
|
|
24
|
+
if (existsSync(runsDir)) {
|
|
25
|
+
const existing = readdirSync(runsDir, { withFileTypes: true })
|
|
26
|
+
.filter(e => e.isDirectory() && e.name.startsWith(prefix));
|
|
27
|
+
for (const e of existing) {
|
|
28
|
+
const seqStr = e.name.slice(prefix.length);
|
|
29
|
+
const seq = parseInt(seqStr, 10);
|
|
30
|
+
if (!isNaN(seq) && seq > maxSeq) maxSeq = seq;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const seq = String(maxSeq + 1).padStart(3, '0');
|
|
35
|
+
return `${prefix}${seq}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ─── Seed planning ───────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build a deterministic seed plan.
|
|
42
|
+
* @param {Object} opts
|
|
43
|
+
* @param {'fixed'|'increment'|'random'} opts.seedMode
|
|
44
|
+
* @param {number} opts.outputCount
|
|
45
|
+
* @param {number} [opts.baseSeed] — explicit base seed (default: random)
|
|
46
|
+
* @returns {{ base_seed: number, seeds: number[] }}
|
|
47
|
+
*/
|
|
48
|
+
export function buildSeedPlan({ seedMode, outputCount, baseSeed }) {
|
|
49
|
+
const base = baseSeed ?? Math.floor(Math.random() * 2_147_483_647);
|
|
50
|
+
const seeds = [];
|
|
51
|
+
|
|
52
|
+
switch (seedMode) {
|
|
53
|
+
case 'fixed':
|
|
54
|
+
for (let i = 0; i < outputCount; i++) seeds.push(base);
|
|
55
|
+
break;
|
|
56
|
+
case 'increment':
|
|
57
|
+
for (let i = 0; i < outputCount; i++) seeds.push(base + i);
|
|
58
|
+
break;
|
|
59
|
+
case 'random':
|
|
60
|
+
seeds.push(base);
|
|
61
|
+
for (let i = 1; i < outputCount; i++) {
|
|
62
|
+
seeds.push(Math.floor(Math.random() * 2_147_483_647));
|
|
63
|
+
}
|
|
64
|
+
break;
|
|
65
|
+
default:
|
|
66
|
+
// Default to increment
|
|
67
|
+
for (let i = 0; i < outputCount; i++) seeds.push(base + i);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { base_seed: base, seeds };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── Run directory setup ─────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Prepare a run directory and copy brief artifacts into it.
|
|
77
|
+
* @returns {{ runId: string, runDir: string }}
|
|
78
|
+
*/
|
|
79
|
+
export async function prepareRunDir(projectRoot, brief) {
|
|
80
|
+
const runsDir = getRunsDir(projectRoot);
|
|
81
|
+
const runId = generateRunId(runsDir);
|
|
82
|
+
const runDir = join(runsDir, runId);
|
|
83
|
+
|
|
84
|
+
await mkdir(join(runDir, 'outputs'), { recursive: true });
|
|
85
|
+
|
|
86
|
+
// Copy brief artifacts
|
|
87
|
+
const briefsDir = getBriefsDir(projectRoot);
|
|
88
|
+
const briefJsonSrc = join(briefsDir, `${brief.brief_id}.json`);
|
|
89
|
+
const briefMdSrc = join(briefsDir, `${brief.brief_id}.md`);
|
|
90
|
+
|
|
91
|
+
await writeFile(join(runDir, 'brief.json'), JSON.stringify(brief, null, 2) + '\n');
|
|
92
|
+
|
|
93
|
+
if (existsSync(briefMdSrc)) {
|
|
94
|
+
await copyFile(briefMdSrc, join(runDir, 'brief.md'));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Write standalone prompt/negative files for easy inspection
|
|
98
|
+
await writeFile(join(runDir, 'prompt.txt'), brief.prompt || '');
|
|
99
|
+
await writeFile(join(runDir, 'negative-prompt.txt'), brief.negative_prompt || '');
|
|
100
|
+
|
|
101
|
+
return { runId, runDir };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ─── Manifest ────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Build and save a run manifest.
|
|
108
|
+
*/
|
|
109
|
+
export async function saveRunManifest(runDir, manifest) {
|
|
110
|
+
await writeFile(
|
|
111
|
+
join(runDir, 'manifest.json'),
|
|
112
|
+
JSON.stringify(manifest, null, 2) + '\n'
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ─── Load / List ─────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Load a run manifest by ID.
|
|
120
|
+
*/
|
|
121
|
+
export async function loadRun(projectRoot, runId) {
|
|
122
|
+
const runDir = join(getRunsDir(projectRoot), runId);
|
|
123
|
+
const manifestPath = join(runDir, 'manifest.json');
|
|
124
|
+
|
|
125
|
+
if (!existsSync(manifestPath)) {
|
|
126
|
+
throw inputError(
|
|
127
|
+
'RUN_NOT_FOUND',
|
|
128
|
+
`Run "${runId}" not found`,
|
|
129
|
+
`Check the run ID or run: sdlab run generate --brief <id>`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return JSON.parse(await readFile(manifestPath, 'utf-8'));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* List all runs in a project.
|
|
138
|
+
*/
|
|
139
|
+
export function listRuns(projectRoot) {
|
|
140
|
+
const runsDir = getRunsDir(projectRoot);
|
|
141
|
+
if (!existsSync(runsDir)) return [];
|
|
142
|
+
|
|
143
|
+
const entries = readdirSync(runsDir, { withFileTypes: true })
|
|
144
|
+
.filter(e => e.isDirectory() && e.name.startsWith('run_'))
|
|
145
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
146
|
+
|
|
147
|
+
const runs = [];
|
|
148
|
+
for (const entry of entries) {
|
|
149
|
+
const manifestPath = join(runsDir, entry.name, 'manifest.json');
|
|
150
|
+
if (!existsSync(manifestPath)) continue;
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
154
|
+
runs.push({
|
|
155
|
+
run_id: manifest.run_id,
|
|
156
|
+
brief_id: manifest.brief_id,
|
|
157
|
+
workflow_template_id: manifest.workflow_template_id,
|
|
158
|
+
output_count: manifest.output_count,
|
|
159
|
+
created_at: manifest.created_at,
|
|
160
|
+
adapter_target: manifest.adapter_target,
|
|
161
|
+
});
|
|
162
|
+
} catch {
|
|
163
|
+
// Skip malformed
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return runs;
|
|
168
|
+
}
|