Round 1 of extensive_testing on 6d49625 found two real defects:
- Unbounded server-side render cost/memory from a client-supplied
pipeline. render_png's image height grows with wrapped-line count,
so one crafted long input value amplified a small request into a
77,400px-tall, ~250MB render taking several seconds of single-
threaded PIL draw calls -- reproduced directly. This route is
unauthenticated, same as every sibling pipeline route, and reachable
over a rental's plain-http IP -- a real DoS surface on the box
actually doing GPU sampling. Fixed with two bounds: _short() now
truncates any one value past 400 chars (a card is a compact summary,
nobody reads a 200k-char row on it), and collect() caps the whole
card at 2000 rows, both declared on the card itself via a "…N more
omitted" note rather than silently dropped. Verified: the 200k-char
reproduction case drops from 3.2s/250MB to 0.09s/22KB; a 200,000-slot
pipeline renders bounded at ~1.7s/5MB with the truncation stated.
- Reopening "Export settings…" via the toolbar button (not the modal's
own ✕/Close) leaked the previous render's blob URL -- close()'s
revoke only runs on ITS OWN overlay, and the top-of-function DOM
cleanup only removed old .sc-overlay nodes, never revoked their
blobs. Carried over unfixed from v4's identical pattern. Fixed by
stashing the blob URL on its own overlay element so the cleanup line
can find and revoke it. Verified live: two clicks without ever
hitting Close produces two distinct blob URLs and exactly one
revokeObjectURL call, one overlay left in the DOM.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
MEDIUM-tier v4 feature ("Which model was that?" answered three days
later): a button in Models & Pipeline renders the currently loaded
pipeline as a PNG -- every slot's node class and typed-in values, wired
inputs marked as wired rather than printed as a stale value, and the
host facts (torch/CUDA/GPU/attention) that decide whether the setup
reproduces elsewhere. Downloadable, copyable to clipboard, and the
report is embedded in the PNG's own tEXt chunk for later machine
reading.
core/settings_card.py is a hard cut of v4's version, not a verbatim
port: v4 spent most of its length (_OWNED_BY, _EDITOR_DEFAULTS,
_pass_rows, _sampling_sections) describing FunPackStudio and the
LTX-AV Scene Chain Sampler's own hardcoded knob tables -- neither node
exists in v5's module-driven pipeline, so there is nothing left to
describe there. What's genuinely generic (walking a slot's real
inputs, detecting a wired one via core.graph.is_link, the PNG
renderer) is what's kept, adapted to v5's slot shape ({id, node,
group, inputs}, a wire arriving as [source_id, index] rather than a
separate input_sources/wires lookup) and v5's existing core/sysinfo.py
for host facts (whose field names already matched v4's needs).
New POST /api/settings-card route: stateless like /api/pipeline, the
CLIENT sends its own live pipeline (window.PipelineState.slots()) since
that's the one copy that includes any unsaved edit; the server supplies
only what it alone knows (host facts) and the render.
Frontend: app/legacy/api.js's settingsCard stub (present since the
Phase A port, explicitly marked "not built in v5 yet") replaced with a
real POST+blob call; app/legacy/models.js's Export-settings modal
ported from v4 near-verbatim, reading PS.slots()/project name instead
of a project id the old per-project models config needed. Verified
live: real pipeline, real host facts (this Mac's actual Apple MPS/
Python/ComfyUI versions), wired inputs correctly marked.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Round 3 of extensive_testing found ae319cb's _noticeIsStaleRenders flag
had the exact footgun it was built to avoid: exportSelected() and
saveSelectedToMediaBin() also write state.notice (through the generic
set() helper, spelled `notice:` in an object literal -- invisible to a
`state.notice =` grep) and never cleared the flag, so a pending
stale-render flag could survive into an unrelated export/save
confirmation and have _recordSegment wrongly clear THAT instead.
Every future writer of state.notice would have needed to remember to
maintain a parallel boolean by hand -- two already didn't. Replaced the
flag with a regex test against the stale-render sweep's own known
message template: _recordSegment now clears state.notice only when its
current text actually IS that message, derived from data instead of
tracked by convention. No writer needs to know about any other writer.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Round 2 of extensive_testing on 2e738a8 found the fix only closed the
race at queue time. state.notice is shared with a third writer:
_recordSegment unconditionally blanked it on every successful render
(state.notice = "" whenever recordedSceneIds.length), which exists to
retire ITS OWN "N saved renders no longer on disk" message once a
regen fixes it -- but fires on every successful run regardless, so it
silently ate the reference-unwired notice the instant the very run it
described finished. A person who wasn't watching the screen during
generation would find the notice gone by the time they looked back.
Added _noticeIsStaleRenders, tracking which case currently owns
state.notice, so _recordSegment only clears its own message.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Round-1 adversarial review of 1abf136 found two real conditions:
- _buildQueueInputs set state.notice for an unwired reference directly,
before GB.generate() ran check() -- whose own "warn" note writes the
same field via GB.on("warn"). Whichever fired last won, so a person
with any Engine Settings configured on a pipeline with no settings
sink (the default pipeline is exactly that) would see "module
settings didn't apply" instead of ever learning their reference was
dropped. Fixed by applying the reference notice AFTER GB.generate()
resolves, so it always wins that race instead of losing it silently.
- The Resolution-source picker's collapsed view can't tell "nothing
picked" from "picked something since deleted from the bin" -- both
render as MediaPicker's noneLabel placeholder. The References list
right next to it already shows "(missing)" for this case; the
source picker didn't. Added the same declaration as an insp-hint.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Fills in the placeholder left in _buildQueueInputs: v5's real per-scene
fields (core/projects.py's Scene.source_image/.references) already
round-trip through api.js's getProject/saveProject today -- it's a raw
whole-object PUT/GET, and the server's own Scene.from_dict()/asdict()
extract and return them regardless of what else v4's project shape carries.
There was just no UI to set them and nothing reading them at generate time.
Added:
- store.js: setSourceImage/setReferences, thin patchScene wrappers (same
pattern as the existing setSourceTrim).
- inspector.js: a "Reference media" section on the scene panel, right after
v4's own i2v anchor/Source field, using the same window.MediaPicker
thumbnail-grid component already used throughout this file for image
picks -- a single Resolution-source picker plus an add/remove list for
References.
- generate_bridge.js: exposes reference_wiring.js's wireReferences()
(untouched, already tested, no DOM dependency).
- store.js's _buildQueueInputs: sets assets.source_image from the run's
first/anchor scene, and calls wireReferences() for its references --
same "first scene stands for the whole run" convention its prompt-text
branch already uses for a multi-scene (whole-timeline) run. An unwired
reference (no matching slot in the current pipeline) surfaces as a real
notice in the timeline header, not a silent drop.
Live-verified against the dev server end to end: picked a real reference
image from the Media bin, watched it persist server-side
(GET /funpack/api/projects/:id showed it in the saved file), then clicked
Generate and confirmed the pipeline check ran with it -- the current
default pipeline has no reference slot, and the app correctly said so
("1 reference could not be wired into this pipeline and will not be
used.") rather than silently dropping it. 691/692 JS tests and 548/548
Python tests pass (the one JS failure is the pre-existing, unrelated
projects.test.js import-race flake).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Round 5 of adversarial review found a real ordering race: generate_bridge.js's
module-level wire() call starts reattach() (a /queue or /history round trip)
the moment the module evaluates -- before store.js's classic script has even
run, let alone reached _wireGenerateBridge() (called deep inside init(), after
seven sequential awaited setup calls). On a reload during or just after a
generation, "adopt" routinely fired and was gone before store.js ever called
GB.on("adopt", ...): _genRunSceneIds stayed [], so the adopted run's eventual
result was never recorded onto its scene (_recordSegment's own length guard
silently skipped it), and _genRunActive/_genAdoptedRun never got set, leaving
that run invisible to the overlap guard this whole review thread has been
hardening.
Fixed by giving "adopt" -- and only "adopt", since it carries the sole copy
of which scene(s) a reattached run belongs to, unlike the transient "say"/
"warn"/"hold"/"release" status text, which is fine to miss -- replay
semantics: the bridge remembers the last adopt payload and hands it to any
listener that registers afterward, immediately and synchronously, the same
way it would have if the listener had been there in time. Verified with a
standalone reproduction of the exact closure logic (late subscribers,
including a second one, both receive the replayed payload correctly) and
against the real dev server (boots clean, no regressions). 691/692 JS tests
and 548/548 Python tests pass (the one JS failure is the pre-existing,
unrelated projects.test.js import-race flake).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Round 4 of adversarial review found that the previous commit's overlap
guard, keyed off state.gen.state, produced false refusals: Export, Save to
Media bin and Render Final all set state.gen to "running" for their own
entirely local work, so clicking H3 Combo Sweep's "Run sweep" button while
any of those was in progress got refused with a message borrowed from that
unrelated job ("Stopped at ...: Exporting 3 clips…") -- not silent, but
actively misleading about the real cause.
Switching the check to GenerateBridge's own run.state.phase (considered as
the obvious fix) would have reopened the very race round 3 closed: phase
stays "idle" for the whole check() network round trip, before run.start()
ever fires, so a second call could still slip through during that window.
Fixed with `_genRunActive`, a flag dedicated to exactly one question --
"does GenerateBridge have a run of this page's making in flight" -- set
synchronously the instant a call is accepted (before any await, so two
calls fired back to back still serialize) and cleared either by the
terminal-phase subscription (covers a run that actually started, click or
adopted) or by _generateRun's own `finally` (covers a run refused before
ever leaving "idle", which that subscription never sees).
Live-verified: a state.gen "running" left over from an unrelated job no
longer blocks a real generate() call, while two genuinely concurrent
generate() calls still correctly serialize (one proceeds, one is refused
with the accurate reason). 691/692 JS tests and 548/548 Python tests pass
(the one JS failure is the pre-existing, unrelated projects.test.js
import-race flake).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Round 3 of adversarial review found that GenerateBridge now holds exactly
ONE run's shared state (_genRunPrefix/_genRunSceneIds/_genAdoptedRun,
window.GenerateBridge's own module-level ranFor/ranForList), but only the
main Generate/Selected/Render buttons are gated against starting a second
one while the first is still queuing/running (actionbar.js's busy(st)). H3
Combo Sweep's "Run sweep" button has no such gate and calls _generateRun
directly -- if clicked while another generation (including one adopted on
reload) is already in flight, it silently overwrote that run's scene
attribution and status message, got refused deeper inside (session.js's own
starting/phase guard), and left the REAL run's eventual result recorded
onto the sweep's scene instead of its own when it finished -- persistent
data corruption, not just a UI glitch -- and could leave _genAdoptedRun
stuck false, reopening round 1's "adopted run's clock never stops" bug.
Fixed at the root: _generateRun itself now checks state.gen.state before
touching any of that shared state, and refuses (loudly, via console.warn)
rather than clobbering it. Fixes every current and future caller in one
place, not just block_sweep.js -- consistent with every other caller
already being a sequential, awaited loop that this guard doesn't affect.
Live-verified: two overlapping Store.generate() calls now produce exactly
one real attempt and one logged refusal, with the first call's status
never overwritten. 691/692 JS tests and 548/548 Python tests pass (the one
JS failure is the pre-existing, unrelated projects.test.js import-race
flake).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Round 2 of adversarial review on the previous commit found that
_genClockStop() firing unconditionally on every run's terminal phase (added
to fix an adopted run's clock never stopping) broke the OTHER case it didn't
account for: generateMontage()/generateSelected() call _generateRun several
times under one press for a chain-sampler pipeline (_runs() splits the
timeline into several runs whenever PipelineCaps.usesChainSampler() is
true, which is the default). genWallStart is documented to span the WHOLE
batch, started once by _clockedMontage/_clockedSelected's own wrapper --
stopping it after run 1 of N left genElapsed() returning null, and nothing
in the batch loop ever restarted it, so the visible timer next to Generate
disappeared for the rest of an in-progress batch.
Fixed with a `_genAdoptedRun` flag: only a run actually ADOPTED on reload
(nothing else will ever stop its clock) clears it from the persistent
subscription; a normally-clicked run's terminal phase leaves the clock for
its own caller's `finally` to stop, same as before this whole feature
existed. 691/692 JS tests and 548/548 Python tests pass (the one JS
failure is the pre-existing, unrelated projects.test.js import-race
flake). Live-verified against the dev server.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adversarial review of the previous commit found four real defects, all
confirmed:
1. A run covering more than one scene (the whole-timeline H3 shape, or any
single-scene click whose gen-unit has sibling subclips) only ever sent
the FIRST scene id as extra_data.funpack_scene_id. On reload mid-run,
reattach recovered just that one id, so _recordSegment only ever wrote
the finished media onto scene 1 -- every other scene the job actually
rendered silently got nothing recorded, no error shown.
Fixed by adding funpack_scene_ids (plural) alongside the existing
singular field, threaded through client.js's queuedFor and session.js's
reattach() (both additive -- a new optional third onAdopt argument and
a new optional response field, ignored by every existing caller such as
app/boot.js) down to generate_bridge.js/store.js, which now record onto
every adopted scene.
2 & 3. A run adopted on reload never primed pollStart/genWallStart, so
_elapsed() and the transport button's own clock read time since the
Unix epoch instead of real elapsed time, and never stopped afterward --
wedging genElapsed() for the rest of the session (its own guard is "if
already started, do nothing"). Fixed: the "adopt" handler now primes
both clocks (flagged approximate, same as the deleted
resumeRunningGeneration used to), and every terminal run phase now
stops them.
4. check()'s `notes` field (e.g. "N settings didn't reach the pipeline")
was fired through transport.warn() but nothing ever subscribed to
GenerateBridge's "warn" event -- silently dropped, reproducing the
exact bug class the deleted code's own comment said must not recur.
Fixed: wired into state.notice, same as the old code did.
New tests added for both the plural-scene-id path (client.test.js,
session.test.js) and the fallback-to-one-scene case. 691/692 JS tests and
548/548 Python tests pass (the one JS failure is the pre-existing,
unrelated projects.test.js import-race flake). Live-verified against the
dev server: a multi-scene GenerateBridge.generate() call still correctly
surfaces a real pipeline refusal with no console errors.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds generate_bridge.js, a small ES-module shim over the untouched
app/shell/run.js + session.js + pipeline.js + client.js, and rewires
store.js's generate()/generateMontage()/generateSelected()/interrupt()/
init() reattach to go through it instead of the now-permanently-stubbed
API.generate/status/active. PipelineState (pipeline_state.js) stays the
one client-side copy of the live pipeline; the bridge only adds the
run/queue/socket layer on top of it.
Single scene (or the whole active timeline, for a non-chain pipeline like
H3) per real queue -- v5 has no chain-sampler/montage stage. REINS captures
and the anchor-guide i2v bypass are said loudly (console.warn) rather than
silently dropped, since neither has a v5 equivalent yet. Reference-image
wiring is left as a placeholder: legacy's saveProject/getProject adapter
doesn't round-trip source_image/references yet, so there is nothing to
wire in from there today.
Live-verified against the real dev server: a real Generate click built the
scene's prompt, POSTed it through /funpack/api/pipeline's check, and
surfaced the genuine refusal reason (an unset model loader) in the existing
status UI, with the button correctly re-enabled after. 688/689 JS tests
and 548/548 Python tests pass (the one JS failure is the pre-existing,
unrelated projects.test.js import-race flake).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Review found the save() queue's pendingBody merge was shallow: two queued
edits that both touch the top-level "inputs" key (two different slots, or
even two fields on the same slot from models.js's per-call-fresh objects)
had the second's inputs object silently REPLACE the first's rather than
combine -- the earlier edit vanished with no error, the field just
reverted on the next render. engine_settings.js's own calls happened to be
immune by accident (one long-lived mutated `values` object, so the last
queued snapshot already contained every earlier edit) -- that immunity
was never a property of save() itself, and models.js's fresh-object-per-
call shape hit it immediately. Fixed with a one-level-deeper merge for
"inputs"/"values" specifically. Verified live via two real race
reproductions run through the browser console: two edits on different
slots queued with no await between them, and two edits on the SAME slot --
both survive now, confirmed against the actual saved graph state.
Also closed a load race: ensureLoaded()'s only guard was `slots !== null`,
which doesn't cover the window while a first load is still in flight -- two
panels mounting close together could both start a fetch. Now every caller
awaits the same in-flight promise, so exactly one fetch ever goes out per
session.
Documented (not fixed, deliberately) that Models & Pipeline's node-level
pin target is currently unreachable: pinTarget() only ever returns a
group-level pin today, since there's no "currently open node" concept in
this flat slot list yet -- the openNode/ctx.sub("node:...") plumbing stays
for whatever old pin data might still reference it, but nothing produces
a fresh one. Real gap against v4's per-node pinning, not a regression.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
models.js's ~2700 lines were entirely built around v4's Studio "roles" and
"candidates per role" concept, neither of which exists in v5 -- the
pipeline is a flat, swappable slot list, not a fixed named-role set.
Replaced with a renderer over the live slots (GET /api/pipeline) grouped
the way the pipeline itself groups them (Loaders/Preparation/Sampling/
Render), each slot's real widget inputs read live from GET /api/nodes and
edited through the same /api/pipeline POST. The "not ready to generate
yet" / incomplete-inputs list is now real and visible, not implied.
Node add/remove/swap/wire is NOT built here -- v4's graph-editing machinery
assumed nodes that no longer exist, and a real editor is its own piece of
work; this establishes the actual working file-picker surface first.
Extracted `pipeline_state.js`: engine_settings.js and models.js both edit
the SAME live pipeline, and each independently caching its own `slots`
copy (as Engine Settings' fix earlier this session did) would let one
panel's save silently revert the other's un-saved edits the moment the
user switched between them -- the exact stale-state bug already fixed
once this session, about to recur in a second file. One shared module now
owns `slots`/the manifest/the save queue; both panels are thin renderers
over it. engine_settings.js refactored to consume it (no behavior change,
confirmed live).
Live-verified end to end: picked real model/clip/vae files scanned off
disk, watched "incomplete" clear field by field down to "ready to
generate", and got back a genuine queueable ComfyUI prompt graph. Then
confirmed the cross-panel fix specifically: saved an Engine Settings
toggle afterward and the POST still carried every file pick Models &
Pipeline had made -- neither panel clobbers the other.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Round-2 review: the reopen-persistence fix scanned every string input on
every slot for anything that JSON-decodes to a plain object, with no check
that it's actually the real settings blob -- a coincidental match (any
future node with an unrelated JSON-object string input) could inject a key
under a module id that doesn't exist, which then round-trips forever
through every future save via place()'s blind overwrite. No route exposes
the real sink's (node, input) identity to the client to look it up
properly (core doesn't name an implementation, by design), so the fix
narrows the heuristic instead: a decoded key is only merged when it names
a module this session already knows is installed. Not reachable with any
real module today (verified), but was a live hazard for the first one that
collides.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
engine_settings.js's ~1100 lines were entirely hardcoded around
FunPackStudio/Chain Sampler fields that don't exist in v5. Replaced with a
renderer over the real module manifest (GET /api/modules): groups every
loaded module's settings by category, builds a typed control per setting
(bool/int/float/enum/color/text), honors `when` conditional visibility, and
saves through the real /api/pipeline POST -- the same settings_sink
mechanism core/graph.py already had, just never had a caller. Outer
registration/DOM shell (SettingsWindow section, EngineSettingsModal.open/
close) kept identical so every existing entry point still works. Added
three new real api.js functions (modules, pipeline, editPipeline) -- v5-
native capability, no v4 equivalent existed.
Adversarial review found and fixed two real defects before this landed:
(1) every reopen re-fetched the (stateless) server default and reseeded
from schema defaults, silently discarding every previously-set value
across every module -- fixed by only fetching once per session and
holding `slots`/`values` in memory across mount/unmount, since the server
itself remembers nothing between GETs; (2) a second edit made while the
first's save was still in flight was dropped rather than queued -- fixed
with a trailing-edge retry so `commit()` re-sends after the in-flight
request resolves if anything changed meanwhile. Also fixed api.js's error
reader missing /api/pipeline's one inconsistently-named 400 field
("refused" instead of "problems"). Live-verified: toggled settings on two
different modules across two categories, closed and reopened the panel,
both survived; the real graph's FunPackModifierSettings node's `settings`
input genuinely updated end to end.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Round-1 adversarial review found the resolve-based unsupported() sentinel
broke every existing try/catch in v4's ~60 caller files (they were written
for a rejecting promise, same as any other failed fetch) -- worst case was
the Models & Pipeline panel hard-crashing on open, the one caller api.js's
own comment claimed was safe. Switched unsupported() to reject; the
existing defensive code across the callers now does exactly what it always
did. Also fixed a self-inflicted double-unwrap in listProjects/listMedia/
shortcuts (api.js unwrapped {field:[...]} to the array, but every real
caller still destructures .field off the result -- onboarding's "resume a
project" list was silently always empty), and switched five stubs that
threw synchronously (escaping before a caller's .then/.catch chain could
even attach) to async so the throw becomes a normal rejection. Live-
verified: Models & Pipeline now shows "Could not load" instead of
crashing, "Continue with existing project" reappears in onboarding,
Export Settings shows its real failure message instead of hanging.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Three buckets per the port plan: (a) same-shape routes just renamed
(projects, media, shortcuts, packs, git/system, temp, log), (b) a small
adapter where v5's shape differs (parsePrompt -> /api/prompt/expand,
resultUrl -> ComfyUI's own /view), (c) no v5 equivalent yet -- refinement
keys, REINS, probes, render/stitch, Models/pipeline-roles, Generate's
actual queue flow.
Every stub resolves to {unsupported, reason} instead of throwing, so a
module nobody has built yet can't take a caller down with it -- the
"feature-independent" backend the port asked for. Live-verified: wizard
through project creation into the real editor, zero console errors, every
real route round-tripping 200 OK.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two real findings from adversarial review of the previous commit (the v4
UI port's route change), both fixed:
- app/tests-layout/*.spec.js (20 files, 133 tests): every one of these
navigated to /funpack/ and asserted on composer-specific DOM
(.cx-workspace*, window.FunPack) that no longer exists there now that
the ported v4 frontend is served at that URL instead. Left as-is they
would silently time out on every run. Each file now carries an explicit
test.skip(true, "...") with a comment explaining why and pointing at
the plan's pixel-exactness step, which is what will eventually rewrite
these against the new DOM -- declared and tracked, not quietly broken.
- core/tests/test_registry.py's fixture-based tests never exercised the
new category field through the real announcement_of()/scan() path --
only test_schema.py's direct validate() calls did, which bypass
registry.py's FIELDS name-mapping entirely. A typo there (e.g.
"Category" instead of "CATEGORY") would have passed every existing
test. The "good module" fixture gained CATEGORY = "guidance" and
test_the_good_module_loads now asserts on it, closing the gap.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two pieces of the "port v4's exact UI onto v5's backend" plan:
- core/contract.py: new optional ModuleSpec.category field (closed set:
continuity, guidance, conditioning, sampling, post, system), validated in
core/schema.py, threaded through core/registry.py's FIELDS and
to_manifest(). Presentation grouping, independent of STAGE (execution
order) -- a module can be stage="sampling" and category="guidance" at
once. Only the 4 existing modules that actually render a settings panel
(alg, sharpen, appearance, full_control) got a category assigned; the
other 12 have none to categorize (no SETTINGS block) and stay
uncategorised, which is valid.
- v4's frontend (movie_editor/frontend/*, ~29k lines, 61 files) copied
verbatim into app/legacy/. core/routes.py's "/" route now serves
legacy/index.html instead of the composer entry point; a <base> tag in
that file resolves its own relative script/link tags against
/funpack/app/legacy/ regardless of what URL it's served at, so every
file moved as a unit with zero per-file path edits. Verified live: all
61 assets load 200 OK, the real v4 onboarding screen renders pixel-for-
pixel (it's the actual DOM/CSS, not a reimplementation), and several v4
API calls (/api/media, /api/projects, /api/git/status) already succeed
against v5's real backend by coincidence of shared route naming -- the
rest 404/405 as expected, since api.js's ~90 functions haven't been
rewired to v5's actual routes yet (next piece of this plan).
The old composer entry point (app/index.html, app/composer/**) stays in
the tree, unreferenced, until a later cleanup pass once nothing imports it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Round 1 + round 2 of adversarial review on the single-track timeline
(2092c8b) found and fixed:
- Clip position now stays absolute (seconds*pxPerSecond) instead of the
MIN_CLIP_PX floor cascading into every later clip -- a floored short
clip no longer pushes everything after it out of sync with the ruler.
- pendingSeek is cleared unconditionally right after project.select(),
closing a gap where a no-op select() (clicking within the already
selected clip) left it dangling for a later, unrelated draw to
wrongly replay.
- draw() now always resyncs the playhead to the current scene's start
on any non-track-click selection change, rather than only when the
old value fell outside the new scene's span -- the old check let a
stale playhead survive a wholesale project switch whenever it
coincidentally still fit the new project's first scene.
- The timeline moved from the centre column's own inner split to an
outer split above/below the whole three-column workspace, matching
v4's actual layout (full-width timeline under resizable columns,
not squeezed inside one of them).
- That restructuring put the Properties column under real height
pressure for the first time, exposing a latent bug: .cx-stack's
base rule set min-block-size:0 on every stack, letting a plain
grouping stack (not meant to scroll) get flex-crushed below its own
content height, so its overflow painted over the next row ("Settings"
over "Loosen the starting image"). Removed from the base rule --
.cx-stack-fill already carries it for the one case that should
actually give up height to scroll.
- Documented, rather than "fixed" by reintroducing the rejected
cascading-position bug: several under-floor clips in a row still
visually occlude each other (later DOM paints over earlier). Click-
seek still resolves correctly since it reads time, not paint order.
- Guarded framesOf() against a negative/NaN scene.length the same way
fps() already guards its own input.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replaces the scene-index-tick ruler with composer's new track.default
element: clips sized by real duration (frames/fps, not equal-width
boxes), a ruler in genuine elapsed seconds, and a playhead that follows
the selected scene but can be dragged to any exact second within a
clip via click-to-seek.
Also fixes a real crash found while live-testing this: addScene()
built a scene object missing source_image/references, which crashed
the Inspector the moment it tried to draw the new scene.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Groups the pipeline window into a card grid with a "+ Add group/node"
card beside it -- matching v4's card-per-loader layout instead of a
separate toolbar button. Picking it offers a new empty group or a node
outside any group (landed in the existing "Other" bucket).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
GGUF: port v4's gguf_support.py (backend selection, content-based .gguf
detection, dequantize fallback) and wire it into both the diffusion-model
and CLIP loaders.
Pipeline editor: reopening the Models-and-pipeline settings window was
silently discarding the whole edited pipeline (a loaded preset, every
loader file, every sampler setting) because GET /api/pipeline is
deliberately stateless server-side and boot.js never fed its own
already-saved slots back into a freshly remounted editor -- found live
while walking through the MiniMax-H3 setup flow. Fixed by re-validating
the saved slots through check() on reopen instead of always replaying
the raw stateless default.
Also fixes, found via the extensive_testing adversarial loop over both
of the above: a stale file-listing cache in the pipeline window (no way
to see a newly-added model file without a full page reload), and a
chain of races/leaks in its own refresh/commit error-banner handling
that surfaced across five review rounds -- a rejection from a superseded
request overwriting a fresher success, a failure banner never clearing
on success, banners bleeding across groups on navigation, and a
commit()'s refusal landing on the wrong view after the user backs out
before it answers.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
wireReferences() (new, extracted from boot.js's queueInputs) now reports an
unwired count instead of silently losing a reference when: the pipeline has
fewer reference slots than the scene has references, two references' roles
resolve to the same slot or downstream destination, or a reference's slot
was already claimed by the scene's source-image assignment. boot.js toasts
whenever unwired > 0, worded to cover both the capacity and collision cases.
None of these are reachable through the one shipped preset (H3's R2V, one
role per slot) -- all are latent contract violations a hand-edited or future
preset could trigger. 10 new tests in reference_wiring.test.js.
The last missing piece connecting the backend preset mechanism to something
a person can actually click: pipeline.js gets a presets() fetcher matching
load/describe/check/search's own shape, and pipeline_window.js's group-card
index offers a "Load preset..." button (only when a caller passes one) that
lists what /api/pipeline/presets returns and replaces the pipeline through
the same commit()/check() path every other structural edit already goes
through -- a preset that does not actually build is refused here, same as
a hand-drawn bad wire would be, never shown as if it had loaded.
Writing the test for this caught a real bug before it shipped: open()'s own
destructuring silently dropped `presets` before ever reaching mount(), so
the button would never have appeared no matter what boot.js passed it.
Pieces B and C: Scene gains source_image (aspect-ratio reference only --
resolution itself stays a project.video setting, ImageTransformKJ's
"total_pixels" keep_proportion fits the picture's aspect into that budget)
and references (media ids, in order). Inspector's Scene tab gets pick/clear/
remove controls; picking routes through the existing Media bin via a small
pending-pick handshake in boot.js (one bin, one onPick, so "which field
asked" has to be tracked between the click that asked and the click that
answered).
References wire into MiniMaxH3ReferenceToVideo's ref_images autogrow input
dynamically, per run, only for what a scene actually has -- an unwired slot,
not an empty image, is what "not in use" means to that node. The target is
data on the role itself (wireTo), not a slot id boot.js would otherwise have
to know, keeping the "core never names an implementation" contract intact
one layer up into the app.
Verifying this exposed a second real core gap (after the MatchType one):
core/graph.py had no idea what an Autogrow (V3's io.Autogrow) input even
is -- "ref_images" reported as one opaque COMFY_AUTOGROW_V3 slot, its real
per-instance sockets (ref_image_0, ref_image_1, ...) invisible to
declared()/build(), so a wire naming one was refused as an input the node
does not have. Fixed generically in comfy_types.py (autogrow_instances())
and graph.py, confirmed against ComfyUI's own _io.py (finalize_prefix's
dotted join, build_nested_inputs's runtime un-nesting) -- not guessed, and
proven end to end with a real override()+build() test using the exact edit
boot.js makes.
Round 4 of adversarial review, both reproduced live:
1. removeButton's mousedown-only wiring (round 3's own fix) dropped keyboard
activation entirely -- Tab-to-focus, Enter/Space did nothing, silently.
onClick is wired back alongside the mousedown handler; a real mouse click
still fires both, but the removal it triggers is idempotent (see below),
so the second call is a safe no-op.
2. Deleting a row that ITSELF still has an uncommitted edit could corrupt a
DIFFERENT, already-saved row. removeButton's own synchronous redraw
detaches whatever field still has a pending edit; a browser fires `blur`
on a detached-while-focused element even with preventDefault() called on
the mousedown, and that reentrant commit -- firing INSIDE the very
setVariables() call still removing a row -- was still indexed against
the row's OLD numeric position in an array that had already shrunk by
one underneath it.
Both share one cause round 3's fix narrowed but didn't remove: every commit
and removal in this editor addressed a row by the numeric index a render
closed over, and a synchronous redraw can invalidate that index before a
still-in-flight commit reads it. Fixed by addressing rows by the row OBJECT
itself, found by reference in the CURRENT array at the moment a commit or
removal actually fires -- a row no longer there (already removed, whether by
the user's own click or a reentrant one) is a no-op instead of a slot number
that now means something else. Confirmed with two new Playwright tests:
editing then removing the SAME row leaves the neighbour untouched, and
Enter/Space on a focused ✕ removes it.
Round 3 of adversarial review, reproduced live: type an uncommitted edit
into one Variables row, then click a DIFFERENT row's own delete button
without blurring the first. The click's mousedown blurs the edited field,
which commits and triggers a full non-keyed redraw of the whole list
(inspector.js draw() -> node.replaceChildren()) -- destroying the delete
button mid-gesture, so the click that started on it never arrives. No
error, nothing removed, no sign anything happened.
Fixed with the same mousedown+preventDefault idiom this session's own
autocomplete.js already uses for the same reason: stop the browser's
default focus-move (and the blur/commit/redraw it would trigger) before it
can pull the target out from under its own pending click. Confirmed with a
Playwright test reproducing the exact sequence.
Round 2 of adversarial review found a real bug in the fix the previous
round's own review prompted: mutating the live project.variables array (and
its row objects) in place before calling setVariables() meant projects.js's
remember() -- which deep-clones the project for the undo stack BEFORE
applying the new value -- captured the already-edited state as the "before"
snapshot. Undo after any variable edit or removal was a silent no-op.
Fixed by never mutating what project.variables already points at: every
commit now reads project.variables fresh and hands setVariables a brand new
array, which is also what closes the SAME stale-closure race the in-place
version was built to fix in the first place (redraw-mid-edit still reads
whatever the previous commit already saved, since it reads live rather than
from a snapshot taken at render time). Also fixed core/prompt_build.py's
docstring, which claimed the opposite of what resolve-variables-last
actually does and what its own test already pinned.
Adversarial review found the "picks at random each time" hint in the
Shortcuts editor was false: boot.js never sent a seed to /api/prompt/expand,
so core/shortcuts.py's own no-seed fallback (hash of the literal text) made
the pick a fixed function of the prompt, identical across every Generate on
unedited text. Fixed by drawing a fresh random seed per queue -- confirmed
with a Playwright test asserting two consecutive Generate clicks send
different seeds.
A trigger->replacement library (core/shortcuts.py, a close port of v4's
apply_prompt_shortcuts/resolve_variables), a Settings section to manage it,
autocomplete on the real prompt textarea (composer.autocomplete.default, so
it outranks the Constructor modal it lives inside), and a stateless
/api/prompt/expand that combines anchor + shortcuts + $variables + postfix
at the moment a run is queued -- scene.text itself stays exactly what was
typed. Project gained anchor/postfix/variables editing UI on its own tab.
Along the way: a real layout bug in the Properties column (every composer
stack can flex-shrink below its own content, and once the Project tab grew
past a couple of rows the column started squeezing its own children's
content into each other and, at the outer level, into the sibling Generation
section below) -- fixed with flex-shrink:0 at each level that needs it,
caught by a Playwright regression test.
Every node's own OUTPUTS were invisible on its own panel in the pipeline
editor -- wiring an input already let you pick a source, but opening the
node that PRODUCES something told you nothing about what it makes or
whether anything reads it. Found live, by a real user just looking at a
node, after two full review rounds that only ever checked the consuming
side. Fixed with a read-only "Outputs" section per node, naming each
output, its type, and who (if anyone) currently wires from it.
Also: model family detection. core/probe.py reads a safetensors header
(tensor names only, no weights) and asks every installed model module,
through the same capability-lookup mechanism already used for
empty_latent/decode, whether the file is its own -- core never names a
family itself. A real, adversarially-found bug in the ported signature:
one of MiniMax H3's three candidate prefixes doesn't correspond to any
prefix ComfyUI's own loader actually resolves to, traced through
comfy/model_detection.py and comfy/sd.py -- the probe could have called a
file H3 that ComfyUI's real loader would refuse. Removed, and pinned with
a dedicated test module. A second finding narrowed the folder search to
only the folders FunPack's own loaders actually read from.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Pipeline wiring: core/graph.py gains wire()/unwire(), and the "Models and
pipeline" editor can now actually connect one node's output to another's
input -- sockets and widgets alike, which is what makes "linked inputs"
(several widgets sharing one Primitive node's value) buildable at all,
without a second mechanism. Three adversarial review rounds found and fixed
real bugs along the way: check()'s request builder silently dropped the
wire-specific fields; the widgets loop had no wiring UI at all despite the
backend supporting it, and a widget already holding a link rendered as an
editable box with the wrong (default) value; wiring an input that already
had an unsaved drafted value let the stale value silently win on Save. Also
fixed, found live rather than by review: the Add-node picker stacked two
disagreeing search boxes (an outer server search and filterList's own
redundant client-side one).
Updates window: "Restart ComfyUI" is now a plain, always-available button,
not just something offered when an update already landed. Rolling back or
switching branch on a dirty tree now auto-stashes instead of refusing --
updating still refuses, unchanged, since pulling new commits onto changes
in progress is not something to do without asking. Three review rounds
here too: stashing before reading the rollback target shifted what HEAD@{1}
meant (a real reflog hazard, not just reasoned about); a stash's disclosure
was lost if the step right after it failed; that fix was too narrow and
missed pull()'s own failures inside checkout(pull_after=True) -- the actual
default path -- and rollback()'s own reset failing; and finally, the
broadened fix still only caught GitUpdateError, missing subprocess-level
failures like a hung fetch timing out, which is the realistic failure mode
on a rented GPU box's network.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Files the user brings IN (a reference image, an imported clip) now have a
real store: core/media.py, /api/media routes, and a "Media" tab beside the
bin's "Results" tab in the Assets zone. Upload, list, fetch, delete all
round-trip through the real server; nothing wires into a pipeline input yet,
since no model module or Studio conditioning takes a reference image today.
Two adversarial review rounds found and fixed three real issues along the
way: MAX_BYTES (512MB) was dead code because aiohttp's own client_max_size
capped every upload far lower first in both prod and dev, with a JSON-parse
error leaking to the user on rejection instead of a clean message; the
index.json read-modify-write had no guard against concurrent writers; and
the devserver's bare Application didn't match ComfyUI's own 100MB default,
which also broke an unrelated e2e assertion once the review polluted the
real, unisolated media/ directory testing the fix.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Found by the adversarial pass on the commit that moved it to the Project
tab: it had no persistence and no per-project sync at all. width/height/
length (project.video) get both -- a real dict field on Project, and
syncVideo() re-reading it into the controls on every project switch.
Negative got neither: it lived only in the pipeline-override controls'
own in-memory state, so it silently carried over from whichever project
was open last. Typed for project A, it was sent as project B's negative
prompt the moment someone switched -- with nothing on screen to say so,
and the Project tab's own copy ("Size and length live in the
Constructor") actively implying every field there was project-scoped.
Project.negative is now a real field (core/projects.py, round-tripped
through the existing whole-project PUT with no route changes needed),
and boot.js gained syncNegative(), wired everywhere syncVideo() already
is: onOpen, and after the pipeline changes underneath. Reproduced the
exact leak end-to-end before fixing it, and the new project_video.spec.js
test is that same reproduction, now asserting it doesn't happen.
Three things the unified Settings window was still missing next to v4's
own, all found by looking at it next to v4 rather than in isolation:
- The sidebar is a 52px icon rail now, expanding to a full list on
hover/focus and overlaying the content instead of a splitPane's
permanently-docked two-pane shape -- v4's own sidebar, not an
approximation of it. Fixing this surfaced two real CSS bugs along the
way: filterList's own min-width:220px silently defeated the collapse
(clipping the search box to "Sear" instead of showing an icon), and
.cx-filter-list's unset overflow-x (computed to auto once overflow-y
was set) put a horizontal scrollbar under it. Centering the icon in
the collapsed row needed the label's flex-basis AND the row's own gap
to both go to zero -- an invisible label still claims its share of
both otherwise, and the icon renders off-centre.
- The modal now has a real height (v4's own 660px) rather than sizing to
whichever section is showing, so switching between a short section
(About) and a long one (Node packs) doesn't visibly resize the whole
window. That exposed a THIRD latent bug: .cx-modal-body never had
flex:1 1 auto, invisible until a modal had a real height to fill --
every prior modal shrink-wrapped its content, so head+body+foot always
summed to exactly the card's height by accident. Fixed at the shared
rule, not just for Settings.
- About now shows what v4's does: commit and dirty state, a codename
when the checkout has one, and the machine's real hardware/software
facts (chip, memory, GPU or MPS, disk, OS, ComfyUI/Python/Torch/
attention-backend versions, hostname) -- a new core/sysinfo.py
(ported from v4's own, individually guarded so a missing probe never
takes the panel down) behind a new GET /funpack/api/system, injected
into settings_window.js the same way every other network call here is.
Also: a nav row's tone (the coloured icon chip) is now validated against
its closed set the same way button.js already validates its own tone --
an unknown one used to render silently wrong instead of failing loudly.
Most FunPack pipelines run at CFG 1, where a negative prompt does
nothing -- it was one more box beside the prompt every Generate reads,
for a field only worth touching occasionally. The reference pipeline's
negative role now targets "project.negative" instead of
"generation.prompt"; the inspector's Project tab hosts it, in a STABLE
node outside the part draw() tears down and rebuilds on every scene
selection, so a field mounted there survives normal use.
An outside click on the Settings window closed it unconditionally --
pipeline_window.js's own standalone modal has always refused to close
that way (a group edit's draft is the only copy of it), and hosting it
inside Settings silently dropped that guarantee, with no way left for a
real user to reach the protected version (boot.js has no other caller
of pipeline_window.js any more). Settings now holds the door the same
way, for every section: an accidental backdrop click during any
half-finished edit no longer costs it.
filterList's icon `tone` claimed to be a closed set, the same rule
el()'s style refusal already holds -- but nothing checked it, so a
typo would have rendered a silent, unstyled chip instead of failing
loudly the way button.js's own tone check already does for the same
mistake. Now it throws, matching that sibling pattern.
A bare glyph character had no framing at all; filterList's icon now gets a
coloured chip, drawn from a fixed tone (accent/danger/warn/good/neutral) --
never a raw colour from data, the same rule el()'s style refusal already
holds everywhere else. Each Settings section picks one.
The sidebar was the filterList element's small dropdown-list shape (a
240px-capped box with its own border, floating in a large pane) rather
than a real full-height sidebar; .cx-filter-full opts a host into the
pane version without changing the compact one pipeline_window's own node
picker still uses.
Round one had a per-row hint line under the icon+label, which doesn't
exist in v4's own sidebar and broke alignment the moment a hint wrapped
(icon and label baseline-aligned against a hint floating as an independent
sibling rather than stacked under its own label). Fixed the real bug --
label+hint are a column now, not loose flex siblings -- then removed the
hint entirely to match v4: a section's subtitle belongs in its own head
once picked, not repeated under every row whether picked or not.
About is centred -- mark, name, version/branch -- rather than a plain
settings-row list, the one section built fresh rather than mounting an
existing window.
Picking Node packs, Updates, the log or Temp files used to close the
Settings window and open that feature's own separate modal, with no way
back except reopening Settings from the menu bar -- not what v4 does,
and not what "unified" is supposed to mean. Each of those five files
(plus pipeline_window.js) now exports mount({ setFooter, close }), the
same content its standalone open() already built, taking the modal
chrome's footer and close from whoever hosts it instead of owning a
modal directly. open() is unchanged for anyone opening one alone --
pipeline_window.test.js and every Playwright spec for these five prove
it, unedited, still passing.
Also fixes two findings from the adversarial pass on the earlier
deep-link version: filterList's search only matched a section's visible
label/hint, so a section findable only by a keyword (e.g. "loaders" for
"Models and pipeline") silently vanished from the filtered list; and
createSettingsWindow's "one at a time" state lived at module scope, so
a second independent instance would have redrawn the first one's modal
instead of opening its own. Both now have regression tests.
Models and pipeline, Updates, Node packs, the log, and Temp files were
five separate dropdown entries with no way to search across them. One
button now opens a single searchable window; About is drawn in place,
everything else is a deep link -- each of those five already has real,
tested modal chrome of its own, so this reuses it rather than rebuilding
it inline.
Uses the existing composer.splitPane.h for the two-pane layout rather
than a new element -- an earlier pass added one, and its CSS collided
with splitPane's own .cx-split class, silently breaking the pipeline
window's own list/parameters split (caught by pipeline.spec.js before
this landed).
Found by the adversarial pass on the exclude commit: every other
early-return in generateAll() says something (empty load, cancelled,
N failed) except this one -- excluding every scene made the button do
nothing with no feedback, indistinguishable from a hang.
The Edit menu gains Toggle Exclude, next to Add/Delete scene. An excluded
scene stays on the timeline, dimmed in the strip, and Generate All skips it
-- both in its count and its loop, so "2 of 2 did not generate" never counts
a scene that was never going to run.
Covered by a jsdom render test, a real end-to-end menu-to-strip test, and a
Generate All exclude test.
Player: Save to bin (whatever the viewer shows, if it has a real file
identity) and Save frame (canvas-capture the video on screen, upload
through ComfyUI's own /upload/image, no new route needed). Both add
without navigating the viewer -- an upload is a real round trip, and
the result is added to the bin, not shown as a stale finished pick.
Save/Load Project File: a real download route with Content-Disposition,
and an import route that never trusts an id in the file -- an import
always lands as its own project, never overwrites one already here.
Generate All: every scene in order, waiting for each to finish before
starting the next -- not a montage (v5 has no render/stitch stage),
N separate generations each landing on its own scene.
Both project-switching (open/newProject/importProject) and the earlier
run-reattach path shared a bug class this session kept finding: state
captured at one moment, applied at a later async moment with no check
that nothing had moved on in between. Closed it properly this time --
a generation counter every switch bumps and checks, including start()
(the page's own handlers are live before it resolves) and undo/redo
(also "a different project is open now"), and scheduleSave() captures
the object an edit actually touched instead of re-reading a variable
that can point somewhere else by the time its debounce timer fires.
Four adversarial rounds against this range, each fixed before landing.
Drag a clip onto another to reorder; an S/M/L control changes clip width
(--strip-w), remembered per browser. Trim already existed as the Scene
inspector's length field.
The dragged clip is tracked by its DataTransfer payload, not a closure
variable keyed on DOM position or id -- a redraw mid-drag (any project
change, not just a removal) tears down and rebuilds every cell, and
dragend fires on the original source node regardless of whether a
redraw detached it from the tree. Two adversarial rounds against this
file specifically, both fixed before landing.
Two routes silently shared /api/log; a pack's "updates available" badge
outlived the update; nothing server-side stopped a restart mid-generation,
and the fixes for that had their own dead end, a lock scoped to the wrong
event loop, an unlocked status fetch, and a check that only ran once
instead of at the moment it mattered. A run's result was lost outright if
the project it belonged to wasn't the one open when it finished -- on a
project switch or a page reload. A brand-new project inherited the
previous one's width, then inherited the wrong "default" for it.
Found and confirmed via an adversarial pass over 9dcd94c..HEAD, one round
per fix.
Fifth off the port list. ComfyUI writes previews and other transient outputs to
a temp directory and wipes it on restart, so everything in there is findable
for exactly as long as the server has been up -- which is the window in which
anyone asks where something went.
Read-only on purpose. This is a place to FIND a file, not to manage one;
deleting temp files is ComfyUI's job and it does it on restart.
Rows rather than tiles, which is the opposite of the bin and for the opposite
reason: a row shows the whole name, the size and when it was written, and the
grid draws none of those. Only media is listed -- a temp directory fills with
whatever any node felt like writing, and a browser listing 400 .pt files is not
a media bin. No <video> thumbnails, which wedged this app's API once already.
Three empty states, because they are three different facts: nothing was written
yet, the directory was wiped, and ComfyUI is not here to ask. An empty list
reads as the first one whichever is true.
One test taught me something about this environment again: opening a file puts
it in the Preview, the dev server has no /view, so the picture 404s and the kit
replaces it with its fallback -- correctly, and leaving nothing in the DOM to
assert on. What the viewer FETCHES is the evidence, so the test reads the
request rather than the picture.
Fourth off the port list. Users do not open devtools, and "it did not work"
with nothing to read is where a bug report dies -- especially on a rented box
behind a tunnel, where the terminal that has the answer is not on this desk.
Read from ComfyUI's own log FILE rather than from a buffer in this process.
v4 kept both and its own comment says why the file is the half that matters: a
crash and restart empties an in-memory buffer, and the lines from before the
crash are exactly the ones worth reading. So this is the smaller half and the
more useful one.
Three things the panel refuses to do, each because of what a log is FOR:
* It does not redraw while text is being selected. Copying a stack trace is the
one thing anybody opens a log to do, and a panel that repaints under the drag
makes it impossible.
* It keeps the last lines when the server stops answering. The most interesting
thing a log can say is that ComfyUI just died, and replacing the lines from
just before it went with the error that says it is gone throws away the only
evidence of why.
* No log file is an ANSWER, with the reason and where to look instead. An empty
list reads as a quiet log, and the difference matters precisely when somebody
opened the panel because something else already broke.
Both findings from the adversarial pass, confirmed and fixed.
RESTARTING FOR NOTHING. Every successful git call scheduled a restart, whether
or not the checkout had moved. Pressing Update while already up to date is a
normal thing to do -- there is no way to know until it has been asked -- and it
cost a full boot. The route now restarts only when something actually moved: a
commit changed, or a branch did. Two branches at the same commit still count,
because they stop being the same the moment anything lands on either.
And the worse half, which the pass rated first: nothing checked whether a
generation was in flight. The restart takes the run with it, GPU time already
spent, with no warning anywhere. The window now says a run is going and turns
off the three buttons that would end it.
CHECK FOR UPDATES COULD NOT REPORT ONE. check_updates() answers under `checked`
and the app read `nodes`. The request succeeded, the JSON was valid, and every
pack reported nothing to update however far behind it was -- a fetch per pack
over the network for an answer that could never be shown. Neither side's tests
could see it: the route test asserted the server's shape, the window test never
pressed the button, and nothing compared the two.
So the tests now name each other. The Python one asserts the key the app reads;
the browser one stubs the server's real shape and reads the words off the row.