rvlib-shipkit 0.17.2 → 0.18.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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.18.0
4
+
5
+ - **The comfy backend runs on comfy-ts.** `.rv-comfy/workflows/` holds `*.cflow.ts` workflow modules (one `host.defineWorkflow` per graph family, plus a `_host.ts` the modules import). Exported json graphs and `.ts` sidekick pairs are gone, and the raw HTTP engine (`comfy-gen.ts`) and pair loader (`workflow-loader.ts`) are deleted with them. Every module must declare `vars.prompt` (`v.prompt`) and `vars.seed` (`v.seed`), shipkit drives both.
6
+ - **One comfy-ts draft per asset.** Shipkit seeds `.comfy-ts/drafts/<workflowId>/<assetId>.json` once and never overwrites it, so `bunx comfy-ts tui` lists every asset as a tweakable row under its workflow. At generation: reset vars → kit `vars` → asset draft → prompt and seed pinned programmatically. The kit + studio overlay stay the prompt source of truth.
7
+ - **BREAKING: `comfy({ workflow, vars })` replaces `bg`.** `vars` is a record of workflow var values applied before the draft, an unknown var name fails loud.
8
+ - **BREAKING: `KitConfig.comfy.host` is removed.** The workflow's `_host.ts` module owns the box connection; `comfy:` keeps `{ workflows, heal }`.
9
+ - **BREAKING: removed exports** `generate`, `submit`, `waitFor`, `applyInputs`, `expectNode`, `ComfyOpts`, `loadWorkflows`, and the pair types `Workflow`, `Sidekick`, `Binding`, `Graph`. New surface: `generateComfyTs`, `loadCflowWorkflows`, `workflowFor` (same resolution semantics, cflow-backed), `applyVarValues`, `ensureAssetDraft`, `readAssetDraft`, `draftPath`, `disconnectAll`, plus types `ComfyTsRuntime`, `LoadedWorkflow`, `GenProgress`, `OnProgress`. `randomSeed` and `batchFolderName` survive unchanged.
10
+ - **The `.comfy-ts/` privacy rule is universal, not one repo's.** The SK004 gitignore policy now carries `.comfy-ts/` and `*.loras.local.txt` into every repo's managed block (shipkit itself writes `.comfy-ts/drafts/` at hub boot, so every consumer needs both ignored before its first commit), and `shipkit init` scaffolds `hooks: { blockedPaths: ['.comfy-ts/'] }`. `validateKit` rejects unknown `comfy:` keys, so a kit still passing the removed `host` gets the migration message instead of silence.
11
+ - **The leak guard scans added lines only.** Deleting a file that carries a banned name used to block the deletion, because the guard read the whole staged diff. Removed and context lines are already in history, so only what the commit ADDS to the object store is scanned. Staged paths and the commit message are scanned as before.
12
+ - **New pre-commit gate `blocked-paths`.** `repo.config.ts hooks: { blockedPaths: ['.comfy-ts/'] }` fails a commit on any staged path under a blocked prefix. Gitignore is advisory and editable; local state that must never enter git (schema dumps naming a private model collection) gets a gate instead. Listed by `shipkit hooks list`.
13
+ - **`assets check` now validates graphs offline.** Modules import with the box off (schema cache or loud base-type fallback), each workflow prints its `.cflow.ts` provenance and vars, kind `workflow`/`vars` resolve dry, drafts are ensured per asset, and a loaded schema cache runs `build()` and fails on graph problems. A missing cache or an unreachable box stays a WARN with the fix command.
14
+
3
15
  ## 0.17.2
4
16
 
5
17
  - **A release no longer probes the world.** The release gate runs only the checks that can BLOCK. A check declaring `blocking: false` cannot change the outcome, so running it spent time and a third party API call per publish to learn whether a DNS provider was reachable. `release.requireHealthy` still makes those failures fatal, and then they run.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "0.17.2",
3
+ "version": "0.18.0",
4
4
  "description": "All-in-one repo companion: ci gate (task DAG), auto-discovered health checks, release-contract fulfillness, asks queue, md-wiki tools, asset atelier, live tui",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -27,6 +27,7 @@
27
27
  "dependencies": {
28
28
  "@napi-rs/canvas": "^1.0.2",
29
29
  "clipanion": "^4.0.0-rc.4",
30
+ "comfy-ts": "^2.4.2",
30
31
  "ink": "^7.1.1",
31
32
  "marked": "^18.0.7",
32
33
  "mobx": "^6.16.1",
@@ -0,0 +1,202 @@
1
+ // the ONE comfy engine: comfy-ts *.cflow.ts workflow modules in the kit's
2
+ // workflows dir, one draft per asset (the TUI's tweakable entry), rolls
3
+ // written from in-memory buffers. Every CLI path that resolves a runtime
4
+ // must disconnectAll() in a finally, or the host websocket keeps bun alive.
5
+ // Workflows are discriminated by duck-typing, never instanceof: a consumer's
6
+ // .cflow.ts may hold a second copy of comfy-ts in its module graph.
7
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs'
8
+ import { basename, join } from 'path'
9
+ import { pathToFileURL } from 'url'
10
+ import { ComfyTS, type AnyVar, type ComfyExecution, type DefinedWorkflow, type MediaImage } from 'comfy-ts'
11
+
12
+ export type GenProgress = { value: number; max: number; node: string | null }
13
+ export type OnProgress = (p: GenProgress) => void
14
+
15
+ export type LoadedWorkflow = { id: string; file: string; wf: DefinedWorkflow }
16
+ export type ComfyTsRuntime = { workflows: Map<string, LoadedWorkflow>; heal?: string }
17
+
18
+ // function declaration, not const — TS only narrows on never-calls to
19
+ // directly-declared functions
20
+ function fail(msg: string): never { throw new Error(msg) }
21
+
22
+ // same duck predicate the comfy-ts TUI and serve bind to: a module valid
23
+ // for shipkit is valid for `bunx comfy-ts tui` and vice versa
24
+ function findDefinedWorkflow(mod: Record<string, unknown>): DefinedWorkflow | null {
25
+ const isDefined = (x: unknown): x is DefinedWorkflow =>
26
+ typeof x === 'object' && x != null && 'spec' in x &&
27
+ typeof (x as DefinedWorkflow).run === 'function' &&
28
+ typeof (x as DefinedWorkflow).entries === 'function'
29
+ return [mod.default, ...Object.values(mod)].find(isDefined) ?? null
30
+ }
31
+
32
+ const varsOf = (wf: DefinedWorkflow): Record<string, AnyVar> => wf.vars as Record<string, AnyVar>
33
+
34
+ /** true when the image belongs in a roll. Preview-family nodes (PreviewImage,
35
+ * PreviewBridge, …) emit raw temp images that comfy-ts downloads like any
36
+ * output, in completion order, and MediaImage drops the server-side
37
+ * 'temp' marker, so the producing node's class_type is the only signal left.
38
+ * no node json (SaveImageWebsocket frames) = deliberate output, kept. */
39
+ export const isRollImage = (img: Pick<MediaImage, 'ComfyNode'>): boolean =>
40
+ !/preview/i.test(img.ComfyNode?.class_type ?? '')
41
+
42
+ /** dir = the flat folder holding the <id>.cflow.ts workflow modules.
43
+ * root pins `.comfy-ts/` (drafts, hosts cache) to the repo, cwd-independent. */
44
+ export async function loadCflowWorkflows(root: string, dir: string): Promise<Map<string, LoadedWorkflow>> {
45
+ // shipkit is a driver like the TUI: comfy-ts INFO chatter would pollute
46
+ // the wire output (and STATUS.md). Errors still print. Respect an
47
+ // explicit COMFY_TS_QUIET=0 from the caller.
48
+ process.env.COMFY_TS_QUIET ??= '1'
49
+ // before the first module import: a bare ComfyTS.create() inside the
50
+ // module then returns this same instance. A pre-existing instance with a
51
+ // different root throws loud — a real misconfiguration.
52
+ ComfyTS.create({ rootPath: root })
53
+ if (!existsSync(dir)) fail(`${dir}/ missing — ship at least one <id>.cflow.ts comfy-ts workflow module (copy from the rv-comfy template)`)
54
+ const out = new Map<string, LoadedWorkflow>()
55
+ for (const f of readdirSync(dir).filter((x) => x.endsWith('.cflow.ts')).sort()) {
56
+ const file = join(dir, f)
57
+ const id = basename(f, '.cflow.ts')
58
+ const mod = (await import(pathToFileURL(file).href)) as Record<string, unknown>
59
+ const wf = findDefinedWorkflow(mod)
60
+ if (wf == null) fail(`${file}: exports no DefinedWorkflow — export default host.defineWorkflow({...})`)
61
+ // the file name IS the id: it is also the comfy-ts draft folder key,
62
+ // so letting them diverge would strand every per-asset draft
63
+ if (wf.spec.id != null && wf.spec.id !== id) fail(`workflow file '${f}' declares id '${wf.spec.id}' — the file name IS the id, rename one`)
64
+ wf.spec.id ??= id
65
+ const vars = varsOf(wf)
66
+ if (vars.prompt?.kind !== 'prompt' || vars.seed?.kind !== 'seed')
67
+ fail(`workflow '${id}' must declare vars.prompt (v.prompt) and vars.seed (v.seed) — shipkit drives both`)
68
+ out.set(id, { id, file, wf })
69
+ }
70
+ if (out.size === 0) fail(`${dir}/ has no *.cflow.ts workflow modules — seed one from the rv-comfy template`)
71
+ return out
72
+ }
73
+
74
+ /** Resolve a workflow: explicit id, else the sole loaded workflow. */
75
+ export function workflowFor(workflows: Map<string, LoadedWorkflow>, ref: { workflow?: string; assetId?: string }): LoadedWorkflow {
76
+ if (ref.workflow != null) {
77
+ const lw = workflows.get(ref.workflow)
78
+ if (lw == null) fail(`asset '${ref.assetId ?? '?'}': unknown workflow '${ref.workflow}' (have: ${[...workflows.keys()].join(', ')})`)
79
+ return lw
80
+ }
81
+ if (workflows.size === 1) return [...workflows.values()][0]!
82
+ return fail(`asset '${ref.assetId ?? '?'}' names no workflow and ${workflows.size} are loaded (${[...workflows.keys()].join(', ')}) — set workflow on the kind`)
83
+ }
84
+
85
+ /** Apply a name→value record onto a workflow's vars: strings go through
86
+ * parse (validated), everything else through loadJSON (the drafts setter).
87
+ * Unknown names throw listing what the workflow has. */
88
+ export function applyVarValues(wf: DefinedWorkflow, vars: Record<string, unknown>, source: string): void {
89
+ const defs = varsOf(wf)
90
+ for (const [name, value] of Object.entries(vars)) {
91
+ const varDef = defs[name]
92
+ if (varDef == null) fail(`${source}: unknown var '${name}' on workflow '${wf.spec.id ?? '?'}' (has: ${wf.entries().map(([n, d]) => `${n}:${d.kind}`).join(', ')})`)
93
+ if (typeof value === 'string') {
94
+ if (!varDef.parse(value)) fail(`${source}: var '${name}' (${varDef.kind}) rejected '${value}'`)
95
+ } else varDef.loadJSON(value)
96
+ }
97
+ }
98
+
99
+ // ── per-asset drafts — comfy-ts disk format, path rule taken from the
100
+ // ComfyTS instance so it cannot drift: .comfy-ts/drafts/<wfId>/<assetId>.json.
101
+ // Shipkit does its own IO here because comfy-ts exports no drafts API yet;
102
+ // swap when it lands.
103
+ const draftDir = (lw: LoadedWorkflow): string => ComfyTS.create().resolveFromDrafts(lw.id)
104
+ export const draftPath = (lw: LoadedWorkflow, name: string): string => join(draftDir(lw), `${name}.json`)
105
+
106
+ export function readAssetDraft(lw: LoadedWorkflow, assetId: string): Record<string, unknown> | null {
107
+ const p = draftPath(lw, assetId)
108
+ if (!existsSync(p)) return null
109
+ try {
110
+ return JSON.parse(readFileSync(p, 'utf8')) as Record<string, unknown>
111
+ } catch (e) {
112
+ throw new Error(`draft ${p} is not valid JSON (${(e as Error).message}) — fix or delete it`)
113
+ }
114
+ }
115
+
116
+ /** Seed `{ prompt }` ONLY when the draft is absent (hand-tuned state is never
117
+ * overwritten). The stored prompt is TUI context; generation overrides it.
118
+ * Returns true when just seeded. */
119
+ export function ensureAssetDraft(lw: LoadedWorkflow, assetId: string, prompt: string): boolean {
120
+ const p = draftPath(lw, assetId)
121
+ if (existsSync(p)) return false
122
+ mkdirSync(draftDir(lw), { recursive: true })
123
+ writeFileSync(p, JSON.stringify({ prompt }, null, 2) + '\n')
124
+ return true
125
+ }
126
+
127
+ /**
128
+ * Run one generation through a cflow workflow: reset vars → kit vars → asset
129
+ * draft → prompt/seed override, run WITHOUT save (in-memory buffers), write
130
+ * <destDir>/<name>.<ext> (suffix -2, -3… on extra images). The kit prompt +
131
+ * studio overlay stay the prompt source of truth; the draft owns every other var.
132
+ */
133
+ export async function generateComfyTs(
134
+ rt: ComfyTsRuntime,
135
+ lw: LoadedWorkflow,
136
+ args: { prompt: string; seed: number; assetId: string; destDir: string; name: string; vars?: Record<string, unknown>; onProgress?: OnProgress },
137
+ ): Promise<string[]> {
138
+ ensureAssetDraft(lw, args.assetId, args.prompt)
139
+ const defs = varsOf(lw.wf)
140
+ // modules are import-cached: a previous asset's values must not leak
141
+ for (const [, varDef] of lw.wf.entries()) varDef.reset()
142
+ if (args.vars != null) applyVarValues(lw.wf, args.vars, `asset '${args.assetId}' kit vars`)
143
+ const draft = readAssetDraft(lw, args.assetId)
144
+ if (draft != null) {
145
+ for (const [key, value] of Object.entries(draft)) {
146
+ const varDef = defs[key]
147
+ if (varDef == null) { console.log(`WARN draft '${args.assetId}': stale var '${key}' not on workflow '${lw.id}' — ignored`); continue }
148
+ varDef.loadJSON(value)
149
+ }
150
+ }
151
+ defs.prompt!.parse(args.prompt)
152
+ // '= N' pins mode AND value, neutralizing any ?/+ mode a draft restored,
153
+ // so the v<seed> file name stays truthful
154
+ defs.seed!.parse(`= ${args.seed}`)
155
+ mkdirSync(args.destDir, { recursive: true })
156
+ const healHint = rt.heal != null ? ` — is the backend up? (${rt.heal})` : ''
157
+ let execution: ComfyExecution
158
+ try {
159
+ execution = await lw.wf.run({
160
+ onProgress: args.onProgress == null ? undefined : (p) => args.onProgress!({ value: Math.round(p.percent), max: 100, node: p.nodeName }),
161
+ })
162
+ } catch (e) {
163
+ throw new Error(`workflow '${lw.id}' run failed on ${lw.wf.host.getServerHostHTTP()}: ${(e as Error).message}${healHint}`)
164
+ }
165
+ if (execution.status === 'Failure')
166
+ throw new Error(`workflow '${lw.id}' failed: ${String(execution.data.error ?? 'unknown error')}${healHint}`)
167
+ const rolls = execution.images.filter(isRollImage)
168
+ const saved: string[] = []
169
+ for (const [i, img] of rolls.entries()) {
170
+ const ext = img.metadata.type ?? 'png'
171
+ const dest = join(args.destDir, `${args.name}${i > 0 ? `-${i + 1}` : ''}.${ext}`)
172
+ await Bun.write(dest, img.buffer as Uint8Array<ArrayBuffer>)
173
+ saved.push(dest)
174
+ }
175
+ const errSummary = execution.imageErrors.length === 0 ? '' : ` (${execution.imageErrors.length} image retrieval(s) failed)`
176
+ const previewSummary = execution.images.length === rolls.length ? '' : ` (${execution.images.length - rolls.length} preview image(s) skipped)`
177
+ if (saved.length === 0) throw new Error(`workflow '${lw.id}' produced no images — does the graph end in a SaveImage node?${previewSummary}${errSummary}`)
178
+ // partial output stays visible on disk, the failure stays loud
179
+ if (execution.imageErrors.length > 0)
180
+ throw new Error(`workflow '${lw.id}': ${saved.length} image(s) written, ${execution.imageErrors.length} retrieval(s) failed`)
181
+ return saved
182
+ }
183
+
184
+ /** Close every loaded workflow's host websocket — CLI paths call this in a
185
+ * finally or the process never exits. */
186
+ export function disconnectAll(rt: ComfyTsRuntime): void {
187
+ const seen = new Set<unknown>()
188
+ for (const lw of rt.workflows.values()) {
189
+ if (seen.has(lw.wf.host)) continue
190
+ seen.add(lw.wf.host)
191
+ lw.wf.host.disconnect()
192
+ }
193
+ }
194
+
195
+ export const randomSeed = (): number => Math.floor(Math.random() * 2_000_000_000) + 1
196
+
197
+ /** Shell-stamped batch folder name — the one-shot output convention. */
198
+ export function batchFolderName(slug: string): string {
199
+ const d = new Date()
200
+ const p = (n: number) => String(n).padStart(2, '0')
201
+ return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}-${slug}`
202
+ }
@@ -1,22 +1,20 @@
1
1
  // The ONE roll generator — canvas (procedural, in-process via @napi-rs/canvas)
2
- // or comfy (HTTP through a workflow pair). The studio worker AND the
3
- // fill/add/gen CLI both call this, so a canvas kit rolls the SAME way in both
4
- // (before this, canvas draw lived only in the studio and the CLI refused
5
- // canvas kinds — A30). Comfy context is resolved LAZILY: canvas-only kits
6
- // never touch a comfy config or the box.
2
+ // or comfy (comfy-ts through a .cflow.ts workflow module). The studio worker
3
+ // AND the fill/add/gen CLI both call this, so a canvas kit rolls the SAME way
4
+ // in both (before this, canvas draw lived only in the studio and the CLI
5
+ // refused canvas kinds — A30). Comfy context is resolved LAZILY: canvas-only
6
+ // kits never touch a comfy config or the box.
7
7
  import { mkdirSync } from 'fs'
8
8
  import { join } from 'path'
9
9
  import type { FoundAsset } from '../kit-config.ts'
10
- import { generate, type OnProgress } from './comfy-gen.ts'
10
+ import { generateComfyTs, loadCflowWorkflows, workflowFor, type ComfyTsRuntime, type OnProgress } from './comfyts-gen.ts'
11
11
  import { recordParents, type ParentContext } from './derived.ts'
12
12
  import { generateGemini } from './gemini-gen.ts'
13
- import { loadWorkflows, workflowFor, type Workflow } from './workflow-loader.ts'
14
13
 
15
14
  /** roll canvas master size (square) — favicon/logo/sprites derive from it */
16
15
  export const CANVAS_SIZE = 1024
17
16
 
18
- export type ComfyRuntime = { workflows: Map<string, Workflow>; host: string; heal?: string }
19
- export type ComfyResolver = () => Promise<ComfyRuntime>
17
+ export type ComfyResolver = () => Promise<ComfyTsRuntime>
20
18
 
21
19
  /** Draw/generate one roll for `found` into destDir as v<seed>.png (canvas) or
22
20
  * v<seed>.<ext> (comfy). `resolveComfy` is only awaited for comfy backends. */
@@ -46,24 +44,23 @@ export async function generateRoll(
46
44
  return
47
45
  }
48
46
  const rt = await resolveComfy()
49
- const wf = workflowFor(rt.workflows, { workflow: gen.workflow, assetId: found.asset.id })
50
- await generate({ host: rt.host, heal: rt.heal }, wf, { prompt: args.prompt, seed: args.seed, destDir: args.destDir, name: `v${args.seed}`, onProgress: args.onProgress })
47
+ const lw = workflowFor(rt.workflows, { workflow: gen.workflow, assetId: found.asset.id })
48
+ await generateComfyTs(rt, lw, { prompt: args.prompt, seed: args.seed, assetId: found.asset.id, destDir: args.destDir, name: `v${args.seed}`, vars: gen.vars, onProgress: args.onProgress })
51
49
  }
52
50
 
53
- /** Memoized lazy comfy runtime for a resolved kit — loads the workflow pairs on
51
+ /** Memoized lazy comfy runtime for a resolved kit — loads the cflow modules on
54
52
  * the FIRST comfy roll only. `onMissing` is called (never returns) if a comfy
55
53
  * asset needs it but the kit declares no comfy config. */
56
54
  export function comfyResolver(
57
55
  root: string,
58
- kit: { comfy?: { host: string; workflows: string; heal?: string } },
56
+ kit: { comfy?: { workflows: string; heal?: string } },
59
57
  onMissing: () => never,
60
58
  ): ComfyResolver {
61
- let cached: Promise<ComfyRuntime> | null = null
59
+ let cached: Promise<ComfyTsRuntime> | null = null
62
60
  return () => {
63
61
  if (kit.comfy == null) onMissing()
64
62
  return (cached ??= (async () => ({
65
- workflows: await loadWorkflows(join(root, kit.comfy!.workflows)),
66
- host: kit.comfy!.host,
63
+ workflows: await loadCflowWorkflows(root, join(root, kit.comfy!.workflows)),
67
64
  heal: kit.comfy!.heal,
68
65
  }))())
69
66
  }
@@ -21,7 +21,9 @@ export type AssetSpec = {
21
21
  ship?: unknown
22
22
  }
23
23
 
24
- export type ComfyGen = { backend: 'comfy'; workflow?: string; bg?: string }
24
+ /** comfy-ts backend: workflow = a .cflow.ts module id in the kit's workflows
25
+ * dir; vars = kind-level var baseline applied before the asset draft */
26
+ export type ComfyGen = { backend: 'comfy'; workflow?: string; vars?: Record<string, unknown> }
25
27
  export type CanvasGen = { backend: 'canvas'; draw: (ctx: unknown, asset: AssetSpec) => void | Promise<void> }
26
28
  /** Human-provided assets (any media): the studio's upload mints rolls, a
27
29
  * manual asset with no rolls is RED + FAILs `shipkit assets check`. The
@@ -30,7 +32,7 @@ export type ManualGen = { backend: 'manual' }
30
32
  /** Gemini image backend (edit-from-parent flows; comfy stays for from-scratch). */
31
33
  export type GeminiGen = { backend: 'gemini'; model?: 'flash' | 'pro' }
32
34
  export type GeneratorSpec = ComfyGen | CanvasGen | ManualGen | GeminiGen
33
- export const comfy = (p?: { workflow?: string; bg?: string }): ComfyGen => ({ backend: 'comfy', workflow: p?.workflow, bg: p?.bg })
35
+ export const comfy = (p?: { workflow?: string; vars?: Record<string, unknown> }): ComfyGen => ({ backend: 'comfy', workflow: p?.workflow, vars: p?.vars })
34
36
  export const canvas = (draw: CanvasGen['draw']): CanvasGen => ({ backend: 'canvas', draw })
35
37
  export const manual = (): ManualGen => ({ backend: 'manual' })
36
38
  export const gemini = (p?: { model?: 'flash' | 'pro' }): GeminiGen => ({ backend: 'gemini', model: p?.model })
@@ -114,7 +116,9 @@ export type Kind = {
114
116
  export type KitConfig = {
115
117
  /** roll workspace root, repo-root-relative; gitignored */
116
118
  workspace?: string
117
- comfy?: { host: string; workflows: string; heal?: string }
119
+ /** workflows = dir of comfy-ts .cflow.ts modules (each module owns its
120
+ * host); heal = shell command shown in error hints, never executed */
121
+ comfy?: { workflows: string; heal?: string }
118
122
  kinds: Kind[]
119
123
  }
120
124
 
@@ -123,6 +127,9 @@ export type ResolvedKit = KitConfig & { workspace: string }
123
127
  export function validateKit(k: KitConfig): string[] {
124
128
  const errs: string[] = []
125
129
  if (!Array.isArray(k.kinds) || k.kinds.length === 0) errs.push('kinds must be a non-empty array')
130
+ for (const key of Object.keys(k.comfy ?? {}))
131
+ if (key !== 'workflows' && key !== 'heal')
132
+ errs.push(`comfy config has unknown key '${key}'${key === 'host' ? " — host is gone, the workflow's _host.ts owns the box (comfy-ts migration)" : ''}`)
126
133
  const kindIds = new Set<string>()
127
134
  const assetIds = new Set<string>()
128
135
  for (const kind of k.kinds ?? []) {
@@ -130,6 +137,8 @@ export function validateKit(k: KitConfig): string[] {
130
137
  if (kindIds.has(kind.id)) errs.push(`duplicate kind id '${kind.id}'`)
131
138
  kindIds.add(kind.id)
132
139
  if (kind.generate?.backend === 'comfy' && k.comfy == null) errs.push(`kind '${kind.id}' uses the comfy backend but the kit has no comfy config`)
140
+ if (kind.generate?.backend === 'comfy' && kind.generate.vars != null && (typeof kind.generate.vars !== 'object' || Array.isArray(kind.generate.vars)))
141
+ errs.push(`kind '${kind.id}': comfy vars must be a plain object`)
133
142
  if (kind.media != null && !(kind.media in MEDIA_EXTS))
134
143
  errs.push(`kind '${kind.id}': unknown media '${String(kind.media)}' (${Object.keys(MEDIA_EXTS).join('/')})`)
135
144
  if (kind.media === 'audio' && kind.generate?.backend !== 'manual')
@@ -16,7 +16,8 @@ export type PromptOverlay = { prompt: string | null }
16
16
  export const PROMPT_INIT: PromptOverlay = { prompt: null }
17
17
 
18
18
  // extension list mirrors kit-config MEDIA_EXTS (audio added 0.6.0)
19
- export const isRollFile = (f: string): boolean => /^v\d+\.(webp|png|mp3|wav|ogg|m4a)$/.test(f)
19
+ // -N suffix: a run that returned several images (batch_size, second save node)
20
+ export const isRollFile = (f: string): boolean => /^v\d+(-\d+)?\.(webp|png|mp3|wav|ogg|m4a)$/.test(f)
20
21
  export const isAudioRoll = (f: string): boolean => /\.(mp3|wav|ogg|m4a)$/.test(f)
21
22
 
22
23
  export const assetDir = (workspace: string, assetId: string): string => join(workspace, 'studio', assetId)
@@ -3,11 +3,10 @@
3
3
  // sinks, and return exit codes — 0 ok · 1 fail).
4
4
  import { existsSync } from 'fs'
5
5
  import { join, relative } from 'path'
6
- import { randomSeed } from './gen/comfy-gen.ts'
6
+ import { applyVarValues, disconnectAll, ensureAssetDraft, loadCflowWorkflows, randomSeed, workflowFor, type ComfyTsRuntime, type LoadedWorkflow } from './gen/comfyts-gen.ts'
7
7
  import { comfyResolver, generateRoll, type ComfyResolver } from './gen/generate-roll.ts'
8
8
  import { parentIdsOf, resolveParentContext, topoSort, type ParentContext } from './gen/derived.ts'
9
- import { loadWorkflows } from './gen/workflow-loader.ts'
10
- import { allAssets, findAsset, loadKit, type ResolvedKit } from './kit-config.ts'
9
+ import { allAssets, findAsset, loadKit, type ComfyGen, type ResolvedKit } from './kit-config.ts'
11
10
  import { NO_PICK_FIX, renderIdentity, type Brand } from './kits/identity-kit.ts'
12
11
  import { assetDir, listRolls, readPick, readPromptOverlay } from './roll-workspace.ts'
13
12
 
@@ -19,8 +18,17 @@ const die = (msg: string): never => { throw new AssetsFail(msg) }
19
18
 
20
19
  // Lazy comfy runtime — a canvas-only kit (no comfy: block) never loads
21
20
  // workflows or needs the box; only a real comfy roll triggers resolution.
22
- const comfyFor = (root: string, kit: ResolvedKit): ComfyResolver =>
23
- comfyResolver(root, kit, () => die('this kit has no comfy config — add comfy: { host, workflows } to fill comfy assets'))
21
+ // The wrapper keeps a handle on the resolved runtime: a CLI path that ran a
22
+ // roll holds an open host websocket and must disconnect or bun never exits.
23
+ type ComfyHandle = { resolve: ComfyResolver; disconnect: () => void }
24
+ function comfyFor(root: string, kit: ResolvedKit): ComfyHandle {
25
+ const inner = comfyResolver(root, kit, () => die('this kit has no comfy config — add comfy: { workflows } to fill comfy assets'))
26
+ let rt: ComfyTsRuntime | null = null
27
+ return {
28
+ resolve: async () => (rt = await inner()),
29
+ disconnect: () => { if (rt != null) disconnectAll(rt) },
30
+ }
31
+ }
24
32
 
25
33
  async function rollOnce(root: string, io: AssetsIo, kit: ResolvedKit, resolveComfy: ComfyResolver, assetId: string, count: number): Promise<number> {
26
34
  const f = findAsset(kit, assetId)
@@ -62,29 +70,38 @@ async function guarded(io: AssetsIo, body: () => Promise<number>): Promise<numbe
62
70
  export const runAssetsTopUp = (root: string, io: AssetsIo, o: { mode: 'fill' | 'add'; n: number; categories: string[] }): Promise<number> =>
63
71
  guarded(io, async () => {
64
72
  const kit = await loadKit(root)
65
- const resolveComfy = comfyFor(root, kit)
66
- // canvas AND comfy kinds top up (A30 — canvas kinds draw in-process);
67
- // manual kinds have nothing to generate — visible skip, never silence
68
- const inScope = allAssets(kit).filter((f) => o.categories.length === 0 || o.categories.includes(f.kind.category))
69
- const manualCount = inScope.filter((f) => f.kind.generate.backend === 'manual').length
70
- if (manualCount > 0) io.print(`skip: ${manualCount} manual asset(s) — upload in the studio`)
71
- // topo (parents-first) replaced sparsest-first: derived children need
72
- // their parents' rolls to exist before they can ever generate
73
- const targets = topoSort(inScope.filter((f) => f.kind.generate.backend !== 'manual'))
74
- .map((f) => ({ f, have: listRolls(assetDir(join(root, kit.workspace), f.asset.id)).length }))
75
- .map((t) => ({ ...t, want: o.mode === 'add' ? o.n : Math.max(0, o.n - t.have) }))
76
- .filter((t) => t.want > 0)
77
- let failed = 0
78
- for (const t of targets) failed += await rollOnce(root, io, kit, resolveComfy, t.f.asset.id, t.want)
79
- io.print(`${o.mode}: ${targets.length} asset(s) topped up${failed > 0 ? ` — ${failed} roll(s) FAILED` : ''}`)
80
- return failed > 0 ? 1 : 0
73
+ const comfy = comfyFor(root, kit)
74
+ try {
75
+ // canvas AND comfy kinds top up (A30 — canvas kinds draw in-process);
76
+ // manual kinds have nothing to generate — visible skip, never silence
77
+ const inScope = allAssets(kit).filter((f) => o.categories.length === 0 || o.categories.includes(f.kind.category))
78
+ const manualCount = inScope.filter((f) => f.kind.generate.backend === 'manual').length
79
+ if (manualCount > 0) io.print(`skip: ${manualCount} manual asset(s) — upload in the studio`)
80
+ // topo (parents-first) replaced sparsest-first: derived children need
81
+ // their parents' rolls to exist before they can ever generate
82
+ const targets = topoSort(inScope.filter((f) => f.kind.generate.backend !== 'manual'))
83
+ .map((f) => ({ f, have: listRolls(assetDir(join(root, kit.workspace), f.asset.id)).length }))
84
+ .map((t) => ({ ...t, want: o.mode === 'add' ? o.n : Math.max(0, o.n - t.have) }))
85
+ .filter((t) => t.want > 0)
86
+ let failed = 0
87
+ for (const t of targets) failed += await rollOnce(root, io, kit, comfy.resolve, t.f.asset.id, t.want)
88
+ io.print(`${o.mode}: ${targets.length} asset(s) topped up${failed > 0 ? ` — ${failed} roll(s) FAILED` : ''}`)
89
+ return failed > 0 ? 1 : 0
90
+ } finally {
91
+ comfy.disconnect()
92
+ }
81
93
  })
82
94
 
83
95
  export const runAssetsGen = (root: string, io: AssetsIo, o: { assetId: string; n: number }): Promise<number> =>
84
96
  guarded(io, async () => {
85
97
  const kit = await loadKit(root)
86
- const failed = await rollOnce(root, io, kit, comfyFor(root, kit), o.assetId, o.n)
87
- return failed > 0 ? 1 : 0
98
+ const comfy = comfyFor(root, kit)
99
+ try {
100
+ const failed = await rollOnce(root, io, kit, comfy.resolve, o.assetId, o.n)
101
+ return failed > 0 ? 1 : 0
102
+ } finally {
103
+ comfy.disconnect()
104
+ }
88
105
  })
89
106
 
90
107
  // ── identity: ONE render path + a brand.ts content hash so drift is
@@ -148,23 +165,82 @@ export const runAssetsCheck = (root: string, io: AssetsIo): Promise<number> =>
148
165
  if (kit == null) return 1
149
166
  io.print(`ok: kit loads — ${kit.kinds.length} kind(s), ${allAssets(kit).length} asset(s), workspace ${kit.workspace}`)
150
167
  if (kit.comfy != null) {
168
+ // modules import offline (schema cache, or a loud base-type
169
+ // fallback inside comfy-ts) — no box needed for any FAIL below
170
+ let wfs: Map<string, LoadedWorkflow> | null = null
151
171
  try {
152
- const wfs = await loadWorkflows(join(root, kit.comfy.workflows))
153
- io.print(`ok: ${wfs.size} workflow pair(s) bound`)
154
- // provenance: where each pair lives + that both halves validated
155
- // (loadWorkflows throws on any invalid pair, so reaching here = valid).
156
- // There is no global workflow store — git owns each repo's pairs; the
157
- // house library (wiki/build/comfyui/references) is the copy-from source.
158
- for (const wf of wfs.values()) {
159
- const rel = relative(root, wf.jsonPath).replace(/\.json$/, '')
160
- io.print(`ok: workflow '${wf.id}' → ${rel}.{json,ts} (pair valid)`)
161
- }
172
+ wfs = await loadCflowWorkflows(root, join(root, kit.comfy.workflows))
173
+ io.print(`ok: ${wfs.size} comfy-ts workflow(s) loaded`)
174
+ // provenance: where each module lives + its declared knobs.
175
+ // Git owns each repo's modules; the house library
176
+ // (wiki/build/comfyui/references) is the copy-from source.
177
+ for (const lw of wfs.values())
178
+ io.print(`ok: workflow '${lw.id}' → ${relative(root, lw.file)} (vars: ${lw.wf.entries().map(([n, d]) => `${n}:${d.kind}`).join(', ')})`)
162
179
  } catch (e) {
163
180
  io.print(`FAIL: ${(e as Error).message}`)
164
181
  bad++
165
182
  }
166
- const up = await fetch(`${kit.comfy.host}/system_stats`, { signal: AbortSignal.timeout(3000) }).then((r) => r.ok).catch(() => false)
167
- io.print(up ? `ok: comfy backend reachable (${kit.comfy.host})` : `WARN comfy backend unreachable (${kit.comfy.host})${kit.comfy.heal != null ? ` — ${kit.comfy.heal}` : ''}`)
183
+ if (wfs != null) {
184
+ // every comfy kind's workflow + vars resolve (dry pass, vars reset after)
185
+ for (const kind of kit.kinds) {
186
+ const gen = kind.generate
187
+ if (gen.backend !== 'comfy') continue
188
+ try {
189
+ const lw = workflowFor(wfs, { workflow: gen.workflow, assetId: kind.assets[0]?.id })
190
+ if (gen.vars != null) {
191
+ for (const [, d] of lw.wf.entries()) d.reset()
192
+ applyVarValues(lw.wf, gen.vars, `kind '${kind.id}'`)
193
+ for (const [, d] of lw.wf.entries()) d.reset()
194
+ }
195
+ } catch (e) {
196
+ io.print(`FAIL: ${(e as Error).message}`)
197
+ bad++
198
+ }
199
+ }
200
+ // one draft per comfy asset — the TUI's tweakable entry.
201
+ // ONE aggregate line: per-asset rows would drown the check
202
+ let draftCount = 0
203
+ let draftSeeded = 0
204
+ for (const f of allAssets(kit).filter((x) => x.kind.generate.backend === 'comfy')) {
205
+ let lw: LoadedWorkflow
206
+ try {
207
+ lw = workflowFor(wfs, { workflow: (f.kind.generate as ComfyGen).workflow, assetId: f.asset.id })
208
+ } catch {
209
+ continue // an unresolvable workflow already FAILed on its kind above
210
+ }
211
+ const prompt = (await readPromptOverlay(assetDir(join(root, kit.workspace), f.asset.id))).prompt ?? f.asset.prompt
212
+ if (ensureAssetDraft(lw, f.asset.id, prompt)) draftSeeded++
213
+ draftCount++
214
+ }
215
+ if (draftCount > 0) io.print(`ok: drafts ensured for ${draftCount} comfy asset(s) in .comfy-ts/drafts/${draftSeeded > 0 ? ` (${draftSeeded} seeded now)` : ''}`)
216
+ // offline graph validation when the host has a schema cache;
217
+ // a missing cache is world state → WARN, never a repo FAIL
218
+ const hostsWarned = new Set<string>()
219
+ for (const lw of wfs.values()) {
220
+ // schema.size counts real spec entries; .nodes always
221
+ // carries the injected UnknownNodeXX even with no cache
222
+ if (lw.wf.host.schema.size > 0) {
223
+ try {
224
+ const built = await lw.wf.build()
225
+ if (built.problems.length > 0) { io.print(`FAIL: workflow '${lw.id}' graph problems: ${built.problems.map((p) => p.title).join('; ')}`); bad++ }
226
+ } catch (e) {
227
+ io.print(`FAIL: workflow '${lw.id}' build failed: ${(e as Error).message}`)
228
+ bad++
229
+ }
230
+ } else if (!hostsWarned.has(lw.wf.host.data.id)) {
231
+ hostsWarned.add(lw.wf.host.data.id)
232
+ io.print(`WARN no schema cache for host '${lw.wf.host.data.id}' — bunx comfy-ts gen --id ${lw.wf.host.data.id} --host ${lw.wf.host.getServerHostHTTP()}`)
233
+ }
234
+ }
235
+ try {
236
+ for (const url of new Set([...wfs.values()].map((lw) => lw.wf.host.getServerHostHTTP()))) {
237
+ const up = await fetch(`${url}/system_stats`, { signal: AbortSignal.timeout(3000) }).then((r) => r.ok).catch(() => false)
238
+ io.print(up ? `ok: comfy backend reachable (${url})` : `WARN comfy backend unreachable (${url})${kit.comfy.heal != null ? ` — ${kit.comfy.heal}` : ''}`)
239
+ }
240
+ } finally {
241
+ disconnectAll({ workflows: wfs })
242
+ }
243
+ }
168
244
  }
169
245
  // machine-clearable red clears itself: drifted/missing identity
170
246
  // outputs re-render HERE; only a missing pick (taste) reaches a human
@@ -1,9 +1,9 @@
1
1
  // Generation queue: single worker, IN-MEMORY (never run the studio with
2
2
  // --watch — a restart drops every queued roll; the fill CLI is the recovery
3
3
  // path), errors surfaced in a ring buffer, never fatal. Per-step progress
4
- // (comfy /ws) lives on state; cancel drops the CURRENT job's remaining
5
- // rolls — queued jobs are `clear()`'s business.
6
- import type { GenProgress } from '../gen/comfy-gen.ts'
4
+ // lives on state; cancel drops the CURRENT job's remaining rolls — queued
5
+ // jobs are `clear()`'s business.
6
+ import type { GenProgress } from '../gen/comfyts-gen.ts'
7
7
 
8
8
  export type Job = { assetId: string; prompt: string; count: number }
9
9
  export type QueueState = { generating: boolean; current: string | null; done: number; total: number; progress: GenProgress | null }
@@ -8,10 +8,9 @@
8
8
  import { existsSync, mkdirSync, unlinkSync } from 'fs'
9
9
  import { join, relative } from 'path'
10
10
  import { findAsset, allAssets, mediaOf, missingPanelScripts, MEDIA_EXTS, type CustomPanelSpec, type FoundAsset, type GeometryPanelSpec, type ResolvedKit, type ShipFn } from '../kit-config.ts'
11
- import { interrupt, randomSeed, type OnProgress } from '../gen/comfy-gen.ts'
11
+ import { ensureAssetDraft, loadCflowWorkflows, randomSeed, workflowFor, type ComfyTsRuntime, type LoadedWorkflow, type OnProgress } from '../gen/comfyts-gen.ts'
12
12
  import { isStale, parentIdsOf, resolveParentContext, type ParentContext } from '../gen/derived.ts'
13
- import { generateRoll, type ComfyRuntime } from '../gen/generate-roll.ts'
14
- import { loadWorkflows, type Workflow } from '../gen/workflow-loader.ts'
13
+ import { generateRoll } from '../gen/generate-roll.ts'
15
14
  import { alphaHeight, toPng } from '../image-ops.ts'
16
15
  import { readJson, updateJson } from '../json-store.ts'
17
16
  import { assetDir, isRollFile, listRolls, metaPath, readPick, readPromptOverlay, updatePick, updatePromptOverlay } from '../roll-workspace.ts'
@@ -31,7 +30,7 @@ export type AssetInfo = {
31
30
  backend: 'canvas' | 'comfy' | 'manual' | 'gemini'
32
31
  /** canvas rolls have no seed input — every roll is identical */
33
32
  deterministic: boolean
34
- /** comfy only: resolved workflow id + its .rv-comfy path (note = why unresolved) */
33
+ /** comfy only: resolved workflow id + its .cflow.ts path (note = why unresolved) */
35
34
  workflow: { id: string | null; path: string | null; note?: string } | null
36
35
  ship: { mode: 'built-in' | 'hook' | 'record'; path?: string; note?: string }
37
36
  /** live write-through target (kind.meta.file), repo-root-relative */
@@ -49,8 +48,8 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
49
48
  const { kit, root } = deps
50
49
  const workspace = join(root, kit.workspace)
51
50
  const dirOf = (id: string): string => assetDir(workspace, id)
52
- let workflows: Map<string, Workflow> | null = null
53
- if (kit.comfy != null) workflows = await loadWorkflows(join(root, kit.comfy.workflows))
51
+ let workflows: Map<string, LoadedWorkflow> | null = null
52
+ if (kit.comfy != null) workflows = await loadCflowWorkflows(root, join(root, kit.comfy.workflows))
54
53
 
55
54
  // custom panel scripts fail-fast here too (tests/embedders may bypass loadKit)
56
55
  const missingPanels = missingPanelScripts(kit, root)
@@ -76,9 +75,9 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
76
75
  if (workflows != null && workflows.size === 1) id = [...workflows.keys()][0]!
77
76
  else if (workflows != null) note = `names no workflow; ${workflows.size} loaded — set workflow on the kind`
78
77
  }
79
- const wf = id != null && workflows != null ? workflows.get(id) : undefined
80
- if (id != null && wf == null && note == null) note = `workflow '${id}' not loaded`
81
- const path = wf != null ? relOf(wf.jsonPath) : id != null && kit.comfy != null ? join(kit.comfy.workflows, `${id}.json`) : null
78
+ const lw = id != null && workflows != null ? workflows.get(id) : undefined
79
+ if (id != null && lw == null && note == null) note = `workflow '${id}' not loaded`
80
+ const path = lw != null ? relOf(lw.file) : id != null && kit.comfy != null ? join(kit.comfy.workflows, `${id}.cflow.ts`) : null
82
81
  workflow = { id, path, note }
83
82
  }
84
83
  let ship: AssetInfo['ship']
@@ -94,11 +93,25 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
94
93
  const promptOf = async (f: FoundAsset): Promise<string> =>
95
94
  (await readPromptOverlay(dirOf(f.asset.id))).prompt ?? f.asset.prompt
96
95
 
96
+ // one draft per comfy asset, seeded at boot so `bunx comfy-ts tui` lists
97
+ // every asset as a tweakable entry under its workflow
98
+ if (workflows != null) {
99
+ for (const f of allAssets(kit).filter((x) => x.kind.generate.backend === 'comfy')) {
100
+ try {
101
+ const gen = f.kind.generate as { workflow?: string }
102
+ const lw = workflowFor(workflows, { workflow: gen.workflow, assetId: f.asset.id })
103
+ if (ensureAssetDraft(lw, f.asset.id, await promptOf(f))) console.log(`ok: draft '${f.asset.id}' seeded → .comfy-ts/drafts/${lw.id}/${f.asset.id}.json`)
104
+ } catch (e) {
105
+ console.log(`WARN draft for '${f.asset.id}' not seeded — ${(e as Error).message}`)
106
+ }
107
+ }
108
+ }
109
+
97
110
  // workflows are pre-loaded above (fail-fast for a comfy studio); the
98
111
  // resolver just hands the shared roll generator the ready runtime.
99
- const resolveComfy = async (): Promise<ComfyRuntime> => {
112
+ const resolveComfy = async (): Promise<ComfyTsRuntime> => {
100
113
  if (kit.comfy == null || workflows == null) throw new Error('kit has no comfy config — only canvas kinds can generate')
101
- return { workflows, host: kit.comfy.host, heal: kit.comfy.heal }
114
+ return { workflows, heal: kit.comfy.heal }
102
115
  }
103
116
  const generateOne = deps.generateOne ?? ((found, prompt, seed, destDir, onProgress, context?: ParentContext) =>
104
117
  generateRoll(found, { prompt, seed, destDir, onProgress, context }, resolveComfy))
@@ -359,9 +372,14 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
359
372
  // only touch the box when the RUNNING job is a comfy kind — the
360
373
  // box is shared, an unrelated prompt must never be interrupted
361
374
  const running = queue.state.current != null ? findAsset(kit, queue.state.current) : null
362
- if (kit.comfy != null && running?.kind.generate.backend === 'comfy')
363
- await interrupt({ host: kit.comfy.host, heal: kit.comfy.heal }).catch((e: Error) =>
364
- queue.pushError(`interrupt: ${e.message} — current roll will finish, its job is still dropped`))
375
+ const rgen = running?.kind.generate
376
+ if (workflows != null && rgen?.backend === 'comfy') {
377
+ try {
378
+ await workflowFor(workflows, { workflow: rgen.workflow, assetId: running!.asset.id }).wf.host.interrupt()
379
+ } catch (e) {
380
+ queue.pushError(`interrupt: ${(e as Error).message} — current roll will finish, its job is still dropped`)
381
+ }
382
+ }
365
383
  return json({ ok: true })
366
384
  }
367
385
  if (pathname === '/api/pick') {
@@ -99,7 +99,7 @@ export class AssetsCheckCommand extends Command {
99
99
 
100
100
  static override usage = Command.Usage({
101
101
  category: 'Assets',
102
- description: 'kit loads, kinds valid, workflows bound, backend reachable, identity outputs present',
102
+ description: 'kit loads, kinds valid, cflow workflows load, drafts present, backend reachable, identity outputs present',
103
103
  examples: [['health-check the asset pipeline', 'shipkit assets check']],
104
104
  })
105
105
 
Binary file
@@ -20,6 +20,9 @@ export type HooksConfig = {
20
20
  commitMsg?: { id: string; cmd: string[] }[]
21
21
  /** keyword file for the leak guard (default '.rv-private/banned-keywords.txt') */
22
22
  keywordFile?: string
23
+ /** staged-path prefixes that may never enter git (local state like
24
+ * '.comfy-ts/'). gitignore is advisory and editable; this gate is not. */
25
+ blockedPaths?: string[]
23
26
  /** disable a built-in gate, visible in `hooks list`, never silent */
24
27
  disable?: ('leak-guard' | 'commit-message' | 'root-identity')[]
25
28
  }
@@ -67,6 +70,22 @@ export async function runPreCommit(root: string, config: RepoConfig): Promise<Ga
67
70
  ],
68
71
  })
69
72
  }
73
+ if ((h.blockedPaths ?? []).length > 0) {
74
+ const names = Bun.spawnSync(['git', 'diff', '--cached', '--name-only'], { cwd: root })
75
+ const staged = names.stdout.toString().split('\n').filter(Boolean)
76
+ const blocked = staged.filter((f) => h.blockedPaths!.some((p) => f.startsWith(p)))
77
+ out.push(blocked.length === 0
78
+ ? { id: 'blocked-paths', ok: true, lines: [`ok: blocked-paths clean (${h.blockedPaths!.join(', ')})`] }
79
+ : {
80
+ id: 'blocked-paths',
81
+ ok: false,
82
+ lines: [
83
+ `FAIL: ${blocked.length} staged file(s) under a blocked path — this local state never enters git`,
84
+ ...blocked.slice(0, 10).map((f) => ` ${f}`),
85
+ ` fix: git reset -- ${[...new Set(blocked.map((f) => h.blockedPaths!.find((p) => f.startsWith(p))))].join(' ')}`,
86
+ ],
87
+ })
88
+ }
70
89
  if (!(h.disable ?? []).includes('root-identity')) {
71
90
  const r = runRootIdentity(root)
72
91
  if (r.status === 'skipped') out.push({ id: 'root-identity', ok: true, lines: [`skip: root-identity (${r.reason})`] })
@@ -140,6 +159,7 @@ export function listGates(config: RepoConfig, root = process.cwd()): string[] {
140
159
  const leakState = off.has('leak-guard') ? ' (DISABLED by config)' : existsSync(join(root, kw)) ? '' : ` (INACTIVE, ${kw} absent)`
141
160
  return [
142
161
  `pre-commit leak-guard${leakState} — staged content + paths vs ${kw}`,
162
+ ...((h.blockedPaths ?? []).length > 0 ? [`pre-commit blocked-paths — nothing under ${h.blockedPaths!.join(', ')} enters git`] : []),
143
163
  `pre-commit root-identity${off.has('root-identity') ? ' (DISABLED by config)' : ''} — the repo's own name in package.json + repo.config.ts, and scratch files`,
144
164
  ...(h.preCommit ?? []).map((c) => `pre-commit ${c.id} — ${c.cmd.join(' ')}`),
145
165
  `commit-msg commit-message${off.has('commit-message') ? ' (DISABLED by config)' : ''} — dash punctuation + AI attribution`,
package/src/init-repo.ts CHANGED
@@ -21,6 +21,9 @@ export default defineRepo({
21
21
  venues: [], // e.g. ['itch', 'steam'] — selects which .rv-<venue>/ contracts are REQUIRED
22
22
  social: [], // e.g. ['x', 'discord'] — .rv-social/<channel>/ post folders (+ .rv-discord/)
23
23
  tasks: [], // repo commit-gate tasks; deps double as race guards
24
+ // .comfy-ts/ holds schema dumps naming a private model collection —
25
+ // gitignore (SK004) is advisory, this pre-commit gate is not
26
+ hooks: { blockedPaths: ['.comfy-ts/'] },
24
27
  // the mandated reviewer agent type (rule SK008) — uncomment with YOUR
25
28
  // standing brief, then \`shipkit check --fix SK008\` writes the agent file:
26
29
  // reviewer: { name: 'my-reviewer', brief: '~/path/to/reviewer-brief.md' },
@@ -15,6 +15,8 @@ import { npmPublishArgv, unitBlockers } from './unit-gate.ts'
15
15
  import { formatUnitError, resolveUnits, unitById, type ReleaseUnit } from './units.ts'
16
16
  import type { RepoConfig } from '../repo-config.ts'
17
17
 
18
+ // adding a FOURTH knob here is the signal that this shape wants rethinking
19
+ // rather than another key (meta/gaps.md, release-path config keys accreting)
18
20
  export type ReleaseConfig = {
19
21
  /** releases ship from this branch only (default 'main') */
20
22
  branch?: string
@@ -32,6 +32,11 @@ const POLICY_LINES = [
32
32
  '.rv-discord/',
33
33
  'CLAUDE.local.md',
34
34
  'tmp/',
35
+ // comfy-ts state (schema dumps, drafts) and per-workflow lora tags name a
36
+ // private model collection; shipkit itself writes .comfy-ts/ at hub boot,
37
+ // so every consumer repo needs both ignored before its first commit
38
+ '.comfy-ts/',
39
+ '*.loras.local.txt',
35
40
  ]
36
41
 
37
42
  export function expectedBlock(config: RepoConfig): string {
package/src/shipkit.ts CHANGED
@@ -60,10 +60,8 @@ export { StudioQueue } from './assets/server/studio-queue.ts'
60
60
  export type { Job, QueueState } from './assets/server/studio-queue.ts'
61
61
  export { cleanRect, liveRect, defaultLegs } from './assets/panels/geometry-rects.ts'
62
62
  export type { GroundRect, LegRect } from './assets/panels/geometry-rects.ts'
63
- export { generate, submit, waitFor, applyInputs, expectNode, randomSeed, batchFolderName } from './assets/gen/comfy-gen.ts'
64
- export type { ComfyOpts } from './assets/gen/comfy-gen.ts'
65
- export { loadWorkflows, workflowFor } from './assets/gen/workflow-loader.ts'
66
- export type { Workflow, Sidekick, Binding, Graph } from './assets/gen/workflow-loader.ts'
63
+ export { generateComfyTs, loadCflowWorkflows, workflowFor, applyVarValues, ensureAssetDraft, readAssetDraft, draftPath, disconnectAll, randomSeed, batchFolderName } from './assets/gen/comfyts-gen.ts'
64
+ export type { ComfyTsRuntime, LoadedWorkflow, GenProgress, OnProgress } from './assets/gen/comfyts-gen.ts'
67
65
  export { toPng, alphaHeight } from './assets/image-ops.ts'
68
66
  export { updateJson, readJson } from './assets/json-store.ts'
69
67
  export { assetDir, listRolls, isRollFile, isAudioRoll, readPick, updatePick, metaPath, pickPath, readPromptOverlay, updatePromptOverlay, promptPath } from './assets/roll-workspace.ts'
@@ -1,157 +0,0 @@
1
- // ComfyUI HTTP engine — post/wait/fetch plumbing, zero node-id knowledge
2
- // (node ids live in each workflow's .ts sidekick).
3
- import { mkdirSync } from 'fs'
4
- import { extname, join } from 'path'
5
- import type { Graph, Sidekick, Workflow } from './workflow-loader.ts'
6
-
7
- export type ComfyOpts = {
8
- /** e.g. 'http://127.0.0.1:8085' */
9
- host: string
10
- /** shell command that heals the backend (tunnel/boot) — shown in error hints, never executed by the lib */
11
- heal?: string
12
- waitTimeoutMs?: number
13
- pollMs?: number
14
- }
15
-
16
- const WAIT_TIMEOUT_MS = 10 * 60_000 // covers cold model loads
17
- const POLL_MS = 1500
18
-
19
- const healHint = (o: ComfyOpts): string => (o.heal != null ? ` — is the backend up? (${o.heal})` : '')
20
-
21
- /** Loud guard for setInputs escape hatches: the node must exist and match. */
22
- export function expectNode(graph: Graph, id: string, classType: string): Graph[string] {
23
- const n = graph[id]
24
- if (n == null) throw new Error(`graph has no node '${id}' (expected ${classType})`)
25
- if (n.class_type !== classType) throw new Error(`node '${id}' is ${n.class_type}, expected ${classType}`)
26
- return n
27
- }
28
-
29
- export function applyInputs(graph: Graph, sk: Sidekick, args: { prompt: string; seed: number; prefix: string }): void {
30
- if (sk.setInputs != null) return sk.setInputs(graph, args)
31
- for (const [role, value] of [['prompt', args.prompt], ['seed', args.seed], ['prefix', args.prefix]] as const) {
32
- const b = sk.bindings[role]
33
- graph[b.node]!.inputs[b.input] = value
34
- }
35
- }
36
-
37
- export async function submit(o: ComfyOpts, graph: Graph, clientId?: string): Promise<string> {
38
- const res = await fetch(`${o.host}/prompt`, {
39
- method: 'POST',
40
- headers: { 'Content-Type': 'application/json' },
41
- body: JSON.stringify({ prompt: graph, client_id: clientId ?? crypto.randomUUID() }),
42
- })
43
- if (!res.ok) throw new Error(`POST /prompt → ${res.status}: ${(await res.text()).slice(0, 300)} — bad graph or server mid-boot${healHint(o)}`)
44
- return ((await res.json()) as { prompt_id: string }).prompt_id
45
- }
46
-
47
- // ── live progress over ComfyUI's native /ws ────────────────────────────────
48
- // ADDITIVE by contract: a dead socket degrades to no-progress, completion
49
- // truth stays the /history poll. The clientId MUST match the /prompt body
50
- // or the server sends nothing.
51
- export type GenProgress = { value: number; max: number; node: string | null }
52
- export type OnProgress = (p: GenProgress) => void
53
-
54
- const wsWarned = new Set<string>() // one WARN per host per process, not per roll
55
- export function subscribeProgress(o: ComfyOpts, clientId: string, on: OnProgress): { close: () => void } {
56
- const url = `${o.host.replace(/^http/, 'ws')}/ws?clientId=${encodeURIComponent(clientId)}`
57
- const warnOnce = (detail: string): void => {
58
- if (wsWarned.has(o.host)) return
59
- wsWarned.add(o.host)
60
- console.log(`WARN comfy /ws unavailable — per-step progress disabled${detail}`)
61
- }
62
- let node: string | null = null
63
- let ws: WebSocket | null = null
64
- try {
65
- ws = new WebSocket(url)
66
- ws.onmessage = (ev) => {
67
- if (typeof ev.data !== 'string') return // binary preview frames — not consumed
68
- let msg: { type?: string; data?: { value?: number; max?: number; node?: string | null } }
69
- try { msg = JSON.parse(ev.data) as typeof msg } catch { return }
70
- if (msg.type === 'executing') { node = msg.data?.node ?? null; on({ value: 0, max: 0, node }) }
71
- else if (msg.type === 'progress') on({ value: msg.data?.value ?? 0, max: msg.data?.max ?? 0, node })
72
- }
73
- // progress is a bonus — a refused/dropped ws warns once and stays quiet
74
- ws.onerror = () => warnOnce('')
75
- } catch (e) {
76
- warnOnce(` (${(e as Error).message})`)
77
- }
78
- return { close: () => { try { ws?.close() } catch {} } }
79
- }
80
-
81
- /** Cancel the RUNNING prompt (queued ones are untouched). */
82
- export async function interrupt(o: ComfyOpts): Promise<void> {
83
- const res = await fetch(`${o.host}/interrupt`, { method: 'POST' })
84
- if (!res.ok) throw new Error(`POST /interrupt → ${res.status}${healHint(o)}`)
85
- }
86
-
87
- type HistoryOutputs = Record<string, { images?: { filename: string; subfolder?: string; type?: string }[] }>
88
-
89
- export async function waitFor(o: ComfyOpts, promptId: string): Promise<HistoryOutputs> {
90
- const timeoutMs = o.waitTimeoutMs ?? WAIT_TIMEOUT_MS
91
- const deadline = Date.now() + timeoutMs
92
- while (Date.now() < deadline) {
93
- const res = await fetch(`${o.host}/history/${promptId}`)
94
- if (res.ok) {
95
- const h = (await res.json()) as Record<string, { outputs: HistoryOutputs; status?: { status_str?: string } }>
96
- const entry = h[promptId]
97
- if (entry != null) {
98
- if (entry.status?.status_str === 'error') throw new Error(`prompt ${promptId} errored on the server — open the ComfyUI UI (${o.host}) for the node error`)
99
- return entry.outputs
100
- }
101
- }
102
- await Bun.sleep(o.pollMs ?? POLL_MS)
103
- }
104
- throw new Error(`prompt ${promptId} still not done after ${Math.round(timeoutMs / 60000)} min — server stuck? check ${o.host} queue${healHint(o)}`)
105
- }
106
-
107
- async function fetchImage(o: ComfyOpts, img: { filename: string; subfolder?: string; type?: string }, dest: string): Promise<void> {
108
- const q = new URLSearchParams({ filename: img.filename, subfolder: img.subfolder ?? '', type: img.type ?? 'output' })
109
- const res = await fetch(`${o.host}/view?${q}`)
110
- if (!res.ok) throw new Error(`GET /view ${img.filename} → ${res.status}`)
111
- await Bun.write(dest, await res.arrayBuffer())
112
- }
113
-
114
- /**
115
- * Run one generation through a workflow pair: fresh graph copy, sidekick
116
- * bindings applied, submitted, waited, non-preview outputs saved as
117
- * <destDir>/<name>.<ext> (ONE clean file per asset — no _00001_ counter noise).
118
- */
119
- export async function generate(o: ComfyOpts, wf: Workflow, args: { prompt: string; seed: number; destDir: string; name: string; onProgress?: OnProgress }): Promise<string[]> {
120
- const graph = structuredClone(wf.graph)
121
- applyInputs(graph, wf.sidekick, { prompt: args.prompt, seed: args.seed, prefix: args.name })
122
- mkdirSync(args.destDir, { recursive: true })
123
- let outputs: HistoryOutputs
124
- if (args.onProgress != null) {
125
- const clientId = crypto.randomUUID()
126
- const sub = subscribeProgress(o, clientId, args.onProgress)
127
- try {
128
- outputs = await waitFor(o, await submit(o, graph, clientId))
129
- } finally {
130
- sub.close()
131
- }
132
- } else {
133
- outputs = await waitFor(o, await submit(o, graph))
134
- }
135
- const saved: string[] = []
136
- for (const node of Object.values(outputs)) {
137
- for (const img of node.images ?? []) {
138
- if (img.type === 'temp') continue // PreviewImage — raw, non-transparent
139
- // >1 saved image (batch_size, second Save node): suffix instead of silently overwriting
140
- const ext = extname(img.filename) || '.webp'
141
- const dest = join(args.destDir, `${args.name}${saved.length > 0 ? `-${saved.length + 1}` : ''}${ext}`)
142
- await fetchImage(o, img, dest)
143
- saved.push(dest)
144
- }
145
- }
146
- if (saved.length === 0) throw new Error(`workflow '${wf.id}' produced no saved images (only previews?) — does the graph end in a Save node?`)
147
- return saved
148
- }
149
-
150
- export const randomSeed = (): number => Math.floor(Math.random() * 2_000_000_000) + 1
151
-
152
- /** Shell-stamped batch folder name — the one-shot output convention. */
153
- export function batchFolderName(slug: string): string {
154
- const d = new Date()
155
- const p = (n: number) => String(n).padStart(2, '0')
156
- return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}-${slug}`
157
- }
@@ -1,67 +0,0 @@
1
- // Workflow pairs: <id>.json exported graph + <id>.ts sidekick (bindings +
2
- // optional expects/setInputs). Every violation THROWS loud with the fix in
3
- // the message — CLIs die, checkers turn it into a FAIL line.
4
- import { existsSync, readdirSync } from 'fs'
5
- import { basename, join } from 'path'
6
-
7
- export type Binding = { node: string; input: string }
8
- export type Sidekick = {
9
- bindings: { prompt: Binding; seed: Binding; prefix: Binding }
10
- expects?: Record<string, string>
11
- requires?: { customNodes?: string[]; models?: string[] }
12
- setInputs?: (graph: Graph, args: { prompt: string; seed: number; prefix: string }) => void
13
- }
14
- export type Graph = Record<string, { inputs: Record<string, unknown>; class_type: string }>
15
- export type Workflow = { id: string; jsonPath: string; graph: Graph; sidekick: Sidekick }
16
-
17
- // function declaration, not const — TS only narrows on never-calls to
18
- // directly-declared functions
19
- function fail(msg: string): never { throw new Error(msg) }
20
-
21
- /** dir = the folder holding the <id>.json + <id>.ts pairs */
22
- export async function loadWorkflows(dir: string): Promise<Map<string, Workflow>> {
23
- const out = new Map<string, Workflow>()
24
- if (!existsSync(dir)) fail(`${dir}/ missing — ship at least one <id>.json + <id>.ts workflow pair`)
25
- for (const f of readdirSync(dir).filter((x) => x.endsWith('.json')).sort()) {
26
- const id = basename(f, '.json')
27
- const jsonPath = join(dir, f)
28
- const tsPath = join(dir, `${id}.ts`)
29
- if (!existsSync(tsPath)) fail(`workflow '${id}': ${f} has no sidekick ${id}.ts — every graph json ships with its adjacent .ts module`)
30
- const graph = (await Bun.file(jsonPath).json()) as Graph
31
- // ?rvload = a DISTINCT module instance: when a self-executing sidekick
32
- // is the running script, importing its bare path would await its own
33
- // in-flight top-level evaluation — a deadlock. NOTE the busted
34
- // instance STILL sees import.meta.main=true (bun compares paths sans
35
- // query), so its CLI tail runs too — the cliRunning guard in the
36
- // sidekick runner is what keeps it inert. Never remove that guard.
37
- const mod = (await import(`${tsPath}?rvload`)) as Partial<Sidekick>
38
- const b = mod.bindings
39
- if (b == null || !b.prompt?.node || !b.seed?.node || !b.prefix?.node) {
40
- fail(`workflow '${id}': sidekick must export bindings = { prompt|seed|prefix: { node, input } }`)
41
- }
42
- for (const [role, bind] of Object.entries(b!) as [string, Binding][]) {
43
- const n = graph[bind.node]
44
- if (n == null) fail(`workflow '${id}': bindings.${role} points at node '${bind.node}' which is not in ${f} — graph re-exported with renumbered nodes? update the sidekick`)
45
- if (!(bind.input in n!.inputs)) fail(`workflow '${id}': node '${bind.node}' (${n!.class_type}) has no input '${bind.input}' (has: ${Object.keys(n!.inputs).join(', ')})`)
46
- }
47
- for (const [node, cls] of Object.entries(mod.expects ?? {})) {
48
- const n = graph[node]
49
- if (n == null) fail(`workflow '${id}': expects node '${node}' missing from ${f}`)
50
- if (n.class_type !== cls) fail(`workflow '${id}': node '${node}' is ${n.class_type}, sidekick expects ${cls} — graph changed under the sidekick, re-check bindings`)
51
- }
52
- out.set(id, { id, jsonPath, graph, sidekick: mod as Sidekick })
53
- }
54
- if (out.size === 0) fail(`${dir}/ has no <id>.json graphs`)
55
- return out
56
- }
57
-
58
- /** Resolve a workflow: explicit id, else the sole loaded workflow. */
59
- export function workflowFor(workflows: Map<string, Workflow>, ref: { workflow?: string; assetId?: string }): Workflow {
60
- if (ref.workflow != null) {
61
- const w = workflows.get(ref.workflow)
62
- if (w == null) fail(`asset '${ref.assetId ?? '?'}': unknown workflow '${ref.workflow}' (have: ${[...workflows.keys()].join(', ')})`)
63
- return w!
64
- }
65
- if (workflows.size === 1) return [...workflows.values()][0]!
66
- return fail(`asset '${ref.assetId ?? '?'}' names no workflow and ${workflows.size} are loaded (${[...workflows.keys()].join(', ')}) — set workflow on the kind`)
67
- }