* feat(maintenance): reviewed, read-only maintenance plans POST /prompt_manager/maintenance/preview reviews duplicate_prompts, optimize, unused_components or missing_links on its own mode=ro snapshot and answers a Plan (scope, protected reasons, backup policy, media, at most 50 detail rows). The fingerprint, targets and judged sources stay in a server-side PlanStore under a single-use token (15 min, 8 plans, 16 MiB, never truncated; a restart forgets them), bound to the database it read. The previews reuse the legacy queries: duplicate groups and the merge are shared with cleanup_duplicates, ORPHANED_PROMPTS_SQL is built from the #239 protection predicates the plan reports, and missing links are judged by the health check's link_states. Legacy /maintenance keeps its shape; vacuum now reports success:false when VACUUM fails, and cleanup_orphaned_images skips links under unavailable sources. * feat(maintenance): apply reviewed plans as Activity jobs POST /prompt_manager/maintenance/apply {token} starts a maintenance job (records admission, not cancellable, groups changed/protected/errors). The token is consumed on the loop turn the job starts: a second apply is 409 plan-used naming the job, a busy refusal keeps the token, an unknown or expired one is 410 plan-expired, and a restore since the review is 409 storage-changed. With the safety backup policy on (maintenance.safety_backup, default true, saved through the settings route with the revision), the job first writes a backup into a temp file beside the database, makes it a standalone rollback-journal file, verifies it, fsyncs it and renames it onto a unique <db>.backup_<YYYYmmdd_HHMMSS_ffffff>[_n] name; any failure leaves no file and nothing changed. The apply then rechecks the reviewed fingerprint inside BEGIN IMMEDIATE (plan-changed writes nothing), deletes in batches of 400 ids, counts changed and protected records, logs the backup by name only and prunes old safety backups. With the policy off the log and the status line say "No safety backup". * feat(maintenance): find and remove duplicate files through Activity Duplicate image files move onto the job registry: - POST /prompt_manager/jobs/duplicates finds content-identical media files over every effective source as a cancellable read job ("Find duplicate image files", group groups_found); the server holds the groups per job and GET /prompt_manager/maintenance/duplicates/{id} pages them ten at a time. - The duplicate_files preview binds a removal to the reviewed keepers. Every keeper and removal is checked again: containment as text before any filesystem access, then not a link, still inside its source, same size and hash. The plan lists the database links that move. - Apply runs duplicate_cleanup (records, not cancellable): the promised safety backup, then per file a recheck, the unlink with every handle closed and its thumbnail. A changed file is kept, the first failed unlink stops the job with a coded message (never the OS text), and only removed files have their image links and favorites moved to the kept copy. - The legacy scan, stream, status and delete routes keep their shapes on the same registry kinds; the hashing, grouping and thumbnail removal are shared with them. Coordinator rulings on Tasks 15-16: - Safety backups share the restore pool of 10 `<db>.backup_*` files; the pool is now also pruned when an apply fails after its backup. - Plans report the safety backup's size and the free disk space, and apply refuses up front (507 insufficient-space) when the backup or VACUUM does not fit, so no job fails halfway for space. * fix(maintenance): never remove the kept copy; move records before unlink Final review of #313: - C1: the duplicate find tells files apart by (st_dev, st_ino), so a source listed twice, a symlinked source inside another and a hardlink are one file. The preview and the job refuse a removal that os.path.samestat matches with its kept copy. - I1: each file's records move to its verified kept copy in one short transaction before that file is unlinked; a failed move deletes nothing and a failed unlink leaves the records on the identical kept copy. The status line counts files removed and not removed. - I2: links are looked up under every spelling of the source (the real path, the configured and monitored folders, ComfyUI's output). - I5: image tags join the kept copy as their union and the removed copy's pending, undecided Needs review items are settled; the plan says so. - I3: the VACUUM disk check measures SQLite's real temporary folder. - M1: the token is looked at again with no await before the job starts. - M2: the backup decision is made once and handed to the job. - M3: backups are linked into place; startup sweeps interrupted temps. - M4: Optimize runs alone (new "exclusive" admission) and its plan says the database is locked while VACUUM runs. - M5: the reviewed duplicate merge is strict: collisions and Library titles are protected, and errors roll back. The legacy cleanup is unchanged. - M7: 2,000 keep entries within a 1 MiB body. M8: event waits in tests. - M9: the preview binds files by size, mtime and inode without hashing; the job hashes right before each unlink. * fix(maintenance): protect one file reached through two mounts Re-review of #313, round 2: - R1: a removal on another device than its kept copy, with the same size and mtimes within 2 s, is protected when its inode is equal or its path within its source is the same ("May be the same file reached through another mount or share"), in the preview and again right before each unlink. The job holds the kept copy open during the unlink, so on Windows deleting the kept copy itself through another path fails. - R2: a file's spellings are found by containment: every configured, monitored, ComfyUI-output or listed folder whose stat identity is a folder above the file. Parent spellings of nested sources and case variants match; favorites move under every listed root holding the file. Lookups stay index searches. - m1: a failed unlink's row says the links now name the kept copy. - m2: a volume with no file ids reads "Can't confirm this is a separate file on this drive". * fix(maintenance): stat duplicate candidates with os.stat Python 3.10's Path.stat uses the os.stat bound when pathlib was imported, so the find and the per-file checks could see different stat calls. The find now calls os.stat directly, like every other check; the mount tests failed on py3.10 only. * fix(maintenance): recheck the kept copy right before each unlink Re-review 2 of #313: - n1: the cross-mount guard also matches when one path within its source ends with the other on a whole folder name (sources listed at different depths, e.g. 2024/x.png and x.png on a FUSE or noserverino mount). - n2: after a file's records move and right before its unlink, the held kept copy must still be the file at its path (fstat of the hold against os.stat) and the cross-mount guard must still hold; otherwise the file is kept, its records move back to it, and it counts as not removed. * test(maintenance): the kept-copy race tests hold on Windows On Windows the removal job holds the kept copy open without FILE_SHARE_DELETE, so the tests' attempt to delete or replace it during the record move is refused. The tests now assert that native outcome: the kept copy stays and the removal goes ahead. * feat(database): store each workflow and prompt graph once generated_images.workflow_data and prompt_metadata repeated the same large JSON text for every image of a run. Each distinct text now lives once in workflow_documents, keyed by the sha256 of its exact UTF-8 bytes and zlib-compressed; image rows reference it through workflow_document_id and prompt_document_id. - New writes (link_image_to_prompt, pair ingestion for scan, monitor and Needs review) store the new form. Old rows keep their inline text and read the same until the migration rewrites them. - Every reader goes through one accessor (read_stored_text, resolve_image_row): gallery and legacy image lists, image by id, prompt info for the inspector and metadata page, diagnostics. Payloads keep their shape. Inline text wins when present; a document that fails its length or hash check reads as unreadable, never as other text. - No trigger (restore validators refuse them): a document goes in the same transaction as its last reference (image and prompt deletes, missing-link cleanup, duplicate merges, duplicate-file link merges, ingestion updates), and a sweep at startup catches anything a path missed. - verify_database_file validates the table, the id columns, their foreign keys and indexes, and the stored values. * feat(database): migrate old workflow rows in batches, then reclaim space The workflow storage migration rewrites old-form image rows (text inline) to document ids, batch by batch: - Each batch is one BEGIN IMMEDIATE transaction of at most 200 rows or 16 MB of text, found by a rowid range search and read one row at a time. - Each text is stored or reused, then read back through the one accessor before its row changes; a text that would not come back identical fails and rolls back the whole batch. - A value that is not text stays inline and is counted as protected. - A crash or a stop leaves committed batches new form and the rest old form; both read the same, and a rerun continues. No media file is opened. - Unreferenced documents are swept at the end. The maintenance preview gains a read-only workflow_storage plan (records to rewrite, inline bytes where SQLite has octet_length, the Reclaim space and older-version notes). reclaim_space sweeps, runs VACUUM and truncates the WAL so the freed pages go back to the disk. * feat(maintenance): store each workflow once as an Activity job The reviewed maintenance plan gains workflow_storage. Applying it starts "Store each workflow once": kind maintenance, records admission, cancellable, groups changed/protected/errors. - The promised verified safety backup comes first; a failed backup ends the job with nothing rewritten. The apply's up-front disk check covers it. - Old-form rows are rewritten batch by batch on the event loop's executor, with progress after each committed batch. Cancel stops between batches; a failed batch rolls back whole and fails the job with a coded message. Either way every record reads as before and the next apply continues. - When every row was looked at, "Reclaim space" starts: exclusive (runs alone), not cancellable. It checks the disk for VACUUM's copies of the live pages, then sweeps, runs VACUUM and truncates the WAL. If another job holds the registry it does not start, and the finished migration says so and names Optimize database. - No image or video file is opened; tests track every open under the output folder and compare the media bytes and mtimes. * feat(maintenance): document the workflow storage contract TESTING.md's maintenance contract gains the workflow storage rules: the documents table and its two forms, the one accessor, garbage in the same transaction plus the sweeps, the batched migration and Reclaim space, the media and memory guarantees, and that a deduped backup cannot be restored into an older version. A test covers the preview on SQLite before 3.43 (no octet_length, as in older Windows Python builds): the inline-bytes detail is left out. * feat(database): keep the migration safe row by row and beside other writers Final review fixes M1, M2, M3 and M5 for the workflow storage dedupe: - M1: a batch also ends after 0.5 s, and the loop leaves the write lock free for 0.15 s after each committed batch (longer than SQLite's longest busy poll), so the image monitor and run counting on another connection get in between. RED: a threaded writer with a 2 s busy timeout hit "database is locked" (4 of 5 links landed). - M2: each row runs in its own savepoint. A row that cannot be rewritten (undecodable text, a text that would not come back) is rolled back alone, stays inline and is listed in errors; the rest of the batch goes on. - M3: a reused document is decompressed and checked before its id is handed out, and repaired from the text (whose hash is its key) when damaged; verify_database_file reads every document and refuses a damaged one. - M5: the two legacy generated_images rebuilds refuse a file that already has the document id columns instead of dropping them. - The preview names the pre-migration backup and says it is always taken. * feat(maintenance): always back up before storing workflows once Final review fixes I1, I2 and I3 for the workflow storage dedupe: - I1: the migration always takes a verified backup, whatever maintenance.safety_backup says; the plan reports backup.policy "required" and the disk check counts it. - I2: that backup is prompts.db.pre-workflow-storage_<stamp>, outside the pruned prompts.db.backup_* pool and kept until the user removes it; the job's last line and the preview name it, and the legacy restore accepts it. README's changelog warns that an older version opens and restores a rewritten database but shows no workflows, and says how to go back. - I3: tools/workflow_text_digests.py writes the sha256 of every record's stored texts (read-only; inline before the rewrite, through the accessor after), so a diff before and after proves byte identity. - A row the migration could not rewrite is listed in the job's errors and named in its last line, and Reclaim space still runs. * feat(database): fail the batch on system errors and name a damaged document Re-review fixes R1 and R6 (database side), plus R3's helpers: - R1: a row is kept inline only for its own cause: text SQLite cannot decode, or a text that would not come back. A full disk, an I/O error, a malformed database or a bug now fails the batch and rolls it back, instead of listing every remaining row as "could not be rewritten". - R6: verify_database_file names the damaged document and says what to do (restore a backup from before the damage, or store an image with the same workflow, which repairs it). - R3: pre_migration_backups lists the kept backups, earliest first; the plan says "Nothing to rewrite" and names an existing kept backup, and the older version note says new images use the new form from the first start and names the earliest backup as the way back. * feat(maintenance): one kept pre-migration backup and nothing-to-do refusal Re-review fixes R2, R3, R4, R5 and R6 (job side): - R3: Apply refuses a plan with nothing to rewrite (409 nothing-to-do, no job, no backup). Only the first apply keeps a pre-workflow-storage backup; later applies (after a Cancel, a rerun) take a pooled safety backup, forced on whatever the policy, so one copy is never pruned. - R4: a second-connection writer test runs against the live job loop; with its pause removed the writer hits "database is locked" (4 of 5 links). - R5: the digest tool loads only database/workflow_documents.py by path (no package start-up, no log handler) and opens a stopped database with immutable=1, so it leaves no -wal/-shm; a live one is read through its WAL. - R6: a backup that fails on a damaged document says which one in the job line. - R2: README's changelog says images recorded by this version use the new form from the first start, and the way back is the earliest pre-workflow-storage backup (or a pre-update backup if the job never ran).
268 KiB
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 unittest discover -s tests -q
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
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.
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 PLANof the hot query, cache builds on cold and warm requests, and calls of the sort key per cursor page. Example:test_filter_work_is_boundedintests/test_api_workspace_images.py. - A wall-clock test that runs only with
PROMPTMANAGER_PERF_BUDGET=1and without a tracer (sys.gettrace()or asys.monitoringtool). 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).
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 SQLitewith connmanages transactions, not connection closure, andaddCleanupruns after teardown; use explicitfinallycleanup 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(seeconfigure_connection) so commits do not hold the write lock across a flush. Make real overlap deterministic (for example hold the first writer open withFirstWriterHold) 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/PureWindowsPathcontrols 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 throughPOST /__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 becomesnull, so the diff names its path (for examplechrome.breadcrumbs) instead of failing the measurement. - Each page's
map<Page>Concept(reference)applies only the recorded deviations, each commented with its D-number.diffInventorymust 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 atchrome.navs.0.rows.0.minHeightand.height, and removing.breadcrumbsmust fail atchrome.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
activitypulse, 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'slinks()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'ssaves one view in both documents and compares the rows;tests/browser/workspace-saved-views.spec.mjscovers 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=1total (/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.#/favoritesis Images filtered to favorites, named "Favorites" (conceptrenderImages);favorites=1keeps only the selected roots' favorites, read per root fromimage_favoritesand 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 savesfavorites: 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; neverwindow.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.mjscovers the row, count, route, star updates, Save view on Favorites, Edit, and the failure paths;tests/test_image_favorites.pycoversfavorites=1. -
D52 (Activity, #249 Activity-1): the top-bar
button#activity-openand the sidebar's Activity row (the concept'sactivitypulse, first insystem-nav) count this session's running jobs. The concept counts every sample job.span.count#activity-countishiddenat 0 (its text stays "0"), and the row has no.nav-countthen. The Activity heading has no "Unified activity · proposed" chip (prototype copy). Atmax-width: 700pxthe full-width.top-actionsrow 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'sflexWraptowrap. -
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 itsaria-label. With no running job it ishidden, which the shell comparison maps as a hidden item before the button. Atmax-width: 700pxit keeps the verb and percentage only; on coarse pointers it is 40px tall like the concept's buttons. Its rules are inworkspace.cssunder D53. -
D54: each
.activity-itemis 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"✓