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.
Files changed (34) hide show
  1. package/dist/orchestrator/gemini-backend.js +109 -29
  2. package/dist/orchestrator/gemini-backend.js.map +1 -1
  3. package/dist/orchestrator/index.js +106 -5
  4. package/dist/orchestrator/index.js.map +1 -1
  5. package/dist/orchestrator/panel-agent.js +12 -1
  6. package/dist/orchestrator/panel-agent.js.map +1 -1
  7. package/dist/services/ai-toolkit.js +220 -0
  8. package/dist/services/ai-toolkit.js.map +1 -0
  9. package/dist/services/training-config.js +138 -0
  10. package/dist/services/training-config.js.map +1 -0
  11. package/dist/services/training-jobs.js +654 -0
  12. package/dist/services/training-jobs.js.map +1 -0
  13. package/dist/services/workflow-validator.js +4 -0
  14. package/dist/services/workflow-validator.js.map +1 -1
  15. package/dist/tools/diagnostics.js +120 -1
  16. package/dist/tools/diagnostics.js.map +1 -1
  17. package/dist/tools/index.js +63 -3
  18. package/dist/tools/index.js.map +1 -1
  19. package/dist/tools/train.js +187 -0
  20. package/dist/tools/train.js.map +1 -0
  21. package/docker/runpod/.gitattributes +8 -0
  22. package/docker/runpod/Dockerfile +486 -0
  23. package/docker/runpod/README.md +658 -0
  24. package/docker/runpod/config.ini +28 -0
  25. package/docker/runpod/deploy-dockerhub.sh +46 -0
  26. package/docker/runpod/extra_model_paths.yaml +61 -0
  27. package/docker/runpod/nginx.conf +124 -0
  28. package/docker/runpod/post_start.sh +619 -0
  29. package/docker/runpod/starting.html +47 -0
  30. package/docker/runpod/test_image.sh +180 -0
  31. package/docker/runpod/verify_image_remote.py +126 -0
  32. package/docker/trainer/Dockerfile +69 -0
  33. package/package.json +2 -1
  34. 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