Commit Graph
132 Commits
Author SHA1 Message Date
Vito Sansevero 18d81c9d96 fix(review): never queue an ambiguous file with nothing to choose
The maintainer's live queue held 39 items whose candidates all read
"unresolved" ("No positive / No negative"). An ambiguous result is now
reviewable only when at least two candidates are resolved with non-blank
positive text. With none the file could not be traced; with exactly one,
the untraced branches may still have made the file, so that text is not
certain either. Both are reported unresolved, never linked and never
queued (also the A1111 two-delimiter case, which has no candidates).

A rescan removes the pending items of such files whose own evidence
offers no choice, with the same safe delete as the linked settle
(pending, revision 0, no decision row). A limited item (over 16 candidates) that holds at least two
candidates is kept. The job scope gains review_untraced and the log
"= N images left Needs review · nothing to choose".
2026-10-23 13:03:10 -07:00
Vito Sansevero 9e125ee4a3 Merge remote-tracking branch 'origin/main' into feat/249-needs-review-backend 2026-10-23 11:09:28 -07:00
Vito cbc53ee9f1 feat(resolver): trace multi-stage chains through pass-through nodes (#308)
* feat(resolver): trace multi-stage chains through pass-through nodes

The inspection resolver (server and local Inspect) now compares a sampler
with the earlier sampler its latent continues through image-space
pass-through nodes: decode, image filter, model upscale, scale and encode,
listed in a new validated `lineage` section of the semantics table. Equal
prompts resolve (hires-fix, refiner); different prompts are
multistage-differs. A later stage whose positive adds conditioning to the
shared base prompt through an additive composite (ConditioningCombine or
Concat) keeps the base pair, the text legacy linked (three-stage Flux).
Image-edit batches stay ambiguous and run-time text stays untraceable.
Ingestion keeps the default profile (D1).

A card no longer shows a seed when the inspector names a chain stage
whose seed differs from the parser's pick.

* feat(resolver): leave out run-time PromptManager parts, labelled

Maintainer option A: when a PromptManager prepend_text or append_text is
fed directly by a node whose output is decided at run time (LoraManager's
TriggerWord Toggle, now a runtime class, or PromptSearchList), the prompt
resolves to the node's own text, as legacy showed. The side's source is
runtime-parts-omitted and the resolved reason says that text added at run
time, such as trigger words, is not included; the prompt block shows that
line (D-NEW-R1). The toggle's output is never computed. Literal and
traced parts are combined as before; an indirect run-time part, a run-time
main text or an unknown feeder keep their outcome. The browser port
changes identically. Ingestion is unchanged.

* fix(resolver): keep run-time and added-conditioning labels honest

A text that left out a run-time PromptManager part is now labelled when
any node on its traced path omitted it, so the label survives encoders,
concatenations and chained PromptManagers. Comparisons include what a
text leaves out, so a partial prompt never equals a complete one: a
branch with trigger words and one without stay ambiguous, and such
stages are multistage-differs. Only classes listed as run time are left
out.

The added-conditioning floor labels its positive (D126, "Conditioning
added at a later stage is not included."); the run-time line is D125.

A card hides its seed only when the stage the inspector names reads a
different seed than the parser; an agreeing seed is kept, as on main.
The browser port moves its lineage, latent and part helpers into
local-resolver-stages.mjs, keeping parity.
2026-10-23 11:09:12 -07:00
Vito Sansevero 95f88d3966 fix(review): queue only ambiguous files that nothing links, and heal the queue
The maintainer's live scan (19,908 files, scope "new") reported 4,677 files
under Needs review, but the queue held 10,232 items: every file the strict
resolver found ambiguous was queued, including the ones the legacy
browser-parser fallback then linked (all sampled "added" rows) and the ones
already linked to a known prompt (135 of 200 sampled "known" rows).

A file is now queued only when its final outcome is ambiguous and unlinked:
the resolver finds it ambiguous, the fallback links nothing and no saved row
links it already. An ambiguous file that a saved row links is reported as
unchanged ("Already linked to its prompt"), not as Needs review.

The queue heals itself. After each batch, the scan removes the pending items
of every listed file (read, or skipped as known) that a saved row now links.
The monitor does the same for each ambiguous output it links, and never
queues one. Only an undecided item is removed (pending, revision 0, no
decision row), so a decision is never deleted or changed and the schema
needs no new state.

The scan job's scope.review_items now counts this scan's files that are in
the pending queue after it, new or already there, so it agrees with the
review group's ambiguous rows and with the queue total. review_added and
review_settled are new, and the log gains "already in Needs review" and
"left Needs review · now linked" lines. A duplicate whose item is decided is
reported as "decided" and is not counted.
2026-10-23 09:41:27 -07:00
Vito 7c25b351f7 test(comfyui): refuse a real-host port another run holds (#310)
The real-host config waited on the /system_stats URL, which any server on
the port answers. When two runs started on 8189 within a few seconds of
each other, the second run's ComfyUI failed to bind ("Port 8189 is already
in use"), but Playwright had already seen the first run's host answer and
ran the second run's tests against it. When the first run finished and
stopped its host, the second run failed mid-test with ECONNREFUSED,
ECONNRESET or ERR_CONNECTION_REFUSED, and passed when rerun alone.

The launcher now refuses a port that is in use, and reports ready only
when /system_stats names its own base directory. The config waits for
that line instead of the URL, and every test checks again that the host
answering the port is the one its run started.
2026-10-23 09:25:32 -07:00
Vito Sansevero 9bfb5992b3 Merge remote-tracking branch 'origin/main' into feat/249-needs-review-backend
# Conflicts:
#	py/api/system.py
#	tests/test_system_api.py
2026-10-23 09:24:07 -07:00
Vito 785b744554 feat(system): Appearance section and browser display defaults (#307)
* feat(system): share browser display defaults

The workspace reads and writes the legacy gallery's gallerySettings object
through preferences.mjs: workspaceLayout and workspaceDensity join the
shared keys, a write re-reads the stored object and writes only the changed
keys, denied storage or a full quota keeps the change for this tab and says
so, and storage events report changes from other pages. The legacy gallery
Save keeps keys it does not edit, and the viewer reads its video defaults
through the shared module.

* feat(system): Appearance defaults used by Images and the viewer

System > Appearance is the concept's Browsing & display surface on real
values: the browser rows edit this browser's display defaults through the
shared gallerySettings object, and Open from ComfyUI and the node search
auto-hide save to the server settings with their revision. A new Images
visit starts from the default layout and size (the current view and saved
views keep theirs); metadata off waits for Load metadata; cache off re-reads
the shown file; paths off hides the Path row; the viewer reads the video
defaults. D97 adds card details, collapsed viewer details and Reset display
defaults; storage refused says Saved for this tab only.

* fix(system): flag changes made while System was closed and keep embedded galleries fixed

Final review fixes for System S3 (Appearance):

- I1: the on-demand metadata deviation is D124 (TESTING.md, inspector
  markers); TESTING.md names the spec that pins it, since no concept
  comparison renders that state.
- I2: System checks the display defaults for conflicts on mount, not only
  on storage events, and a browser write refuses a draft another page
  overtook, so a draft is never saved over another tab's value without
  the conflict status first. New two-tab spec.
- I3: the layout and size defaults start the Images page only; embedded
  galleries (a prompt history's Shared gallery and dialogs) keep
  Grid/Comfortable.
- M1: host rows whose /settings read failed say "Server settings could
  not be read."
- M2: a rejected timeout shows the "Some changes were not saved" status.
- M3: the browser loads on mount and on storage events never reject
  unhandled.
- M4: the next accepted write stores the tab-only changes and clears the
  overlay.
- M5: cache off never re-reads a source-changed selection, and refresh()
  never loads a deferred one.
- M6: the System fixture refuses a config.json outside its temp root and
  restores it with os.replace.
- M7: the inspection read counts wait on a request barrier, not frames.
- M8 recorded as an open saved-views question in TESTING.md.

* fix(system): answer a failed System read only after its other reads finish

CI (py3.10 on ubuntu) crashed with a segfault inside SQLite in
test_statistics_failure_is_an_error_envelope: the statistics and overview
routes used asyncio.gather without return_exceptions, so the 500 answered
on the first failure while get_statistics still ran in the executor, and
the test tore the database down under it. Both routes now await every
read before raising the first failure (failing before, passing after:
TestFailedReadsSettleFirst).

The frontend coverage merge failed on workspace/activity.mjs branches
(86.3 < 87): no test asserted Activity's empty and failed-read states, so
their branch was only reached in passing, depending on timing. A spec now
asserts both.
2026-10-23 07:49:39 -07:00
Vito Sansevero 519ab59d71 fix(system): answer the Overview and Statistics only after every read ended
asyncio.gather answered at the first failed read while its sibling
executor reads were still inside SQLite. In the suite the test teardown
then deleted the database under the running read, and py3.10 on Ubuntu
segfaulted in get_statistics (test_statistics_failure_is_an_error_envelope).
Both routes now wait for all their reads before answering.
2026-10-23 07:45:57 -07:00
Vito Sansevero 369f38be9a fix(review): show the pending count on the System Overview review row
The Overview's review row now reads "N ambiguous image links" with the
concept's line, from needs_review.pending. Its Review metadata action
stays aria-disabled until the Needs review page ships. The system specs
and their concept comparison follow the real count.
2026-10-23 07:11:56 -07:00
Vito Sansevero e6ed2914e1 fix(review): store non-finite evidence as null and count pending items
canonical_evidence now follows main's non-finite policy (#303): NaN and
±Infinity are written as null instead of raising, so a NaN-stamped graph
never turns a review item limited. The System Overview reports the review
queue's pending total in needs_review.pending, read in the same snapshot
as the Library counts.
2026-10-23 06:55:31 -07:00
Vito Sansevero 878612c22d Merge remote-tracking branch 'origin/main' into feat/249-needs-review-backend
# Conflicts:
#	database/pair_ingestion.py
#	py/api/__init__.py
2026-10-23 06:55:29 -07:00
Vito 966a25d129 fix(scan): ingest metadata whose graph holds NaN or Infinity (#303)
* fix(scan): ingest metadata whose graph holds NaN or Infinity

ComfyUI writes its prompt with Python's default json.dumps, so a node whose
IS_CHANGED returns float("nan") saves "is_changed": [NaN] in every PNG. The
exact-pair ingestion stored the snapshots with allow_nan=False, so the scan
failed those files ("Out of range float values are not JSON compliant: nan")
and the monitor dropped their link. The legacy pages replaced NaN with null
before JSON.parse and ingested them.

- utils/json_strict.dumps_strict: strict JSON with non-finite numbers as
  null; identical bytes for every other value.
- Pair snapshots and provenance, and the plain prompt link, store strict
  JSON (the legacy link wrote bare NaN into the database).
- The browser-parser scan fallback parses records as the legacy pages do
  (parse_page_json), so a graph with NaN still traces its prompt.
- The prompt images route answers strict JSON instead of regex-replacing
  "NaN" (which also rewrote prompt text such as "ratio: NaN"); the NaN
  cleaners also null Infinity.

Prompt text and its hash are unchanged: only numbers become null.

* fix(scan): leave the browser-parser NaN parse to the resolver parity fix

The scan fallback's legacy-page parse (parse_page_json in _graph_prompt_text)
is owned by the resolver parity change, which adds the same helper with the
legacy pages' exact rule (NaN reads as null, Infinity still rejected). This
branch keeps only the strict serialization of stored and answered JSON.
2026-10-23 06:48:21 -07:00
Vito 7511a0fd38 fix(activity): no Cancel on jobs that ended before Activity opened (#306)
* fix(activity): drop Cancel job from a job that had ended before Activity opened

Since #289 the Activity list removed an item's actions only when they were
isConnected. On the first render the item is not in the page yet, so a job
that had already ended when Activity opened kept its "Cancel job" (and a
running job's progress was moved after its actions, then back). The list
now asks parentNode instead. The review spec "a scan job with review items
offers Review metadata in Activity" failed on this before the change
(Expected 0 buttons on a finished job, Received 1) and passes after it.

* test(backup): wait for the restore, not a 5 s timer, in the thread test

test_database_is_usable_from_every_thread_after_restore let its worker
wait 5 s for the restore. On a stalled Windows runner (py3.11, where the
previous restore test also took 25 s instead of 0.3 s) the restore took
longer, the wait timed out, and the worker read the old database before
the restore ran. The worker now waits for the event under a 120 s bound
that only a hung test reaches, reports a timeout as an error, and the
main thread releases it even when the restore raises. With a restore
delayed by 6 s the old test fails and the new one passes.

* test(activity): no Cancel job on jobs that ended before Activity opened

Since #289 a list item dropped its actions only when they were isConnected,
so the first render of jobs the store already knew (Activity opened from
another page) kept "Cancel job" on a finished or cancelled job and moved a
running job's progress after its actions. Activity's own list read renders
again a moment later and hides it, so the new spec holds that read and checks
the first render: no progress or Cancel job on the ended jobs, and a running
job's children in order heading, detail, progress, actions.

With the parentNode fix reverted it fails on chromium and touch (Expected 0,
Received 1 on the finished job); with it, it passes.
2026-10-23 06:29:45 -07:00
Vito add035d940 fix(images): release a collection viewer image Viewer.js has not built yet (#293)
* fix(images): release a collection viewer image Viewer.js has not built yet

Leaving a viewer image while it was still loading (Previous, Next, the
arrow keys, Retry) deferred the inline Viewer.js destroy() until the
image's view settled, failed or a 10 s fallback ran. Before its image
loads Viewer.js has not built, so nothing could settle: the stage
dropped the image, the late load ran Viewer.js's build() on an image
without a parent, and getComputedStyle(null) threw a page error
("Argument 1 is not an object" in Firefox, "must be an instance of
Element" in WebKit). Under CI load this failed "collection viewer
follows filtered loaded order beyond mounted cards".

A viewer that is not ready yet is now destroyed at once, which cancels
its pending build, and its image src is released. A built viewer with
a still-loading view keeps the existing deferral.

* fix(images): build Viewer.js only on a loaded viewer image

Destroying an unbuilt inline Viewer.js at once (429b3a8) is not safe in
one state. Viewer.js's own cancel removes its image listeners only while
`image.complete` is false, so an image that had finished downloading
with its load event still queued kept them. Closing the viewer then
released the src, the queued load built Viewer.js anyway, and its first
view found no listed image: "can't access property querySelector, c is
undefined" (workspace-history.spec.mjs:222 on Firefox in CI).

The viewer now constructs Viewer.js from its own load listener, which the
media scope removes when the image is left. Before the load no Viewer.js
exists, so leaving the image only releases its src; after it Viewer.js
sees a complete image and only its zero-delay build is pending, which
destroy() cancels. Transforms report Viewer.js as unavailable only when
it did not load at all.

* fix(images): describe transforms by the Viewer.js an image was built with

1086436 creates Viewer.js only once the image has loaded, so the
"Viewer.js could not load" reason moved from the instance to the live
window.Viewer. An image shown natively while Viewer.js was missing then
offered transforms as soon as Viewer.js reappeared, which
full-inspector.spec.mjs:854 ("collection viewer preserves original
download and rooted retries") caught on every project.

Each rebuild now captures the Viewer.js it builds with. The reason, the
native fallback and the later construction all use that capture.
2026-10-23 06:08:44 -07:00
Vito 88e5b78207 fix(gallery): check duplicate deletes in the scanned root and refresh the listing (#305)
The duplicate cleanup workflow failed in CI with the deleted file still
served (200). The delete was real: the scan reads only the output root,
and the browser fixture's input root holds a different shared/same.png.
The test checked a bare /images/serve/<path> URL, which tries every
gallery root, so after the output copy went it answered with the input
copy. That only happened when the kernel stamped a bulk copy a fraction
of a millisecond before the wall-clock stamp on shared/same.png, which
made shared/same.png a Delete candidate (CI trace: 0000 .8605533,
same .8606777, 0001 .8618972).

The workflow now checks each file in its own root (?root=N), checks the
input copy survives, and a new workflow forces the CI order through the
scan response so the collision runs every time.

The server log in that failure also showed the gallery listing a file
the cleanup had deleted: delete_duplicate_images left the 30 s listing
cache, so the gallery kept counting the file. A successful delete now
invalidates the gallery cache.
2026-10-23 05:42:59 -07:00
Vito b0a025a326 fix(resolver): read NaN as null in the scan's legacy-parser fallback (#304)
ComfyUI stamps "is_changed": [NaN] on nodes that always re-run, the
PromptManager node among them. The legacy gallery and admin pages replaced
NaN with null before JSON.parse, so they traced these files' prompts. The
scan's fallback parsed records strictly, so every NaN-stamped file the
resolver could not decide (hires-fix and refiner chains, custom latent
nodes) fell to "Needs review" although legacy linked its prompt.

parse_page_json parses as the legacy pages did, but only the NaN number
reads as null: the word inside prompt text is kept. Infinity still makes
the record unreadable, as it did in the pages.
2026-10-23 05:17:10 -07:00
Vito c86739178d feat(system): System page shell, review dialog and Overview (#298)
* feat(system): read-only health check and truthful overview counts

POST /prompt_manager/system/health runs the health check off-loop as a
read registry job of kind "health" (one at a time, beside a scan, not
cancellable, no file outcomes) and keeps the last report; GET returns it
or null. The check reads one snapshot on its own mode=ro connection,
closed in finally: integrity_check(100), foreign_key_check, stored hashes
against utils.hashing, duplicate pair rows, the legacy consistency
queries (now shared with check_consistency), and image links whose file
is gone, counted only under readable sources. An unavailable source, or
the absent Needs-review queue, makes its check unavailable and the
report incomplete instead of producing a count.

GET /prompt_manager/system/overview returns Library-eligible positive
prompts and active negative variants, indexed images, the last health
summary or null, backup null, the Needs-review count null, the effective
sources with whether they were auto-detected, and the thumbnail plan's
missing count. GET /prompt_manager/system/statistics lists runs and
average rating per category of Library parents, with the /stats totals.

* feat(system): native System route, drafts and shared review dialog

- #/system/<section> renders the concept's System shell: section nav,
  settings layout and Save row with truthful status (D92); each section
  not delivered yet hands off to its legacy tools (D94). Overview's System
  card opens #/system/overview (Q12).
- A module-level draft store saves browser, then host (expected_revision),
  then runtime; failed domains stay dirty, a stale revision is a conflict.
- System row builders (setting, operation, section nav) on the scope.
- openReviewDialog and backupNotice extracted from the scan review, which
  is now a thin caller with identical markup.
- Overview images gain a "rated" count (images whose linked prompt is
  rated); the missing-files health check reports per source, so partial
  availability is visible.

* feat(system): native Overview with health report and statistics

System > Overview renders the concept's metrics and its Library health and
Workspace preferences surfaces from GET /system/overview: real counts,
"Not checked" until a health check ran, no invented review count, backup
or thumbnail status (D93). Explore opens "Usage statistics" with the real
runs and ratings per category and the Library totals (D96); the
statistics route now also returns rated_images.

Maintenance shows the concept's notice with Run health checks above its
hand-off: the read-only check runs as a registry read job and opens the
"Health report" with a chip per real result, missing files per source
(D95). A load failure shows Retry and no zeros; late answers never
repaint a newer section.

Adds the System concept comparison (shell, Overview, both dialogs, the
notice, mutation checks), the System workflow spec and its fixture
hooks, and the TESTING.md "Native System" section.

* test(system): coverage floors for the native Overview

Adds the system-overview.mjs baseline and raises the system.mjs floors to their measured coverage; no other floor changes.

* fix(system): keep health reports per database and off the event loop

- A restore that replaces the database forgets the stored health report,
  and a check that started before the swap is not kept (database
  generation stamped at start).
- The Overview no longer walks every source for the missing-thumbnail
  count; the page reads /images/thumbnails/status on its own.
- Image sources for the health check and the Overview resolve in the
  executor; a failure to start the check answers the coded envelope.
- Any exception fails only its own check; missing files reads its rows in
  the snapshot and stats the files after it ended.
- The read-only URI keeps Windows UNC paths (empty authority).
- An integrity count at SQLite's limit is flagged as capped.

* fix(system): thumbnail row read, tracked health job and guarded host save

- The Overview's thumbnail row is its own read of the thumbnail status:
  "Checking thumbnails…" until it answers, unavailable when it fails.
- The health job is tracked in the Activity store (new store.track) and
  followed through its event stream instead of polling every 150 ms.
- A host save before a read returned a revision fails without a request;
  System reads the host values on mount and after a conflict.
- "100+ problems found" when the integrity count is capped.
- D-NEW-S2-5 is D123 everywhere; the unused Overview hand-off is gone.
- The concept spec builds the health report from the concept's own rows
  and asserts the full unmapped path list; surface tops pin the 18px
  offset.
- TESTING.md: D96's reason, the read-only (WAL/SHM) standard, the UNC URI
  and the new behavior. Coverage floors raised for the touched files.

* fix(system): make two health tests portable to Windows

- Windows forbids "?" in file names, so the URI-character open test
  uses "#", "%" and a space there.
- ntpath.realpath on Python 3.11+ returns a NUL-byte path instead of
  raising, so the bad-path test raises as POSIX does to reach the
  missing-files failure path on every platform.
2026-10-23 04:59:18 -07:00
Vito Sansevero 4e5c810f96 test(backup): wait for the restore, not a 5 s timer, in the thread test
test_database_is_usable_from_every_thread_after_restore let its worker
wait 5 s for the restore. On a stalled Windows runner (py3.11, where the
previous restore test also took 25 s instead of 0.3 s) the restore took
longer, the wait timed out, and the worker read the old database before
the restore ran. The worker now waits for the event under a 120 s bound
that only a hung test reaches, reports a timeout as an error, and the
main thread releases it even when the restore raises. With a restore
delayed by 6 s the old test fails and the new one passes.
2026-10-23 04:05:44 -07:00
Vito b50bf862f0 fix(inspect): hold a route change until a starting video settles (#294)
* fix(inspect): keep a replaced video in place until its pipeline has started

WebKit (GStreamer) deadlocks the page when a video leaves the page or the
viewport before its pipeline has started: the main thread changes the
pipeline state and waits for the source's streaming thread, which waits on
the main thread in a synchronous bus handler (native stacks of the hung WPE
web process in the pinned image). Removing, hiding or merely pushing the
video below the fold all hung; none of pause(), removing src, load() or the
revoke was the cause on its own.

A video replaced while still starting now stays where it is, inert and
aria-hidden, with the new preview inserted after it, and the session's
revoke of its URL waits. Once it reports metadata or an error (or after
15 s) it is paused, loses its src, is reset with load() and removed, and
only then is its URL revoked. Every released video is reset with load(), so
no decoder is kept per replaced video.

The loadstart replacement spec now runs on WebKit too (15 fresh pages,
replaced at loadstart + 0-4 ms). TESTING.md D62 records the cause.

* fix(inspect): hold a route change until a starting video settles

A route change within the workspace detaches the Inspect page, and WebKit
(GStreamer) deadlocks when a video still starting is detached. Route
changes at loadstart + 0-7 ms hung 4-5 of 120 fresh pages (14 of 120 with
no page code releasing anything, so the detach itself is the trigger).

Inspect now exposes leaveGuard(): a promise while a video on the page is
still starting (current or replaced). The shell renders the next route
once it settles (metadata, error, or the 15 s bound); the latest route
wins. Route changes at loadstart + 0-7 ms now hang 0 of 120.

Not changed, with numbers in TESTING.md D62:
- A replaced video still shows above the new preview until it settles.
  Same-slot stacking changes the video's own box and hung again
  (absolute 5/120, absolute + opacity 0 8/120, one-cell grid 4/120).
- A real page leave cannot wait. Navigating away hung 8/120 and reload
  22/120 even with no page-side release, so it stays a known limitation.
2026-10-23 03:51:38 -07:00
Vito Sansevero fcf6445de0 fix(review): refuse monitor recording beside record-changing jobs
System S1 (#289) gives every record change a registry admission class. The
scan already records Needs review items under its own records job. The
monitor's recording lasts one file and is not a job, so it now checks the
records admission: while a records or guarded job or hold runs it records
nothing, logs that the file waits for the next scan, and answers deferred.
The fallback link stands and the next "new" scan reads the ambiguous file
again, because a fallback link is not a resolved one.

JobRegistry.blocker and active iterate a snapshot of the jobs, since the
monitor's worker thread now asks them too.
2026-10-23 03:43:21 -07:00
Vito Sansevero 0f3559bedf Merge remote-tracking branch 'origin/main' into feat/249-needs-review-backend
# Conflicts:
#	py/api/__init__.py
#	tests/test_api_core.py
2026-10-23 03:34:00 -07:00
Vito 4f1c6e236b test(browser): report a slow test reset with every thread's stack (#299)
* test(browser): report a slow test reset with every thread's stack

On #291 (run 39904288262, WebKit shard 2/2) one /__test__/reset
{chronological_history} waited 16 s on its worker's server, with nothing
on the server's stderr. The cause did not reproduce: the same worker's
file sequence (library, library editor, tagging lifecycle, visual) at
2 CPUs three times, and WebKit shard 2/2 at 2 CPUs, kept every reset
under 2 s.

A reset still running after 2 s now reports, while it waits, every
thread's stack (faulthandler), the default executor's threads and queue,
the open connections and WAL size, and on Linux each thread's scheduler
state and kernel wait channel. When it ends it reports the total and how
long each executor call queued and ran. The lines carry a
"pm-slow-reset| " prefix and the worker fixture copies them to the job
log, so the next one shows up even in a test that passes.

The report never reads another thread's connection. Reading even
in_transaction on a connection busy in SQLite blocks on its mutex with
the GIL held: a first version of this report froze the whole server
until the 30 s busy timeout. A test holds a real write lock behind a
reset and checks the server keeps answering while it waits.

* test(browser): name each thread in the slow-reset stacks

Before Python 3.14 faulthandler prints thread ids only, so the report
now lists which name each id belongs to. CI on 3.10 to 3.13 failed the
unit test that looks for the blocked thread by name.

* test(browser): keep the slow-reset summary on a coarse clock

On Windows the report timer can fire a few milliseconds short of the
threshold, and the summary was then dropped. A reset that was reported
now always gets its summary. The server test uses the plain reset, which
the Windows jobs can seed, and puts the server stderr in its failure
messages.
2026-10-23 03:24:20 -07:00
Vito 5b2c21bdaf test(annotations): capture the details panel after it has loaded (#292)
"Tag opens the dialog without selecting the card" read the panel while it
still said "Loading file details…", then compared it with the same card's
finished details. On a slow WebKit runner the read raced the load.
2026-10-23 03:03:38 -07:00
Vito 25e7ae158b test(history): isolate restore-rotation subtests and wait on the rotation event (#300)
On a slow Windows runner the restore that the rotation test injects between
the writer's transactions outlived wait_for's 3 s worker deadline. The
subtest failed, and because every subtest shared the process-wide context
budget and left its writer running, the still-held restore cascaded: the
next subtest saw the in-flight 621-byte payload as charged, and the late
restore rotated the epoch under the last subtest's writer.

Each subtest now owns its writer and budget and closes them before the next
starts, and the handoff waits on an event set once the rotation returns,
with the existing BLOCK_SECONDS ceiling, before the short pending wait.
A new test holds a restore between transactions with events and proves the
payload is charged only while in flight, released afterwards, and that the
writer re-probes and accepts new evidence.
2026-10-23 02:36:07 -07:00
Vito 08151ca93d test(library): read one "now" in the fixture and the page across midnight (#287)
* test(library): read one "now" in the fixture and the page across midnight

The concept fixture recorded each prompt's last use with the database's wall
clock at seeding, while the page computed "today" from its own clock at
render. A run seeded at 23:59:5x UTC and rendered after midnight read "Used
yesterday" against the concept's "Used today" (PR #284, frontend coverage
shard 2/3).

The concept fixture now takes one instant (optional "now" through the
concept reset hook, the wall clock otherwise) for every last use, mtime and
run time it writes and returns it as manifest.now; seedConceptAt fixes the
browser's Date at that instant. The Library and Images concept specs seed
through it, and the two workspace specs that assert "Used today" and the
"Today" caption fix the page clock at the time they patch or seed. A new
test starts the browser at 23:59:58 UTC, seeds at that instant and passes
midnight before either document opens.

* test(activity): judge the lost-server backoff by its gaps, not a timed window

"lost server clears the pill and marks jobs ended" counted failed job-list
re-reads in a 3.5 s waitForTimeout window that started once the test saw
the ended job. Re-reads back off 1 s, 2 s, then 4 s, so the third lands
4 s after the second. A runner that noticed the ended job more than 0.5 s
late moved the window over the third read (firefox shard 1/2 on #287:
3 reads, expected at most 2).

The test now waits for the third failed read and asserts the gaps between
reads: at least 2 s, then at least 4 s, with 100 ms allowed for the
request being reported late.

* test(activity): replace the spec's remaining timer waits with gaps and a run-out clock

Three checks in workspace-activity.spec.mjs waited on wall-clock timers:

- "lost server": waitForTimeout(2500), then at most 2 list reads. The read
  after the one that found the restart must be the periodic poll, so the
  test now waits for it and asserts the gap is at least POLL_MS (less
  100 ms for late request reporting). Any extra read lands in that gap.
- "200-row cap note" (review M12): waitForTimeout(1500), then no detail
  read. The page's timers are now Playwright's clock. The test runs one
  read-throttle interval out, so any read the counter update scheduled is
  sent before the log opens. Once the log's read has answered, exactly one
  read must be listed.
- "busy start" (review M14): waitForTimeout(7000), then the link toast is
  still visible. The test now runs the page's clock 7 s on, which fires a
  6.5 s clear however slowly the runner got there.

RED, each against a temporary product change: POLL_MS 3000 (gap 3004 ms,
expected at least 14900), detail re-read on new log lines while the log is
closed (an unexpected read), LINK_TOAST_MS 6500 (toast link gone), and
BACKOFF_FIRST_MS 200 for the earlier backoff check (gap 407 ms, expected at
least 1900).
2026-10-23 01:58:02 -07:00
Vito b2cc489fd4 feat(inspect): copy settings summary and local workflow roles (#286)
* feat(inspect): copy settings summary in the full inspector

The legacy metadata page's COPY ALL (LI-4) becomes "Copy settings summary"
after the six facts of the shared full inspector, so the Images viewer,
the recorded history's current file and Inspect all offer it; the Images
side panel keeps the concept's footer. The text is the legacy copyAllData
template with its placeholders, filled from what the inspector shows. A
unit test runs the template read from web/metadata.html on the legacy
parse of every parity PNG: 22 of 25 are byte-equal, and the other three
differ only by the ZeroOut guard (#249) or facts the legacy parse missed.

Copying follows the concept's prompt copy: the toast "Settings summary
copied.", or the select-to-copy dialog (now titled per copy) without a
clipboard. The concept comparisons map the button as D-NEW-1.

* feat(inspect): local execution evidence and prompt tracing

A browser port of the server's inspection resolver for one sampler
(PR L3 Task 8, LI-1). local-resolver.mjs answers inspect_prompt_evidence's
{prompts, resolution} for a local read's records: execution evidence
(read_pair_execution and its per-node match), every traced strategy
(encoders, strings, concatenations, PromptManager and its historical
classes, passthrough, zeroed, selector branches, composites, guiders),
the latent stage walk, the R1 overwritten-text guard and the inspection
profile's visit, depth, text, path, built and scanned bounds, with the
same reason codes and outcome lines. py-json.mjs reads the records as
CPython's json.loads does (dict order and last-wins keys, int versus
float, BigInt past 2^53, NaN) and measures and strips text as str does.
Several sampler candidates, detailer stages and A1111 parameters raise
LocalResolverGap until Task 9. Nothing is wired into Inspect yet.

Python is the oracle: test_local_inspect_parity compares the port with
the server on the parity corpus, 107 named record sets, every work bound
at its edge and 600 seeded fuzzed graphs, and requires gaps to be exactly
the Task 9 paths. Sixteen porting mutations each fail the suite. The
Node unit test replays committed server answers, which the Python suite
keeps current.

* fix(inspect): a list or object workflow node type answers malformed

The R1 overwritten-text check looked every UI workflow node's `type` up
among the subgraph IDs. A `type` that is a JSON list or object raised
TypeError there, so the inspect route answered 500 "Prompt inspection
failed". Such a workflow is now malformed evidence: the resolution is
unresolved with reason_code `malformed` and the inspector's own
"Prompt evidence is malformed" line, never exception text.

The browser port answers the same, and the parity corpus gains the
list, object, nested-subgraph and skipped-ID shapes.

* fix(inspect): a list or object graph class_type is never a preview output

With several sampler candidates the inspector first drops stages whose
outputs reach only previews. That pass looked each API graph node's
`class_type` up in the preview class set, so a list or object
`class_type` raised TypeError and the inspect route answered 500. Only
a string names a class now; such a node is an ordinary terminal, as the
rest of the resolver already treats it.

* feat(inspect): local candidate selection matching the inspector

The browser port of the inspection resolver now answers every record set
the server does, so no path is left to the server any more:

- several sampler candidates, with the identical-branch rule (#270): a
  compare grid whose branches all resolve to one pair (text and recipe)
  is resolved, any other mix is ambiguous;
- stages whose outputs reach only previews are dropped first;
- detailer stages are judged with the samplers in their image ancestry
  (multistage-differs, cycles, depth and the per-stage visit budgets),
  and a sampler that only feeds detailers is not a candidate of its own;
- A1111 `parameters`, with Python's multiline anchors, `\s` and Unicode
  `\d`, the bare footer and Template trailers, counted in code points.

The parity corpus gains the selection cases and a seeded fuzzer, and
every inspection vector now gives the server's prompts, resolution and
facts locally. 16 of 17 mutations are caught; the uncaught one (not
flattening an ambiguous upstream stage) cannot change any answer.

* feat(inspect): local prompts resolved like the server's

A file chosen on the Inspect page now shows the prompts and resolution
the server's inspector gives the same records: buildLocalPayload takes
its roles from the local resolver instead of the interim open roles, so
the legacy fill applies exactly where it does for Images.

The resolver reads the server's own node table. The page fetches
web/js/inspection-semantics.json once through the API client as it
mounts, before any selection; the static route serves that one JSON
file by exact path (every other JSON stays forbidden). A table that
cannot be fetched answers "The inspector's node table is unavailable."
as the server does, and a later selection asks again.

The Copy settings summary deviation is numbered D117.

* fix(inspect): skip a workflow node whose type is a list or object

The overwritten-PromptManager walk now skips such a node, on the server
and in the browser port, instead of marking the whole file malformed.
A prompt that resolves still resolves; the other nodes still feed the
check.

* fix(inspect): one JSON depth cap for both browser parsers

py-json.mjs and local-video.mjs now refuse past the same 1,000 nested
arrays or objects, counting every container as CPython's scanner does.
Boundary vectors at 999, 1,000 and 1,001 for a deep prompt and a deep
workflow run through the Python oracle, and TESTING.md gives the
measured per-version server limits and both divergence directions.

* fix(inspect): realpath containment for the exact-path node table route

inspection-semantics.json now goes through the same realpath check as
every other static file, so a symlink out of web/ is refused. The
forbidden-path test asserts exactly 403.

* fix(inspect): abortable, stepped local reads with a busy state

The wait for the node table now ends with the selection's signal. A
local read resolves and projects in bounded steps with a turn of the
event loop between them, and the inspector carries aria-busy while it
loads. A budget test covers maximal 2 MiB graphs and checks the stepped
read is byte-identical to one synchronous build. The D117 wrap rule now
targets only the summary button, and TESTING.md scopes the restart line
to PRs L1 and L2.

* fix(inspect): record the two CPython-JSON emulations as a follow-up
2026-10-23 01:38:36 -07:00
Vito 347de6a023 test(logging): keep prior.log closed against in-flight records (#296)
TestPackageLoggerIsolation installed a plain logging.FileHandler on
prior.log and closed it while it was still attached to the live
"prompt_manager" logger. FileHandler.emit reopens the file whenever
stream is None, so a record another thread had already routed to the
handler (other tests leave image workers and watchdog threads logging
under prompt_manager.*) reopened prior.log after close and kept it open
through TemporaryDirectory.cleanup(), which Windows refuses (WinError 32).

Use a prior handler that refuses to emit once closed (the same guard
as the product's _OwnedRotatingFileHandler), detach handlers before
closing them, add a deterministic in-flight-record reproduction, and
assert no open handles under the temp directory before its cleanup.
2026-10-23 00:52:00 -07:00
Vito fd747a0ffc fix(inspect): keep a replaced video in place until its pipeline has started (#291)
WebKit (GStreamer) deadlocks the page when a video leaves the page or the
viewport before its pipeline has started: the main thread changes the
pipeline state and waits for the source's streaming thread, which waits on
the main thread in a synchronous bus handler (native stacks of the hung WPE
web process in the pinned image). Removing, hiding or merely pushing the
video below the fold all hung; none of pause(), removing src, load() or the
revoke was the cause on its own.

A video replaced while still starting now stays where it is, inert and
aria-hidden, with the new preview inserted after it, and the session's
revoke of its URL waits. Once it reports metadata or an error (or after
15 s) it is paused, loses its src, is reset with load() and removed, and
only then is its URL revoked. Every released video is reset with load(), so
no decoder is kept per replaced video.

The loadstart replacement spec now runs on WebKit too (15 fresh pages,
replaced at loadstart + 0-4 ms). TESTING.md D62 records the cause.
2026-10-23 00:31:37 -07:00
Vito 7a4b6eebb2 test(shell): split the shell concept comparison per route and motion (#297)
"shell geometry, tokens and controls match the concept" compared all three
routes (Images, Library, Tags) under both motions in one test per size and
theme: 12 page loads, 12 captures and 36 hovers under the 30 s default
budget. Over the last 40 Viewer.js runs it took 22 s median and up to
33 s on CI WebKit, and timed out 6 times (main run 39886719087 among them).
The CI traces show steady progress with no stalled step (the longest one
is a 0.8 s navigation), so the cause is the amount of work in one test,
not a hang or a race.

Each route and motion is now its own test, still covering both sizes and
both themes, with the same assertions and captures.

Under run-pw-ci.sh on WebKit at 2 CPUs, --repeat-each=10:
before 18/40 timed out (median 26.9 s, max 32.5 s);
after 0/240 (median 6.5 s, p90 9.8 s, max 17.2 s).
2026-10-23 00:08:43 -07:00
Vito a64e99402b test(images): wait for the resized grid row before asserting no overflow (#295)
At 700px and 390px the Images grid has two columns, so the poll for a card at
the row start was already true of the 700px layout. Under CI load WebKit can run
no rendering frame between setViewportSize and the overflow check, so the cards
still carried the 700px widths and scrollWidth exceeded the viewport by 296px.
Wait until the first row fits the resized grid instead.
2026-10-22 22:46:50 -07:00
Vito 3b1d00501f fix(config): serialized config store and registry admission (#289)
* fix(config): report settings persistence failures (#220)

Settings saves now go through one serialized config store
(py/config_store.py). A commit re-reads config.json under a process
lock, applies the change to a copy and replaces the file atomically
(temp file beside it, fsync, close, os.replace), so a failed write
leaves the old file in place.

POST /prompt_manager/settings commits first and only then publishes
the values at runtime. A write failure is 500 config-write-failed, an
unparseable config.json is 409 config-unreadable (the file is kept
instead of replaced) and a stale expected_revision is 409
settings-changed. A save reports persisted, revision, runtime_applied
and restart_required_fields; GET adds the revision. Only submitted keys
are written; other sections and the CivitAI key are kept.

* fix(lora): report integration save failures (#220)

POST /prompt_manager/lora/enable commits integrations.lora_manager
through the config store and publishes the runtime values only after
config.json holds them. Before, it set the runtime values, rewrote the
whole runtime config (dropping sections it did not know) and reported
success even when the write had failed.

A write failure is 500 config-write-failed and changes nothing at
runtime; an unparseable config.json is 409 config-unreadable. The
body goes through the capped JSON reader and the flags must be
booleans. A trigger word cache that fails to load is reported as
persisted:true, cache_loaded:false. The CivitAI key is never echoed.
The LoRA tests now write to a temporary config path.

* fix(jobs): refuse legacy record-changing operations while a job runs

The job registry gains admission classes. A records job is refused
while any records or guarded job runs, a guarded job while a records
job runs, and read jobs share only the one-job-per-kind rule.
record_changing=True stays as the spelling of records. Job summaries
add cancellable and admission, and a non-cancellable job ignores
cancel.

JobRegistry.hold() registers a running, non-cancellable job for a
legacy-page operation and ends it when the request does. The legacy
restore, maintenance, prompt cleanup, duplicate image delete, LoRA
import and batch auto-tag routes hold as records; thumbnail generation
(both routes) and thumbnail clear hold as guarded. A maintenance run
of only read checks holds as read. So a restore during a scan, or a
scan during a legacy restore, is refused with the running job named
instead of interleaving; this closes the gap noted on #269.

Refusals keep each route's shape: the JSON routes return the legacy
409 "<title> already running", and the thumbnail and LoRA streams
send their existing error event.

* test(lora): keep the preserved-section fixture free of secret values

CodeQL flagged the fixture that writes the secret constant into a test
config file as clear-text storage. The test only needs a marker value
to prove the stored key survives a save.

* fix(config): read under the commit lock and publish saves in write order

- ConfigStore.read takes the commit lock, so no read handle is open while
  config.json is replaced (Windows refuses that replace).
- os.replace is retried briefly on a Windows PermissionError (antivirus or
  indexer handles) before the save is reported as not written.
- The temp file is written beside realpath(config.json), so a symlinked
  config keeps its link and a bind mount fails truthfully.
- commit() takes a publish callback that runs under the lock after the
  write; settings and LoRA saves publish there, so two concurrent saves
  reach the runtime in write order and the runtime ends on the last one.
- The concurrency tests now wait until the second caller is blocked on the
  lock (tests/lock_probe.py) before releasing the first.

* fix(jobs): require an admission class, admit holds on entry, true Activity states

- JobRegistry.start raises TypeError when neither admission nor
  record_changing is given, so a new job never runs as "read" beside a
  scan by omission.
- hold() admits when its block is entered, so a hold that is never entered
  leaves no running records job behind; callers that answer a refusal in
  their own way check blocker() first, with no await before the block.
- A held SSE operation that ends in an error event (thumbnail generation,
  legacy batch auto-tag) is listed as failed, and refused input (duplicate
  delete without paths, an unreadable thumbnail body) is answered before
  the hold, so it is not listed as a job.
- Activity keeps a job's cancellable flag (true when an older server omits
  it) and shows Cancel only for a running, cancellable job, in Recent work
  and in the job view. A held legacy job no longer offers a Cancel that
  does nothing.

* fix(admin): legacy pages show why a request was refused

A job running in the workspace refuses the legacy record-changing requests,
and a corrupt config.json refuses a settings save. The legacy pages now
name the reason instead of hiding it:

- LoRA import: the busy and failure frames carry status and progress, and
  the admin modal handles type "error": it shows the message, notifies and
  closes, instead of staying on "Initializing..." at 0%.
- Legacy batch auto-tag: a refusal by another job is a 200 stream with one
  error frame ({type, status, message}), which both pages already render;
  a second batch keeps its exact JSON 409.
- Gallery cache clear: a refused server clear shows the reason and stops
  before the success toast and the "Not generated" reset.
- Admin settings save and maintenance show the reply's error ("config.json
  cannot be read...", "Scan for metadata already running") instead of
  "Failed to save settings" or "HTTP 409: Conflict".
- Tests cover the read-only /maintenance branch: a read-only selection runs
  during a scan as a read hold; a mixed or default selection is refused.

* refactor(api): move the settings routes out of admin.py

GET and POST /prompt_manager/settings, their validation, the config commit
and runtime publication, and the shared commit-failure reply now live in
py/api/settings_routes.py as SettingsRoutesMixin, before S3 adds host
fields there. No behaviour change: the same routes, payloads and status
codes; admin.py drops about 300 lines.

* chore(coverage): raise the floors of the frontend files this round touched

admin.js, gallery.js, activity.mjs and activity-job-view.mjs, to their
measured coverage. No floor is lowered.

* fix(admin): the gallery starts no thumbnail status read while it leaves

The gallery's startup thumbnail check runs one second after load. When that
second ended while the page was leaving (CI, WebKit: the workflow test
navigates from the gallery to the admin page), WebKit failed the new read
with "Fetch API cannot load ... due to access control checks." and
Playwright reported a page error.

Like the admin page (#290), the gallery now has a page lifetime that ends
on beforeunload and pagehide (and starts again on a back/forward restore).
The check starts no read once it has ended and passes it as the read's
signal. A page-clock workflow test that leaves before the second ends
failed on both events, in Chromium and WebKit, before this change.
2026-10-22 21:39:01 -07:00
Vito 5123654b0d fix(admin): start no list or stats read while the admin page leaves (#290)
On WebKit, a reload cancels the page's reads between beforeunload and
pagehide. After an in-place edit, finishEdit awaits loadStatistics, which
swallows its cancelled read, and then calls refreshList. That /recent read
starts while the page is leaving, and WebKit fails it with "Fetch API cannot
load ... due to access control checks." Playwright reports that console
error as a page error. This failed workflows.spec on WebKit in CI.

It is not an unhandled rejection: the page catches the failure. The read
should never start.

The admin page now has a lifetime (AbortController) that ends on
beforeunload, unless a running scan asks to stay, and on pagehide.
- ApiClient.latestOnly({lifetime}) starts no request once the lifetime has
  ended, aborts the one in flight, and resolves both to STALE.
- loadStatistics skips and silences a read that ended with the page.
- Real errors still propagate and show as before.
- A page restored from the back/forward cache, or one whose navigation never
  left, gets a new lifetime on pageshow or its next pointer or key input.

Tests:
- The test server can hold a real request at the server (request_hold.py),
  because a page.route hold is cancelled differently by WebKit.
- A WebKit regression test holds /prompt_manager/stats after an in-place
  edit and reloads. It failed 5/5 before the fix with the CI message and
  passes 5/5 after it.
- A test checks that a called-off leave reads the list again.
- api-client unit tests cover the lifetime.
2026-10-22 19:49:14 -07:00
Vito a8aff3a39d feat(annotations): bulk Add tags and Auto-tag selected as Activity jobs (#283)
* feat(activity): image tagging jobs for bulk Add tags and Auto-tag

POST /prompt_manager/jobs/image-tags starts one record-changing
"image-tags" job over 1..500 listed images (202, or the same 409 busy
envelope as /jobs/scan). Add tags hashes each current file and unions a
fixed set into that byte record; Auto-tag captures each still image, asks
one legacy engine for its top N suggestions on the capture (count from
the options or autotag.defaults.suggestion_limit), re-hashes the file and
unions only onto the same bytes. Outcomes are grouped as tagged,
unchanged, skipped (video, too large, file changed, file not found, not a
still image) and errors; cancel stops at a file boundary.

Auto-tag holds the shared model gate for the whole job (409 model-busy at
start when it is held), loads the model once and never downloads one: an
unavailable model fails the job before the first file. The registry gains
blocker() so a start answers busy before taking the gate.

* feat(annotations): Add tags and Auto-tag selected in the selection bar

The Images selection bar gains the concept's Add tags and, beside it,
Auto-tag selected (D85). Add tags starts the image-tags Activity job with
the dialog's comma-separated names; Auto-tag selected reuses the Tag image
model panel (server defaults, Suggestions count) without Generate, refuses
a model that is not downloaded, and either starts the job (Apply
automatically) or walks the selection one Tag image dialog at a time
(Review each image, D90). Busy, model-busy and refused starts toast why;
the selection stays; a finished job's files are read again.

* feat(activity): show image tagging jobs with their own outcomes

Image tagging jobs get their own Activity view (D89): the pill reads
"Tagging", the item and heading line name the images and end with
"N tagged · M errors", and the job view shows Tagged, Unchanged, Skipped
and Errors tiles and outcomes, opens Tagged and Unchanged rows in the
viewer, and reads "Tagging log" and "Cancel tagging". The per-kind table
lives in activity-store.mjs; the scan's view is unchanged.

The selection bar's copy without a concept line is recorded as D117.

* fix(annotations): final review round for bulk image tagging

- Rename the D117 copy deviation to D118 (D117 belongs to another PR).
- The tagging job's progress line gives the scan's "about X min left"
  estimate, with each finished file as one batch.
- The start body cap fits 500 targets of the longest legal path, even
  escaped; one byte over answers 413.
- A capture store at capacity is retried with a short backoff, then the
  file is skipped with a rerun sentence instead of a Tag-dialog error.
- Output roots and their labels are resolved once per job in the
  executor, not twice per file on the event loop.
- Review each image holds Stop reviewing, Escape and x while a save is in
  flight, and its primary reads "Save and next" when only the manual
  field would be saved.
- An Add tags field that parses to no names keeps the dialog and its
  text and reports the message on the field.
- Image tagging finish toasts carry the counts ("... finished · N tagged
  · M errors").
- Specs: the bulk spec waits for the answered Start before checking that
  the invalid press sent nothing; the Activity spec uses a real error row.
2026-10-22 18:55:24 -07:00
Vito Sansevero f00933ee16 refactor(admin): move Needs review recording into its own mixin
_record_scanned_review, _record_review_sync, record_review_file and
_log_review_items move unchanged from py/api/admin.py to
py/api/review_recording.py (ReviewRecordingMixin), keeping admin.py from
growing further past the size ceiling. No behaviour change.
2026-10-22 17:41:15 -07:00
Vito Sansevero beaba9ae84 fix(review): make only the shutdown cleanup final
The previous commit made every close of the capture store final, which
also refused the explicit closes that swap in a fresh store. Only the host's
on_cleanup hook is final now; an observe after it answers a coded capture
error instead of an unhandled 500.
2026-10-22 17:41:14 -07:00
Vito Sansevero ab47d7529b fix(review): final capture store close and recoverable stale cursors
Closing the capture store at shutdown now runs under the store lock and is
final: a monitor recording or scan batch still in flight gets a capture
error instead of creating a new temp directory and restore subscription
after the cleanup.

A well-formed queue cursor this process cannot verify (opened before a
ComfyUI restart, whose cursor secret is per process) now answers 409
review-conflict with the current total, like any other stale page, instead
of an uncoded 400. Malformed cursors stay 400.
2026-10-22 17:38:09 -07:00
Vito Sansevero b84e6ea111 fix(review): refresh the recorded spelling when a file is seen under a new one
A case-only rename on Windows or macOS keeps size and mtime_ns, so the
rescan skipped the file and the item kept the old spelling. Its target was
then formatted from a name the directory no longer lists, the capture
refused it, and the item stayed pending with no way to apply it. The skip
path and a duplicate insert now record the current spelling for every item
at the locator and move the review index revision; item revisions do not
change, so an open decision stays valid. Tested under case-folding
semantics on every platform.
2026-10-22 17:37:00 -07:00
Vito Sansevero fe5a0017a9 fix(review): keep one capture worker for interactive requests
Needs review recording from the scan and the monitor shared the capture
store's two workers with the Tag dialog and Apply, so a busy scan could
answer a user's preview or decision with "Too many previews are open".
Background captures now never take the last worker or capture slot, and
wait up to two seconds for room before counting the file as not added.
Interactive captures are admitted or refused at once, as before.
2026-10-22 17:35:06 -07:00
Vito Sansevero d24f3f989f fix(review): validate stored review evidence once per startup
Startup used to run the full review validation twice (the early guard and
the install) and the annotation group's row checks inside each, so every
evidence document was parsed and re-encoded twice. The early guard is now
catalog-only (a review group without annotation tables), and the install
validates the group once, trusting the annotation group it has just
validated. The evidence predicate no longer pattern-scans every document in
SQL, because the streamed canonical pass proves it, and the codec is built
once.

Measured on a database with 40,000 items of 4 KiB evidence: startup open
went from 7.7 s to 2.5 s. A test pins one evidence pass per reopen.
Restore and verify_database_file keep the full validation.
2026-10-22 17:33:46 -07:00
Vito Sansevero 32cc071d07 fix(review): Apply sees the file under every spelling and rechecks only its choice
Apply now finds the legacy rows of the same physical file however its root
is reached. The roots are real paths while the monitor links under the
directory it watches, so a symlinked or junctioned root, subst or a mapped
drive used to hide the monitor's row and Apply added a second, differently
linked row. Same-name rows are resolved to their physical identity
(normcase(realpath), else samefile); the dependency fingerprint covers all
of them, two rows refuse the choice, one row is enriched under the
ingester's compatible-only rules, and a same-name row the request did not
classify is a changed association.

Apply re-verifies only what the decision depends on: the bytes are still
ambiguous and still offer the chosen candidate. A resolver change that
rewords the reason or reorders candidates leaves pending items applicable.
The resolver version is one constant in prompt_pair_resolver, shared by
ingestion provenance and Needs review.

The transaction's byte check receives the re-hashed digest (or the body's
for Leave unresolved), every Apply refusal branch is tested with zero
writes, and the root listing and realpath calls run on a worker thread.
2026-10-22 17:29:15 -07:00
Vito Sansevero ed392e77e2 fix(review): build the spelling validation test from native paths
On Windows the locator is case-folded and uses backslashes, so the fixed
POSIX pair /out/a.png no longer folded to itself and the new spelling
check refused the good case. The test now spells a mixed-case native path
and folds it with os.path.normcase, and also refuses a locator of another
file.
2026-10-22 16:50:46 -07:00
Vito Sansevero 3e20c533fa feat(review): explicit association decisions with compatible linking
Needs review gains its queue reads and decisions:

- GET /prompt_manager/review/items lists pending items oldest first with
  the exact pending total and an HMAC keyset cursor bound to the storage
  epoch and the review index revision.
- GET /prompt_manager/review/items/{id} adds the candidates (role texts,
  label, selectability), the dependency fingerprint and the digest.
- POST /prompt_manager/review/items/{id}/decision applies a candidate only
  for a capture of the item's bytes that the current file still hashes to
  and whose evidence is unchanged, through the ingester's compatible-only
  rules in the same transaction as the decision row; a conflict writes
  nothing. Leave unresolved records the decision and links nothing.

ingest_resolved_pair keeps its contract; its body moves into
_ingest_resolved_pair_in, which runs in the caller's transaction.

Items keep the file's recorded spelling so targets keep their case on
Windows, and a rescan skips the copy and hash of a file whose size and
mtime match the bytes an item already cites.
2026-10-22 16:27:49 -07:00
Vito Sansevero d25c2cb4f2 Merge remote-tracking branch 'origin/main' into feat/249-needs-review-backend 2026-10-22 16:09:06 -07:00
Vito e29ff4d4b2 feat(inspect): the Inspect page for local files (#280)
* feat(inspect): local adapter seams in the shared inspector and viewer

The inspection loader calls adapter.read when an adapter reads by itself, so a
file chosen in this browser never reaches the API. The full inspector shows the
D66 local provenance (no Path row, no Show in folder, local association line),
and the viewer uses adapter.mediaUrl exactly, so a blob: URL is never given the
_pm_viewer cache-buster and Retry media reuses it. Rooted and recorded-history
adapters keep their api.get paths and cache-busted originals.

* feat(inspect): owned local selection session

createLocalSession owns one chosen File at a time: it classifies the bytes,
creates the blob: URL only for a classified image or video, and reads it
through a real inspection controller with the local adapter. Replacement,
dispose, parent abort and pagehide each release the URL, the handle and the
record once; a released handle (and every copy) is foreign to the adapter, and
every await is fenced by a generation counter so a replaced read never
publishes. Empty, multiple and directory drops keep the previous selection.
Baseline entry for local-session.mjs; branch floors raised for the touched
inspection-state.mjs and local-inspection.mjs.

* feat(inspect): native Inspect a file page

#/inspect is now the concept's "Inspect a file" page instead of the legacy
handoff. One chosen or dropped image or video previews inline, and the one
heading action, "Open file metadata", opens the shared viewer with the full
inspector on a record read entirely in this browser. Nothing is uploaded,
imported or stored; only an explicit Send crosses the host bridge.

- inspect.mjs mounts the concept DOM (surface, file-drop, #local-preview)
  under the shell heading, owns one local session and the shared viewer and
  prompt sender, and releases media before the session revokes its URL on
  replacement, route change, page leave and dispose (D60-D65).
- A file that cannot be read after choosing opens the inspector's
  unreadable state when the browser declares a supported type, instead of
  the "unsupported" copy; the local adapter maps a browser read failure to
  an unreadable record.
- The route is native; Overview keeps its card to the legacy metadata page,
  which is unchanged.
- workspace-inspect.spec.mjs covers the chooser and viewer rail against the
  Images rail for the same bytes, drop, cancel and reselect, unsupported and
  unreadable files, video, URL ownership, mid-read replacement, exact send
  and the no-request/no-storage rule.

* test(inspect): concept parity and managed-host send for local files

Compare the native Inspect a file page with the immutable concept's
#metadata at 1600x1100 and 390x844 in both themes (initial, selected,
viewer-open), mapping D60-D66 and pinning the concept's CSS values and
the chooser's displayed file name, with a mutation check. Send a local
PNG (explicitly empty negative) and core-tags.webm to a real Prompt
Manager (+/-) node across Classic and Nodes 2.0 switches and reloads;
a connected input rejects the send unchanged. Document D60-D66, the
legacy-floor dispositions and the commands in TESTING.md.

* fix(inspect): reset a released preview video in the next task

On WebKit (GStreamer) a video replaced while its pipeline was still
starting blocked the page: pause, src removal and load() in one task
never returned, so the next chooser selection hung (CI page workflows
webkit shard 2/2, "URLs and media end with their owning session").
The src is still dropped before the URL is revoked; only the load()
reset moves to the next task. A fresh-page regression replaces the
first video as soon as it shows, fifteen times.

* fix(inspect): never reset a preview video that is still starting

Deferring load() to the next task was not enough: CI on b7db4c3 hung
WebKit one step later in the fresh-page regression. Reset with load()
only a settled video (metadata read, or failed); a starting one is
paused, loses its src before the URL is revoked and is removed, then
left to be collected. The ownership workflow waits for the video to
settle before asserting the reset.

* fix(inspect): final review round for the local Inspect page

- Each inline preview owns its listeners, so a released node is never
  pinned; a video released while still starting is reset with load()
  once it reports metadata or an error (I1).
- The viewer's video teardown on a just-started local video is guarded
  by a 15-page WebKit test; it needed no change (I2).
- The chooser keeps naming the current file after a cancelled picker
  and names the dropped file after an accepted drop (I3).
- The unrelated activity-scan.mjs floor raise is reverted (I4).
- The page owns pagehide: the viewer closes and the inline media goes
  before the session revokes the URL, and the chooser is cleared (M1,
  M2); the session gains clear() and the viewer close().
- The "metadata is available" toast shows only for readable metadata
  (M3); a rejected drop is a second, transient alert that never
  replaces the selection's status (M4).
- TESTING.md: D62/D63 updates, the WebKit loadstart follow-up and the
  pair-node follow-up (M5).
2026-10-22 16:06:07 -07:00
Vito Sansevero f3366a13f8 feat(review): persist ambiguous sampler evidence from scans and new outputs
The output scan and the live monitor now queue every file the ingestion
resolver finds ambiguous for Needs review. The file is captured, its metadata
is read again from the capture and resolved with the default profile, and only
an ambiguous result is stored, bound to the capture's annotation byte record
and fenced by its storage epoch. Evidence holds one hashed projection per
selectable candidate; none, more than 16 or over 8 MiB is a limited item.
Recording is idempotent per bytes and evidence and never touches a decision.

Scan statuses, counters, outcome rows and legacy SSE events are unchanged; the
job scope gains review_items/review_errors and the log a Needs review line.
The monitor records after its fallback link, best effort.
2026-10-22 15:56:58 -07:00
Vito Sansevero de6af76064 feat(review): add review queue schema with restore validation
Add the Needs review schema group (media_review_metadata,
media_review_items, media_review_decisions) beside the image annotation
group it cites. review_schema_error validates the catalog, columns,
foreign keys, row values and canonical 8 MiB evidence read-only;
migrate_review_state installs it in a SAVEPOINT after the annotation
group, and verify_database_file requires it under require_library_state.
A review group without a valid annotation group is refused. Decisions
null their pair and image navigation through indexes when those are
deleted. Old backups and annotation-era backups restore and gain the
group; browser fixtures clear the new tables on reset.
2026-10-22 15:33:26 -07:00
Vito 195a1d33ac feat(annotations): Tag image dialog, card Tag button and inspector tag chips (#281)
* feat(annotations): image tagging controller with staged suggestions

Add the UI-agnostic Tag image dialog controller (#249 C1): observe the
file into a capture, prefill the saved tags and the server's tagging
defaults, stage the top suggestions as removable chips, and save manual
tags plus the remaining chips with case-insensitive dedupe (manual
spelling first). Every read is serial-guarded; saves are sent once and a
lost answer is resent identically only by Retry. Conflict, storage,
file and capture outcomes keep the draft and gate Save on a reopen.

ApiError now carries a validated copy of a tag-conflict body's current
tag set so the dialog can show it.

* feat(annotations): Tag image dialog beside Info on every image card

Every Images and Library-embedded gallery card shows the concept's Tag
after Info. Tag opens the concept's Tag image dialog (image-tagging.mjs
over the C1 controller) in the shared modal shell with the sticky
tagging-modal footer: capture preview, manual tags, the four legacy
engines with real defaults, explicit Generate, removable suggestion
chips (D83), legacy model labels (D84), a Suggestions count (D91) and
Retry where the controller allows it. The capture is released on close,
route change and pagehide.

The Tags page gains the concept's Auto-tag images for the image selected
in Images (aria-disabled without one, D115) and real Images counts that
follow every save. Retires D9, #245 D2 and #245 D3. The toast now uses
the mock's copy verbatim ("tags" even for one), C1's D60/D61/D68
markers are renumbered to D83/D84/D91, and at extreme zoom a card's info
row stacks (and wraps Info and Tag) when its row is narrower than the
narrowest bounded card, list rows included (D31).

* feat(annotations): show image tags in the inspector and viewer

The inspector parses the B3 envelope's image_tags facet (strictly: exact
keys, current/none/unavailable, at most 100 vocabulary names) and draws one
chip row after the prompt blocks: the file's current image tags first, then
D21's linked tags not already shown (case-insensitive). In the Images panel
image tags are Tags filter buttons and linked tags keep D21's rule; in the
viewer and wherever no Tags filter exists every chip is plain. none and
unavailable add no chips (D88, plan D65; D21 amended).

The tagging fixture reset now drops leftover captures, so a long run of
tagging tests in one worker stays below the capture store's limit.

* fix(annotations): re-read a tagged file in place after a save

After a tag save the selected file's inspection is re-read quietly: the
panel and the viewer keep the current details until the new answer lands,
with no "Loading file details…" in between. While a Tags filter or a
search is active, Images re-reads its loaded range in place (same query,
from the first page up to the loaded count, published once), keeping the
cards, the scroll position and the selection while the selected file still
matches; when it no longer matches, the panel follows the collection.

* fix(annotations): give suggestion chips the concept chip corner

The D83 chips took the later `.tag-suggestion` row rule's 5px corner; the
concept `.chip` has 4px. The concept comparison of the Tag dialog found it.

* test(annotations): compare the Tag dialog with the approved concept

The Tag image dialog's inventory (order, text, field attributes, tokens,
preview box, chips, status, sticky actions) must equal the concept's at
1600x1100 and 390x844, dark and light, opened, generated with WD14 and
with a language model, for an empty result and in the review scope, and
the card's Info and Tag group must equal the concept's. Production runs on
the concept fixture with the concept image's tags saved and the fake model
answering the concept's own sample; only D29, D83, D84 and D91 are mapped,
and a mutation check shows the comparison notices a change.

The D3 footer check again asserts a strictly shorter footer, with short
seedless subtitles that stay on one line beside Info and Tag.

* fix(annotations): final review round for the Tag image dialog

- Option fields show the server defaults that Generate sends: the
  controller bumps an options revision on open and on a model change,
  and the panel re-renders only then (I1).
- A tag conflict shows the current set at once ("Saved now: ...") and
  puts it ahead of the draft in the manual field, so the next Save
  sends what is shown (I2).
- The opening sentence is the concept's on-screen copy; "Remove
  anything you do not want to add." leads the chips only while chips
  exist; the comparison's opened mapping is gone (I3).
- ApiClient gains an additive keepalive option; the capture release
  and the Save PUT use it, and pagehide closes the dialog (I4).
- Closing during a save keeps the capture until the PUT settles, then
  releases it and still reports the outcome (toast, page refresh) (I5).
- A refresh asked during a page load is pending and runs once it
  settles; re-reads use pages up to 200 files; a failed re-read
  toasts; Load more waits for a refresh (I6, M2, M3).
- The selection bar drops checked files a refreshed result no longer
  lists (I7).
- D116 records Retry; after a failed Generate it takes Generate's own
  path, and after a changed storage, file or expired capture it
  observes again keeping the draft (I9, M4, M5).
- A save made from Tags marks a filtered Images visit stale (M6).
- images.mjs drops under 800 lines: the tagging wiring moves to
  images-tagging.mjs and the favorites star to images-favorites.mjs
  (unchanged), and reset() reuses dropSelection() (M1).
- Restore the activity-scan.mjs coverage floors; drop the duplicate
  paired captures test (I8, M9).

* fix(annotations): coverage floors for the Tag dialog round

Floors for the new images-favorites.mjs and images-tagging.mjs, and the
measured raises of image-tagging-state.mjs and image-tagging.mjs.
activity-scan.mjs keeps main's floors.
2026-10-22 15:21:07 -07:00
Vito 2561d3b789 fix(library): restore a shelf left before its first page answered (#282)
Leaving the Library while its first page was still loading (or after it
failed) saved a stream with no variant total. Returning to the shelf in
the same page restored that state, the restore validation rejected the
null variant total, and mounting threw "Invalid library page" as an
uncaught page error, leaving the shelf empty.

A saved state with no page yet (no items, no cursor, total 0, more to
load) now restores as a fresh stream, and the shelf reads page one as
it already does for a restored loading or failed state. Any other saved
state still goes through the full page validation.
2026-10-22 12:37:19 -07:00
Vito 1b8fbdbb75 feat(annotations): image tag routes, model gate and scored suggestions (#278)
* feat(annotations): add scoped observe, capture preview and tag save routes

POST /prompt_manager/image-annotations/observe captures the file named by
the listing identity {root_index, root_scope, path} (scope checked against
the current roots before any file access) and answers the saved tag set of
exactly those bytes, refreshing a matching record's verified fingerprint.
GET/DELETE .../capture/{token} serve the private copy (no-store, nosniff,
pinned with acquire/release_use while it streams) and release it.
PUT .../tags re-hashes the current file and writes only when it still
holds the captured bytes, under the storage epoch and tag revision
(file-changed, storage-changed, tag-conflict, tag-limit, replayed).
Bodies are capped at 64 KiB with unknown fields refused; envelopes never
carry server paths or locators. Restores invalidate every capture and the
host's cleanup removes the capture directory.

* feat(autotag): scored suggestions with a configurable count behind a shared model gate

- py/model_gate.py: one process-wide, non-blocking gate for loading,
  swapping, running, downloading and unloading a tagging model. Worker
  calls run shielded; a release requested while one still runs takes
  effect only when it exits (client disconnect keeps the gate).
- Legacy /autotag/single, /start, /download/{type} and /unload hold the
  gate and answer 409 {code: "model-busy"} while another operation holds
  it; their bodies are otherwise unchanged. /autotag/models gains
  suggestion_limit.
- AutoTagService.generate_tag_suggestions: WD14 (name, probability) best
  first, GGUF/HF (name, None) in output order, first `limit` distinct
  case-insensitively; generate_tags keeps its return; the image file is
  closed before inference returns.
- POST /prompt_manager/image-annotations/suggest runs one engine on the
  pinned capture, with strict per-engine options, never downloads
  (409 model-unavailable) and never writes.
- config.json autotag.defaults.suggestion_limit, integer 1..100, default
  10, validated on load.
- Capture probe decides Generate from the image header alone, so prompt
  metadata over the inspection budgets no longer blocks it.

* feat(images): match image tags in the Tags filter, search and inspector

The workspace Images listing now admits a file under a Tags filter when
every requested tag is carried by its linked positive or its current image
tag set, and q also matches image tag names (case-folded substring). Image
tags count only while a byte record is verified at the file's present
(size, mtime_ns) (spec amendment S1): the tagged records are streamed
once, only listed files carrying a filter tag or q hit are stat'ed, and
their linked positives are read through the file_path index so tags,
category, rating and the positive count combine.

The rooted inspector envelope gains a validated image_tags facet
(current / none / unavailable) read at the inspection's own stable stat.
A failed image-tag read fails a Tags filter like a failed link read and
degrades a q search to no image tags; plain listings read none. Legacy
gallery and search responses are unchanged.

* fix(annotations): answer a fixed coded message when the filtered image list fails

A failed prompt-link or image-tag read under a Tags, category or rating
filter returned the raw exception text (SQL or database messages) in the
500 body. The workspace listing now answers code listing-failed with a
fixed sentence and logs the traceback. The legacy output listing and the
inspector image-tag read now log with exc_info as well.

* fix(annotations): drop suggestion names that Save would reject

A JoyCaption part over 50 characters or holding a newline came back as a
preselected chip, so the first Save of the dialog failed with a 400 and
nothing was stored. generate_tag_suggestions now applies the vocabulary
tag-name rules before the case-insensitive dedupe and the limit count, so
the requested number of valid chips still comes back.

* fix(annotations): stop changing the process warnings filters during capture

header_dimensions wrapped Image.open in warnings.catch_warnings with an
error filter for DecompressionBombWarning. catch_warnings is not
thread-safe, so two overlapping captures could leave that error filter
installed for every later Pillow open in the ComfyUI process. Only
Pillow's DecompressionBombError is now mapped to HeaderTooLarge; a size
that only warns is refused by the existing inference pixel cap.

* fix(annotations): free pins on cancellation, serve preview ranges, code every storage error

- A preview cancelled while its file opens closes the handle and only
  then ends the pin, so a doomed capture is never unlinked while open.
- An observe cancelled while capturing or reading releases the capture
  instead of leaving it until the idle expiry.
- The model gate takes an on_skipped callback that runs once when the
  work never starts; Generate passes the capture's release_use, so an
  executor refusal no longer leaks a pin against the capture budget.
- The preview answers a single byte range with 206 and Content-Range
  (416 for an unsatisfiable or multi-range header), still streamed inside
  the pin, so Safari and WebKit can play video captures.
- _coded maps tag-limit (400), metadata-conflict and vocabulary-conflict,
  and falls back to 409 with a fixed sentence for any other code instead
  of raising KeyError.

* fix(annotations): deny case and Unicode spellings of a listed file

On macOS (case- and normalization-insensitive APFS) a crafted target such
as A.PNG or an NFD name opened the listed a.png or NFC file while
realpath kept the requested spelling, so the tags were stored under a
second locator the Images filter never shows. Capture and verify now
require each path part to be spelled as its directory lists it and deny
any other case or Unicode spelling as an alias (409 source-changed, like
a symlink alias). A missing file with no other spelling keeps its usual
outcome.

* test(annotations): one shared folder_paths stub for the API test modules

test_api_images and test_api_prompts installed a MagicMock folder_paths,
so whenever one of them was imported before test_api_autotag the later
patch("folder_paths.get_output_directory") in test_api_history_outputs
left a mock attribute behind and two test_diagnostics_config tests
failed. tests/host_stubs.py now installs one real-module stub, used by
test_api_images, test_api_prompts and test_api_autotag (and through
test_api_images by the annotation modules, whose copy is gone), so the
modules pass in either import order.

* fix(annotations): derive the suggestion cap from the image tag set cap

py/autotag.py now takes MAX_SUGGESTION_LIMIT from database.annotations
MAX_IMAGE_TAGS. The two copies that cannot import it (py/config.py loads
before the database, utils/inspection_response.py stays storage-free) are
pinned equal by a test, so the 100 limits cannot drift apart.

* docs(testing): record capture aliases, preview ranges and the PR B follow-ups

TESTING.md now states that case and Unicode spellings are aliases, that
the preview serves byte ranges inside the capture pin, where the
cancellation tests live, and two tracked follow-ups: the legacy batch's
ungated cleanup unload under its own last inference, and the three files
over the 800-line ceiling (split py/autotag.py first).
2026-10-22 10:36:58 -07:00
Vito 0095db8cbc feat(inspect): local evidence readers for images and videos (#277)
* feat(inspect): bounded browser PNG reader matching the server's

The Inspect page will read a chosen file entirely in the browser, and the
record it shows must equal what Images shows for the same bytes. This adds
to web/js/png-metadata.js (loaded by the legacy pages as before, with
parsePngTextChunks unchanged):

- readPngMetadataFile(file, {signal, prefix, yieldControl, decompress}):
  a port of utils/prompt_inspector.py's _png_metadata, _PngRecords,
  _png_text, _inflate and _batch_normalized. It slices only chunk headers,
  80-byte keyword prefixes and the retained prompt, workflow, parameters
  and comfyassets_prompt_pairs records, CRC-checks what it keeps, applies
  the same budgets (2 MiB shared role budget encoded and decoded, 4 MiB
  workflow that becomes limited, 8 MiB and 4 MiB duplicate budgets, 4 MiB
  duplicate decoded parameters, 10,000 chunks, the batch-index rule for
  parameters copies) and compares duplicate chunks byte for byte. Every
  range is checked against file.size before slicing and charged against a
  19,794,389-byte ceiling, so a multi-gigabyte PNG costs only its metadata.
  Compressed text inflates through DecompressionStream with one 256-byte
  write in flight and the output capped at the record's allowance; an
  AbortSignal rejects the read with an AbortError and leaves no unhandled
  rejection; yieldControl runs about every 8 ms of work.
- classifyPrefix(bytes): png, jpeg, webp, iso-bmff, matroska, avi, asf or
  null from the first 32 bytes.
- readImageHeader(file, kind): JPEG (SOFn walk as Pillow) and WebP (VP8,
  VP8L, VP8X) dimensions from at most 64 KiB.

tests/test_local_inspect_parity.py drives tests/js/support/local-inspect-
oracle.mjs over the committed and test-time inspection parity corpus plus
compressed, oversized, Latin-1, batch-duplicate and all-four-record shapes,
and asserts records, record formats, dimensions and readability equal
read_inspection_file on each file; the two JPEG/WebP fixtures are checked
against Pillow. tests/js/png-local-reader.test.js pins each server rule,
the sparse 3 GiB read, the prefix reuse, inflation back-pressure and abort.
tests/fixtures/local-inspect/tiny.{jpg,webp} are CC0 FFmpeg test patterns.

* feat(inspect): bounded browser video tag readers

Add web/js/workspace/local-video.mjs: ISO-BMFF (MP4/MOV/M4V) and Matroska
(MKV/WebM) walkers that read only the container tags the server asks ffprobe
for, slicing box and element headers, carrier names and retained values within
the server's budgets and never a byte inside mdat or a Cluster; a port of
utils/video_metadata.select_video_carrier with the record budgets of
read_inspection_file; and a byte-exact json.loads/json.dumps round trip so
envelope objects reserialize as the server spells them.

Every naming rule mirrors ffprobe 9.0.2 on hand-built files: legacy ilst atoms,
mdta keys, joined repeats, data types, Matroska language and default handling,
and Tags behind a Cluster found only through a SeekHead.

Tests: node units over byte-exact container builders and the committed
fixtures; a Python parity test comparing the local carrier with the server's
selection from the same tag maps (mocked probe) and from the same files (real
ffprobe, including 1 MiB + 2.5 MiB per-key records); an ffprobe
characterization of the recorded fixture maps.

* feat(inspect): server-identical local projection and payload

Port utils/inspection_response.py to the browser: a strict parse with Python's
Decimal spelling, duplicate-key rejection, NaN/Infinity as null and integers
past 2^53-1 as text; the prompt, workflow and carrier projection with the
depth-64 and 20,000-node ceilings; _metadata; and the 8 MiB omission steps
(workflow graph, then prompt graph, then all raw) measured as UTF-8 bytes.

local-inspection.mjs builds the inspection_version 1 wire for one chosen File
from the Task 1 and Task 2 readers (observation selected-local-file, interim
unresolved roles, unavailable library), fits it to the response budget, and
exposes localFileAdapter for the shared controller, inspector and viewer:
handles are session-owned by a private token, so a tampered or hand-made
descriptor is rejected and blob: URLs are never resolved through the API
client. projectInspectionPayload admits selected-local-file only for a
local-file identity.

A Python oracle test compares projectMetadata with _metadata byte for byte over
the parity PNG corpus, the committed video fixtures and tag maps, 75 committed
adversarial record sets (projection-cases.json) and node-ceiling sets built at
test time.

* test(inspect): import oracle modules by file URL for Windows

The local-inspect oracle imported product modules with path.join(...), which
Node parses as a URL scheme on Windows (D:\... -> ERR_UNSUPPORTED_ESM_URL_SCHEME),
failing every tests/test_local_inspect_parity.py oracle test on the Windows CI
job. Every dynamic import in tests/js/support now goes through moduleUrl, a
pathToFileURL wrapper, and a node unit proves the Windows form on any host and
scans the oracles so a path import cannot return.

* fix(inspect): read video tags as ffprobe reports them

Same bytes now give the server's record in five shapes where the local
reader was stricter or kept different tags (PR #277 final review I1, I2,
M1, M2, M4):

- Invalid UTF-8 in a tag value is replaced as ffprobe's default
  string_validation=replace does (av_utf8_decode, one U+FFFD per failing
  decode step), instead of failing the read. Key names and TagNames that
  do not decode are no carrier and are skipped.
- An ilst item whose keys index is 0 or out of range is skipped, as
  ffmpeg does, instead of failing the read.
- Only the first keys box is read, as ffmpeg does.
- A first per-key workflow over 4 MiB keeps its place in the carrier
  choice as an unread marker, so the server's carrier (per-key, workflow
  limited) is chosen; when the server's first probe would overflow, the
  re-probe leaves it out and its own bound applies.
- An envelope integer of more than 4300 digits fails the Python json
  port, so that envelope is skipped as CPython's json.loads skips it.

Each shape was checked against ffprobe 9.0.2 and read_inspection_file;
the real-ffprobe parity test now builds them, and a 600-case random-byte
comparison of the replacement against ffprobe matched.

* perf(inspect): measure the local response size natively and yield between steps

fitInspectionPayload counted the UTF-8 size of an 8-12 MB wire with a
per-character loop and ran every omission step on the main thread in one
turn (PR #277 final review M7). The size is now counted by TextEncoder
encodeInto through one bounded scratch buffer, and each re-measure
follows a yieldControl turn, with an abort signal checked between steps.
The fit is async; the fitted payload is the same as before.

* fix(inspect): hold video tags as ffmpeg's dictionary does, decode Mac Roman text

Three more same-bytes divergences, found while verifying the review
fixes against ffprobe 9.0.2 and read_inspection_file:

- ffmpeg keeps one tag per ASCII-folded name under the spelling set
  last (ISO-BMFF joins the values with ";", Matroska keeps the last), so
  PROMPT and Prompt are one record on the server; the local reader kept
  both and failed the read as a duplicate. A legacy comment atom and a
  later COMMENT key likewise leave no exact-case envelope.
- ffprobe's format_tags filter folds ASCII case only, so a name that is
  "workflow" only under Unicode case rules is never reported; the local
  reader took it.
- ISO-BMFF data types 0 and 3 are Mac Roman text to ffmpeg, written into
  twice the input size (a long value is cut); the local reader decoded
  type 0 as UTF-8 and skipped type 3.

The real-ffprobe parity test builds each shape.

* docs(testing): list the local Inspect divergences; raise touched coverage floors

TESTING.md now tracks the local Inspect (#249 PR L1) divergences that
remain after the review fixes, each checked on a host with ffprobe 9.0.2:
media kind by bytes versus extension (parity is for the same bytes with a
matching extension), JPEG dimensions past the 64 KiB header read, classic
QuickTime udta text atoms, raw versus escaped tag bounds, the 10,000
element ceiling, unknown EBML DocTypes, the JSON depth approximation and
the C _decimal assumption.

coverage:update raised the branch floors of the three local Inspect
modules this PR touches; the unrelated activity-scan raise it proposed is
left at main's values.
2026-10-22 10:21:02 -07:00