comfyui-mcp 0.41.0 → 0.43.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/dist/orchestrator/gemini-backend.js +109 -29
- package/dist/orchestrator/gemini-backend.js.map +1 -1
- package/dist/orchestrator/index.js +106 -5
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/orchestrator/panel-agent.js +12 -1
- package/dist/orchestrator/panel-agent.js.map +1 -1
- package/dist/services/ai-toolkit.js +220 -0
- package/dist/services/ai-toolkit.js.map +1 -0
- package/dist/services/training-config.js +138 -0
- package/dist/services/training-config.js.map +1 -0
- package/dist/services/training-jobs.js +654 -0
- package/dist/services/training-jobs.js.map +1 -0
- package/dist/services/workflow-validator.js +4 -0
- package/dist/services/workflow-validator.js.map +1 -1
- package/dist/tools/diagnostics.js +120 -1
- package/dist/tools/diagnostics.js.map +1 -1
- package/dist/tools/index.js +63 -3
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/train.js +187 -0
- package/dist/tools/train.js.map +1 -0
- package/docker/runpod/.gitattributes +8 -0
- package/docker/runpod/Dockerfile +486 -0
- package/docker/runpod/README.md +658 -0
- package/docker/runpod/config.ini +28 -0
- package/docker/runpod/deploy-dockerhub.sh +46 -0
- package/docker/runpod/extra_model_paths.yaml +61 -0
- package/docker/runpod/nginx.conf +124 -0
- package/docker/runpod/post_start.sh +619 -0
- package/docker/runpod/starting.html +47 -0
- package/docker/runpod/test_image.sh +180 -0
- package/docker/runpod/verify_image_remote.py +126 -0
- package/docker/trainer/Dockerfile +69 -0
- package/package.json +2 -1
- package/plugin/skills/train-character-lora/SKILL.md +83 -0
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
// Training job registry for the LoRA trainer — the state layer between the
|
|
2
|
+
// train_* MCP tools and the ai-toolkit container driver.
|
|
3
|
+
//
|
|
4
|
+
// A job ties together: a dataset dir (staged by prepareDataset), a generated
|
|
5
|
+
// ai-toolkit config (training-config.ts), and a running docker container
|
|
6
|
+
// (ai-toolkit.ts). Jobs persist a small JSON record under
|
|
7
|
+
// <trainingRoot>/jobs/<id>.json so `train_status`/`train_cancel` still work
|
|
8
|
+
// after an MCP restart (the container keeps running; we re-read the record).
|
|
9
|
+
//
|
|
10
|
+
// On success the final `.safetensors` is copied into ComfyUI models/loras/ and
|
|
11
|
+
// upserted into the LoRA catalog (trigger keyword + base model) so it shows in
|
|
12
|
+
// the mobile LoRA hub immediately. Live step/loss progress is also mirrored
|
|
13
|
+
// onto the cross-process download-progress channel for the panel/mobile tray.
|
|
14
|
+
import { appendFileSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { basename, extname, join, resolve } from "node:path";
|
|
17
|
+
import { containerRunning, startTraining, stopTraining, } from "./ai-toolkit.js";
|
|
18
|
+
import { buildTrainingConfig, } from "./training-config.js";
|
|
19
|
+
import { reportDownloadProgress } from "./download-progress.js";
|
|
20
|
+
import { getLoraCatalog } from "./lora-catalog.js";
|
|
21
|
+
import { resolveModelSubfolder } from "./model-resolver.js";
|
|
22
|
+
import { getInstanceSlug } from "../config.js";
|
|
23
|
+
import { logger } from "../utils/logger.js";
|
|
24
|
+
const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".webp"]);
|
|
25
|
+
const LOG_RING = 50;
|
|
26
|
+
/** Container-side mount points (must match ai-toolkit.ts startTraining). */
|
|
27
|
+
const CONTAINER_DATASET = "/dataset";
|
|
28
|
+
const CONTAINER_OUTPUT = "/output";
|
|
29
|
+
// ---- paths -----------------------------------------------------------------
|
|
30
|
+
function dataBaseDir() {
|
|
31
|
+
return process.env.COMFYUI_MCP_DATA_DIR?.trim() || join(homedir(), ".comfyui-mcp");
|
|
32
|
+
}
|
|
33
|
+
/** Root for datasets, job dirs, and the shared HF cache. */
|
|
34
|
+
export function trainingRoot() {
|
|
35
|
+
return process.env.COMFYUI_MCP_TRAINING_DIR?.trim() || join(dataBaseDir(), "training");
|
|
36
|
+
}
|
|
37
|
+
export function datasetsRoot() {
|
|
38
|
+
return join(trainingRoot(), "datasets");
|
|
39
|
+
}
|
|
40
|
+
export function jobsRoot() {
|
|
41
|
+
return join(trainingRoot(), "jobs");
|
|
42
|
+
}
|
|
43
|
+
/** Shared HF cache mounted into every training container (models persist
|
|
44
|
+
* across runs so Flux isn't re-downloaded each job). */
|
|
45
|
+
export function hfCacheRoot() {
|
|
46
|
+
return join(trainingRoot(), "hf-cache");
|
|
47
|
+
}
|
|
48
|
+
// ---- registry ---------------------------------------------------------------
|
|
49
|
+
const jobs = new Map();
|
|
50
|
+
const handles = new Map();
|
|
51
|
+
/** Throttle for persisting live progress (codex finding: cross-process readers
|
|
52
|
+
* only see what's on disk, so running jobs must snapshot, not just finalize). */
|
|
53
|
+
const lastProgressPersistAt = new Map();
|
|
54
|
+
const PROGRESS_PERSIST_MS = 5000;
|
|
55
|
+
function jobFile(id) {
|
|
56
|
+
return join(jobsRoot(), `${id}.json`);
|
|
57
|
+
}
|
|
58
|
+
function persist(job) {
|
|
59
|
+
try {
|
|
60
|
+
mkdirSync(jobsRoot(), { recursive: true });
|
|
61
|
+
writeFileSync(jobFile(job.id), JSON.stringify(job, null, 2));
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
logger.warn(`[training-jobs] could not persist ${job.id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** True when the ON-DISK record says cancelled — i.e. another process issued a
|
|
68
|
+
* cancel this process hasn't seen (its memory still says running). */
|
|
69
|
+
function diskCancelled(job) {
|
|
70
|
+
try {
|
|
71
|
+
const disk = JSON.parse(readFileSync(jobFile(job.id), "utf-8"));
|
|
72
|
+
return disk?.status === "cancelled";
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Persist a live progress/log snapshot WITHOUT clobbering a foreign cancel:
|
|
80
|
+
* if the disk record was cancelled by another process since we last looked,
|
|
81
|
+
* adopt that state into memory and skip the write (codex finding: the owner's
|
|
82
|
+
* throttled persist overwrote the cross-process cancel, then finalize ran the
|
|
83
|
+
* handoff anyway).
|
|
84
|
+
*/
|
|
85
|
+
function persistLiveState(job) {
|
|
86
|
+
if (job.status === "cancelled")
|
|
87
|
+
return;
|
|
88
|
+
if (diskCancelled(job)) {
|
|
89
|
+
job.status = "cancelled";
|
|
90
|
+
job.finishedAt = new Date().toISOString();
|
|
91
|
+
job.updatedAt = job.finishedAt;
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
persist(job);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Merge the on-disk records into the in-memory map. Runs on EVERY read: the
|
|
98
|
+
* orchestrator's long-lived in-process client (mobile `train_status`) is a
|
|
99
|
+
* different process from the one running `train_start`, so a load-once
|
|
100
|
+
* registry would show stale/empty state forever (codex finding #1).
|
|
101
|
+
*
|
|
102
|
+
* Merge rules:
|
|
103
|
+
* - A job with a live in-process handle is authoritative in memory (fresher
|
|
104
|
+
* than disk between throttled persists) — disk never overwrites it.
|
|
105
|
+
* - Anything else takes the disk record (new, updated, or absent in memory).
|
|
106
|
+
* - A record persisted as running/queued with NO live handle here belongs to
|
|
107
|
+
* another process (or a crashed one): probe the container. Definitively
|
|
108
|
+
* gone → mark failed; running or unknown → report as recorded, never
|
|
109
|
+
* mislabel a live foreign job as failed (codex finding #2).
|
|
110
|
+
*/
|
|
111
|
+
async function refreshRegistry(deps = {}) {
|
|
112
|
+
let files = [];
|
|
113
|
+
try {
|
|
114
|
+
files = readdirSync(jobsRoot()).filter((f) => f.endsWith(".json"));
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return; // no jobs dir yet
|
|
118
|
+
}
|
|
119
|
+
for (const f of files) {
|
|
120
|
+
let job;
|
|
121
|
+
try {
|
|
122
|
+
job = JSON.parse(readFileSync(join(jobsRoot(), f), "utf-8"));
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
continue; // skip a garbled record
|
|
126
|
+
}
|
|
127
|
+
if (!job || typeof job.id !== "string")
|
|
128
|
+
continue;
|
|
129
|
+
if (handles.has(job.id))
|
|
130
|
+
continue; // live in this process — memory wins
|
|
131
|
+
if ((job.status === "running" || job.status === "queued") && job.containerName) {
|
|
132
|
+
const probe = deps.containerRunning ?? containerRunning;
|
|
133
|
+
const running = await probe(job.containerName).catch(() => null);
|
|
134
|
+
if (running === false) {
|
|
135
|
+
// Container gone with no live handle anywhere: the owning process died.
|
|
136
|
+
// Recover ONLY a proven-successful run — artifact presence alone is not
|
|
137
|
+
// enough (a crashed run leaves periodic checkpoints behind, and handing
|
|
138
|
+
// those off would publish partial weights as a finished LoRA; codex
|
|
139
|
+
// finding). Proof = the FINAL save exists AND ai-toolkit's own summary
|
|
140
|
+
// in train.log reports completed jobs.
|
|
141
|
+
try {
|
|
142
|
+
if (recoveredSuccessfully(job)) {
|
|
143
|
+
handoffToComfyUI(job, deps);
|
|
144
|
+
job.status = "completed";
|
|
145
|
+
const samples = findSamples(job.outputDir, job.name, 4);
|
|
146
|
+
if (samples.length > 0)
|
|
147
|
+
job.progress.samples = samples;
|
|
148
|
+
if (job.progress.totalSteps !== undefined)
|
|
149
|
+
job.progress.step = job.progress.totalSteps;
|
|
150
|
+
job.finishedAt = new Date().toISOString();
|
|
151
|
+
persist(job);
|
|
152
|
+
jobs.set(job.id, job);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
job.status = "failed";
|
|
158
|
+
job.error = `recovered output but handoff failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
159
|
+
job.finishedAt = new Date().toISOString();
|
|
160
|
+
persist(job);
|
|
161
|
+
jobs.set(job.id, job);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
job.status = "failed";
|
|
165
|
+
job.error = "training container is no longer running (the MCP process that started it exited or the container died); any output (checkpoints, samples) is under the job's output/ dir.";
|
|
166
|
+
job.finishedAt = new Date().toISOString();
|
|
167
|
+
persist(job);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
jobs.set(job.id, job);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
export async function getJob(id, deps = {}) {
|
|
174
|
+
await refreshRegistry(deps);
|
|
175
|
+
return jobs.get(id) ?? null;
|
|
176
|
+
}
|
|
177
|
+
export async function listJobs(deps = {}) {
|
|
178
|
+
await refreshRegistry(deps);
|
|
179
|
+
return [...jobs.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
180
|
+
}
|
|
181
|
+
function sanitizeDirName(name) {
|
|
182
|
+
const cleaned = name.trim().replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "");
|
|
183
|
+
if (!cleaned || /^\.+$/.test(cleaned))
|
|
184
|
+
return "dataset";
|
|
185
|
+
return cleaned;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Stage images + same-basename .txt captions into a dataset dir ai-toolkit can
|
|
189
|
+
* consume. Images are copied in as img_00001.<ext> etc. (stable, caption-safe
|
|
190
|
+
* names); a missing caption falls back to defaultCaption, and if that's also
|
|
191
|
+
* absent the image is staged uncaptioned with a warning (ai-toolkit trains on
|
|
192
|
+
* it with an empty caption).
|
|
193
|
+
*
|
|
194
|
+
* Re-staging the same name REPLACES the old dir — but refuses when a
|
|
195
|
+
* non-terminal job currently trains from it (the dir is bind-mounted into the
|
|
196
|
+
* running container; wiping it mid-run would corrupt the job — codex finding).
|
|
197
|
+
*/
|
|
198
|
+
export async function prepareDataset(opts, deps = {}) {
|
|
199
|
+
if (!Array.isArray(opts.items) || opts.items.length === 0) {
|
|
200
|
+
throw new Error("dataset needs at least one image");
|
|
201
|
+
}
|
|
202
|
+
const dir = join(datasetsRoot(), sanitizeDirName(opts.name));
|
|
203
|
+
const resolvedDir = resolve(dir);
|
|
204
|
+
const active = await listJobs(deps);
|
|
205
|
+
const inUse = active.find((j) => (j.status === "running" || j.status === "queued") && resolve(j.datasetPath) === resolvedDir);
|
|
206
|
+
if (inUse) {
|
|
207
|
+
throw new Error(`dataset "${opts.name}" is in use by ${inUse.status} job ${inUse.id} — pick another name or cancel the job first`);
|
|
208
|
+
}
|
|
209
|
+
// Validate EVERY item first and stage into a temp sibling, then swap — an
|
|
210
|
+
// invalid item or a copy failure must not destroy the previously valid
|
|
211
|
+
// dataset or leave a partial replacement (codex finding).
|
|
212
|
+
const resolvedItems = opts.items.map((item) => {
|
|
213
|
+
const src = item.path?.trim();
|
|
214
|
+
if (!src || !existsSync(src))
|
|
215
|
+
throw new Error(`image not found: ${item.path}`);
|
|
216
|
+
const ext = extname(src).toLowerCase();
|
|
217
|
+
if (!IMAGE_EXTS.has(ext))
|
|
218
|
+
throw new Error(`not a supported image (${[...IMAGE_EXTS].join("/")}): ${src}`);
|
|
219
|
+
return { src, ext, caption: (item.caption ?? opts.defaultCaption)?.trim() };
|
|
220
|
+
});
|
|
221
|
+
const tmp = `${dir}.staging-${process.pid}`;
|
|
222
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
223
|
+
mkdirSync(tmp, { recursive: true });
|
|
224
|
+
const warnings = [];
|
|
225
|
+
let captionedCount = 0;
|
|
226
|
+
try {
|
|
227
|
+
resolvedItems.forEach((item, i) => {
|
|
228
|
+
const base = `img_${String(i + 1).padStart(5, "0")}`;
|
|
229
|
+
copyFileSync(item.src, join(tmp, `${base}${item.ext}`));
|
|
230
|
+
if (item.caption) {
|
|
231
|
+
writeFileSync(join(tmp, `${base}.txt`), item.caption);
|
|
232
|
+
captionedCount++;
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
warnings.push(`${basename(item.src)}: no caption (defaultCaption not set)`);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
241
|
+
throw err;
|
|
242
|
+
}
|
|
243
|
+
rmSync(dir, { recursive: true, force: true });
|
|
244
|
+
renameSync(tmp, dir);
|
|
245
|
+
return { datasetPath: dir, imageCount: resolvedItems.length, captionedCount, warnings };
|
|
246
|
+
}
|
|
247
|
+
function countDatasetImages(datasetPath) {
|
|
248
|
+
try {
|
|
249
|
+
return readdirSync(datasetPath).filter((f) => IMAGE_EXTS.has(extname(f).toLowerCase())).length;
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return 0;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
function pushLog(job, line) {
|
|
256
|
+
job.log.push(line);
|
|
257
|
+
if (job.log.length > LOG_RING)
|
|
258
|
+
job.log.shift();
|
|
259
|
+
try {
|
|
260
|
+
appendFileSync(join(job.jobDir, "train.log"), line + "\n");
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
// log file is best-effort
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
/** Map a container-side /output path (from a sample line) back to its host path. */
|
|
267
|
+
function toHostOutputPath(job, p) {
|
|
268
|
+
if (p.startsWith(CONTAINER_OUTPUT + "/"))
|
|
269
|
+
return join(job.outputDir, p.slice(CONTAINER_OUTPUT.length + 1));
|
|
270
|
+
return p;
|
|
271
|
+
}
|
|
272
|
+
function reportProgress(job, status, force = false) {
|
|
273
|
+
reportDownloadProgress({
|
|
274
|
+
id: `train-${job.id}`,
|
|
275
|
+
name: `LoRA ${job.name}`,
|
|
276
|
+
downloaded: job.progress.step ?? 0,
|
|
277
|
+
total: job.progress.totalSteps ?? 0,
|
|
278
|
+
bytes_per_sec: 0,
|
|
279
|
+
status,
|
|
280
|
+
}, force);
|
|
281
|
+
}
|
|
282
|
+
/** Pick the LoRA ai-toolkit produced: the exact <name>.safetensors final save,
|
|
283
|
+
* else the highest-step <name>_NNNNNNN.safetensors checkpoint. */
|
|
284
|
+
export function findProducedLora(outputDir, jobName) {
|
|
285
|
+
const dir = join(outputDir, jobName);
|
|
286
|
+
if (!existsSync(dir))
|
|
287
|
+
return null;
|
|
288
|
+
const finalPath = join(dir, `${jobName}.safetensors`);
|
|
289
|
+
if (existsSync(finalPath))
|
|
290
|
+
return finalPath;
|
|
291
|
+
let best = null;
|
|
292
|
+
for (const f of readdirSync(dir)) {
|
|
293
|
+
const m = f.match(new RegExp(`^${jobName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}_(\\d+)\\.safetensors$`));
|
|
294
|
+
if (m) {
|
|
295
|
+
const step = Number(m[1]);
|
|
296
|
+
if (!best || step > best.step)
|
|
297
|
+
best = { step, path: join(dir, f) };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return best?.path ?? null;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Durable proof an owner-less job actually FINISHED training: the FINAL save
|
|
304
|
+
* (<name>.safetensors) exists. ai-toolkit writes it only after the training
|
|
305
|
+
* loop completes — periodic <name>_NNNN.safetensors checkpoints prove nothing
|
|
306
|
+
* (a crash leaves those too), and stdout markers can't help: after the owner
|
|
307
|
+
* process dies, nothing appends its end-of-run summary to train.log (codex
|
|
308
|
+
* finding). The residual risk — a crash in the sampling phase AFTER the final
|
|
309
|
+
* save — still yields a complete LoRA, so handing it off is correct.
|
|
310
|
+
*/
|
|
311
|
+
export function recoveredSuccessfully(job) {
|
|
312
|
+
return existsSync(join(job.outputDir, job.name, `${job.name}.safetensors`));
|
|
313
|
+
}
|
|
314
|
+
/** Latest sample images from <output>/<name>/samples/, newest first. */
|
|
315
|
+
function findSamples(outputDir, jobName, limit = 4) {
|
|
316
|
+
const dir = join(outputDir, jobName, "samples");
|
|
317
|
+
if (!existsSync(dir))
|
|
318
|
+
return [];
|
|
319
|
+
try {
|
|
320
|
+
return readdirSync(dir)
|
|
321
|
+
.filter((f) => IMAGE_EXTS.has(extname(f).toLowerCase()))
|
|
322
|
+
.map((f) => ({ f, mtime: statSync(join(dir, f)).mtimeMs }))
|
|
323
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
324
|
+
.slice(0, limit)
|
|
325
|
+
.map((x) => join(dir, x.f));
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
return [];
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Success handoff: copy the produced LoRA into models/loras/ and upsert the
|
|
333
|
+
* catalog (trigger as keyword, base model from the trained arch) so the LoRA
|
|
334
|
+
* is immediately usable in workflows and visible in the mobile LoRA hub.
|
|
335
|
+
*/
|
|
336
|
+
function handoffToComfyUI(job, deps) {
|
|
337
|
+
const produced = findProducedLora(job.outputDir, job.name);
|
|
338
|
+
if (!produced) {
|
|
339
|
+
throw new Error(`training exited cleanly but no .safetensors found under ${join(job.outputDir, job.name)}`);
|
|
340
|
+
}
|
|
341
|
+
// The destination resolved at job START wins (codex finding): if the ComfyUI
|
|
342
|
+
// target changed mid-run, re-resolving now would copy the LoRA to the new
|
|
343
|
+
// instance's models dir.
|
|
344
|
+
const lorasDir = job.lorasDir ?? (deps.lorasDir ? deps.lorasDir() : resolveModelSubfolder("loras"));
|
|
345
|
+
mkdirSync(lorasDir, { recursive: true });
|
|
346
|
+
const dest = join(lorasDir, `${job.name}.safetensors`);
|
|
347
|
+
copyFileSync(produced, dest);
|
|
348
|
+
// The catalog is per-instance: after a mid-run retarget, upserting here
|
|
349
|
+
// would register the LoRA in the WRONG instance's catalog. Copy still
|
|
350
|
+
// happened (into the original dir, above); skip the catalog honestly.
|
|
351
|
+
if (job.instanceSlug && job.instanceSlug !== getInstanceSlug()) {
|
|
352
|
+
logger.warn(`[training-jobs] ComfyUI instance changed mid-run (${job.instanceSlug} → ${getInstanceSlug()}); ` +
|
|
353
|
+
`LoRA copied to ${dest} but the catalog upsert was skipped — re-run lora_catalog_upsert on the original instance.`);
|
|
354
|
+
job.result = { loraPath: dest, loraRelPath: `loras/${job.name}.safetensors` };
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const catalog = deps.catalog ?? getLoraCatalog();
|
|
358
|
+
const entry = catalog.upsert({
|
|
359
|
+
relPath: `loras/${job.name}.safetensors`,
|
|
360
|
+
displayName: job.name.replace(/_/g, " "),
|
|
361
|
+
description: `Character LoRA trained locally on FLUX.1-dev via ostris ai-toolkit (comfyui-mcp trainer, job ${job.id}).`,
|
|
362
|
+
setupInstructions: "Load with LoraLoaderModelOnly on a FLUX.1-dev checkpoint" +
|
|
363
|
+
(job.trigger ? ` and include the trigger word "${job.trigger}" in the prompt.` : "."),
|
|
364
|
+
keywords: job.trigger ? [job.trigger] : [],
|
|
365
|
+
baseModels: ["FLUX.1-dev"],
|
|
366
|
+
strengthDefault: 1.0,
|
|
367
|
+
tags: ["trained-locally", "character"],
|
|
368
|
+
// Explicitly clear the flag — retraining a LoRA whose entry was marked
|
|
369
|
+
// missing must become visible again (upsert otherwise preserves it).
|
|
370
|
+
missing: false,
|
|
371
|
+
});
|
|
372
|
+
// Best-effort: newest sample image becomes the catalog preview.
|
|
373
|
+
let previewFile;
|
|
374
|
+
const samples = findSamples(job.outputDir, job.name, 1);
|
|
375
|
+
if (samples.length > 0) {
|
|
376
|
+
try {
|
|
377
|
+
previewFile = catalog.setPreview(entry.id, samples[0]).previewFile;
|
|
378
|
+
}
|
|
379
|
+
catch (err) {
|
|
380
|
+
logger.debug(`[training-jobs] preview copy skipped: ${err instanceof Error ? err.message : String(err)}`);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
job.result = { loraPath: dest, loraRelPath: `loras/${job.name}.safetensors`, catalogId: entry.id, previewFile };
|
|
384
|
+
}
|
|
385
|
+
async function finalizeJob(job, code, tail, deps) {
|
|
386
|
+
// A cancel marks the job before the container exits — don't overwrite it.
|
|
387
|
+
// Check BOTH this process's memory and the on-disk record: a cancel issued
|
|
388
|
+
// from another process (e.g. the orchestrator's call_tool client) only shows
|
|
389
|
+
// up on disk (codex finding: cross-process cancel was clobbered by finalize).
|
|
390
|
+
if (job.status === "cancelled") {
|
|
391
|
+
if (diskCancelled(job))
|
|
392
|
+
return;
|
|
393
|
+
// Memory says cancelled but disk doesn't: the cancel was rolled back after
|
|
394
|
+
// a failed stop — reconcile and finalize normally (codex finding).
|
|
395
|
+
job.status = "running";
|
|
396
|
+
job.finishedAt = undefined;
|
|
397
|
+
job.error = undefined;
|
|
398
|
+
}
|
|
399
|
+
if (diskCancelled(job)) {
|
|
400
|
+
job.status = "cancelled";
|
|
401
|
+
job.finishedAt = new Date().toISOString();
|
|
402
|
+
job.updatedAt = job.finishedAt;
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
job.finishedAt = new Date().toISOString();
|
|
406
|
+
// Surface the generated samples in train_status regardless of outcome —
|
|
407
|
+
// ai-toolkit prints only "Generating Images" bars (no saved-file lines), so
|
|
408
|
+
// onProgress never sees sample paths (codex finding; confirmed by the E2E).
|
|
409
|
+
const samples = findSamples(job.outputDir, job.name, 4);
|
|
410
|
+
if (samples.length > 0)
|
|
411
|
+
job.progress.samples = samples;
|
|
412
|
+
if (code === 0) {
|
|
413
|
+
try {
|
|
414
|
+
handoffToComfyUI(job, deps);
|
|
415
|
+
job.status = "completed";
|
|
416
|
+
// The last training bar can read e.g. 199/200 before the final save +
|
|
417
|
+
// sampling phases — normalize so a completed job shows a complete count
|
|
418
|
+
// (codex finding).
|
|
419
|
+
if (job.progress.totalSteps !== undefined)
|
|
420
|
+
job.progress.step = job.progress.totalSteps;
|
|
421
|
+
reportProgress(job, "done", true);
|
|
422
|
+
}
|
|
423
|
+
catch (err) {
|
|
424
|
+
job.status = "failed";
|
|
425
|
+
job.error = `output handoff failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
426
|
+
reportProgress(job, "error", true);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
else {
|
|
430
|
+
job.status = "failed";
|
|
431
|
+
job.error = `training container exited ${code}${tail ? ` — last output:\n${tail}` : ""}`;
|
|
432
|
+
reportProgress(job, "error", true);
|
|
433
|
+
}
|
|
434
|
+
job.updatedAt = new Date().toISOString();
|
|
435
|
+
persist(job);
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Build the config, launch the container, and register the job. Returns as
|
|
439
|
+
* soon as the container is up; completion is handled by the handle's `done`
|
|
440
|
+
* promise (finalizeJob). Preflight (docker/image) belongs to the caller — the
|
|
441
|
+
* train_start tool runs trainerDoctor checks first.
|
|
442
|
+
*
|
|
443
|
+
* The LoRA handoff destination is resolved BEFORE launch (codex finding): in
|
|
444
|
+
* remote mode / with no local workspace, resolveModelSubfolder throws — better
|
|
445
|
+
* here than after an hours-long run.
|
|
446
|
+
*/
|
|
447
|
+
export async function startTrainingJob(input, deps = {}) {
|
|
448
|
+
const start = deps.startTraining ?? startTraining;
|
|
449
|
+
const now = deps.now ?? (() => Date.now());
|
|
450
|
+
const datasetPath = resolve(input.datasetPath);
|
|
451
|
+
if (!existsSync(datasetPath))
|
|
452
|
+
throw new Error(`dataset not found: ${input.datasetPath}`);
|
|
453
|
+
const imageCount = countDatasetImages(datasetPath);
|
|
454
|
+
if (imageCount === 0)
|
|
455
|
+
throw new Error(`dataset has no images (${[...IMAGE_EXTS].join("/")}): ${datasetPath}`);
|
|
456
|
+
// Pre-launch handoff check — throws early when no local ComfyUI is resolvable.
|
|
457
|
+
const lorasDir = deps.lorasDir ?? (() => resolveModelSubfolder("loras"));
|
|
458
|
+
const resolvedLorasDir = lorasDir();
|
|
459
|
+
const effDeps = { ...deps, lorasDir };
|
|
460
|
+
const id = `t${now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
|
461
|
+
const jobDir = join(jobsRoot(), id);
|
|
462
|
+
const outputDir = join(jobDir, "output");
|
|
463
|
+
mkdirSync(outputDir, { recursive: true });
|
|
464
|
+
// Config sees CONTAINER paths; the driver bind-mounts the host dirs there.
|
|
465
|
+
const built = buildTrainingConfig({
|
|
466
|
+
name: input.name,
|
|
467
|
+
flow: input.flow,
|
|
468
|
+
model: input.model,
|
|
469
|
+
datasetPath: CONTAINER_DATASET,
|
|
470
|
+
outputDir: CONTAINER_OUTPUT,
|
|
471
|
+
trigger: input.trigger,
|
|
472
|
+
device: input.device,
|
|
473
|
+
params: input.params,
|
|
474
|
+
});
|
|
475
|
+
const configPath = join(jobDir, "config.yml");
|
|
476
|
+
writeFileSync(configPath, built.yaml);
|
|
477
|
+
const job = {
|
|
478
|
+
id,
|
|
479
|
+
name: built.jobName,
|
|
480
|
+
flow: input.flow,
|
|
481
|
+
model: input.model,
|
|
482
|
+
trigger: input.trigger,
|
|
483
|
+
status: "queued",
|
|
484
|
+
progress: { samples: [] },
|
|
485
|
+
containerName: `comfyui-train-${id}`,
|
|
486
|
+
datasetPath,
|
|
487
|
+
jobDir,
|
|
488
|
+
outputDir,
|
|
489
|
+
log: [],
|
|
490
|
+
lorasDir: resolvedLorasDir,
|
|
491
|
+
instanceSlug: getInstanceSlug(),
|
|
492
|
+
createdAt: new Date().toISOString(),
|
|
493
|
+
updatedAt: new Date().toISOString(),
|
|
494
|
+
};
|
|
495
|
+
jobs.set(id, job);
|
|
496
|
+
persist(job);
|
|
497
|
+
const handle = start({
|
|
498
|
+
containerName: job.containerName,
|
|
499
|
+
configPath,
|
|
500
|
+
datasetPath,
|
|
501
|
+
outputDir,
|
|
502
|
+
hfCacheDir: hfCacheRoot(),
|
|
503
|
+
hfToken: process.env.HF_TOKEN?.trim() || undefined,
|
|
504
|
+
onProgress: (p) => {
|
|
505
|
+
// Terminal jobs ignore ticks — a progress line arriving while a cancel's
|
|
506
|
+
// docker stop is in flight must not resurrect the job (codex finding).
|
|
507
|
+
if (job.status === "cancelled") {
|
|
508
|
+
// …but a FOREIGN cancel can be rolled back: the cancelling process
|
|
509
|
+
// reverts the disk record to running when its docker stop fails. If the
|
|
510
|
+
// disk no longer says cancelled, resume — otherwise stay cancelled
|
|
511
|
+
// (codex finding: permanent adoption suppressed a later completion).
|
|
512
|
+
if (!diskCancelled(job)) {
|
|
513
|
+
job.status = "running";
|
|
514
|
+
job.finishedAt = undefined;
|
|
515
|
+
job.error = undefined;
|
|
516
|
+
}
|
|
517
|
+
else {
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
if (job.status === "completed" || job.status === "failed")
|
|
522
|
+
return;
|
|
523
|
+
if (job.status !== "running") {
|
|
524
|
+
job.status = "running";
|
|
525
|
+
reportProgress(job, "downloading", true);
|
|
526
|
+
}
|
|
527
|
+
if (p.step !== undefined)
|
|
528
|
+
job.progress.step = p.step;
|
|
529
|
+
if (p.totalSteps !== undefined)
|
|
530
|
+
job.progress.totalSteps = p.totalSteps;
|
|
531
|
+
if (p.loss !== undefined)
|
|
532
|
+
job.progress.loss = p.loss;
|
|
533
|
+
if (p.sample) {
|
|
534
|
+
job.progress.samples.unshift(toHostOutputPath(job, p.sample));
|
|
535
|
+
job.progress.samples = job.progress.samples.slice(0, 4);
|
|
536
|
+
}
|
|
537
|
+
job.updatedAt = new Date().toISOString();
|
|
538
|
+
reportProgress(job, "downloading");
|
|
539
|
+
// Throttled disk snapshot so OTHER processes (orchestrator call_tool
|
|
540
|
+
// client → mobile train_status) see live progress, not just the final
|
|
541
|
+
// state (codex finding: progress was memory-only until finalize).
|
|
542
|
+
const last = lastProgressPersistAt.get(id) ?? 0;
|
|
543
|
+
if (Date.now() - last >= PROGRESS_PERSIST_MS) {
|
|
544
|
+
lastProgressPersistAt.set(id, Date.now());
|
|
545
|
+
persistLiveState(job);
|
|
546
|
+
}
|
|
547
|
+
},
|
|
548
|
+
onLog: (line) => {
|
|
549
|
+
pushLog(job, line);
|
|
550
|
+
// Log lines also snapshot (same throttle): during the long first-run
|
|
551
|
+
// model download there are NO progress ticks, so without this a
|
|
552
|
+
// cross-process train_status sees an empty, apparently stalled record
|
|
553
|
+
// (codex finding).
|
|
554
|
+
const last = lastProgressPersistAt.get(id) ?? 0;
|
|
555
|
+
if (Date.now() - last >= PROGRESS_PERSIST_MS) {
|
|
556
|
+
lastProgressPersistAt.set(id, Date.now());
|
|
557
|
+
persistLiveState(job);
|
|
558
|
+
}
|
|
559
|
+
},
|
|
560
|
+
});
|
|
561
|
+
handles.set(id, handle);
|
|
562
|
+
handle.done
|
|
563
|
+
.then(({ code, tail }) => finalizeJob(job, code, tail, effDeps))
|
|
564
|
+
.catch((err) => {
|
|
565
|
+
logger.warn(`[training-jobs] finalize ${id} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
566
|
+
})
|
|
567
|
+
.finally(() => {
|
|
568
|
+
handles.delete(id);
|
|
569
|
+
lastProgressPersistAt.delete(id);
|
|
570
|
+
// NOTE: the terminal download-progress row is intentionally NOT cleared —
|
|
571
|
+
// the orchestrator watcher lingers done/error rows ~8s (so completion is
|
|
572
|
+
// visible in the tray) and then prunes them itself (codex finding).
|
|
573
|
+
});
|
|
574
|
+
job.status = "running";
|
|
575
|
+
job.updatedAt = new Date().toISOString();
|
|
576
|
+
persist(job);
|
|
577
|
+
return job;
|
|
578
|
+
}
|
|
579
|
+
/** Stop the container and mark the job cancelled. Idempotent. Works
|
|
580
|
+
* cross-process: the container name is persisted, so a cancel from another
|
|
581
|
+
* process (orchestrator call_tool) still reaches `docker stop`.
|
|
582
|
+
*
|
|
583
|
+
* Ordering matters (codex findings): the cancelled state is persisted BEFORE
|
|
584
|
+
* awaiting `docker stop` — otherwise a clean-exit container can finalize
|
|
585
|
+
* (handoff + catalog) while the stop is in flight. After the stop we verify
|
|
586
|
+
* with a liveness probe; if the container is genuinely still alive the job
|
|
587
|
+
* reverts to running with an error instead of lying about being cancelled. */
|
|
588
|
+
export async function cancelJob(id, deps = {}) {
|
|
589
|
+
const job = await getJob(id, deps);
|
|
590
|
+
if (!job)
|
|
591
|
+
throw new Error(`no training job ${id}`);
|
|
592
|
+
if (job.status === "completed" || job.status === "failed")
|
|
593
|
+
return job;
|
|
594
|
+
if (job.status === "cancelled") {
|
|
595
|
+
// Already cancelled — but if a previous cancel died between persisting the
|
|
596
|
+
// state and finishing `docker stop`, the container may still be alive.
|
|
597
|
+
// Retry the stop instead of blindly returning (codex finding).
|
|
598
|
+
if (!job.containerName)
|
|
599
|
+
return job;
|
|
600
|
+
const probe = deps.containerRunning ?? containerRunning;
|
|
601
|
+
const alive = await probe(job.containerName).catch(() => null);
|
|
602
|
+
// Only a definitive "gone" short-circuits; unknown (daemon temporarily
|
|
603
|
+
// unreachable) still attempts the stop so a live container can't keep
|
|
604
|
+
// burning GPU behind a stale cancelled record (codex finding).
|
|
605
|
+
if (alive === false)
|
|
606
|
+
return job;
|
|
607
|
+
const stop = deps.stopTraining ?? stopTraining;
|
|
608
|
+
const res = await stop(job.containerName);
|
|
609
|
+
// Always probe after a stop attempt: a CLI timeout can fire AFTER the
|
|
610
|
+
// daemon honored the stop (codex finding). Only when liveness is unknown
|
|
611
|
+
// do we fall back to the stop command's own result.
|
|
612
|
+
const probed = await probe(job.containerName).catch(() => null);
|
|
613
|
+
const stillRunning = probed ?? res.ok === false;
|
|
614
|
+
if (stillRunning === true) {
|
|
615
|
+
job.status = "running";
|
|
616
|
+
job.finishedAt = undefined;
|
|
617
|
+
job.updatedAt = new Date().toISOString();
|
|
618
|
+
job.error = `cancel retry failed — container ${job.containerName} is still running${res.error ? `: ${res.error.message}` : ""}`;
|
|
619
|
+
persist(job);
|
|
620
|
+
}
|
|
621
|
+
return job;
|
|
622
|
+
}
|
|
623
|
+
job.status = "cancelled";
|
|
624
|
+
job.finishedAt = new Date().toISOString();
|
|
625
|
+
job.updatedAt = job.finishedAt;
|
|
626
|
+
job.error = undefined;
|
|
627
|
+
persist(job);
|
|
628
|
+
if (job.containerName) {
|
|
629
|
+
const stop = deps.stopTraining ?? stopTraining;
|
|
630
|
+
const res = await stop(job.containerName);
|
|
631
|
+
const probe = deps.containerRunning ?? containerRunning;
|
|
632
|
+
// Probe after the stop regardless of its exit status (see above).
|
|
633
|
+
const probed = await probe(job.containerName).catch(() => null);
|
|
634
|
+
const stillRunning = probed ?? res.ok === false;
|
|
635
|
+
if (stillRunning === true) {
|
|
636
|
+
// Stop genuinely failed — the container is still training.
|
|
637
|
+
job.status = "running";
|
|
638
|
+
job.finishedAt = undefined;
|
|
639
|
+
job.updatedAt = new Date().toISOString();
|
|
640
|
+
job.error = `cancel failed — container ${job.containerName} is still running${res.error ? `: ${res.error.message}` : ""}`;
|
|
641
|
+
persist(job);
|
|
642
|
+
return job;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
reportProgress(job, "error", true);
|
|
646
|
+
persist(job);
|
|
647
|
+
return job;
|
|
648
|
+
}
|
|
649
|
+
/** Public, JSON-safe view of a job (drops nothing — TrainingJob is already
|
|
650
|
+
* plain data; exposed so tools/tests have one obvious shape). */
|
|
651
|
+
export function toJobSummary(job) {
|
|
652
|
+
return job;
|
|
653
|
+
}
|
|
654
|
+
//# sourceMappingURL=training-jobs.js.map
|