Commit Graph
25 Commits
Author SHA1 Message Date
malkuthroandClaude Opus 5 090eb460eb fix(snapshot): boot drift check no longer false-positives on unsaved edits (#278)
* fix(snapshot): boot drift check no longer false-positives on unsaved edits (#276)

The #161 boot-time drift guard flagged any canonical difference between
the tracked snapshot file and the live /userdata+picks state — which is
also what ordinary "kept working after the last named save" looks like,
so nearly every session booted into a false "drifted" warning and
periodic autosaves were misrouted to _unsaved_autosave/.

detectBootDrift() is now three-way: a mismatch counts as drift only when
the live state's fingerprint also matches the persisted last-named-save
baseline (it claims to be saved, yet the file disagrees — pinning the
out-of-band change to the file side). Otherwise it reports the honest
"unsaved". First-session baseline seeding in koolook_sidebar.js moved
after the drift check so a freshly seeded baseline can't fabricate the
claims-saved condition.

Tests: two new cases (unsaved-edits, no-baseline); drift-expecting cases
now establish the claims-saved baseline; the states-match and
canonicalization tests baseline first and assert reason is absent so
they pin the match branch rather than the new early exit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(snapshot): gate first-session baseline seeding on a proven boot check (#276)

Review follow-up. Ordering the drift check before the seeding was not
enough: seeding still ran whenever the fingerprint key was absent, so a
session whose tracked-preset name outlived its baseline (upgrade from
before the key existed, or a persist that lost to localStorage quota)
baselined whatever the live state happened to be. That both showed
"saved" for state never saved to the named file and handed the NEXT boot
a live state claiming saved against a file it never matched — a fresh
false "drifted", i.e. #276 re-opened from the other side.

Seeding is now gated on bootCheckProvedAligned(), a pure predicate
exported from snapshot.js (the entry point imports ComfyUI's app.js and
can't be loaded headlessly — same split as extension_guard.js's
claimSidebarRegistration). Only a boot check that reached the
fingerprint comparison and matched authorizes seeding; null and
reason-carrying outcomes prove nothing.

Also: branch the !claimsSaved log so the no-baseline case stops claiming
"unsaved edits since the last named save" when there is no such save,
and refresh the _autosaveSubdir redirect rationale, which still framed
the live state as the corrupt side.

Tests: predicate truth table, an end-to-end blocking case carrying a
counterfactual leg that proves the gate is load-bearing (ungated seeding
does flag drift on the next boot), and the allow case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 12:23:49 +02:00
malkuthroandClaude Fable 5 3608ab5a21 fix(ltx): scrub base64 posters from persisted Director timelines (draft-quota toast storms) (#277)
* fix(ltx): scrub base64 posters from persisted Director timelines

The vendored upstream 2.0.2 timeline editor stamps a base64 poster JPEG
onto every video segment (canvas.toDataURL) and kept it across serialize
round-trips, so timeline_data - and with it the workflow draft ComfyUI
1.44+ writes to localStorage ~512ms after every edit - grew by ~50-150k
chars per video segment. Large timelines then exceeded the origin quota
no matter how many old drafts koolook_draft_guard.js evicted, and the
frontend latched its session-wide storage-unavailable flag: every edit
toasted "Failed to save workflow draft" until reload.

Reinstate a minimal media scrub as a third documented Koolook delta,
applied at all six persist/restore maps (commitChanges + parseInitial):

- video / motion_video posters with a backing file (imageFile on the
  main track, videoFile on the motion track) are dropped; the seeked
  handler re-derives them from the video element on load
- legacy pre-2.0.2 image segments carrying full inline data-URLs are
  rewritten to the /view URL form 2.0.2 itself writes (loadMedia renders
  image previews from imageB64 only - deleting would blank them)
- legacy inline audioB64 (raw base64) is dropped when audioFile exists;
  playback only falls back to it when no file resolves
- blobs with no backing file are left untouched

Disk Save / Save As payloads stay unscrubbed on purpose: exported .json
keeps upstream's format and meets the restore scrub on load. On-screen
previews are unaffected - only persisted copies are scrubbed.

Covered by Node behavior tests that evaluate the delta block against a
stubbed api, plus a source contract that all six maps route through the
scrub.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ltx): persist the scrubbed timeline on restore; review follow-ups

Addresses the verified findings on #277.

The restore-time scrub only updated the editor's in-memory timeline. The
node's timeline_data widget kept the original payload, and that is what
onSerialize and LiteGraph's widgets_values read - so an already-fat saved
workflow still fed the draft autosave that fires on load, before any edit
triggers commitChanges. Both restore paths (the onConfigure sync block and
the editor constructor) now rewrite the widget from the scrubbed timeline.
The round-trip is lossless: parseInitial whitelists exactly the keys
commitChanges persists. Chose this over calling commitChanges during
configure, which would also recompute the prompt/length/strength widgets on
every node's configure - far wider blast radius than the defect warrants.

Also:
- keep the data-URL when a malformed imageFile yields no /view URL, instead
  of blanking the preview with an empty string
- rewrite the stale provenance-header paragraph that still claimed the
  base64-scrub deltas were obsolete (it now contradicted the third delta)
- update the v2.0.2 entry in forks/forks_manifest.yaml from "two retargeting
  edits" to the three current Koolook edits, with the scrub's rationale
- note the audioB64 leg and the widget rewrite in the CHANGELOG entry

The bare catch in parseInitial is left alone: pre-existing upstream
behavior, and the scrub introduces no realistic new throw.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ltx): trigger the widget rewrite on legacy raw-base64 audio too

The restore-time widget rewrite gated on the payload containing "data:",
which catches the poster/image blobs that caused the quota storms but not
the scrub's third removal: legacy audioB64, which is raw base64 with no
"data:" prefix. An audio-only fat timeline would have its memory scrubbed
while the widget stayed fat until the next commitChanges.

Gate on either token instead, so the trigger covers everything the scrub
actually removes. A false positive still costs only a lossless rewrite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 11:16:37 +02:00
malkuthro 7b00a551d9 feat(publish): independent output type for published setups; fix(pipeline): auto-versioning re-runs each queue (#267)
Publish: Koolook_PublishOutput output_mode/input_switch/switch; setupSurface.app.outputSwitch; routers auto-bound as the output selector; unwired output types hidden; 'Same as input' offered only when every input type has a wired writer; result path follows the output switch. Pipeline: EasyAIPipeline IS_CHANGED keeps version=auto/next perpetually dirty so the vNNN disk scan re-runs every queue; literal versions stay cacheable.
2026-07-19 23:34:30 +02:00
ba37ec3d41 feat: LTX Director 2.0.2 fork, keyframe grid-snap, and audio→video bind schedule (#259)
* feat(forks): LTX Director v2_0_2 fork + keyframe latent-grid snap (#258)

Bump the WhatDreamsCost Koolook fork to upstream 2.0.2 (pinned fe09f73) in a
new versions/v2_0_2 namespace, and add `snap_keyframes_to_grid` (default on)
to fix hard-pinned image keyframes "jumping" at latent-bucket edges.

Why: a Director image keyframe is injected as a single latent frame at the
segment's left edge, quantized to the model temporal stride s
(L(f)=0 if f==0 else 1+(f-1)//s). Pins near a bucket boundary round
ambiguously and two pins in one bucket collide. Snapping each pin to its
bucket center removes the ambiguity without changing which latent frame it
lands on; colliding pins are bumped to the next free bucket with a warning.

- forks/whatdreamscost_koolook/versions/v2_0_2/: vendored 2.0.2 sources with
  the v1.3.9 Koolook deltas carried forward (relay_overrides,
  audio_transcript_json, per-segment sigma — build_segments is byte-identical
  1.3.9->2.0.2 so the sigma patch drops in verbatim), plus new pure
  keyframe_grid.py snapper wired into the insert_frames build.
- Canonical LTXDirector__koolook rolls to 2.0.2 (entrypoint loads v2_0_2
  last); LTXDirector__koolook_v1_3_2 alias preserved; v1.3.9 kept on disk.
- LICENSE re-verified GPL-3.0 (2026-06-22). Manifest + THIRD_PARTY + CHANGELOG
  + audio-lipsync findings/CHEATSHEET updated.
- 37 new unit tests for the snapper; 107 fork tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(forks): append Koolook LTX Director widgets last for clean load

Move the v2_0_2 Koolook widgets (relay_overrides, audio_transcript_json,
snap_keyframes_to_grid) to the end of the schema, after override_audio. They
were inserted after img_compression, which shifted the last upstream widget
when an upstream-2.0.x-saved workflow is loaded onto LTXDirector__koolook.
With them last, an upstream graph's positional widgets_values map onto the
upstream widgets untouched and only the Koolook widgets append with defaults
(snap_keyframes_to_grid=on). Execution is unaffected (kwargs by name).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(forks): make v2_0_2 a clean upstream-2.0.2 replica + grid-snap only

Per maintainer direction: the v2_0_2 Koolook LTX Director should faithfully
replicate upstream 2.0.2 (so all latest features work) rather than carry the
older customizations, which are unused.

- Drop relay_overrides, audio_transcript_json, and the per-segment sigma patch.
  prompt_relay.py and patches.py are now vendored VERBATIM from upstream 2.0.2;
  _relay_overrides.py removed.
- ltx_director.py is upstream 2.0.2 verbatim except two additive changes:
  the namespaced node ID LTXDirector__koolook, and the snap_keyframes_to_grid
  latent-bucket keyframe snap (issue #258), appended last in the schema.
- v2_0_2 registers only LTXDirector__koolook; the legacy
  LTXDirector__koolook_v1_3_2 ID stays backed by the v1_3_9 package. v1.3.9
  (with the older customizations) remains on disk for rollback.
- Manifest / THIRD_PARTY / UPSTREAM_PIN / CHANGELOG updated to the clean scope.
  grid-snap unit tests unchanged (107 fork tests pass).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(forks): vendor upstream 2.0.2 timeline editor JS for LTXDirector__koolook

The v2_0_2 Python was upstream-exact but the node still rendered with the old
1.3.9-era timeline editor (fewer toolbar actions, no IC-LoRA track, leaked
*_second widgets, frame_rate NaN). Replace web/whatdreamscost_koolook/
ltx_director.js with upstream 2.0.2's js/ltx_director.js vendored verbatim,
retargeted with only two edits: unique extension name 'LTXDirector_Koolook'
and node guard nodeData.name === 'LTXDirector__koolook'. UX now matches
upstream 2.0.2 exactly; only the Python differs (grid-snap).

The old koolook base64-scrub deltas are dropped as obsolete — upstream 2.0.2
persists timeline media via imageFile/audioFile + /api/view URLs (saved
timelines are already lean), and the localStorage quota guard stays in the
global web/koolook_draft_guard.js (upstream does not override localStorage).

Rewrote tests/sidebar/test_ltx_director_draft_guard.py to the new contract
(no embedded guard, retargeted to the koolook node, pinned-upstream
provenance). 110 fork+contract tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(scripts): resolve .env from main repo when dev-sync runs in a worktree

Unrelated to the LTX work: plain `dev-sync` failed with "KOLOOK_COMFYUI_DEV_PATH
not set" from any worktree because scripts/sync_to_dev.py only loaded
`.env` from the worktree root, where the committed `.env` does not exist
(it lives in the main checkout). sync_to_dev_audio.py already had the
worktree->main fallback, which is why dev-sync-audio worked.

Move that fallback into a shared sync_to_dev.find_dotenv() and use it in both
scripts so they can't drift. Adds regression tests for the worktree fallback,
the worktree-own-.env preference, and the not-a-worktree case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: gitignore root-level generated workflow JSONs

Node-swapped/converted workflow copies (e.g. *.koolook.json) are dropped at
the repo root during dev iteration; they're scratch deliverables, not package
content. Ignore root-level *.json only (leading `/`), with a `!config.json`
exception, so the 30+ tracked JSON in subdirs (test fixtures, web/, .claude/,
docs/runs/*/workflow.json) stay tracked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: LTX A/V Bind Schedule node — gate audio→video binding by sigma

New Koolook node LTXAVBindSchedule decouples big motion from lip-sync in LTX
2.3. The AV transformer (comfy/ldm/lightricks/av_model.py, BasicAVTransformerBlock)
adds audio into video via a gated `audio_to_video_attn` per block, run only when
transformer_options["a2v_cross_attn"] is set. This node patches each block's
audio_to_video_attn.forward to scale its output by a per-step gain, and a
unet-function-wrapper computes that gain from the current sigma: early/high-sigma
steps run near `early_gain` (coarse motion settles audio-blind), ramping
(smoothstep) to full at low sigma (lips bind onto settled motion). One pass, no
re-noise — the fix for keyframe transitions jumping on audio peaks.

early_gain=1.0 reproduces stock behavior; a graph without the node is unchanged.
Registered as a root node + added to RUNTIME_PATHS so dev-sync ships it. Pure
ramp (a2v_gain) unit-tested (9 cases); model wiring validated by live render.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: LTX A/V Bind Schedule — add transition-window mode (lips synced + smooth)

The global sigma ramp decoupled motion from lips but desynced the mouth (lip
timing is coarse motion too, so holding audio out globally moved the mouth
audio-blind). Add a WINDOWED mode: audio binds at full strength everywhere
(lips sync across the whole clip) EXCEPT a +/-transition_window band around each
keyframe transition, where the sigma ramp applies — so only those moments are
motion-protected early and rejoined late.

Implemented as a per-latent-frame gain on a2v_out: read grid_sizes from
transformer_options to map frame-major video tokens -> latent frames, build a
gain vector (1.0 outside bands, smoothstep-tapered to sigma_gain at each
transition center), expand per-frame -> per-token, scale. New optional inputs:
transition_frames, total_frames, transition_window. Empty transition_frames =
prior global-ramp behavior. windowed_frame_gains unit-tested (7 cases).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: LTXAVBindSchedule — one-shot diagnostic log (shapes/grid/mode)

Logs out.shape, grid_sizes, transition_fracs, and whether the per-frame mask
fired vs fell back to scalar — so a single render reveals whether the a2v patch
hooks the model and whether the windowed token->frame mapping aligns, instead
of guessing blind.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(audio-lipsync): record validated a2v bind-schedule knee (0.35/0.25/0.55)

Promote the finding that LTX 2.3's audio->video coupling must be sigma-ramped
(not full-on) to keep motion smooth AND lips synced, with the validated knee
early_gain=0.35 / bind_start=0.25 / bind_end=0.55 on the 8-step base pass, plus
the trade-axis table and the per-frame-mode caveat (grid_sizes None + appended
guide frames -> scalar fallback).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(forks): keyframe ease — strength-ramped neighbor pins to de-robotic transitions

Adds `keyframe_ease` / `ease_falloff` to the Koolook LTX Director (default off).
Each hard image pin keeps its exact center pose, but emits same-pose neighbor
pins one latent bucket apart at decreasing strength (center*falloff**k), so the
model glides into and out of the locked pose instead of snapping to a dead stop
— softening the "stop/dissolve/stop" robotic feel without softening the pose.

Implemented as a pure list expansion (`expand_keyframe_ease` in keyframe_grid.py,
6 unit tests) applied to guide_data after grid-snap, so the upstream guide node
is untouched (no fork). Center stays exact; neighbors that fall off-clip or onto
another center are skipped. Costs extra guide frames (more compute), hence opt-in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(audio-lipsync): downgrade a2v knee to 'promising' — note lows-placement confound

The 0.35/0.25/0.55 win was measured on a clip whose transitions sit on audio
lows, so 'no jumps' may be the placement, not the node. Mark the controlled
on-peak test (early_gain 1.0 vs 0.35 on the same on-peak layout) as still pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: mark vendored WhatDreamsCost fork files linguist-vendored

The koolook Director ships upstream 2.0.2 code (the 8.5k-line timeline-editor JS
+ verbatim prompt_relay/patches) so the pack runs and distributes, but it's
upstream, not authored here. Mark those linguist-vendored (drop from language
stats) and the JS -diff (collapse the large blob in PR diffs). Our edited/new
files stay attributed to us.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ltx-av): drop unused `import math` (ruff F401)

a2v_gain uses an explicit smoothstep polynomial, not math.*, so the
import was dead. Unblocks the Lint & Smoke CI gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci(codeql): advanced setup that excludes vendored upstream code

CodeQL default setup cannot read a config file, so it scanned the
verbatim-vendored WhatDreamsCost editor (web/whatdreamscost_koolook/
ltx_director.js) and raised 4 high-sev DOM-XSS alerts in upstream code we
do not own. Switch to advanced setup with a committed config whose
paths-ignore mirrors the .gitattributes linguist-vendored block, so the
scanner treats vendored upstream as out-of-scope while still fully
scanning our own nodes and sidebar JS.

Languages match the prior default-setup matrix (actions,
javascript-typescript, python).

NOTE: requires disabling "default setup" once in repo Settings before
this workflow can run (GitHub rejects advanced runs while default setup
is enabled).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ci): scope vendored fork exclusions to v2_0_2

* fix(forks): preserve final LTX keyframe snap bucket

---------

Co-authored-by: AI Machine <ai.machine@x.baconcph.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 11:15:41 +02:00
malkuthro bda4df576a Release v0.4.1 (#250)
* Release v0.4.0: update footer notice

* Release v0.4.1
2026-06-19 01:38:40 +02:00
6f158c9020 fix(nodes): Easy_LoadVideo empty-branch degrade (bundles #243 publish-tab cleanup) (#244)
* docs(setups): add setup runner quickstart runbook

Standalone reference for opening the published-setup mockup frontend
(setup runner simulator): the `<comfy-address>/koolook/setup_runner_simulator.html`
URL pattern, the live-registry vs setup-file (inspect-only) load paths,
and demo mode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(sidebar): close throwaway workflow tabs left by publish capture

Publishing a setup captures ComfyUI's API-format prompt by loading the
graph onto the canvas (graphToPrompt) and reloading the prior canvas.
In current Comfy each loadGraphData spawns an "Unsaved Workflow" tab, so
every publish left two stray tabs behind.

captureWorkflowApiPrompt now snapshots the open workflows before capture
and, afterward, re-activates the user's original tab and closes whatever
the capture opened, via app.extensionManager.workflow. All of it is
guarded and best-effort: the workflow-store API is Comfy-version
specific, so if it is absent capture still succeeds and behavior falls
back to today (no crash, no broken publish).

Covered by a source-pattern test. Needs live verification against the
1.45 frontend (dev-sync) to confirm the closeWorkflow API shape.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(nodes): Easy_LoadVideo degrades to empty on an unselected folder branch

When an upstream mode switch routes a path into Easy_LoadVideo, both the
EXR sequence loader and the video loader execute eagerly. On the EXR
branch the video loader was handed a folder (no filename) and raised
ValueError, crashing the whole prompt -- even though that branch is
discarded downstream. Lenient sequence loaders just ignore a wrong-type
path; the video loader was the only one that hard-failed.

_compose_input_video_path now returns EMPTY_BRANCH_SENTINEL instead of
raising when input_path resolves to an existing directory. load_video
turns that into a correctly-shaped empty result (IMAGE zeros,
frame_count 0, empty AUDIO, empty video_info) matching VHS_LoadVideoPath
returns, so the dead branch no longer crashes the prompt. VALIDATE_INPUTS
and IS_CHANGED handle the sentinel too. A path that does not exist is
still a genuine mistake and keeps raising.

torch is imported lazily in the empty-result builder so the path logic
stays importable/testable without torch.

Covered by unit tests for the sentinel (absolute + relative dirs), the
still-raises case, and a load_video integration test that asserts the
empty result and that the loader never runs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(setups): expose prompt as an always-on field in the publish frontend

The publish surface modelled `prompt` as a 4th source mode (mutually
exclusive with EXR/QT/Img) and hid it (visible:false), so a published
setup couldn't offer "pick a source AND describe the shot". This makes
prompt a standalone, always-on input.

- published_surface.js: prompt becomes visible + standalone + multiline,
  rendered independently of the source switch; the author's prompt-widget
  text is surfaced as a `placeholder` hint (submitted default is empty so
  an untouched hint is never sent), with a `help` line. The switch
  excludes standalone fields from its source options.
- setup_runner_simulator.js: renders always-on standalone inputs alongside
  the selected source (textarea + placeholder + help), includes them in the
  run payload, and updates the demo setup.
- setup_runner_simulator.html: .field-help styling.
- contract doc: documents the always-on prompt behaviour.
- launch.json: portability fix (hardcoded C:\Python313 -> python).

Contract test updated + a new test asserting prompt is always-on, carries
the placeholder, and is excluded from the switch source options (runs on
CI where Node is available). Behaviour verified in the runner simulator
(demo mode) in a browser: textarea + hint, EXR/QT/Img-only switch, prompt
stays visible across source changes, payload includes the typed prompt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(setups): mirror always-on prompt field in the Python publish inference

The publish surface is inferred server-side in Python (koolook_setups.py
_infer_app_surface, called by publishSetup), which overrides the client.
The earlier change only updated the JS (published_surface.js), so
re-publishing still produced the old surface with a hidden prompt.

Mirror the JS here: prompt is a standalone, always-on input (visible +
multiline) with the author's prompt-widget text as a placeholder hint and
an empty submitted default; it is excluded from the source switch options.
Keeps koolook_setups.py and published_surface.js in sync.

Registry test updated to the new shape. Server + publish-contract suites
green (115); full non-sidebar suite green (530).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(setups): keep the runner simulator polling through long renders

The poller used a fixed 30s total budget, so a multi-minute render (e.g.
428s) timed out mid-run with "Timed out waiting for run" even though
ComfyUI was still rendering and the run later landed as succeeded.

timeoutMs is now a "no-progress" window: while ComfyUI keeps reporting the
run as queued/running, the deadline is pushed forward, so a long-but-active
render never spuriously times out; only a stalled/vanished run gives up.
Default raised 30s -> 120s. The run status line shows elapsed seconds while
active so a long wait visibly ticks instead of looking frozen.

Happy path (queued -> running -> succeeded) unchanged; verified in the
runner simulator (demo mode) in a browser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(setups): serve simulator assets with no-store so dev-syncs take effect

The runner-simulator HTML/JS route returned no cache headers, so browsers
cached the assets and kept running stale JS after a dev-sync -- e.g. the
old 30s poller persisted client-side even though the server was serving
the long-render fix, which looked like "the fix didn't land". Serve these
dev-synced assets with Cache-Control: no-store so a normal reload always
picks up the current code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(setups): add live run/poll smoke check for the published-setup API

tools/check_setup_run.py queues a published setup through the same routes
the runner simulator uses and polls the run endpoint with the same
deadline-extension logic, printing the status timeline. Lets us self-verify
end-to-end run tracking against a live ComfyUI (e.g. "does a long render
reach succeeded?") without driving the browser UI.

Used it to confirm the run/poll server flow is correct (terminal status
detected in ~2s; run-000001 reported succeeded), isolating the simulator's
earlier "Timed out" to stale browser cache rather than a server bug.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(nodes): Koolook_NextVersion auto-detects the next output version

Adds filesystem-aware version auto-detection so a re-run lands on a fresh
version instead of failing with "Output file already exists and overwrite
is disabled" (the real EasyAIPipeline failure seen on the RMGB setup).

- koolook_versioning.next_version_token(directory, name, ...): scans for
  existing <name>_vNNN files AND sequence-folders (handles name_v002.png,
  name_v002.0001.exr, name_v003/), returns highest+1 zero-padded; missing/
  empty dir -> start (v001); exact base-name match so other shots never
  bump this one; empty name matches bare vNNN; configurable prefix/padding.
- Koolook_NextVersion node (Koolook/Publish): folder + name -> next version
  token. Wire once into both the EXR and video version inputs so branches
  stay in sync. IS_CHANGED returns NaN to re-scan every run.

Tested: helper edge cases + node registration/run. Preflight clean (20
node IDs). Full non-sidebar suite green (539).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(nodes): 'auto' version keyword for EasyAIPipeline + Easy_VideoCombine

No new node or wiring needed: type 'auto' (or 'next') in the version field
and the node scans its own output folder for existing <name>_vNNN entries
and writes the next free version -- fixing "Output file already exists and
overwrite is disabled" without manual version bumps.

- koolook_versioning.is_auto_version(): detects the auto/next keyword.
- EasyAIPipeline (build_pipeline_outputs): on auto, scans the version-folder
  parent (base, or base/shot/method when subfolders are on) for vNNN dirs and
  picks the next; version computed after path segments so the scan dir is known.
- Easy_VideoCombine: on auto, resolves the token after the output dir + name
  root are composed, scanning that dir for <name>_vNNN outputs.
- Non-auto/typed/wired tokens and disable_versioning are unchanged.

Tests: is_auto_version keywords + EasyAIPipeline auto (v003 after v001/v002,
v001 first run). Full non-sidebar suite green (542), preflight clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(nodes): drop redundant Koolook_NextVersion node

The 'auto' version keyword on EasyAIPipeline + Easy_VideoCombine covers
auto-detection without any new node or wiring, so the standalone
Koolook_NextVersion node is redundant. Remove it (and its node test) to keep
the node list clean; the shared next_version_token helper stays (the 'auto'
keyword uses it).

Node-ID count back to 19; full non-sidebar suite green (541), preflight clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(tools): satisfy security gate for setup smoke check

* fix(pr244): address verified review findings

* fix(video): fail if relative auto-version scan root is unavailable

---------

Co-authored-by: Cristian Predut <cristian.predut@x.baconcph.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: AI Machine <ai.machine@x.baconcph.com>
2026-06-19 00:10:59 +02:00
malkuthroandClaude Opus 4.8 ae2480eee9 feat(setups): publish success feedback + published-setup discoverability (#227) (#239)
* feat(setups): publish success feedback + published-setup discoverability (#227)

Post-publish feedback (issue #227 Part 1):
- POST /koolook/api/setups now returns the on-disk storagePath
- new POST /koolook/api/setups/reveal opens the published-setups folder
  (shared _open_dir_in_file_manager helper; the snapshot reveal route was
  refactored onto it, behaviour-identical)
- the publish modal renders a success card reusing the snapshot Save dialog's
  "Saved to ... Open folder" language (Open folder / Copy path / Close)
  instead of closing silently; a draft publish still gets a confirmation

Published discoverability (reuses the tag system, no new schema):
- publishing auto-tags the source workflow `published` -> a "published" badge
  in the Workflows tree + the Tags-section "published" pool
- a new "P" toggle in the sidebar Tools row prunes the Workflows tree to
  published-only, folders force-expanded, so setups show in their origin
  structure; toggling off restores the full tree
- workflowHasTag() shared helper (unit-tested)

Deferred, still open on #227: separate destination/library setting (Part 2),
relocating the storage folder beside the snapshot library, and clearing the
filter when entering Compare mode.

Tests: +server (storagePath, reveal route happy + 404), +client (reveal
client, buildPublishSuccessView view-model), +sidebar (workflowHasTag).
Full suite: 610 passed, 1 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(setups): store published setups beside the snapshot library (#227)

- the published-setup registry path now derives from the configured snapshot
  library (`_configured_dir().parent / koolook-published-setups/setups.json`)
  instead of a fixed user-dir folder, so it follows libraryPath /
  KFORGELABS_PRESETS. The default config resolves to the same path as before
  (target == legacy), so existing default installs are unchanged.
- non-destructive one-time migration: copy a legacy user-dir registry to the
  new location when the new one is absent, leaving the original in place.
- sidebar: clear the "P" published filter when entering Compare mode so it
  doesn't render both compare columns filtered with no toggle to undo it.

Tests: +tests/server/test_published_setups_relocation.py (path beside library;
migration copy / no-op-when-present / no-op-when-no-legacy). Full suite: 614
passed, 1 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(tests): remove unused pathlib.Path import (ruff F401)

The relocation test only uses the `tmp_path` fixture, not `Path` directly, so
`from pathlib import Path` was unused and failed the Lint & Smoke (ruff) gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(setups): address PR #239 review — migration masking + tag persist

- HIGH: a failed legacy->new migration no longer masks real setups with the
  bundled sample. The default registry keeps the legacy file as its primary
  storage when migration could not produce the new file, so stranded data
  stays visible. (+2 tests: keeps-legacy-on-copy-failure / uses-new-on-success)
- MEDIUM/LOW: the publish flow now awaits the `published`-tag persist so the
  success card reflects a committed tag (persist failures already surface via
  persistMutation's critical toast), and warns when the source workflow could
  not be tagged (vanished mid-publish) rather than on a harmless republish.

Full suite: 616 passed, 1 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(setups): harden migration against partial/corrupt relocation target

Addresses the remaining HIGH from the PR #239 consolidated re-review: a
zero-byte/partial/corrupt setups.json at the relocated path could shadow the
intact legacy registry (existence was treated as proof of migration).

- migration now copies via temp file + atomic os.replace (the same pattern
  FileSetupStorage.save_setups uses), so an interrupted copy can never leave
  a partial setups.json at the target; failed-copy temp files are cleaned up.
- primary selection is corruption-aware: when the relocated file exists but
  does not parse as a setups document while the legacy file is intact, the
  registry keeps serving the legacy file and logs a loud diagnostic. The
  unreadable file is left untouched (never overwritten) as evidence. A valid
  relocated file stays authoritative; if both are corrupt, the relocated path
  stays primary so storage diagnostics point at the current location.

Tests (+4): corrupt-target-prefers-intact-legacy (file left untouched),
valid-target-stays-authoritative, both-corrupt-keeps-target, and
interrupted-copy-leaves-no-partial-target-and-no-tmp-litter.

Full suite: 620 passed, 1 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 23:54:12 +02:00
malkuthroandClaude Fable 5 c6a155a033 fix(web): generation-agnostic Comfy draft-quota guard (#237)
* fix(web): generation-agnostic Comfy draft-quota guard

"Failed to save workflow draft" toasts returned on ComfyUI frontend
1.44+ because draft storage moved to new localStorage key names
(per-workspace V1 keys, then a V2 index+payload scheme) that the old
exact-name guard embedded in ltx_director.js never matched - and the
1.44 V2 layer latches a session-wide storage-unavailable flag after one
failed save+evict cycle, re-toasting on every edit until reload.

Move the guard to its own global extension, web/koolook_draft_guard.js:

- match draft keys by prefix (Comfy.Workflow.Draft) across all known
  generations, with unknown future keys as last-resort evictions
- rescue quota failures inside localStorage.setItem (evict the oldest
  draft across generations, retry) before Comfy's own catch sees the
  error, so the save succeeds and the latch never trips
- boot prune: drop the suffixed-V1 dead weight the frontend's V1->V2
  migration never deletes, scope corrupt-key cleanup to the offending
  key, drop oversized entries, enforce a total draft budget
- ship the guard with dev-sync-audio so a scoped sync cannot strand a
  dev install guard-less

Behavior is covered by tests/js/test_draft_guard.mjs (12 scenarios,
wired into CI next to the existing node smoke test); static integration
contracts live in tests/sidebar/test_draft_guard.py.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): address deep-review findings on the draft guard

- latch the "still full" hint toast once per session so a genuinely
  full origin cannot re-toast on repeated failing autosave attempts;
  regression assertion added to the exhaustion scenario
- document the bounded-eviction termination contract and the
  post-rethrow storage-unavailable latch at the wrapper, plus the
  budget's V1-blob-only (1.5M) -> all-generations (2M) scope widening
  (also now stated in the CHANGELOG entry)
- version-robust wording for the suffixed-V1 cleanup rationale: the
  shipped 1.44 migration leaves the keys behind (verified against the
  installed 1.44.19 dist - no removeItem exists there), later
  frontends clean up on successful migration, and an interrupted
  migration can leak the keys on any version
- comment the intentional best-effort swallow in the toast helper

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:29:25 +02:00
malkuthro c3c34f0171 Capture API prompt when publishing setups (#233)
* feat(sidebar): capture API prompt when publishing setups

* feat(setups): preview and load canonical setup records

* fix(setups): close publish review gaps
2026-06-08 20:28:35 +02:00
malkuthro 85206ab321 feat(setups): add external runner simulator (#231)
* feat(setups): add external runner simulator

* feat(setups): render app-surface simulator form

* docs(setups): capture publish-to-run frontend plan

* docs(setups): preserve sidebar inventory breadcrumbs

* fix(setups): address runner simulator review findings

* fix(setups): validate simulator run payload shape
2026-06-08 08:30:10 +02:00
malkuthro 3b35e6a195 feat(setups): infer setup surface from workflow groups (#222)
* feat(setups): infer setup surface from workflow groups

* fix(setups): validate inferred setup surfaces on read

* fix(setups): reject boolean setup geometry
2026-06-07 14:45:16 +02:00
malkuthro b4314f3b3d feat(sidebar): preserve workflow groups in saved selections (#220) 2026-06-07 12:41:14 +02:00
malkuthro 16023662e6 feat(setups): publish sidebar workflows (#216) 2026-06-07 11:07:52 +02:00
malkuthroandClaude Opus 4.8 38d1f8425d feat(snapshot): Compare Phase 2 — bidirectional path-preserving copy + folder merge + diff filter (#197) (#202)
* feat(snapshot): Compare Phase 2 — bidirectional path-preserving copy + folder merge + diff filter (#197)

Makes the read-only A/B Compare view (live kit A ↔ loaded snapshot B) fully
actionable, in both directions, with orientation signage and a diff browser.

Copy. Right-click a node/workflow on the read-only SOURCE side → "Copy to
<target>". Path-preserving: items land at the SAME folder path they came
from (new copyWorkflowIntoStore auto-creates/merges dirs, deep-clones the
graph, applies a skip-identical / keep-both collision policy; tags + module
ride along). Right-click a folder → "Copy folder (with contents)" bulk-copies
everything under it (the Workflows root copies the whole tree — merging two
snapshots) with an added/kept-both/already-there summary. Archived versions
are excluded from the diff and from bulk copy.

Both directions. B→A writes the live kit via persistMutation (Save round-
trips it). A→B writes back into the snapshot FILE on disk (writePreset),
targeting the exact file/dir it was loaded from (autosave dir threaded
through onChoose). The A→B path is serialized + epoch-guarded: it re-clones
the latest snapshot inside the queued task and only swaps the in-memory
snapshot in after a successful write while the same Compare session is open
(no resurrecting a closed view, no concurrent-copy lost update).

Orientation + diff browser. Each column shows a SOURCE/TARGET footer stripe;
the swap flips which side is the editable target and moves the read-only
guard with it. The bottom green/red legend doubles as filters (new/modified)
that collapse the SOURCE panel to just those items and back. The highlight,
counts, and filter all follow the source side and mean "in the source, not
the target" (the copy candidates).

The two panels keep independent folder-expansion maps so a copy's re-render
never collapses the other side; copies open the destination folder (not the
Archive child) so you can see where they landed.

Non-destructive throughout: no applySnapshot on the Compare path; the read-
only side only writes through the explicit copy affordance; graphs are deep-
cloned so neither store aliases the other.

Tests: copy-engine behaviour (path create/merge, skip/keep-both, deep-clone,
bulk folder, archived exclusion) + structural invariants (read-only guard,
bidirectional additive copy, path-preserving, serialized/epoch-guarded
write-back, source-following diff, panel-isolated expansion, folder pull-in).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(snapshot): guard the Compare copy engine against prototype pollution (#197, #203)

copyWorkflowIntoStore writes destination keys from user-controlled workflow
names / directory segments (a loaded snapshot can be hand-crafted), so reject
__proto__ / constructor / prototype via the same isSafeObjectKey() guard the
other name-keyed store mutators adopted in #203. copyFolderIntoStore inherits
the guard through it. Added after rebasing onto main so the new merge surface
matches the hardened sinks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(snapshot): Compare A→B is an in-memory working copy with an explicit Save (#197)

A→B no longer writes to disk per copy. The loaded snapshot (B) is treated as
an in-memory working copy: copying into it mutates `compareSnapshot` and marks
it dirty — no writePreset, so a rotating autosave (which "Load latest" can
hand you) is never a write target. The user Saves explicitly via a footer
button: writePreset to a NAMED snapshot in the default library dir, pre-filled
with the shadowed/loaded name (compareDefaultSaveName strips the `_autosave`
suffix to reach the named parent). Exiting Compare with unsaved edits prompts
to discard first.

Each column footer now has two lines: line 1 = SOURCE/TARGET + name; line 2 =
save behavior — "Live — changes auto-save" for your kit, and for the snapshot
either "Read-only file", "edits need Save", or "● Not saved" + a Save button
(shown whenever the snapshot is dirty, so it survives a swap). The word "live"
disappears from the UI; the footer carries the meaning.

Removes the per-copy persistSnapshotEdit + compareEpoch + snapshotWriteChain
machinery (no longer needed — there's a single explicit Save). B→A is
unchanged (auto-persists into the live kit). Non-destructive guarantees hold:
no applySnapshot; sources are clones; the snapshot panel stays read-only apart
from the pull-in. Fresh-eyes review: no Critical/Important findings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(snapshot): Compare bottom = two separate stripes; clean autosave name (#197)

Per maintainer feedback, split the bottom into two distinct stripes instead
of cramming save state into the SOURCE/TARGET footers:
  - Stripe 1: the SOURCE/TARGET footer under each column — orientation only.
  - Stripe 2: a dedicated, full-width SAVE stripe with the snapshot's save
    state, instructions, the dirty indicator, and the Save button.

Also fix the long autosave name: an autosave-loaded target showed the raw
`Foo_autosave/pre_load_...` path in the header and footer. compareSnapName()
now derives a clean name (the named parent + " · autosave"); the Save button
reads "Save as file…" and still pre-fills the named parent so A->B merges
land in a named file, never the rotating autosave.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(snapshot): guard Compare snapshot Save against silent overwrite (#197)

PR #202 review (data-safety): saveCompareSnapshot wrote `writePreset(name, …)`
with no existence check and no overwrite confirm. Since the Save name is
pre-filled with the snapshot's own name and the server route ends in an atomic
os.replace, accepting the default could silently overwrite a DIFFERENT
same-named snapshot file. Now mirrors the live Save flow (modals.js
promptForName): sanitizeName the input, presetExists() → abort on null
(library unreachable) → confirm on true (Overwrite?) → write only on false.
Adds a `compareSaving` in-flight guard so rapid Save clicks can't fire
overlapping writePreset POSTs.

Also dedup: copyWorkflowIntoStore's collision check now reuses the existing
`normalizedStoresEqual` instead of the byte-identical local `graphsEqual`
helper (removed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:26:26 +02:00
malkuthro 7f8c09ad29 feat(sidebar): clean up workflow archives (#201) 2026-06-02 22:25:09 +02:00
8058e17456 fix(snapshot): paired drift-corruption fix — duplicate-install guard + boot-time mismatch (#161, #162) (#186)
* fix(snapshot): prevent drift corruption from duplicate installs + boot-time mismatch (#161, #162)

Closes #162 and #161 as the paired fix the issues describe — both touch
the same data-loss surface and verify against the same dev-sync rig.

installs at import time, log critical, register nothing in the
alphabetical loser; client-side `window` sentinel as belt-and-suspenders;
align .env.example / dev-iteration-loop / sync_to_dev / README at the
Registry-derived `koolook/` path.

compare against live /userdata + picks, set a drift flag on mismatch;
gate periodic + pre-load autosave routing to `_unsaved_autosave/` while
drifted; promote status pill to "drifted" precedence over "saved" with a
recovery tooltip; auto-clear on the next markStateSaved (Save / Load).

26 new tests (14 install guard, 4 sidebar sentinel, 8 boot drift) —
316 total passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(snapshot): apply review fixes — canonical fingerprint + early loser exit + drift timestamp

Review-driven follow-ups on PR #186:

1. **Symmetrize file-side fingerprint (MEDIUM).** `detectBootDrift`
   now uses a new `_canonicalStateFingerprint()` that normalizes the
   workflows tree through `normalizeWorkflowsStore` AND deep-sorts keys
   via `sortJsonValue`. Without this, hand-edited / cross-machine /
   pre-v0.3 files would false-positive as drifted on first boot
   (e.g. `archived: "false"` string vs `false` bool, missing
   `directories: {}`, key insertion-order asymmetry from the
   `{...wf, archived, module, tags}` spread). `_computeFingerprint`
   stays unchanged so the localStorage save baseline contract holds
   for existing users. New regression test verifies a shape-only diff
   does NOT trigger drift.

2. **Gate fork imports on `_is_winning_install` (LOW).** Non-winning
   duplicate installs now short-circuit before the heavy torch / PIL /
   diffusers / aiohttp imports, exiting in ~5 LoC instead of paying
   full import cost just to discard the mappings.

3. **Drift timestamp uses `driftDetectedAt`, not stale
   `lastNamedSaveAt` (LOW).** The localStorage save baseline could be
   from a prior session whose live state was already corrupt —
   surfacing it as "Last named save" in the drift tooltip would
   mislead. Replaced with the session-noticed-at timestamp from
   `_bootDriftDiagnostics`.

352 tests passing (316 prior + 26 from PR #186 + 1 new normalize-
symmetry case).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(install-guard): fail safe on unreadable / non-UTF-8 siblings (#162)

Re-review follow-up. The import-time duplicate-install scan could crash
the entire plugin load (fail-silent — the alphabetical winner vanished
too, not just the loser) on two unguarded paths:

- detect_duplicate_koolook_installs: the per-entry is_dir()/is_file()
  over custom_nodes/ siblings was unguarded, so a non-traversable
  sibling (restrictive perms, broken mount, a special dir) raised
  PermissionError up into __init__.py. No duplicate even required.
  Now caught per-entry and skipped.
- read_pyproject_version: read_text(encoding="utf-8") raises
  UnicodeDecodeError (a ValueError, NOT OSError) on a non-UTF-8 /
  binary pyproject.toml, escaping the `except OSError`. Now degrades
  to "?" via `except (OSError, ValueError)`.
- __init__.py: wrapped the guard call site as a final backstop — any
  unforeseen guard failure now degrades to register-normally rather
  than aborting the import.

Both paths reproduced before the fix; the first surfaced while running
the suite from a /tmp checkout whose parent held a root-owned dir.

3 regression tests added (non-traversable sibling skipped; non-UTF-8
pyproject -> "?"; report survives a non-UTF-8 sibling). Full suite:
452 passed (449 + 3).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: kflame99 <261674183+kflame99@users.noreply.github.com>
2026-06-02 18:44:57 +02:00
malkuthro b5b46d7fcc feat(sidebar): Snapshot Compare — read-only A/B side-by-side with diff tint (#181) (#183)
Phase 1 of #181: read-only side-by-side Compare. A/B toggle renders the existing sidebar twice — live working copy (A) + a loaded snapshot (B), B labeled with its own name + diff-tinted (green=only in comparison, red=same name/graph differs ignoring savedAt). Non-destructive: read-only capture guard on B, onChoose chooser never calls applySnapshot. Phase 2 (pull-in + A<->B swap) tracked in #197.
2026-06-02 08:22:15 +02:00
malkuthro 1d9d40ab70 Add audio transcript timeline node (#189)
* Add audio transcript timeline node

* Hook audio transcripts into Koolook Director

* Add modular timeline audio timing workflow

* fix(frontend): guard Comfy workflow draft cache

* fix(audio): compose transcript timeline prompts

* fix(audio): address transcript review findings

* fix(ci): keep transcript tests lightweight

* fix(timeline): restore image previews from saved paths

* fix(timeline): scrub restored preview blobs
2026-05-30 18:31:41 +02:00
malkuthroandkflame99 d0fb000239 fix(snapshot): show disk-renamed snapshot names (#174)
* fix(snapshot): show disk-renamed snapshot names

* fix: address kflame99 review feedback (#174)

Split the autosave embedded-name guard into its own test and make the root snapshot filename assertion whitespace-tolerant.

Co-Authored-By: kflame99 <kflame99@users.noreply.github.com>

---------

Co-authored-by: kflame99 <kflame99@users.noreply.github.com>
2026-05-22 22:43:26 +02:00
malkuthro 0fea0a6a70 fix(sidebar): keep archive collapsed during search (#165) 2026-05-18 22:20:21 +02:00
malkuthro e3f94cca6b fix(sidebar): reuse draft id for workflow loads (#166) 2026-05-18 11:43:55 +02:00
malkuthro 96d4b51d7b Release v0.3.5 (#160)
* Release v0.3.5 prep

* fix(sidebar): bypass stale browser cache for snapshot state

* test(sidebar): guard no-store snapshot fetches
2026-05-18 00:54:49 +02:00
malkuthro 1c1c870439 fix(sidebar): keep loaded workflows untitled in Comfy (#157) 2026-05-17 22:04:38 +02:00
malkuthro bc7ec09971 Fix sidebar workflow persistence recovery (#155)
* fix(sidebar): recover workflow saves after fallback

* fix(sidebar): merge workflow fallback recovery

* fix(sidebar): clear redundant workflow fallback
2026-05-17 16:30:11 +02:00
malkuthroandClaude Opus 4.7 8e489ad37a feat(snapshot): redesigned Save/Load dialogs + folder picker (#137) (#144)
* feat(snapshot): row-augment max(periodic, pre_load_*) + pytest infra (#137)

Server-side preparation for the snapshot dialogs redesign. The Load
dialog's "newer auto-save available" row flag now reflects the freshest
recovery file regardless of which mechanism wrote it — before this we
only stat'd ``periodic.json``, missing cases where the user's most
recent Load wrote a fresher ``pre_load_*.json`` snapshot inside the
same autosave subfolder.

Extracts ``_latest_autosave_mtime(autosave_dir, named_mtime)`` from the
inline block in ``list_presets`` so the scan is unit-testable. The
helper walks ``periodic.json`` and every ``pre_load_*.json`` once,
returns the max mtime if strictly newer than the named file, else
``None``. Same shape as before, broader source set.

Introduces pytest as a first-class test runner:

  * ``tests/server/test_listing_autosave_mtime.py`` — 5 row-augment
    test cases (periodic-only, pre_load-only, max-of-both, no-newer,
    missing-dir).
  * ``[tool.pytest.ini_options]`` in ``pyproject.toml`` scopes
    discovery to ``tests/`` and uses ``importmode=importlib``.
  * ``[project.optional-dependencies] test = [...]`` defines the
    test extra (pytest + aiohttp, which is a runtime dep of
    ``koolook_routes`` that must be importable even when only a pure
    helper is under test).
  * ``.github/workflows/ci.yml`` gains a ``pytest`` job that installs
    via ``pip install .[test]`` and runs ``pytest -q``.

Guards ``__init__.py``'s relative imports in a ``try / except
ImportError / else`` block. Pytest's package collector walks up from
test files looking for ``__init__.py`` and tries to import the one at
the repo root — but the relative imports inside it only resolve under
ComfyUI's custom-node loader, not under a standalone Python import.
Without the guard, every pytest run aborts during collection with
``attempted relative import with no known parent package``. The
fallback emits a one-line diagnostic and registers empty mappings so
the package directory can still be imported by tooling. ComfyUI's
loader still hits the ``else`` branch and behaviour is unchanged for
real users.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(snapshot): folder picker + agent-driven visual harness (#137)

Commit 2 of the snapshot dialogs redesign. Introduces the navigate-
into folder picker from mockup section 6 — the picker that opens when
the user clicks Save to… / Load from… in the redesigned dialogs (and
that the Settings cog will be removed in favour of, once commit 6
lands). Path input is the source of truth: type/paste + Enter
navigates, clicking a folder row drills in, ↑ Up climbs one level.
Files appear greyed below the directories as a "yes, this is the
folder I expected" affordance.

End-visible path overflow: the path input uses ``direction: rtl`` +
``unicode-bidi: plaintext`` so when the path exceeds the input width
the leading ``/Users/…`` clips on the left, never the trailing folder
name. Per mockup section 6.

Visual harness — the path the issue called out as "visual verification
is a hard gate":

  * ``.claude/launch.json`` defines ``design-server`` (``python3 -m
    http.server 8765``). Agent / maintainer can spin this up via
    ``preview_start`` or directly from a terminal.
  * ``docs/designs/_harness/folder-picker.html`` mounts the real
    ``showFolderPicker`` from ``web/sidebar/modals.js`` against a
    canned filesystem, with one trigger button per mockup-illustrated
    state (default / new-folder-input / empty / error). Cache-busts
    module imports so editing modals.js while the harness is open
    picks up fresh code on reload.
  * ``docs/maintainers/visual-harness.md`` documents the workflow,
    when to use the harness vs. dev-sync, and the backtick-in-CSS-
    comments pitfall that broke this commit twice during development.
  * ``CLAUDE.md`` now lists the harness as the preferred agent-driven
    path; dev-sync stays the integration-level gate.

Server: ``/koolook/presets/browse`` gains an opt-in ``?files=1``
parameter that returns child JSON files alongside the directories.
Default response shape unchanged so the legacy Settings dialog
keeps working. ``_list_child_files`` mirrors ``_list_child_dirs``'s
swallow-OSError-per-entry policy so a broken symlink mid-listing
doesn't sink the whole response.

Tests: six new pytest cases in
``tests/server/test_browse_list_children.py`` covering directory
sorting, autosave-subfolder hiding, JSON-only filter, case-insensitive
extension match, and the empty-result paths. ``pytest`` runs all 11
tests cleanly under the existing CI job from commit 1.

What's still verified via dev-sync (not via the harness):
  * Real Comfy theme tokens (the harness fakes ``--comfy-menu-bg``
    etc. with dark-theme defaults).
  * End-to-end Save → Load round-trip across the new library path.
  * Picker interaction with ComfyUI's keyboard manager.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(snapshot): modal headers stay title-only; route gloss to tooltip (#137)

Follow-up to the folder picker commit. Maintainer flagged that the
"Where snapshots are saved and loaded from." subtitle below the title
was extra information the user doesn't need at first glance — if the
title isn't self-explanatory, fix the title, don't pad it with a
sentence. The mockup at docs/designs/snapshot-dialogs.html section 6
shows a title-only header for exactly this reason.

Locks the rule in as a project convention so every dialog box behaves
the same way going forward:

  * docs/maintainers/conventions.md — new "Modal headers" section
    spells out the rule, what counts as a "descriptive subtitle"
    (remove) vs. a "functional info row" (keep), and how to plumb
    explanatory copy via a hover tooltip on the title instead.
  * web/sidebar/modals.js — makeModalShell now accepts ``titleTooltip``,
    applied as the ``title`` attribute on the modal title element so
    the gloss is mouseover-discoverable but never visible by default.
    showFolderPicker drops its ``subtitle`` param (it was added one
    commit ago, no other callers) in favour of ``titleTooltip``.
  * docs/designs/_harness/folder-picker.html — harness re-points its
    explanatory text to ``titleTooltip``. Verified in the harness: the
    visible header is title-only, the tooltip surfaces on hover.

Applies prospectively to the Save / Load dialogs (commits 3 and 4) —
they will also be title-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(snapshot): redesigned Save dialog with inline library row + Save to... + 4-state button (#137)

Commit 3 of the snapshot dialogs redesign. The Save dialog is now a
single modal (was three: three-way overwrite / input prompt / confirm)
that matches mockup section 2:

  * Inline library-path info row at the top of the body — "Saved to"
    label on the left, "Open folder ↗" link on the right, full path
    below. Pure information; every action lives in the bottom command
    bar.
  * Bottom command bar with split layout: Save to... on the left,
    Cancel + Save as new... + Save on the right (spacer in between).
  * Save to... opens the folder picker from commit 2. On Use this
    folder, the dialog calls saveSettings() to persist the new library
    path, refreshes its in-place library row, and flips the primary
    button into Dirty state.
  * The primary Save button cycles through the four states from
    mockup section 5:
      - Default ("Save") — clean state, primary blue.
      - Dirty ("Save to new folder") — folder differs from the one the
        dialog opened with; the label spells out the consequence.
      - In progress ("Saving...") — disabled, dimmed primary.
      - Done ("Saved") — disabled, subtle green, brief pause before
        the dialog closes.
  * Save as new... still routes through the existing showInputModal
    plus presetExists guard, but the prompt no longer carries a
    library-path subtitle — the parent dialog already shows the
    target. Convention from the previous commit applied.

tree.js now passes the new capability functions (saveSettings,
browseDirectories, createBrowseDirectory, revealPresetFolder) into
showSaveSnapshotDialog so the dialog can invoke the folder picker
end-to-end inside the Save flow.

Visual harness: docs/designs/_harness/save-dialog.html exercises all
five mockup states (default tracked, untracked Save-as-new, dirty,
in-progress, done) so the maintainer (or future agent) can re-verify
without dev-syncing. Verified all states render correctly in the
preview server.

Static guard against the recurring backtick-in-CSS-comments trap:
tests/sidebar/test_css_template_literal.py asserts that the CSS
template literal in web/sidebar/constants.js contains exactly two
backticks (open + close) and nothing in between. The trap bit this
work three times (commits 2 and 3) — a literal backtick inside a
comment closes the template early and silently bricks the entire
sidebar. The test runs under the same pytest job introduced in
commit 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(snapshot): redesigned Load dialog with scoped recovery + inline delete (#137)

Commit 4 of the snapshot dialogs redesign. The Load dialog is now a
single modal matching mockup section 3:

  * Inline library row at the top, same shape as the Save dialog —
    "Loaded from" label + Open folder ↗ link + leaf folder name +
    full path. No 📂 emoji button (the link supersedes it).
  * Preset rows: click to load. The "Replace current state?" confirm
    modal is gone — clicking Load is the intent, the dialog title
    already says Load snapshot, no double-confirm.
  * Scoped recovery: when a preset has a newer autosave (per the
    server-side row-augment from commit 1), the first click DOES NOT
    load. It expands exactly ONE recovery row directly beneath the
    parent, showing the freshest entry (newest of periodic.json or
    pre_load_*.json) with its kind badge + meta line. The user then
    chooses: re-click the named row to load the named version, OR
    click the recovery row to restore the auto-save. Both options
    are surfaced inline; the user picks by clicking the one they
    want.
  * Inline delete confirm: × outlines the target row red, the bottom
    Close button transforms to 'Yes — delete "<name>"' (danger
    styling). A second click commits; Escape cancels and reverts.
    No second modal.
  * Bottom command bar matches mockup section 3 split layout:
    [Load from…] on the left, [Close] on the right. Load from…
    opens the folder picker from commit 2; on Use this folder it
    persists via saveSettings() and refreshes the listing.

The pre-#137 YES/NO choice modal (``promptApplyChoice``) and the
collapsible Recovery auto-saves <details> section are both removed —
the scoped-recovery inline row + the click flow above subsume their
job. (Commit 5 in the issue plan was specifically for dropping the
YES/NO modal, which happens here as a natural consequence of the
routing change; commit 5 will be a small follow-up if needed.)

Behavior preserved from the pre-#137 implementation:
  * doNamedLoad — pre-load auto-save backup chain, partial-failure
    tracker handling, identical toast messages.
  * doAutosaveRestore — same backup chain, same markStateAutosaved()
    semantics, same restored-name derivation from subdir.

Tree.js plumbs the three new caps (saveSettings, browseDirectories,
createBrowseDirectory) into showLoadSnapshotDialog so the dialog can
invoke the folder picker end-to-end.

Visual harness: docs/designs/_harness/load-dialog.html exercises
default, empty, scoped-recovery, and pending-delete states. Verified
all four render correctly in the preview server.

Net diff: -56 lines in the sidebar source (the redesign deletes more
than it adds because the YES/NO modal + collapsible recovery
disclosure + confirm modal for plain loads are all gone).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(designs): reconcile snapshot redesign sketch with shipped behaviour (#137)

Commit 5 of the snapshot dialogs redesign. The code-level removal of
the autosave-newer YES/NO modal happened in commit 4 as a natural
consequence of the scoped-recovery inline row taking over its job;
this commit reconciles the design docs so they describe what was
actually built.

docs/designs/snapshot-dialogs-redesign.md:
  * "What changes" item 4 reworded — the YES/NO modal is dropped;
    the affordance is now an inline scoped recovery row that opens
    directly beneath the clicked preset.
  * "What changes" gains items 2, 3, 5 covering the Save/Load dialog
    redesigns and the inline delete confirm that landed in commits 3
    and 4.
  * Mermaid flow rewritten to reflect the new routing (preset click
    → scoped recovery expand → user picks named or recovery row),
    matching commit 4's onPresetClick implementation.

docs/designs/snapshot-dialogs.html "Locked-in decisions":
  * Updated the autosave-modal item from "modal head reads X" to
    "modal — dropped" and explains the inline scoped recovery row
    replacement.
  * Added the title-only modal header convention (commit 2 follow-up)
    pointing to docs/maintainers/conventions.md.
  * Added a pointer to the visual harness workflow added in commit 2.

No code changes — the YES/NO modal was already gone after commit 4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(snapshot): remove Settings cog + showSnapshotSettingsDialog + CHANGELOG (#137)

Final commit of the snapshot dialogs redesign. The Settings cog disappears
from the Snapshot toolbar — the snapshot row is now status indicator
| Load | Quick Save | Save, matching mockup section 1. Library-path
management now lives inside the Save dialog ("Save to…") and Load
dialog ("Load from…"), both of which open the folder picker introduced
in commit 2 and persist via the existing `saveSettings` route.

Removals:
  * `showSnapshotSettingsDialog` (~325 lines) — the dialog body, its
    inline directory browser, and the "save folder location" state
    machine. The same browse functionality lives in `showFolderPicker`
    now; the same persistence happens via `saveSettings()` called from
    inside Save / Load.
  * `showThreeWayDialog` (~45 lines) — only consumer was the pre-#137
    Save flow (overwrite / Save as new / Cancel), which the redesigned
    Save dialog absorbed into its primary action bar.
  * `renderLibraryLocation` (~17 lines) — replaced by inline rendering
    in the Save and Load dialogs' library rows.
  * Settings cog toolbar button + `openSettingsDialog` wiring in
    tree.js (~12 lines).
  * Orphan `.koolook-browse-*`, `.koolook-path-input-row`,
    `.koolook-load-library-row*`, `.koolook-settings-save-note` CSS
    rules in constants.js (~16 lines) — only consumed by the removed
    dialog. `.koolook-settings-folder-name` and `.koolook-settings-
    folder-path` stay; the new Save / Load library rows reuse them
    verbatim (git-blame continuity for the visual style).

Adds:
  * CHANGELOG.md [Unreleased] entry summarising the whole #137
    feature — picker, redesigned Save/Load dialogs, scoped recovery,
    inline delete, removed Settings cog, pytest infrastructure.

Net diff for this commit: -377 lines.

Cumulative PR diff (commits 1–6 + harness/convention follow-ups):
all six issue ACs satisfied. The maintainer should dev-sync once
before merging to confirm real-Comfy theme tokens render correctly
(harness fakes the dark-theme defaults) and end-to-end Save → Load
round-trips against the live preset folder behave.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ci): install pytest + aiohttp directly, not via pip install . (#137)

The Pytest job introduced in commit 1 of this PR was failing on CI
because ``pip install .[test]`` tries to build a wheel of the entire
project. setuptools's flat-layout discovery then trips on the
non-Python ``web/`` and ``forks/`` directories at the repo root and
errors out with "Multiple top-level packages discovered in a flat-
layout: ['web', 'forks']".

The project is a ComfyUI custom node loaded directly from its source
directory, not a pip-installable package. There's no wheel to build.
Install the test deps explicitly instead — the ``[project.optional-
dependencies] test`` block in pyproject.toml is the spec, CI mirrors
it by hand.

Worked locally because the venv install path went straight to
``pip install pytest aiohttp`` and never touched the project.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(snapshot): untracked dirty state + Load dialog Escape listener leak (#137)

Two findings from the external review on PR #144:

**Blocker — Save dialog's primary action with no tracked preset + folder
changed via Save to...**

``updatePrimaryLabel`` was entering the "dirty" state whenever the
library path diverged from the original, regardless of whether a
tracked preset existed. The primary click handler then routed "dirty"
to ``doOverwrite(current)`` — and with ``current === null``,
``sanitizeName(null)`` would throw / silently coerce to an empty
string instead of asking the user for a name.

Fix: ``isDirty()`` only matters when there is something to overwrite.
With no tracked preset, the primary stays in ``default-new``
regardless of folder divergence. The Save to... button still updates
the library row, so the user has feedback the destination changed; the
eventual primary click then routes to the Save-as-new name prompt and
writes into the new folder cleanly.

Verified in the visual harness: opening Save as untracked, switching
the library to ``/Volumes/Shared/different-library`` via the picker,
then inspecting the primary button:

  * Label stays "Save" (was "Save to new folder" pre-fix)
  * Tooltip "Choose a name and write the snapshot." (was "Write into
    /Volumes/Shared/different-library.")
  * Library row still reflects the new path (saveSettings DID run)

**Major — Load dialog leaked a document keydown listener per session**

The inline-delete Escape-to-cancel handler was registered with
``document.addEventListener`` but never removed. Each open/close cycle
of the Load dialog accumulated another stale handler; long sessions
ended up firing the cancelDelete check on every Escape press across
every dialog (no-op because ``pendingDelete`` was null in closed
dialogs, but the listener was still alive and called).

Fix: capture the handler by name, attach on open, tear down on close
by wrapping ``overlay.remove`` (mirrors the pattern the modal shell
already uses at ``modals.js:52-66`` for its own Escape handler).

Verified by instrumenting ``document.{add,remove}EventListener`` and
opening + closing the Load dialog five times in a row: every keydown
listener added during the cycle was removed before the next cycle
opened. No accumulation.

CI gates: ruff, compileall, bandit (via preflight equivalent), pytest
12/12, preflight 4/4 — all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 21:00:34 +02:00