Files
ComfyAssets-ComfyUI_PromptM…/TESTING.md
T
Vito c7ba481d3c perf(images): refresh the listing when the monitor sees a new output (#348, PR 2) (#353)
* perf(images): refresh the listing when the monitor sees a new output (#348, PR 2)

The image monitor now reports every settled output (linked or not, also
when linking raised) with its resolved path through set_output_listener.
The API's notify_output_settled hops from the monitor's worker thread onto
the server's loop (call_soon_threadsafe) and does nothing before the loop is
known or after cleanup. mark_root_dirty matches the path against each
walk's resolved_root (normcase(realpath), computed in the executor by
walk_root, now its fourth value) and marks the root dirty.

A dirty root gets one background walk, no sooner than
rewalk_interval = min(15, max(5, 5 x last walk's duration)) after the
previous walk started (last_started_at / last_duration are recorded on
every walk). _schedule_walk is shared with PR 1's delayed stale refresh:
one deferred walk per root, never armed while a walk runs (the walk's end
re-checks dirty: the trailing walk), at least 0.5 s away, the earlier
deadline wins, and every walk start cancels it. A failed walk clears dirty.
Rescan semantics are unchanged.

* fix(images): keep removed roots and cancelled walks from walking again (#348, PR 2)

Review findings on the monitor-triggered walks:

- reset_gallery_roots_cache now retires every listing record: its pending
  timer is cancelled, dirty is cleared, and no mark, timer or trailing walk
  starts a walk of it until a reader or the warm-up asks for that root
  again. Before, a timer or trailing walk pending when the roots changed
  stored a listing for the removed root, and the monitor (which watches the
  old roots until restart) kept it walking on every new output.
- A walk task cancelled outside cleanup_gallery_listings no longer starts a
  trailing walk while the loop shuts down.
- root_contains ignores a Windows extended-length prefix (\\?\ and
  \\?\UNC\) on either side, which realpath can keep on a file past MAX_PATH.
- The REWALK comment states the ~20% GIL share only for walks up to 3 s.

* test(images): prove a cancelled walk starts no trailing walk (#348, PR 2)

The previous assertions held even with the trailing walk, because that
walk finished before they ran; assert the walk number and the scan count.

* perf(images): add a settled output to the listing without a walk (#348, PR 2)

Live test: each landing in a 20,000-file folder cost a full walk (~0.4 s of
GIL) plus a full workspace rebuild, so Images reads during a run took up to
2.1 s. The monitor's report now patches the stored listing instead.

- output_settled (the monitor hop's target) slices the file's part below
  the root in its own case (relative_part; Windows compares folded), and
  _run_adds places one root's pending files in one executor batch:
  _settled_entry applies the walk's filters (suffix, thumbnails, depth,
  symlink by lstat; macOS on-disk spelling), insert_entry puts the
  (Path, mtime) entry where the newest-first sort does, add_folder copies
  the folder sets. Stored only when no walk started and no drop happened
  meanwhile; walked_at is untouched, so the 30 s refresh still walks.
- Anything inexact marks the root dirty and walks as before: a running
  walk, an unsliceable path, a failed lstat, a tie in mtime, the cap, an
  unexpected error. Rescan, the refresh, the warm-up and invalidation stay
  full walks; a walk start forgets the pending adds.
- The workspace caches follow a patched listing by entry identity
  (listing_delta): only added values are built, the newest order places
  them by bisection, the favorites lookup is copied and extended.

Synthetic 20,000 files: per landing 252 ms (walk + full build) -> 18 ms
(3 ms add + 15 ms warm build).

* fix(images): review fixes for adds without a walk (#348, PR 2)

- _newest_step steps the cached newest order only when the per-file cache
  still holds exactly the values this call built. A stale page read racing
  a newer warm build (both run in executor threads) stored an order
  labelled with older values than it held, and the next read inserted the
  new file a second time. It also sorts in full when a roots reset dropped
  the per-file values meanwhile.
- The stored spelling is checked on Windows too (nt._getfinalpathname:
  realpath keeps the given case when it cannot open the file), and before
  the name filters, which compare the walk's stored spelling. macOS opens
  non-blocking, so a FIFO named like an image never holds the executor.
- An output in a folder the walk cannot list (no read permission) is not
  added, as the walk skips it.
- Windows-portable unit tests: POSIX paths in the insert tests, and a
  str.lower fold for the length guard (Windows' normcase keeps lengths).

* test(images): reproduce the newest-order race and fix a Windows clock flake (#348, PR 2)

- The race test now has the newer build step from the values the cached
  order was built from, which is the interleaving that inserted a file
  twice; it fails without the parts check in _newest_step.
- test_a_failed_walk_records_its_timing_and_clears_dirty compared two
  monotonic readings that Windows' ~15.6 ms clock can make equal; the
  previous start is moved back instead.
- TESTING.md: os.access ignores ACLs on Windows.
2026-10-26 16:14:50 -07:00

347 KiB
Raw Blame History

Testing and merge confidence

Routine changes should merge after the required checks pass and the diff is reviewed. Add a regression test for each bug; prove it fails before the fix.

What runs

Layer Real components Replaced boundaries
Python tests Domain logic, SQLite, API handlers, filesystem checks ComfyUI host and ML runtimes where needed
JavaScript tests Frontend modules and controller methods DOM/network fixtures for focused edge cases
Viewer checks Production Viewer.js and frontend methods in Chromium desktop/touch Controlled images and page fixtures
Real ComfyUI Pinned CPU host, actual extension, node definitions, widgets, routes, SQLite and real execution lifecycle Stock UI cases isolate host writes; the execution suite queues deterministic test-only nodes without models
Page workflows Production HTML, startup, click handlers, HTTP routes, SQLite, PNG files, thumbnails ComfyUI host services and the tagging model

Page workflows cover save/edit/reload, duplicate-edit rejection, gallery image identity, Tags selection and viewer cleanup, delayed responses, auto-tag review and persistence, and infinite scrolling without replacing existing cards. Data safety workflows download and upload actual SQLite backups, verify restored prompts/tags/image links, reject corrupt uploads without changing live data, and exercise cancellation and network-failure recovery for restores and deletion. Duplicate cleanup verifies only the selected file disappears; prompt deletion verifies the other prompt and original image files survive. They run in Chromium, Firefox, WebKit, and Chromium touch emulation. No GPU, model downloads, running ComfyUI instance, or personal configuration is required. The server binds to localhost and creates disposable directories and a database; its reset endpoint exists only in the test harness.

Run locally

Use a Python environment containing requirements-dev.txt and Node.js 24:

python -m tests.run_suite -q        # what CI runs; see "Time limits and hangs"
npm ci --prefix tools/vendor --ignore-scripts
npx --prefix tools/vendor playwright install --with-deps chromium firefox webkit
npm run --prefix tools/vendor test
npm run --prefix tools/vendor test:coverage
npm run --prefix tools/vendor test:workflows -- --project firefox --project webkit

python -m unittest discover -s tests still works and finds the same tests; it just has no hang watchdog.

PM_TEST_PYTHON selects the Python executable; set it when Python is outside PATH. Every Playwright worker starts its own tests/browser/server.py --port 0, which binds a port the OS picks and prints it as PM_TEST_SERVER_PORT=<n>, with its own temp root and database (tests/browser/fixtures.mjs), so parallel workers and concurrent runs never share state. PM_TEST_WORKERS sets the worker count (default 1 under CI, otherwise a quarter of the CPUs, at most 8). Test servers are never reused across runs. The launcher holds each server's stdin open and the server (--exit-on-stdin-eof) shuts down cleanly, removing its temp root, when that pipe closes, so a worker killed without teardown leaves no server behind. A test that fails gets the stderr its server wrote during the test attached to the report as server-stderr (an aiohttp traceback behind a 500). A /__test__/.../reset still running after 2 s writes a report to the job log, passing test or not: every thread's stack taken while it waits, the default executor's threads and queue, the open connections and WAL size, then the total and how long each executor call queued and ran. Its lines start with pm-slow-reset| (tests/browser/slow_reset_watch.py). The report never reads a connection another thread is using: one that is busy inside SQLite would block that read with the GIL held and freeze the whole server.

The coverage run can be split the way CI splits it, and each shard keeps the raw V8 files it writes under coverage/js/raw:

PM_TEST_WORKERS=8 npm run --prefix tools/vendor test:coverage            # whole run, one report
npm run --prefix tools/vendor test:coverage:shard -- 1/3                 # node units, viewer suite, workflows 1/3
npm run --prefix tools/vendor test:coverage:shard -- 2/3                 # workflows 2/3 (likewise 3/3)
npm run --prefix tools/vendor coverage:merge                             # report and baseline check of every raw file
npm run --prefix tools/vendor test:workflows -- --project webkit --shard 2/2

coverage:merge refuses an empty raw directory; it does not count shards, so run every shard first (CI's merge job checks all three arrived). Measured on a 32-CPU machine in the pinned image (2026-10-21): test:coverage takes about 5 minutes with 8 workers and 24 minutes with 1; all four projects, 8 workers, each test 3 times (6132 tests), 24 minutes. A CI-sized run (docker run --cpus=4, PM_TEST_WORKERS=3, all four projects once, 2044 tests) takes 25.5 minutes.

Specs must pass beside each other, not only alone. Wait for the page's own signal, never for time: a rendering frame (requestAnimationFrame) for layout work queued by a render or a scroll, a response or a visible state for data. A timer that "usually" covers a frame is a race on a loaded runner. Library virtualization specs settle with libraryObservation.settled() (two consecutive frames with no Library frame queued). To look for such races, repeat a spec under load (--workers 8 --repeat-each 10, and in the image with --cpus=1 or 2). On unsupported Linux distributions, browser system dependencies may need manual installation. For a quick browser run, use test:workflows -- --project chromium.

CI runs the browser jobs inside the pinned image mcr.microsoft.com/playwright:v<@playwright/test version>-noble (tests/vendor/browser-image.test.mjs keeps the tag in step with the lockfile). Concept comparisons measure geometry, and geometry follows the fonts a browser falls back to: the concept names Inter, which neither the image nor a stock runner ships. On a plain ubuntu-latest runner the fallback is DejaVu Sans, whose wider metrics make the concept's own Tags table overflow a 390px viewport, so the 390px comparisons are only meaningful in the pinned image. To reproduce CI, run the suites in that image.

Managed real ComfyUI

The shared engine comes from an exact commit of ComfyAssets Toolkit, recorded in tools/vendor/package.json and its integrity-checked lockfile. The local adapter sets PromptManager's database/configuration and monitoring policy; source discovery, coverage mapping/gates and process lifecycle are shared code. KikoTools remains the reference and is unchanged by this integration.

Use a dedicated disposable ComfyUI checkout at the revision in tools/vendor/comfyui-runtime.json. With a separate Python environment, install that checkout's requirements, this repo's requirements-dev.txt, and the exact CPU torch/torchvision pins from the JSON (using PyTorch's CPU wheel index). Install Chromium using the Playwright command above, then run:

PM_COMFY_ROOT=/path/to/disposable/ComfyUI \
PM_TEST_PYTHON=/path/to/cpu-env/bin/python \
npm run --prefix tools/vendor test:comfyui

CI performs this setup automatically. PM_COMFY_URL can select another http://127.0.0.1:PORT/ address (default port 8189). This is an owned managed server: a running host is never reused. Do not point it at your live checkout. One port serves one run. The launcher refuses a port that is already in use and reports ready only when /system_stats names its own base directory, so two runs started together on 8189 fail at startup ("already in use" or "answered by a ComfyUI host this run did not start") instead of one run testing the other's host and losing it mid-test (ECONNREFUSED, ERR_CONNECTION_REFUSED, Network Error). Every test checks the answering host again. Agents sharing a machine give each run its own PM_COMFY_URL port. The launcher verifies the upstream SHA and clean tracked files, snapshots only tracked PromptManager files, and creates temporary user/input/output/database/ configuration/log directories. Personal untracked files are excluded. It runs CPU/offline with only PromptManager enabled and removes owned state on shutdown. The upstream checkout must also be disposable: ComfyUI may initialize local runtime files there. Browser host-settings writes are intercepted; actual PromptManager routes use the disposable host and database.

real-comfyui-reports retains startup logs, version attachments, screenshots and traces for 14 days. Update upstream/frontend/CPU pins together after reviewing and running both startup projects; never replace the SHA with a moving branch. Update the toolkit via an exact reviewed commit archive and commit both npm manifest and lockfile. No toolkit or npm installation is needed by end users.

Coverage and failures

Python CI enforces 85% combined statement/branch coverage. Frontend coverage merges Node V8 unit coverage with Chromium page-workflow coverage through c8. Every web/js/**/*.js and web/js/**/*.mjs file appears, including untouched files at zero coverage. The controller tests provide V8 filenames so their executed code is counted too.

tools/vendor/frontend-coverage-baseline.json holds a floor for every file and metric. CI rejects regressions, missing measurements, and new files without a reviewed baseline. These floors reflect current coverage, with two percentage points of allowance for runtime/timing differences; they do not claim 80% coverage of the large controllers. New files need at least 80% line coverage when added via the baseline updater. Raise floors with npm run --prefix tools/vendor coverage:update; it reruns the suites and cannot lower an existing floor. Review baseline changes like code changes.

npm run --prefix tools/vendor coverage:baseline-check runs in well under a second without a browser and fails, naming the files, when a measured web/js file has no baseline entry or the baseline lists a file that no longer exists. It uses the gate's own discoverSources, and npm test runs the same check, so run it before pushing a new or deleted frontend file instead of waiting for the frontend coverage jobs.

The viewerjs check runs on every PR, including backend-only changes. It is an aggregate job that passes only when every frontend job succeeds: the three frontend coverage (shard N/3) jobs, frontend coverage (merge), and the four page workflows (firefox|webkit, shard N/2) jobs. A failed, cancelled or skipped job fails it. Every browser job runs with PM_TEST_WORKERS: 3 (the public runners have 4 vCPUs). Each coverage shard runs test:coverage:shard -- N/3 (shard 1 also runs the node units and the viewer suite, shard 3 the vendor pins and tooling tests) and uploads its raw V8 files as coverage-raw-N (kept 3 days). The merge job downloads them, fails unless all three arrived with raw files and without clashing names, then runs coverage:merge (report plus baseline check). tests/vendor/ci-workflow.test.mjs checks this shape. GitHub artifacts named frontend-test-reports contain the merged HTML coverage and JSON summary; frontend-traces-shard-N hold the Chromium/touch failure screenshots/traces and browser-traces-<browser>-shard-N the other browsers' traces. Open traces with Playwright's show-trace tool.

Every npm ci first restores npm's download cache (~/.npm), keyed on the vendor lockfile and the job's pinned image tag (tests/vendor/ci-workflow.test.mjs checks the key names the same tag as the job's container.image). Without it, npm ci stalled for about 300 s (npm's fetch timeout) in most of the eight concurrent jobs; with a hit it takes 0 to 2 s. A lockfile change misses the cache once, so expect one slow run after it. Measured on PR #274 (run 39725176320 at cbc8a61, cache hit): the whole workflow took 8m38s, against about 33 minutes with one worker and no shards. Jobs: coverage shards 7m34s/6m04s/5m22s (tests 6m38s/4m53s/4m04s), merge 54s, firefox shards 7m57s/5m21s (tests 7m08s/4m34s), webkit shards 8m16s/5m03s (tests 7m26s/3m58s). Browser jobs keep a 20-minute limit: the slowest job is about 8.5 minutes, a cache miss adds up to 5 to npm ci, and runner speed varies (the first sharded run was cancelled at a 12-minute limit). Playwright shards by test count, whole files at a time, so shard 1 carries the slow concept-comparison specs (*-concept.spec.mjs, alphabetically first) and runs longer; the remedy, if it starts to matter, is more shards, not reordering.

The real-host suite covers both Classic and Nodes 2.0 startup, switching both ways, actual database prompt selection, execution-text serialization, workflow reload, widget/style cleanup, node schemas and standalone-page opening. It does not certify every existing feature, arbitrary host versions, GPU/model changes, cross-pack installation or visual design changes. Those still need targeted validation.

Time limits and hangs

CI and the pre-push hook run the Python suite as python -m tests.run_suite (-v, -q, -s START and -p PATTERN as in unittest discover), and the coverage job as python -m coverage run -m tests.run_suite. The runner (tests/run_suite.py) discovers the same tests with the same sys.path and warning filters as python -m unittest discover -s tests, exits 0, 1, or 5 when no tests ran (Python 3.12+ behaviour, on every version), and adds a watchdog: it writes start: <test id> to stderr before every test, at every verbosity, and arms faulthandler.dump_traceback_later. If a test, its teardown, its event-loop shutdown or interpreter exit stops making progress, the stack of every thread goes to stderr (Timeout (0:02:00)! then Thread 0x... blocks) and the process exits non-zero.

  • PM_TEST_HANG_LIMIT (default 120 s) bounds each test from its start: line to the next one, including the class and module fixtures (tearDownClass, setUpModule, ...) that run in between. PM_TEST_SHUTDOWN_LIMIT (default 120 s) bounds the end of the run: the summary, non-daemon threads, atexit handlers and coverage's save. Both must be positive integers; anything else stops the runner with exit 2. 120 s stays below asyncio's 300 s wait for default-executor threads on Python 3.12 and 3.13 (3.10 and 3.11 wait without limit), so a hang in an IsolatedAsyncioTestCase loop shutdown is dumped inside the test that hung.
  • Reading a dump: the last start: line names the test. An ok after it means the test body passed and the hang came after it: in its teardown or event-loop shutdown (where IsolatedAsyncioTestCase joins executor threads), or in a class or module fixture before the next test; the stack shows which. A dump after the summary is a hang at interpreter exit. The dump shows thread stacks, not asyncio task stacks.
  • When the watchdog fires under coverage, that run's coverage data is lost; the job fails anyway and the dump is what matters.

Every job in test.yml, code-quality.yml and viewerjs-check.yml has timeout-minutes (tests/vendor/ci-workflow.test.mjs checks it). The rule is ceil(1.5 x the slowest of three recent green runs on main) + 3 minutes, never under 5; the three run ids, their times and the resulting limits are written in a comment above jobs:. The 3 minutes leave room for one watchdog firing, so the dump prints before GitHub cancels the job. A job that hits its limit, or a watchdog dump, is a failure like any other: read the dump, find the blocking call, fix it. Never raise a limit or rerun to get green; a limit changes only with new measurements written next to it.

After a push

Follow the run for the commit you pushed, never an older one:

gh run list --commit <pushed sha> --workflow test.yml     # the run id for that commit
gh run watch <run id> --exit-status                       # returns when the run ends
gh run view <run id> --json jobs                          # shows a job that already failed
gh run view --job <job id> --log-failed                   # that job's failed log, at once

Start fixing as soon as a failed log shows the cause, but push the fix only after the run for that commit has finished: pull-request runs use cancel-in-progress, so a push cancels the running jobs and can hide a second failure.

Performance budgets

A spec performance budget gets two tests:

  • A structural test that always runs, on every OS/Python job and under the coverage tracer. It asserts counts, never time: SELECTs per request, the EXPLAIN QUERY PLAN of the hot query, cache builds on cold and warm requests, and calls of the sort key per cursor page. Example: test_filter_work_is_bounded in tests/test_api_workspace_images.py.
  • A wall-clock test that runs only with PROMPTMANAGER_PERF_BUDGET=1 and without a tracer (sys.gettrace() or a sys.monitoring tool). It skips otherwise. It warms up once, then measures three rounds and asserts the best round's p95; the failure message lists every timing. Example: test_filter_budget (Images filters, spec D12: p95 < 150 ms at 50,000 files and 20,000 links).

The perf budget job in .github/workflows/test.yml is the reference measurement: ubuntu-latest, one Python, no coverage, only the wall-clock tests. It runs on every push to main and on workflow_dispatch, never on pull requests, because shared-runner timing must not block a merge. Dispatch it on a branch to measure before merging. Shared Windows and macOS runners do not run them; the structural tests cover portability. Run it locally with PROMPTMANAGER_PERF_BUDGET=1 python -m unittest tests.test_api_workspace_images.TestWorkspaceImages.test_filter_budget and quote the printed p95 in the PR body.

A red perf budget run on main means the code got slower: fix the code (or change the budget in the spec with a reason) in a follow-up PR. Never rerun it to green. It is not a required check, and it cannot be one while it skips pull requests (.github/branch-protection.json).

Images listing cache (#348)

/prompt_manager/images/output, /prompt_manager/gallery/subfolders and POST /prompt_manager/gallery/rescan read one shared listing per output root (RootListing in py/api/gallery_listing.py, _listing in py/api/images.py). The tests are tests/test_gallery_listing.py (pure helpers) and TestSharedListing in tests/test_api_images.py (routes); they hold a walk open with a gated scan instead of sleeping.

  • Single flight. At most one walk per root runs at a time; every reader that needs that root while it runs waits for it (shielded, so a client that disconnects never cancels it). The walk, the comparison with the previous listing and both folder sets run in the executor (walk_root). A walk that finds the same (path, mtime) list keeps the previous list object, so the favorites lookup and the sort orders are not rebuilt.
  • 30 s background refresh. A listing older than REFRESH_AFTER (30 s) is served at once and one background walk replaces it. Files added or removed by hand therefore show up one open later than before; Rescan shows them at once.
  • Blocking invalidation after deletes. Duplicate cleanup and the legacy duplicate delete still call invalidate_gallery_cache(): the listing is dropped and the next read waits for a walk, so a deleted file is never listed. A walk running during the drop is discarded (the record's generation outlives every drop); a cold reader gives up with a 500 after 3 discarded walks in a row. Thumbnail writes (jobs, conversion, the legacy generate and clear routes) no longer drop the listing: thumbnail URLs are computed per request.
  • Startup warm-up. The root __init__.py registers api.warm_gallery_listings on the host's on_startup, which starts one background walk per root, and api.cleanup_gallery_listings on on_cleanup, which cancels running walks. A warm-up failure never stops startup; the first read retries.
  • Failures and Rescan. walk_root opens the root with os.scandir first, so a deleted or unreadable root fails the walk instead of listing nothing. A failed walk keeps the previous listing, logs one warning per streak of failures and is retried at most once per REFRESH_AFTER; a reader with no listing gets a 500. Rescan waits for a walk that started after the click (by walk number, not by clock), Rescans clicked during one walk share the next, and a failed walk is a 500 even when a previous listing exists.
  • New outputs (added without a walk, PR 2). The image monitor reports every settled output it accepts (by default .png .jpg .jpeg .webp .gif; videos only when their extension is in monitoring.extensions), linked or not, with its realpath, through set_output_listener. On its worker thread the API's notify_output_settled only does loop.call_soon_threadsafe(output_settled, path), and does nothing before the loop is known or after cleanup_gallery_listings. output_settled matches the path against each walk's resolved_root (normcase(realpath), computed in the executor; root_contains ends the match at a separator and ignores a Windows \\?\ or \\?\UNC\ prefix on either side).
  • The add. A root with a stored listing and no walk running gets the file added without a walk. relative_part slices the path below the root in its own case (Windows compares folded, slices the original); _run_adds places the pending files of one root in one executor batch (_patch_listing): _settled_entry applies the walk's filters (not a symlink or folder by lstat, _name_suffix in MEDIA_EXTENSIONS, no thumbnails folder, at most MAX_SCAN_DEPTH folders, every folder on the way listable by os.access, which on Windows ignores ACLs: a folder whose ACL denies listing is added there and corrected by the next walk). Where names fold case the stored spelling must match first, as the filters compare it: Windows through nt._getfinalpathname (its realpath keeps the given case when it cannot open the file), macOS through F_GETPATH on a non-blocking open (a FIFO never holds the executor); a case-insensitive mount on Linux is not checked and is corrected by the next walk. insert_entry builds a new list with the (Path, mtime) entry where the walk's newest-first sort puts it, replacing the same path's entry. add_folder copies both folder sets. The batch is stored only when no walk started and no drop happened while it ran (a walk started after the report lists the file itself); walked_at is untouched, so the 30 s refresh still walks and corrects any drift, and walk_root keeps the patched list object when that walk finds the same. Tests compare every added listing with a real walk (_walked).
  • When it walks instead. The root is marked dirty (mark_root_dirty, _mark_dirty) when the add cannot be exact: a walk is running (it may have passed the folder: the trailing walk), the path cannot be sliced, the lstat or the spelling read fails, the stored spelling differs, another file has the same mtime (a walk orders those by its visit order), the listing is at MAX_GALLERY_FILES, or the batch raised. A file the walk's filters exclude is neither added nor walked. A root without a listing, or a dropped one, is walked by its next read. A dirty root gets one background walk, no sooner than rewalk_interval = min(15, max(5, 5 × last walk's duration)) seconds after the previous walk started. _schedule_walk never arms a timer while a walk runs (the walk's end re-checks dirty: the trailing walk), arms at most one per root and never sooner than 0.5 s, and keeps the earlier deadline when the stale refresh and a dirty root both want one; every walk start (read, Rescan, warm-up or timer) cancels it and forgets the pending adds. A failed walk clears dirty; a cancelled walk starts no trailing walk. When the output roots change (reset_gallery_roots_cache) every record is retired: its timer is cancelled and no add, mark, timer or trailing walk touches it until a reader or the warm-up asks for that root again (the monitor keeps watching a removed root until restart). Tests patch REWALK_FLOOR/REWALK_CAP and run the pending timer's callback by hand (_fire) instead of waiting for it, and hold an add batch in the executor (_hold_adds) to race it with a walk, a drop or the cleanup.
  • Workspace caches after an add. A patched listing shares every unchanged entry object with the one before it, so listing_delta finds the change by identity (a walk builds new tuples and is rebuilt in full, as is any change of more than MAX_DELTA entries). The per-file values build only the added files (_workspace_listed_step), the newest order places them by bisection (_newest_step, only when the per-file cache still holds exactly the values this call built: a page read and the warm build run in executor threads at once, and an order labelled with older values than it holds would add a file twice), the favorites lookup is copied and extended (_favorite_listing_step, rebuilt when a key collides), and the other orders, wanted paths and locators rebuild lazily. On a synthetic 20,000-file listing a one-file add costs about 3 ms in the executor and 15 ms for the warm build, against about 155 ms + 80 ms for a walk and a full build (test_a_warm_build_after_an_add_builds_only_the_added_values counts the values built and the sort keys read).
  • Measuring it. tools/vendor/perf/images_load_probe.mjs is a maintainer tool, never run in CI. After npm ci --prefix tools/vendor and npx --prefix tools/vendor playwright install chromium, run node tools/vendor/perf/images_load_probe.mjs http://127.0.0.1:8188 against a running ComfyUI. It prints the time to the first decoded thumbnail, the time to network idle and the 10 slowest requests, from a fresh browser context each run.

Windows portability preflight

Before changing paths, SQLite, streams, restore or cleanup:

  • Follow the boundary's existing native canonical/identity contract; Path.resolve() alone does not fold Windows case. Keep lexical aliases distinct from resolved targets. Cover mixed case, drive and UNC spellings, plus foreign-namespace denial with an allowed native control. See Python's normcase documentation.
  • Close every owned database/model connection, worker and stream handle before unlink or TemporaryDirectory.cleanup(). Await worker/stream retirement. A SQLite with conn manages transactions, not connection closure, and addCleanup runs after teardown; use explicit finally cleanup when teardown removes files first.
  • Windows can reject unlink of an open file; Unix can remove its directory entry while the handle remains usable. Preserve the shared behavior assertions and exercise the actual native branch, then prove handle release and successful cleanup. Do not hide failures with blanket skips or retries. See Python's remove/unlink semantics.
  • Concurrent SQLite tests must not depend on how fast the host commits. Managed connections use WAL with synchronous=NORMAL (see configure_connection) so commits do not hold the write lock across a flush. Make real overlap deterministic (for example hold the first writer open with FirstWriterHold) and give peers that must all succeed the ordinary busy timeout; cross-thread handoffs wait on events with generous ceilings, never a short wall clock (#242).
  • Run native Windows CI early for these changes and include a Windows impact and evidence note in the PR. All twelve OS/Python combinations and every applicable exact-head check must pass before merge. Portable ntpath/PureWindowsPath controls do not establish native filesystem, locking or cleanup success.

Required merge checks

.github/branch-protection.json records the required checks: all twelve Python/OS combinations, Python coverage, formatting, frontend modules, viewerjs, real ComfyUI compatibility, and bandit (high severity). The branch must be current with main, and administrators follow the same checks. It does not require an additional human approval.

A repository administrator applies the configuration after the new workflow is passing, then verifies it:

gh api --method PUT repos/ComfyAssets/ComfyUI_PromptManager/branches/main/protection \
  --input .github/branch-protection.json
gh api repos/ComfyAssets/ComfyUI_PromptManager/branches/main/protection/required_status_checks

Update the manifest whenever required job names change. Keep the frontend check free of path filters so a required check is never silently skipped.

Unified workspace

The opt-in /prompt_manager/workspace page opens the native Images workspace. Overview retains real /prompt_manager/stats counts. Both use the shipped toolkit. Existing admin, gallery and metadata URLs remain available while replacement workspaces are developed. Browser smoke tests in tests/browser/workspace.spec.mjs cover navigation/history, keyboard skip links, mobile layout, theme persistence/fallback, loading/retry and stale responses. The Images cases in tests/browser/workspace-images.spec.mjs additionally cover server-side filters, cursor append/retry, Grid/List/Masonry, bounded DOM, keyboard navigation, restored browsing context, Viewer.js cleanup and actual video playback. tests/test_api_workspace_images.py checks signed cursors, source identities, legacy compatibility and header-only dimensions. Frontend stream/layout units cover cancellation, malformed responses and bounded visibility lookup. These run automatically in the existing four-browser-project workflow and frontend coverage job. tests/comfyui/workspace.spec.mjs verifies the page against the managed host with both renderer startup configurations.

The shared inline and collection inspector reads the current rooted file's supported embedded records, exact generation facts and recorded or inferred prompt evidence. It renders in two concept surfaces (#249 D20–D22, D38). The Images side panel shows only the concept content: cover, title, filename, the evidence badge with a category chip, the + and − prompt blocks with copy and send, tag chips (the file's own image tags first, D88, then the linked tags; image tags and the linked positive's own tags filter Images, D21), the six facts, and a footer with "Send to ComfyUI" and "Prompt history" only when prompt roles resolved or a link is saved; the sticky panel scrolls inside the window. The viewer's details (#viewer-details) repeat that content without the cover, then "File & provenance" (the honest file facts, the Prompt evidence line, Download original and, for a listed file, D38's local-only "Show in folder" after the Path) and "Workflow & raw metadata" (raw records and their two downloads), then the pair send and Prompt history buttons. D22 words an empty positive explicitly and makes confirmed prompt text focusable. tests/browser/inspector-concept.spec.mjs compares both surfaces with the concept (see the D20–D39 notes under Images). tests/browser/full-inspector.spec.mjs covers original/metadata/workflow downloads, response limits, source changes, nested sending, matched dark/light and mobile geometry, and the loaded-collection viewer. viewer matches the concept shell and toolbar order measures the concept's #viewer-dialog shell, header copy, toolbar order and frame geometry in the concept and in production at 1600 and 390 pixels; its #249 D34 mapping keeps production's Download link, the appended position and reason lines, coarse 40px targets and the toolbar inside .viewer-left (Viewer.js's own .viewer-toolbar rule lifts the concept's across the dialog). The viewer never fetches another page or preloads all originals. Its explicit slideshow waits 3.5 seconds after image readiness, pauses at native video without advancing on ended, and resumes on a manually selected image only while prior intent remains. Pause/Stop, inspector reading/copy/download/send, hidden document, media failure, close, query/route disposal and pagehide cancel that intent; returning visibility or closing the sender does not resume it. Stop leaves native video playback alone. Video uses only the existing normalized gallerySettings autoplay/mute/loop values, read without writes; denied/malformed storage falls back to autoplay off, mute on and loop on. Rejected autoplay keeps native controls usable.

The inspector does not establish historical file identity, infer run membership, edit image tags, export all EXIF, or load/execute a workflow. Saved vocabulary is labelled by its recipe or positive-prompt scope. Limited raw evidence is omitted atomically, with JSON exports unavailable and the rooted original still downloadable. The inspection API uses one bounded read with per-record budgets: role records (prompt, parameters, the pair execution record) share 2 MiB and exceeding it fails the read; the workflow record has its own 4 MiB and past it only the workflow becomes record format limited (no raw text, graph limited, roles and facts unaffected); other text chunks are skipped unread; byte-identical duplicates are accepted within a bounded read budget, while differing duplicates (and any duplicate role record over that budget) still fail. Other read bounds are unchanged: 8 MiB non-PNG header, 10,000 PNG chunks and a three-second ffprobe (per probe, at most two probes). Video containers are read through one shared carrier selector (utils/video_metadata.select_video_carrier, used by the inspector and by the admin scan): carriers are, in order, the per-key tags that current savers write (prompt, workflow, parameters, comfyassets_prompt_pairs, each original-text; Matroska upper-cased names fold on our side), then the old VideoHelperSuite comment JSON envelope, then description (object values are reserialized-json; envelope keys are matched exactly, as the legacy readers did, never folded). The first carrier holding a role key wins, otherwise the first holding a workflow, and carriers never merge. Two per-key tags folding to one key are a duplicate record: byte-identical ones are one record, differing ones fail the read. ffprobe is asked for exactly those six tags. Its JSON output escapes tag text (at most doubling it), so the stdout bounds are sized so that only the decoded per-record budgets above can fail or limit a record, and a video reads exactly as the identical PNG: the first probe allows twice the role and workflow budgets plus 64 KiB of envelope (12 MiB + 64 KiB); past that the inspector probes once more without the workflow tag within twice the role budget plus 64 KiB (4 MiB + 64 KiB) and the per-key workflow becomes limited, while an envelope over that bound still fails the read because it cannot be split. The admin scan reads the same carrier with the default resolver profile, so a per-key video links like the same graph in a PNG; the live monitor processes images only (unchanged, see the follow-up below) (tests/test_utils_video_metadata.py, tests/test_api_admin.py TestScanReadsVideos, tests/test_api_prompt_inspector.py test_video_*, tests/test_inspection_response.py TestVideoProbeBoundsMatchPngBudgets; the real-container round trips skip without ffmpeg). Resolution under INSPECTION_PROFILE bounds work, not graph size: 2,000 visits per candidate chain, 20,000 in total, 64 link/JSON-container levels, 100,000 resolved characters, and a two 20,000-node ceilings: one over the top-level entries of the API prompt, and one over the workflow graph's UI nodes plus subgraph-definition nodes (past it the workflow graph and its facts become limited). The serialized response stays under 8 MiB by omitting, in order, the workflow graph, then the prompt graph, then all raw records atomically. Retry does not raise these limits. Ingestion (scan, live monitor, admin import) and queue-time run counting keep the default profile, including its 500-node gate. Only INSPECTION_PROFILE reads the node semantics table web/js/inspection-semantics.json (at most 1 MiB and eight nesting levels, loaded on first use). With it, SamplerCustomAdvanced follows its guider link (through Reroutes) to CFGGuider (positive and negative) or BasicGuider (positive only; the negative is absent, "Guider has no negative input"). Any other guider, such as DualCFGGuider, is unsupported-node. Conditioning is traced through the table's pass-throughs (FluxGuidance, ReferenceLatent, the ConditioningSet* nodes, ControlNet apply nodes, InpaintModelConditioning and WanImageToVideo by output slot). ConditioningZeroOut is an explicit empty text with source zeroed-conditioning. easy ifElse with a literal boolean follows that branch only; with a linked boolean both branches are traced, equal texts resolve and different texts make the sampler ambiguous with one candidate per choice (at most 32, else work-limit). ConditioningCombine/Concat/Average resolve only when every part has the same text, otherwise unresolved with composite naming the node (but see the added-conditioning floor below for a later stage). Results that depend on a forced choice are never memoized. A FaceDetailer (detailer-stage) is a sampler stage: the samplers reached through its image ancestry are compared with it. Equal texts resolve with the detailer as the sampler; different texts are ambiguous with multistage-differs and one candidate per stage; an unreadable stage makes the chain unresolved. Unanchored, a sampler whose output only feeds detailers (or previews) is not a candidate of its own. A sampler's latent ancestry is walked through every link input of non-sampler nodes within the same visit and depth budgets: with no earlier sampler it is a single stage whatever the origin (VAEEncode, EmptyLatentBatch, EmptyFlux2LatentImage, EmptyHunyuanLatentVideo, SetLatentNoiseMask, InpaintModelConditioning or WanImageToVideo slot 2). Pass-through lineage (#304 follow-up, maintainer option B, 2026-10-23): the table's lineage section lists the outputs whose image or latent continues one input's (VAEDecode/VAEDecodeTiled samples, VAEEncode/ VAEEncodeTiled pixels, ImageUpscaleWithModel, ImageScale, ImageScaleBy, ImageScaleToTotalPixels image, LatentUpscale/LatentUpscaleBy samples, cg-image-filter Image Filter slot 0 images and slot 1 latents; a selection of the same batch), plus Reroutes. Lineage describes no prompt, so its classes are not claimed by a prompt category. When the latent walk finds exactly one earlier sampler and the latent descends from that sampler's output slot 0 only through lineage nodes (a hires-fix or refiner chain, img2img from an in-graph decode, a direct latent link), the two stages are compared like a detailer and its upstream stage: equal role texts resolve with the later sampler named, different texts are ambiguous multistage-differs with one candidate per stage, and an unreadable earlier stage makes the chain unresolved with that stage's reason. Unanchored, the earlier sampler is judged inside the chain when its output reaches no other file output. A size or resolution node reading the same image beside the lineage (a ResolutionCalculator feeding ImageScale width and height) is allowed, since the walk then still finds only that sampler. Any other earlier sampler, or one reached only through an unlisted image node, stays unresolved "Multistage sampler ancestry is ambiguous". Added-conditioning floor: a later stage whose own positive fails only as composite keeps its earlier stages' shared pair ("A later stage adds conditioning to the base prompt", naming the earlier stage's sampler) when that positive reaches, through Reroutes and conditioning pass-throughs, a composite the table marks additive (ConditioningCombine, ConditioningConcat; never ConditioningAverage, which can weight a part away), one of its parts is exactly the earlier stages' positive text, and its negative text equals theirs. This is the three-stage Flux run whose UltimateSDUpscale combines the PromptManager prompt with a style-trigger encoder: legacy linked the PromptManager text and every stage is conditioned on it. The positive is always labelled (maintainer ruling): its source is added-conditioning (D126; with a D125 omission too, runtime-parts-omitted-and-added-conditioning) and its provenance names the composite (added_by), whatever the added text is; no length heuristic. A combine without the base text, a different negative, or different earlier stages stay as they were (composite or multistage-differs). An image-edit batch (several samplers with distinct prompts, one shared saver prefix) stays ambiguous, and text generated at run time stays untraceable (tests/test_resolver_pass_through.py on synthetic shapes from tests/passthrough_graphs.py; the browser port is compared on the named select-* chain cases and passthrough_fuzz_cases). Ingestion keeps the default profile's historical latent rule (D1), so the admin scan still reaches these files through its legacy-parser fallback. Run-time PromptManager parts (maintainer option A, 2026-10-23): LoraManager's TriggerWord Toggle is a runtime class (its output is the LoRA's trigger words as filtered at run time). A PromptManager (or PromptManagerPair) prepend_text or append_text linked directly to a runtime node is left out: the text is the node's own, combined with any other literal or traced part exactly as before, the side's source is runtime-parts-omitted, and a resolved pair's reason is "Text added at run time, such as trigger words, is not included". The prompt block shows that line under the text (D125; the evidence badge stays "Workflow inferred"). The label follows the text through encoders, concatenations and chained PromptManagers: a side is labelled when any node on its traced path left a part out, and its provenance lists those PromptManager nodes (omitted_parts). Comparisons include what a text leaves out, so a prompt that left out a part never equals a complete one (a branch with the toggle and one without stay ambiguous; such stages are multistage-differs). Only classes in the table's runtime list are left out, never another runtime-value source (a JoinStringMulti with a linked list flag stays runtime-value). The toggle's output is never computed. A run-time node further upstream (inside a concatenation), a run-time main text, or an unknown feeder keep their outcome (runtime-value or unsupported-node). This is what legacy linked for the June hires-fix runs (TestRunTimePromptManagerParts, the select-runtime-* parity cases); an unlisted node with sampler-shaped inputs (positive, negative, guider, latent_image, a pipe, video embeds, custom-sampler parts, or a model link together with conditioning, neg_conditioning, samples or latent) is unsupported-node naming it, here and in a detailer's image ancestry; a latent that is not a link or reaches its own sampler is malformed; running out of budget is work-limit or depth-limit, never single stage. Each sampler's walk runs once per resolution; selector branch runs share it and other candidates are charged its visit cost. A UI workflow node whose type is a list or an object (read only for the overwritten PromptManager check) is skipped like a non-object node, since it can be neither a subgraph instance nor a PromptManager node, so a prompt that resolves still resolves and the other nodes still feed the check; a list or object class_type is never a preview output. Before #286 both raised and the inspect route answered 500 (test_unhashable_* in tests/test_api_prompt_inspector.py, the workflow-type-* golden cases on both sides). Text encoders: single encoders (the Qwen image encoders, TextEncodeQwenImage21 slot 0 prompt and slot 1 negative_prompt, KikoTextEncode) read one string input unchanged. parts-equal encoders (CLIPTextEncodeSDXL, Flux, SD3) resolve when every part is identical or exactly one part is non-empty (exact "", whitespace is text); otherwise composite. String sources reproduce the pinned KJNodes code from literal settings only: StringConstant is its string; StringConstantMultiline with a literal true strip_newlines is replace("\n", "").strip(); JoinStringMulti keeps string_1 (even empty), appends string_2..string_N (N = inputcount) after the literal delimiter unless missing or exactly "", and ignores later inputs. A linked setting is unsupported-node; a list output (return_list true or linked) and PromptSearchList are runtime-value ("Text is decided at run time"), never one list item. Unverified text nodes (LoRAfromText, RegexReplace, XYZPrompt, ImpactWildcardProcessor) stay unsupported-node. Every intermediate text is length-checked and charged to the scanned-character budget. If the table cannot be loaded, graph inspection is unresolved ("Inspection node semantics are unavailable") and ingestion is unaffected, because it never imports the table.

tools/inspection_corpus.py --root DIR --out JSONL [--workers N] [--baseline JSONL] [--summary TXT] [--code TREE] measures a media folder read-only against the #240 invariants H1 to H8. It runs the inspection route pipeline and the workspace JS facts of --code (default: its own tree), opens media only for reading, follows no symlinks, imports no database, and refuses --out/--summary inside the root or a code tree. Its rows hold statuses, sizes, timings and SHA-256 digests of resolved role texts, never file names or prompt text unless --with-paths is given; digests of short prompts can still be guessed, so the JSONL stays local and only the aggregate summary goes into a PR body. Run it on the base commit first and pass that JSONL as --baseline for H1 and H8 (2x the baseline p99, never below 50 ms). It exits 1 when an invariant fails, including the harness line: a tool exception or facts that were not computed (no Node.js) fail the run. Unreadable or unsupported files are inspector outcomes, not harness errors. Never point it at a live install's folders from an automated job. Installing the Python inspection/source-scope changes requires a maintainer ComfyUI restart. Automated tests use disposable hosts and never restart the running personal ComfyUI.

Run the focused page checks with:

npm run --prefix tools/vendor test:workflows -- workspace.spec.mjs workspace-images.spec.mjs
node --test tests/js/workspace-*.test.js

Use the disposable fixture and managed-host prerequisites above. A maintainer live check needs a ComfyUI restart after adding the Python route, then opening /prompt_manager/workspace; the mockup preview server is a separate design tool.

Concept shell and comparison method (#249)

CSS base and drift check. web/js/workspace/concept.css is a byte-identical copy of the approved concept's docs/concepts/workspace/style.css. Never edit the copy; it is only rewritten from the concept with npm run --prefix tools/vendor sync:concept. npm run --prefix tools/vendor check runs sync-concept-css.mjs --check, which fails on a missing copy or names the first differing byte. workspace.html loads, in order, the toolkit controls, Viewer.js, concept.css and the production overlay workspace.css last; every page stylesheet that waited for its page PR is retired. tests/vendor/concept-css.test.mjs asserts that order, the drift check, and that no other page or canvas script loads workspace CSS.

Overlay decision-marker lint. lintOverlay in tools/vendor/sync-concept-css.mjs fails any workspace.css rule whose selector is also a concept selector unless the rule carries a /* D<n> */ comment, before its selector or inside its block. The D-number must be one of this spec's (#249): a comment that cites any other issue, such as D5 (#245), is that issue's ruling and is not a marker, so write #245 decision 5 for those. The lint test fails if workspace.css is missing. The match is by exact selector, so a narrower selector over a concept rule (for example .ca-tools dialog.prompt-sender) passes the lint; by convention such rules carry a marker too.

PR D deleted images.css and inspector.css, and PR F-b deleted library.css, the last page stylesheet (test the retired shell, vocabulary, editor, Images, inspector and library stylesheets stay deleted). the overlay keeps no retired Images page or inspector rules keeps the replaced card, filter, toolbar, preview, facts and inspector rules out, and the overlay keeps no rule waiting for a page rebuild keeps out the interim guards and the history page's private role colors. Every rule that stayed has a live consumer and a marker: the Library rows windowed (D3) and the shelf's states (D40), the history panel's wrap, archived-lane and coarse-target rules (D45), and under D48 the production surfaces the concept never shows, drawn in the concept's tokens (--green, --red, --ca-*): recorded executions and the Recorded run dialog (#209, D26), pre.prompt-text for stored text in the frozen field provenance and the Archive negative confirmation, whose section.prompt-block (#235) keeps its rules too, and the acknowledged prompt sender (spec §2.2). Rules that repeated the concept (the .library-grid tracks and breakpoints, the history .row-actions flex row and button size, summary cursors, h2 margins, the sender and Recorded run dialogs' colors and border) or had no effect (min-inline-size on block children, a .recorded-run-dialog pre limit, a 700px padding that a later rule overrode) were deleted; the workspace browser specs on Chromium and the concept specs on touch prove the pages unchanged.

Comparison method. Production must equal the concept except for the recorded deviations in the #249 design (spec §8, D-numbers). It is never compared against blessed production screenshots.

  • The fixture is tests/browser/workspace_concept_fixture.py, reset through POST /__test__/concept/reset. It holds the concept's six prompts and pairs, and 24 PNGs with real links, runs and the concept's inspector facts. The sidebar counts are therefore the concept's own: 24, 6 and 15.
  • The shared inventory is tests/browser/concept-inventory.mjs. One self-contained in-page measurer runs the same concept selectors in the immutable concept (/__test__/canonical/workspace) and in production. An absent element becomes null, so the diff names its path (for example chrome.breadcrumbs) instead of failing the measurement.
  • Each page's map<Page>Concept(reference) applies only the recorded deviations, each commented with its D-number. diffInventory must then report nothing.
  • Sizes, offsets and heights that follow from a removed element are derived from the concept's own measurements, never copied from production.
  • mutate(page, {css, remove}) appends a stylesheet and detaches elements, and returns an undo. Each comparison has a mutation check proving it fails. For the shell, a .sidebar nav a { min-height: 30px } rule must fail at chrome.navs.0.rows.0.minHeight and .height, and removing .breadcrumbs must fail at chrome.breadcrumbs.
  • Paired concept/production captures are attached as test artifacts. There are no PNG baselines.

tests/browser/shell-concept.spec.mjs compares the shell: the sidebar, topbar, page heading and footer, at 1600x1100 and 390x844, in dark and light, under normal and reduced motion, on Images, Prompt library and Tags. The heading is compared in full on Tags; on Images and Library only its frame and h1 are compared, because their copy and actions belong to their own PRs. The spec also compares the hover state of the first non-current nav row, the Theme select and the ComfyUI link, and the keyboard rings of the first nav link and heading button.

Recorded shell deviations:

  • D4: a "Theme" select with Dark, Light and System. System follows the ComfyUI palette, then the OS.

  • D5: retired by Activity (#249 Activity-1). The concept's Activity button and Activity row are shown; their differences are D52 to D54.

  • D28: no COLLECTIONS, and no Design review. The bottom nav is the concept's Activity and System, plus the production Overview hub (its own outline home glyph, not the concept's activity pulse, and no count). The connection reads "Your library, on your server" with a neutral muted dot.

  • D49 (saved views, #249 D16): the SAVED VIEWS group label has no "PROPOSED" badge, because saved views are persisted (/prompt_manager/saved-views). Its rows are the concept's links() rows: the search icon, the view name and no count, linking #/saved/<id>. Opening one shows its page with the saved toolbar value and Refine filters, and the view's name as the h1, breadcrumb and title. A value its page no longer accepts (a removed Source or Folder) stays default; an unknown id opens Images. A toolbar change made while the sources are still loading keeps the saved Source and Folder until discovery settles. A saved view, like Favorites, never replaces the plain Images or Library return state. Writes (POST, PATCH) validate strictly; a state over 4 KB is refused in the browser with "The view is too large to save. Shorten its search, folder or tags and try again." Startup and restore check only the table's structure and that each row's state is a JSON object of at most 4 KB, never the value rules, so a row saved under older rules (a missing or retired key, a sort no longer offered) never stops the database from opening: reads fill missing keys with their defaults, drop unknown keys, default invalid values and make the name displayable (tests/test_saved_views.py). A live row whose state is not a JSON object never blocks startup either: its id is logged as a warning, the row is left in place, the listing and rename skip it, and its #/saved/<id> link behaves like a missing view. Restore stays strict and rejects such a backup ("Saved views contains invalid values"). Only request validation is a 400; any other failure is a logged 500 with a generic message. The Favorites count re-read after starring is coalesced to one read 300 ms after the last toggle. a saved view row matches the concept's saves one view in both documents and compares the rows; tests/browser/workspace-saved-views.spec.mjs covers saving, reopening, the defaults that replace rejected values, and failed reads and saves.

  • D50 (retired by #249 PR F, ruling R5): SAVED VIEWS now lists the concept's "Needs review" row after Favorites, with its count; the comparison maps nothing out for it (only the concept's sample count "3" reads as the fixture's "0").

  • D51 (maintainer ruling, 2026-10-21, beyond the concept): SAVED VIEWS starts with the concept's Favorites row (star icon, name, count). Its count is the Favorites listing's own limit=1 total (/images/output?mode=workspace&favorites=1), follows the nav-count rules (hidden until loaded, absent when its read fails, never a placeholder) and is re-read after every confirmed star or unstar, on any page. #/favorites is Images filtered to favorites, named "Favorites" (concept renderImages); favorites=1 keeps only the selected roots' favorites, read per root from image_favorites and matched against the scan with the toggle's case folding, and a failed read is an error, never an unfiltered page. Unlike the concept, an unstarred card stays on Favorites (showing ☆) until the next load, so the star can be undone in place. Favorites neither restores nor replaces the Images return state. Save view there saves favorites: true, so the view reopens the favorites. An open saved view's heading leads with Edit: "Edit saved view" with "View name" and Delete… (danger), Cancel and Save. Save re-reads the list, retitles the page and toasts "Saved view renamed."; an empty name keeps the dialog open. Delete… swaps the same dialog to "Delete saved view" (Cancel / Delete; never window.confirm; its Cancel closes the dialog without deleting rather than returning to Edit); Delete removes the view, replaces the dead route with the view's plain page and toasts "Saved view deleted.". Failures toast "The view could not be renamed. Try again." or "The view could not be deleted. Try again."; a view another tab already deleted counts as deleted. tests/browser/workspace-saved-views.spec.mjs covers the row, count, route, star updates, Save view on Favorites, Edit, and the failure paths; tests/test_image_favorites.py covers favorites=1.

  • D52 (Activity, #249 Activity-1): the top-bar button#activity-open and the sidebar's Activity row (the concept's activity pulse, first in system-nav) count this session's running jobs. The concept counts every sample job. span.count#activity-count is hidden at 0 (its text stays "0"), and the row has no .nav-count then. The Activity heading has no "Unified activity · proposed" chip (prototype copy). At max-width: 700px the full-width .top-actions row may wrap (flex-wrap: wrap), so the button, Theme and ComfyUI stay inside a zoomed phone. The shell comparison hides the concept's count at runtime (#activity-count { display: none }), so the concept's own CSS lays out its button without it, and maps the row's count to none, the button's count to "0" and the phone row's flexWrap to wrap.

  • D53: while a job runs, the approved Activity mockup's progress pill sits before the Activity button on every page: a#activity-pill.activity-pill, "Scanning · 41% · 8,240 / 19,622" (the verb by job kind, the server's floored percentage, and the file counts once the total is known), a 70px mini bar, href="#/activity/<id>" and the same text as its aria-label. With no running job it is hidden, which the shell comparison maps as a hidden item before the button. At max-width: 700px it keeps the verb and percentage only; on coarse pointers it is 40px tall like the concept's buttons. Its rules are in workspace.css under D53.

  • D54: each .activity-item is a real job of this session (GET /prompt_manager/jobs), running first, then finished newest first. The concept's structure and CSS stay: .panel-heading > h3 "✓