Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54dd5264d9 | ||
|
|
c4d7dd0c67 | ||
|
|
128b08ff26 | ||
|
|
ba22b3721c | ||
|
|
7fc36283f8 |
@@ -3,10 +3,17 @@
|
||||
"strict": false,
|
||||
"contexts": [
|
||||
"py3.12 on ubuntu-latest",
|
||||
"py3.13 on ubuntu-latest",
|
||||
"py3.14 on ubuntu-latest",
|
||||
"py3.12 on windows-latest",
|
||||
"py3.13 on windows-latest",
|
||||
"py3.14 on windows-latest",
|
||||
"py3.12 on macos-latest",
|
||||
"py3.13 on macos-latest",
|
||||
"py3.14 on macos-latest",
|
||||
"coverage gate (85%)",
|
||||
"black and flake8",
|
||||
"frontend modules",
|
||||
"all tests",
|
||||
"viewerjs",
|
||||
"real ComfyUI compatibility",
|
||||
"bandit (high severity)"
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
name: Windows and macOS
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Non-blocking portability runs (#420): never on pull requests or in the merge queue, and no context
|
||||
# here is a required check. A red run is fixed at its cause before the workspace cutover (#334).
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: "17 3 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
# Every run gets its own group (run_id), so no main commit's run is ever cancelled or dropped as pending.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.run_id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
PYTHONFAULTHANDLER: "1"
|
||||
|
||||
# Time limit rule as in test.yml: ceil(1.5 x the slowest of three recent green runs on main) + 3
|
||||
# minutes, never under 5. Measured 2026-10-26 in test.yml on runs 40257217681 (3be0ada),
|
||||
# 40240706568 (fb9134f), 40228167328 (f9835aa), before these legs moved here in #420:
|
||||
# slowest py3.10 on windows-latest: 12m43s / 15m54s / 14m53s -> 27 min
|
||||
# (macos 6m14s-8m30s, windows 11m42s-15m54s)
|
||||
jobs:
|
||||
test:
|
||||
name: py${{ matrix.python-version }} on ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 27
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows-latest, macos-latest]
|
||||
python-version: ["3.12", "3.13", "3.14"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: pip
|
||||
cache-dependency-path: requirements-dev.txt
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
- name: Run tests
|
||||
run: python -m tests.run_suite -v
|
||||
@@ -28,18 +28,10 @@ env:
|
||||
# py matrix, slowest py3.10 on windows-latest: 12m43s / 15m54s / 14m53s -> 27 min
|
||||
# (ubuntu 5m27s-6m27s, macos 6m14s-8m30s, windows 11m42s-15m54s)
|
||||
# 3.10 and 3.11 left the matrix and 3.14 joined it in #356; re-measure after three green main runs.
|
||||
# Windows and macOS moved to portability.yml (#420), which keeps this 27-minute limit; this
|
||||
# Linux-only matrix keeps it too until it is re-measured.
|
||||
# real ComfyUI compatibility: 13m32s / 8m55s / 11m46s -> 24 min
|
||||
# coverage gate (85%): 7m26s / 7m22s / 7m54s -> 15 min
|
||||
# perf budget 30s / 29s / 27s, black and flake8 39s / 37s / 39s,
|
||||
# frontend modules 23s / 19s / 29s -> 5 min (the floor)
|
||||
# all tests: a result check without setup, 5 min (the floor)
|
||||
#
|
||||
# Pull requests run a fast subset (#420): Python 3.12 on ubuntu-latest, coverage, lint and frontend
|
||||
# modules. The merge queue (merge_group), pushes to main and manual runs add Python 3.13 and 3.14 on
|
||||
# Linux and the real ComfyUI job. "all tests" is the required gate that holds the queue to that set.
|
||||
# Windows and macOS run in portability.yml (push to main, nightly, on demand) and never gate a merge.
|
||||
jobs:
|
||||
test:
|
||||
name: py${{ matrix.python-version }} on ${{ matrix.os }}
|
||||
@@ -48,8 +40,8 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
python-version: ${{ github.event_name == 'pull_request' && fromJSON('["3.12"]') || fromJSON('["3.12", "3.13", "3.14"]') }}
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
python-version: ["3.12", "3.13", "3.14"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -71,9 +63,6 @@ jobs:
|
||||
|
||||
comfyui:
|
||||
name: real ComfyUI compatibility
|
||||
# Merge queue, main and manual runs only. A job skipped by its condition reports Success, so this
|
||||
# required context never blocks a pull request; "all tests" fails if it is skipped anywhere else.
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
# Chromium and its system libraries come from the pinned Playwright image (tag = @playwright/test in
|
||||
# tools/vendor/package-lock.json; tests/vendor/browser-image.test.mjs checks it). The runner install
|
||||
@@ -264,32 +253,3 @@ jobs:
|
||||
|
||||
- name: Run JS tests
|
||||
run: node --test tests/js/*.test.js
|
||||
|
||||
# The required context "all tests". A dropped matrix leg never reports a check at all, so the eight
|
||||
# legs that pull requests skip cannot be required by name; this gate requires them instead. Every
|
||||
# job must succeed; real ComfyUI must be skipped on a pull request and succeed on every other event.
|
||||
all-tests:
|
||||
name: all tests
|
||||
needs: [test, comfyui, coverage, lint, js-test]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Require every test job for this event
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
TEST_RESULT: ${{ needs.test.result }}
|
||||
COMFYUI_RESULT: ${{ needs.comfyui.result }}
|
||||
COVERAGE_RESULT: ${{ needs.coverage.result }}
|
||||
LINT_RESULT: ${{ needs.lint.result }}
|
||||
JS_RESULT: ${{ needs.js-test.result }}
|
||||
run: |
|
||||
queue_only=success
|
||||
if [ "$EVENT_NAME" = pull_request ]; then queue_only=skipped; fi
|
||||
echo "event: $EVENT_NAME (real ComfyUI must be $queue_only)"
|
||||
echo "test matrix: $TEST_RESULT"
|
||||
echo "comfyui: $COMFYUI_RESULT"
|
||||
echo "coverage: $COVERAGE_RESULT"
|
||||
echo "lint: $LINT_RESULT"
|
||||
echo "js-test: $JS_RESULT"
|
||||
test "$TEST_RESULT" = success && test "$COVERAGE_RESULT" = success && test "$LINT_RESULT" = success && test "$JS_RESULT" = success && test "$COMFYUI_RESULT" = "$queue_only"
|
||||
|
||||
@@ -165,8 +165,6 @@ jobs:
|
||||
|
||||
browser-workflows:
|
||||
name: page workflows (${{ matrix.browser }}, shard ${{ matrix.shard }}/2)
|
||||
# Merge queue, main and manual runs only (#420); pull requests run the Chromium/touch coverage shards.
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
# Layout comparisons depend on the fonts each browser falls back to, so browser jobs run in the
|
||||
# same pinned Playwright image as local runs (tag = @playwright/test in tools/vendor/package-lock.json;
|
||||
@@ -221,9 +219,8 @@ jobs:
|
||||
retention-days: 14
|
||||
|
||||
# Branch protection requires the exact context "viewerjs". This job keeps that
|
||||
# name and fails unless every coverage shard and the coverage merge succeeded and
|
||||
# the browser shards succeeded, or, on a pull request only, were skipped (#420).
|
||||
# Any failure, cancellation or other skip fails it.
|
||||
# name and fails unless every frontend job above succeeded (every shard, the
|
||||
# coverage merge and every browser shard), including when one was cancelled or skipped.
|
||||
viewerjs:
|
||||
needs: [frontend-coverage, frontend-coverage-merge, browser-workflows]
|
||||
if: always()
|
||||
@@ -232,15 +229,11 @@ jobs:
|
||||
steps:
|
||||
- name: Require every frontend job to succeed
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
COVERAGE_RESULT: ${{ needs.frontend-coverage.result }}
|
||||
MERGE_RESULT: ${{ needs.frontend-coverage-merge.result }}
|
||||
WORKFLOWS_RESULT: ${{ needs.browser-workflows.result }}
|
||||
run: |
|
||||
queue_only=success
|
||||
if [ "$EVENT_NAME" = pull_request ]; then queue_only=skipped; fi
|
||||
echo "event: $EVENT_NAME (browser-workflows must be $queue_only)"
|
||||
echo "frontend-coverage: $COVERAGE_RESULT"
|
||||
echo "frontend-coverage-merge: $MERGE_RESULT"
|
||||
echo "browser-workflows: $WORKFLOWS_RESULT"
|
||||
test "$COVERAGE_RESULT" = success && test "$MERGE_RESULT" = success && test "$WORKFLOWS_RESULT" = "$queue_only"
|
||||
test "$COVERAGE_RESULT" = success && test "$MERGE_RESULT" = success && test "$WORKFLOWS_RESULT" = success
|
||||
|
||||
+14
-128
@@ -181,12 +181,10 @@ check, so run it before pushing a new or deleted frontend file instead of
|
||||
waiting for the `frontend coverage` jobs.
|
||||
|
||||
The `viewerjs` check runs on every PR, including backend-only changes. It is an
|
||||
aggregate job. It requires the three `frontend coverage (shard N/3)` jobs and
|
||||
`frontend coverage (merge)` to succeed on every event. The four
|
||||
`page workflows (firefox|webkit, shard N/2)` jobs run only in the merge queue,
|
||||
on `main` and on `workflow_dispatch` (see "Pull requests and the merge queue").
|
||||
`viewerjs` requires them to be skipped on a pull request and to succeed
|
||||
everywhere else. Any other failed, cancelled or skipped job fails it. Every browser job runs with `PM_TEST_WORKERS: 3` (the public
|
||||
aggregate job that passes only when every frontend job succeeds: the three
|
||||
`frontend coverage (shard N/3)` jobs, `frontend coverage (merge)`, and the four
|
||||
`page workflows (firefox|webkit, shard N/2)` jobs. A failed, cancelled or skipped
|
||||
job fails it. Every browser job runs with `PM_TEST_WORKERS: 3` (the public
|
||||
runners have 4 vCPUs). Each coverage shard runs `test:coverage:shard -- N/3`
|
||||
(shard 1 also runs the node units and the viewer suite, shard 3 the vendor pins
|
||||
and tooling tests) and uploads its raw V8 files as `coverage-raw-N` (kept 3
|
||||
@@ -273,13 +271,6 @@ gh run view <run id> --json jobs # shows a job that alr
|
||||
gh run view --job <job id> --log-failed # that job's failed log, at once
|
||||
```
|
||||
|
||||
Python 3.13/3.14, real ComfyUI and Firefox/WebKit failures first show up in the
|
||||
merge queue, on the merge-group commit rather than the
|
||||
pushed one. Find that run with `gh run list --event merge_group --workflow test.yml`
|
||||
(or `viewerjs-check.yml`) and match its head branch
|
||||
(`gh-readonly-queue/main/pr-<N>-...`). Windows and macOS failures show up only
|
||||
after the merge, in `gh run list --workflow portability.yml`.
|
||||
|
||||
Start fixing as soon as a failed log shows the cause, but push the fix only after
|
||||
the run for that commit has finished: pull-request runs use `cancel-in-progress`,
|
||||
so a push cancels the running jobs and can hide a second failure.
|
||||
@@ -528,95 +519,29 @@ Before changing paths, SQLite, streams, restore or cleanup:
|
||||
open with `FirstWriterHold`) and give peers that must all succeed the
|
||||
ordinary busy timeout; cross-thread handoffs wait on events with generous
|
||||
ceilings, never a short wall clock (#242).
|
||||
- Run native Windows CI early for these changes. Pull requests and the merge
|
||||
queue run only Linux, so start Windows and macOS on the branch with
|
||||
`gh workflow run portability.yml --ref <branch>`.
|
||||
Include a Windows impact and evidence note in the PR. All six Windows/macOS
|
||||
combinations in that run, every Linux leg and every applicable
|
||||
- Run native Windows CI early for these changes and include a Windows impact and
|
||||
evidence note in the PR. All twelve OS/Python combinations and every applicable
|
||||
exact-head check must pass before merge. Portable `ntpath`/`PureWindowsPath`
|
||||
controls do not establish native filesystem, locking or cleanup success.
|
||||
|
||||
## Pull requests and the merge queue
|
||||
|
||||
Each PR runs the full matrix once, in the merge queue (#420). The event decides
|
||||
what runs:
|
||||
|
||||
| Job | `pull_request` | `merge_group`, push to `main`, `workflow_dispatch` |
|
||||
| --- | --- | --- |
|
||||
| `py3.12 on ubuntu-latest` | runs | runs |
|
||||
| `py3.13 on ubuntu-latest`, `py3.14 on ubuntu-latest` | not created | run |
|
||||
| `real ComfyUI compatibility` | skipped | runs |
|
||||
| `coverage gate (85%)`, `black and flake8`, `frontend modules`, `bandit (high severity)` | run | run |
|
||||
| `frontend coverage (shard N/3)`, `frontend coverage (merge)` (vendor pins and tooling tests on shard 3) | run | run |
|
||||
| `page workflows (firefox\|webkit, shard N/2)` | skipped | run |
|
||||
| `all tests`, `viewerjs` (gates) | run | run |
|
||||
| `perf budget` | skipped | push and dispatch only (not in the queue) |
|
||||
| `py3.12-3.14 on windows-latest/macos-latest` (`portability.yml`) | not run | push to `main`, nightly, `workflow_dispatch` only; never required |
|
||||
|
||||
GitHub has one required set for pull requests and the queue. A job skipped by
|
||||
its `if:` reports Success. A dropped matrix leg never reports, so it would stay
|
||||
pending. That is why `all tests` (in `test.yml`) and `viewerjs` carry the
|
||||
guarantee:
|
||||
- On a pull request they require the subset to succeed and the queue-only jobs
|
||||
to be skipped.
|
||||
- On every other event they require every job to succeed.
|
||||
|
||||
So a merge group cannot turn green unless all three Linux Python legs, real
|
||||
ComfyUI and the four browser shards passed.
|
||||
|
||||
Windows and macOS (Python 3.12-3.14) are out of the blocking path for now
|
||||
(maintainer, 2026-10-28). They run in `portability.yml` on every push to `main`,
|
||||
nightly and on demand, and no context there is required. A red portability run
|
||||
is still a failure: fix it at its cause, not by rerunning, and have every one
|
||||
green before the workspace cutover (#334). `tests/vendor/ci-workflow.test.mjs` pins the
|
||||
event split and runs both gate scripts for each event.
|
||||
|
||||
## Required merge checks
|
||||
|
||||
`.github/branch-protection.json` records the required checks:
|
||||
`py3.12 on ubuntu-latest`, `coverage gate (85%)`, `black and flake8`,
|
||||
`frontend modules`, `all tests`, `viewerjs`, `real ComfyUI compatibility` and
|
||||
`bandit (high severity)`. Each one reports on a pull request.
|
||||
The same set lives in two places, and both must change together: the `main`
|
||||
ruleset (25960526, which also holds the merge queue) and the classic branch
|
||||
protection on `main`, which the manifest mirrors. GitHub requires the union of
|
||||
the two, so a context left in either one blocks every PR that does not report it.
|
||||
`.github/branch-protection.json` records the required checks: all twelve Python/OS
|
||||
combinations, Python coverage, formatting, frontend modules, `viewerjs`, `real ComfyUI compatibility`,
|
||||
and `bandit (high severity)`.
|
||||
The branch must be current with main, and administrators follow the same checks.
|
||||
It does not require an additional human approval.
|
||||
|
||||
A repository administrator applies the configuration after the new workflow is
|
||||
passing, then verifies it:
|
||||
|
||||
```sh
|
||||
gh api repos/ComfyAssets/ComfyUI_PromptManager/rulesets/25960526 \
|
||||
| jq '.rules[] | select(.type == "required_status_checks") | [.parameters.required_status_checks[].context]'
|
||||
```
|
||||
|
||||
A PUT replaces the whole `rules` array, so edit the current ruleset in place.
|
||||
Keep the `deletion`, `non_fast_forward` and `merge_queue` rules, and give every
|
||||
context `integration_id` 15368 (GitHub Actions), so no other integration can
|
||||
post a required check. Save the current ruleset first; it is the rollback:
|
||||
|
||||
```sh
|
||||
gh api repos/ComfyAssets/ComfyUI_PromptManager/rulesets/25960526 > ruleset-before.json
|
||||
jq '{name, target, enforcement, conditions, bypass_actors, rules: [.rules[]
|
||||
| if .type == "required_status_checks"
|
||||
then .parameters.required_status_checks = [$ARGS.positional[] | {context: ., integration_id: 15368}]
|
||||
else . end]}' ruleset-before.json --args "py3.12 on ubuntu-latest" "..." > ruleset-new.json
|
||||
gh api --method PUT repos/ComfyAssets/ComfyUI_PromptManager/rulesets/25960526 --input ruleset-new.json
|
||||
```
|
||||
|
||||
Afterwards, read it back. Check that the `merge_queue` rule is still there and
|
||||
that every context keeps its `integration_id`. Then apply the manifest to the
|
||||
classic protection and read both back:
|
||||
|
||||
```sh
|
||||
gh api --method PUT repos/ComfyAssets/ComfyUI_PromptManager/branches/main/protection \
|
||||
--input .github/branch-protection.json
|
||||
gh api repos/ComfyAssets/ComfyUI_PromptManager/branches/main/protection/required_status_checks
|
||||
gh api repos/ComfyAssets/ComfyUI_PromptManager/rules/branches/main
|
||||
```
|
||||
Update the manifest whenever
|
||||
required job names change. Keep the frontend check
|
||||
|
||||
Update the manifest whenever required job names change. Keep the frontend check
|
||||
free of path filters so a required check is never silently skipped.
|
||||
|
||||
## Unified workspace
|
||||
@@ -4058,30 +3983,7 @@ modals use the same routes and keep working.
|
||||
other: each file's identity is checked just before its unlink.
|
||||
- **Clear all logs** (`POST /logs/truncate`, also the admin page's Clear) empties the
|
||||
active log through its handler, deletes the rotated copies (Rule R) and clears the
|
||||
buffer; other files and links are kept. Response keys are unchanged. It deletes or
|
||||
empties no more than its confirmation showed (#389):
|
||||
- `/logs/files` rows carry `identity`, the string `"<size>:<mtime_ns>:<ino>"` (a
|
||||
string: `mtime_ns` and many inodes are beyond a browser's exact integers). The
|
||||
workspace sends `{"expected": [{name, identity}]}` with the active and rotated rows
|
||||
its dialog was built from. Under the files lock and every handler lock each listed
|
||||
rotated copy must still match (`_matches_review`) and the listed active log must be
|
||||
the same inode, or nothing changes: 409 `logs-changed`. Only listed copies are
|
||||
deleted; one that appeared after the confirmation is kept. A malformed list is 400.
|
||||
- Without a body (the legacy admin page, whose confirm lists nothing; also the
|
||||
workspace when the file list could not be read and the dialog said "deletes its
|
||||
rotated copies") every rotated copy present at the locked rescan is deleted.
|
||||
- Either way, before anything changes (best effort, not a cross-process lock: a
|
||||
write before our own last write, or any while file logging is off here, is not
|
||||
seen): a second writer of the active log (our
|
||||
handler's flushed stream is behind the file's size, or its file is now a rotated
|
||||
copy: another ComfyUI sharing the folder) is 409 `logs-foreign-writer`; an active
|
||||
log with more than one name (`st_nlink > 1` by `lstat` or the handler's `fstat`,
|
||||
NTFS included) is 409 `logs-hard-link`, never truncated (replacing it instead would
|
||||
rename a file our handler holds open, which Windows refuses). Each copy is
|
||||
`lstat`-checked again just before its unlink; one that changed stops the clear there
|
||||
and keeps it, the copies after it and the active log, which is emptied last.
|
||||
- The browser fixture grows the active log through the handler's own stream, since a
|
||||
second handle appending to it is exactly what Clear refuses.
|
||||
buffer; other files and links are kept. Response keys are unchanged.
|
||||
- **Runtime settings** (`GET/POST /logs/config`): the level (DEBUG…CRITICAL) and console
|
||||
logging apply until the server restarts; the retention values are shown read-only in
|
||||
the workspace and the API keeps accepting them.
|
||||
@@ -4721,23 +4623,7 @@ files, oldest forgotten first, keyed by 16-byte digests: 8.6 MiB measured at
|
||||
the cap). A file whose inspection raises is logged, counted in
|
||||
`review_errors` and reported `unresolved`; the scan goes on. An older version's
|
||||
pending, undecided item of bytes now untraced leaves the queue
|
||||
(`settle_stale_review_items`), so an item never stays stuck at 409.
|
||||
Saver anchor (#390, `tests/test_review_saver_anchor.py`): the scan and Apply
|
||||
resolve with the file's name as the workspace inspector does, so the one saver
|
||||
whose `filename_prefix` names the file anchors its branch. A file is never both
|
||||
unlinked and unqueued (maintainer ruling): when only that anchor resolves it
|
||||
(the named inspection run resolves, the unnamed one does not) and neither the
|
||||
default profile nor the parser floor links it, the scan queues it with the
|
||||
anchor's pair as its one candidate (evidence `anchor: "saver"`; the page
|
||||
preselects it), never linking it. Such an item counts as a choice for the
|
||||
untraced settle. Files already linked or resolved by the default path are
|
||||
unchanged. No match, several matches or an unfinished walk anchor nothing, and
|
||||
the file is queued as before. `REVIEW_RULE_VERSION` is folded into inspection
|
||||
item versions, so older inspection items are recorded again once. Apply accepts
|
||||
the anchored pair and refuses a choice the anchor contradicts. Measured on the
|
||||
maintainer's 40,890 outputs: the queue is unchanged (249 files); the 242
|
||||
`gpu_unlock_bench` files the inspector resolves are linked by the parser
|
||||
floor. The 16-candidate limited
|
||||
(`settle_stale_review_items`), so an item never stays stuck at 409. The 16-candidate limited
|
||||
rule and the 8 MiB bound apply unchanged. 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, and the monitor does the same for each ambiguous output it links
|
||||
|
||||
@@ -229,15 +229,13 @@ def evidence_offers_choice(evidence: Any) -> bool:
|
||||
"""
|
||||
if not isinstance(evidence, dict):
|
||||
return False
|
||||
# The saver anchor's one pair (#390) is a suggestion to confirm.
|
||||
needed = 1 if evidence.get("anchor") == "saver" else MIN_CHOOSABLE
|
||||
if evidence.get("limited"):
|
||||
total = evidence.get("candidate_total")
|
||||
return type(total) is int and total >= needed
|
||||
return type(total) is int and total >= MIN_CHOOSABLE
|
||||
candidates = evidence.get("candidates")
|
||||
if not isinstance(candidates, list):
|
||||
return False
|
||||
return sum(1 for c in candidates if choosable(c)) >= needed
|
||||
return sum(1 for c in candidates if choosable(c)) >= MIN_CHOOSABLE
|
||||
|
||||
|
||||
def evidence_digest(text: str) -> str:
|
||||
|
||||
+4
-13
@@ -1630,14 +1630,7 @@ class AdminRoutesMixin:
|
||||
if self._review_file_linked(media_file, roots):
|
||||
outcome = "unchanged" # already linked: not a review case
|
||||
else:
|
||||
# The records name the item versions that count; the
|
||||
# saver anchor (#390) is checked on the captured bytes.
|
||||
self._record_scanned_review(
|
||||
media_file,
|
||||
roots,
|
||||
review,
|
||||
scanned=(records.get("canonical"), None),
|
||||
)
|
||||
self._record_scanned_review(media_file, roots, review)
|
||||
elif untraced and outcome == "unresolved":
|
||||
# Untraced by the default profile: the inspection profile may
|
||||
# still offer a choice (queued for review, never linked).
|
||||
@@ -1698,9 +1691,9 @@ class AdminRoutesMixin:
|
||||
``"ambiguous"`` (nothing linked it) is a Needs review case. An
|
||||
ambiguous result with nothing to choose (``reviewable`` is False: fewer
|
||||
than two candidates carry a prompt) that nothing links returns
|
||||
``"unresolved"`` instead, and ``media_file`` goes to ``untraced``.
|
||||
The parsed records of either ambiguous file go to
|
||||
``records["canonical"]`` when ``records`` is a dict.
|
||||
``"unresolved"`` instead, and ``media_file`` goes to ``untraced``;
|
||||
its parsed records go to ``records["canonical"]`` when ``records`` is
|
||||
a dict.
|
||||
"""
|
||||
if not metadata:
|
||||
# No prompt metadata (--disable-metadata, bare savers): only the
|
||||
@@ -1730,8 +1723,6 @@ class AdminRoutesMixin:
|
||||
records["canonical"] = canonical
|
||||
elif resolution["state"] == "ambiguous" and ambiguous is not None:
|
||||
ambiguous.append(media_file)
|
||||
if records is not None:
|
||||
records["canonical"] = canonical
|
||||
if resolution["state"] == "resolved":
|
||||
result = ingest_resolved_pair(
|
||||
self.db, str(media_file), canonical, resolution
|
||||
|
||||
@@ -11,11 +11,6 @@ import stat
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
try:
|
||||
from ...utils.logging_config import ClearRefused, parse_confirmed
|
||||
except ImportError: # loaded as the top-level ``py`` package (tests)
|
||||
from utils.logging_config import ClearRefused, parse_confirmed
|
||||
|
||||
# Keys PromptManagerLogger.update_config() understands. Anything else is
|
||||
# rejected so a client cannot inject arbitrary entries into the logging config.
|
||||
LOG_CONFIG_KEYS = frozenset(
|
||||
@@ -268,43 +263,10 @@ class LoggingRoutesMixin:
|
||||
)
|
||||
|
||||
async def truncate_logs(self, request):
|
||||
"""Clear all logs: what ``{"expected": [...]}`` lists, or all (#389).
|
||||
|
||||
``expected`` holds the ``{name, identity}`` rows of ``/logs/files`` the
|
||||
confirmation showed. Without a body (the legacy admin page, whose
|
||||
confirmation lists nothing) every rotated copy present now is deleted.
|
||||
A malformed list is 400; a clear that would empty or delete what was
|
||||
not confirmed changes nothing and is 409 with the reason's ``code``.
|
||||
"""
|
||||
"""Truncate all log files."""
|
||||
try:
|
||||
args = ()
|
||||
if request.can_read_body:
|
||||
data, error_response = await self._read_json_body(request)
|
||||
if error_response is not None:
|
||||
return error_response
|
||||
if "expected" in data:
|
||||
try:
|
||||
parse_confirmed(data["expected"])
|
||||
except ValueError:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "expected must list name and identity"
|
||||
" rows from /logs/files",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
args = (data["expected"],)
|
||||
logger_manager = self._get_logger_manager()
|
||||
try:
|
||||
results = await self._run_in_executor(
|
||||
logger_manager.truncate_logs, *args
|
||||
)
|
||||
except ClearRefused as refused:
|
||||
return web.json_response(
|
||||
{"success": False, "error": str(refused), "code": refused.code},
|
||||
status=409,
|
||||
)
|
||||
results = await self._run_in_executor(logger_manager.truncate_logs)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
|
||||
+1
-11
@@ -677,14 +677,7 @@ def _verify_and_read(store, capture, item, root, need_metadata: bool):
|
||||
facts = store.verify(root, relative)
|
||||
captured = None
|
||||
if need_metadata:
|
||||
# The file's own name, so an inspection item resolves anchored on its
|
||||
# saver as it was recorded (#390).
|
||||
captured = captured_review(
|
||||
store,
|
||||
capture,
|
||||
item_profile(item["evidence"]),
|
||||
relative.rsplit("/", 1)[-1],
|
||||
)
|
||||
captured = captured_review(store, capture, item_profile(item["evidence"]))
|
||||
return relative, facts, captured
|
||||
|
||||
|
||||
@@ -730,9 +723,6 @@ def _candidate_still_offered(resolution: dict, choice: str) -> bool:
|
||||
that rewords the top-level reason or reorders candidates leaves a
|
||||
pending item applicable.
|
||||
"""
|
||||
if isinstance(resolution, dict) and resolution.get("anchor") == "saver":
|
||||
# Only the saver anchor resolves the bytes (#390): its pair alone.
|
||||
resolution = {"state": "ambiguous", "candidates": [resolution]}
|
||||
if not isinstance(resolution, dict) or resolution.get("state") != "ambiguous":
|
||||
return False
|
||||
evidence, _count, limited = review_evidence(resolution)
|
||||
|
||||
@@ -88,8 +88,7 @@ class ReviewRecordingMixin:
|
||||
def _record_scanned_review(self, media_file, roots, review=None, scanned=None):
|
||||
"""Record one ambiguous, unlinked scanned file (logged and counted).
|
||||
|
||||
``scanned`` is the scan's ``(metadata, inspection_resolution)``; the
|
||||
resolution is None when the scan did not run the inspection profile.
|
||||
``scanned`` is the scan's ``(metadata, inspection_resolution)``.
|
||||
"""
|
||||
try:
|
||||
outcome = self._record_review_sync(
|
||||
@@ -200,7 +199,7 @@ class ReviewRecordingMixin:
|
||||
key = _memory_key(locator)
|
||||
if state is not None and memory.get(key) == state:
|
||||
return None
|
||||
resolution = review_capture.inspection_choice(canonical, media_file.name)
|
||||
resolution = review_capture.inspection_choice(canonical)
|
||||
if resolution is not None:
|
||||
memory.pop(key, None)
|
||||
return canonical, resolution
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
// A restore the open workspace page has not noticed yet (#413).
|
||||
//
|
||||
// Specs that restore through the API while a page is open check what the page does with a restore it did not see:
|
||||
// its next write is refused as "Prompt storage was restored since this dialog opened". But the page's Activity store
|
||||
// reads the job list every 15 s (activity-store.mjs POLL_MS), and a read the server answers while the restore job runs
|
||||
// makes the page follow it: when it finishes, the page is shown again from the restored data (S9, index.mjs
|
||||
// onRestoreFinished), which closes any open dialog. On a slow runner a restore can take longer than the time left to
|
||||
// the next read (CI run 40646112668: the restore took 16 s), so whether the page saw it depended on timing.
|
||||
//
|
||||
// `armUnseenRestores(page)`, before the page loads, passes every job-list read of the page through one route, so it
|
||||
// knows which reads the server has not answered yet. `unseenRestore(page, restoring)` then closes the gate, waits
|
||||
// for those reads' answers (sent before the restore began, they cannot list it), runs `restoring()`, and only after
|
||||
// it has answered lets the held reads go on: they read the restore as already ended, as a tab that read its job list
|
||||
// just before and just after the restore does. A restore that is never seen running never reloads the page.
|
||||
const JOBS = '**/prompt_manager/jobs';
|
||||
const gates = new WeakMap();
|
||||
|
||||
/** Route `page`'s job-list reads through the restore gate. Call before the page's first navigation. */
|
||||
export async function armUnseenRestores(page) {
|
||||
const gate = { closed: null, inFlight: 0, idle: [] };
|
||||
gates.set(page, gate);
|
||||
await page.route(JOBS, async route => {
|
||||
if (gate.closed) {
|
||||
await gate.closed;
|
||||
return route.fallback();
|
||||
}
|
||||
gate.inFlight += 1;
|
||||
try {
|
||||
const response = await route.fetch();
|
||||
await route.fulfill({ response }).catch(() => {});
|
||||
} catch {
|
||||
await route.abort().catch(() => {});
|
||||
} finally {
|
||||
gate.inFlight -= 1;
|
||||
if (gate.inFlight === 0) for (const resolve of gate.idle.splice(0)) resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Run `restoring()` (an API restore) unseen by `page`'s Activity store; resolves with what it resolves with. */
|
||||
export async function unseenRestore(page, restoring) {
|
||||
const gate = gates.get(page);
|
||||
if (!gate) throw new Error('armUnseenRestores(page) must run before the page loads');
|
||||
let open;
|
||||
gate.closed = new Promise(resolve => { open = resolve; });
|
||||
try {
|
||||
// Reads already on their way were sent before the restore: their answers cannot list it.
|
||||
if (gate.inFlight > 0) await new Promise(resolve => gate.idle.push(resolve));
|
||||
return await restoring();
|
||||
} finally {
|
||||
gate.closed = null;
|
||||
open();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {armUnseenRestores,unseenRestore} from './unseen-restore.mjs';
|
||||
import {readFile,writeFile} from 'node:fs/promises';
|
||||
import {createHash} from 'node:crypto';
|
||||
|
||||
@@ -17,13 +16,13 @@ const marker=(page,id,anchor='10')=>recorded(page).locator(`button[data-run-id="
|
||||
const exact=async locator=>locator.evaluate(node=>node.textContent);
|
||||
|
||||
test.beforeEach(async({page,request,browserName})=>{
|
||||
await startCoverage(page,browserName);await armUnseenRestores(page);page.historyErrors=[];page.on('pageerror',error=>page.historyErrors.push(error.message));
|
||||
await startCoverage(page,browserName);page.historyErrors=[];page.on('pageerror',error=>page.historyErrors.push(error.message));
|
||||
const reset=await request.post('/__test__/reset',{data:{chronological_history:true}});await expect(reset).toBeOK();page.fixture=await reset.json();
|
||||
});
|
||||
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.historyErrors).toEqual([]);});
|
||||
|
||||
const command=async(request,data)=>{const response=await request.post('/__test__/history/commands',{data});await expect(response).toBeOK();return response.json();};
|
||||
const restoreSnapshot=async(page,request,bytes)=>{const response=await unseenRestore(page,()=>request.post('/prompt_manager/restore',{multipart:{database_file:{name:'history.db',mimeType:'application/octet-stream',buffer:bytes}}}));await expect(response).toBeOK();expect((await response.json()).success).toBe(true);};
|
||||
const restoreSnapshot=async(request,bytes)=>{const response=await request.post('/prompt_manager/restore',{multipart:{database_file:{name:'history.db',mimeType:'application/octet-stream',buffer:bytes}}});await expect(response).toBeOK();expect((await response.json()).success).toBe(true);};
|
||||
const historyRead=async(request,id)=>{const response=await request.get(`/prompt_manager/library/${id}/history`);await expect(response).toBeOK();return response.json();};
|
||||
const frozenContexts=contexts=>contexts.map(({navigation,positive_prompt_id,negative_prompt_id,pair_id,...frozen})=>frozen);
|
||||
const immutableReceipts=items=>items.map(({current_candidate,...receipt})=>receipt);
|
||||
@@ -63,7 +62,7 @@ for(const boundary of ['window','detail','candidate','inspection','media']) test
|
||||
await command(request,{action:'arm',boundary});const pending=request.get(target);
|
||||
try {
|
||||
await expect.poll(async()=> (await(await request.get('/__test__/history/checkpoint')).json()).entered).toBe(true);
|
||||
await restoreSnapshot(page,request,bytes);
|
||||
await restoreSnapshot(request,bytes);
|
||||
} finally {await command(request,{action:'release'});}
|
||||
const retired=await pending;expect(retired.status()).toBe(409);expect((await retired.json()).code).toBe('history-changed');
|
||||
for(const url of [`/prompt_manager/library/${f.detail_id}/history?limit=1&cursor=${encodeURIComponent(firstWindow.next_cursor)}`,`${base}?epoch=${epoch}`,`${base}/outputs?epoch=${epoch}`,`${base}/outputs?epoch=${epoch}&cursor=${encodeURIComponent(outputs.next_cursor)}`,media.url,media.thumbnail_url,media.inspection_url]) {
|
||||
@@ -131,7 +130,7 @@ test('archive delete merge and prune preserve frozen evidence without repairing
|
||||
|
||||
test('actual restore with reused numeric owner rejects stale history and offers explicit saved recovery',async({page,request})=>{
|
||||
const f=page.fixture;await open(page,f.detail_id);const original=await exact(saved(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'));
|
||||
const source=await request.get('/__test__/history/reused-snapshot');await expect(source).toBeOK();await restoreSnapshot(page,request,await source.body());
|
||||
const source=await request.get('/__test__/history/reused-snapshot');await expect(source).toBeOK();await restoreSnapshot(request,await source.body());
|
||||
await marker(page,f.runs.shared).click();await expect(dialog(page)).toContainText('Recorded evidence changed');await page.keyboard.press('Escape');
|
||||
await expect(page.locator('.library-parent-status')).toContainText('changed');expect(await exact(saved(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'))).toBe(original);
|
||||
await expect(recorded(page).getByRole('button',{name:'Reload recorded history',exact:true})).toBeVisible();
|
||||
@@ -143,12 +142,12 @@ for(const outcome of ['success','error'])test(`delayed actual old browser ${outc
|
||||
const f=page.fixture;await open(page,f.detail_id);const snapshot=await request.get('/__test__/history/snapshot');const bytes=await snapshot.body();
|
||||
let entered,release;const started=new Promise(resolve=>{entered=resolve;}),held=new Promise(resolve=>{release=resolve;});
|
||||
await page.route(new RegExp(`/history/runs/${f.runs.shared}\\?`),async route=>{
|
||||
if(outcome==='error')await restoreSnapshot(page,request,bytes);
|
||||
if(outcome==='error')await restoreSnapshot(request,bytes);
|
||||
const response=await route.fetch();expect(response.status()).toBe(outcome==='success'?200:409);entered();await held;
|
||||
await route.fulfill({response}).catch(()=>{});
|
||||
});
|
||||
try {
|
||||
await marker(page,f.runs.shared).click();await started;if(outcome==='success')await restoreSnapshot(page,request,bytes);
|
||||
await marker(page,f.runs.shared).click();await started;if(outcome==='success')await restoreSnapshot(request,bytes);
|
||||
await page.evaluate(()=>{location.hash='#/overview';});await expect(page.locator('#workspace-main h1')).toHaveText('Overview');release();
|
||||
await page.unroute(new RegExp(`/history/runs/${f.runs.shared}\\?`));await open(page,f.detail_id);
|
||||
await expect(recorded(page).locator('button[data-run-id]')).toHaveCount(0);
|
||||
@@ -259,7 +258,7 @@ test('inspection first discovers a real restore and immediately retires every su
|
||||
if(changed)window.recordedRecoveryStates.push({receipts:run.querySelectorAll('[data-receipt]').length,active:[...run.querySelectorAll('[data-send-role],[data-copy-role],[data-view-receipt]')].filter(node=>!node.disabled&&node.checkVisibility()).length});
|
||||
});observer.observe(run,{subtree:true,childList:true,attributes:true,characterData:true});
|
||||
});
|
||||
await restoreSnapshot(page,request,await snapshot.body());
|
||||
await restoreSnapshot(request,await snapshot.body());
|
||||
} finally {await command(request,{action:'release'});}
|
||||
const response=await inspectionRead;expect(response.status()).toBe(409);expect((await response.json()).code).toBe('history-changed');
|
||||
await expect(dialog(page).locator('.recorded-run-status')).toContainText('Recorded evidence changed');
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {armUnseenRestores,unseenRestore} from './unseen-restore.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {inventoryOf,mapConcept,diffInventory} from './library-editor-canonical-comparison.mjs';
|
||||
|
||||
@@ -14,7 +13,7 @@ const RATINGS=[['5','5 / 5'],['4','4 / 5'],['3','3 / 5'],['2','2 / 5'],['1','1 /
|
||||
const editorDialog=page=>page.locator('dialog.library-editor');
|
||||
|
||||
test.beforeEach(async({page,request,browserName})=>{
|
||||
await startCoverage(page,browserName);await armUnseenRestores(page);
|
||||
await startCoverage(page,browserName);
|
||||
const reset=await request.post('/__test__/library-editor/reset',{data:{}});await expect(reset).toBeOK();page.fixture=await reset.json();
|
||||
page.editorErrors=[];page.on('pageerror',error=>page.editorErrors.push(error.message));
|
||||
page.writes=[];page.on('request',item=>{if(item.method()!=='GET')page.writes.push({method:item.method(),url:new URL(item.url()).pathname,body:item.postDataJSON()});});
|
||||
@@ -261,8 +260,7 @@ test('recovery states render inline and never replay a write',async({page,reques
|
||||
page.writes.length=0;
|
||||
dialog=await openEditor(page,{mode:'new'});await ready(dialog);
|
||||
const snapshot=await request.get('/__test__/library-editor/snapshot');await expect(snapshot).toBeOK();
|
||||
const buffer=await snapshot.body();
|
||||
const restore=await unseenRestore(page,()=>request.post('/prompt_manager/restore',{multipart:{database_file:{name:'editor.db',mimeType:'application/octet-stream',buffer}}}));
|
||||
const restore=await request.post('/prompt_manager/restore',{multipart:{database_file:{name:'editor.db',mimeType:'application/octet-stream',buffer:await snapshot.body()}}});
|
||||
await expect(restore).toBeOK();page.writes.length=0;
|
||||
await field(dialog,'Title').fill('After restore');await field(dialog,'Positive prompt').fill('Restored storage prompt');await button(dialog,'Save').click();
|
||||
await expect(note(dialog)).toHaveText('Prompt storage was restored since this dialog opened. Nothing was saved.');
|
||||
@@ -590,7 +588,7 @@ test('saved refresh failure retries only reads',async({page})=>{
|
||||
|
||||
test('restored storage after a confirmed save or before an archive offers an explicit Library reload',async({page,request})=>{
|
||||
const f=page.fixture;const snapshot=await(await request.get('/__test__/library-editor/snapshot')).body();
|
||||
const restore=()=>unseenRestore(page,()=>request.post('/prompt_manager/restore',{multipart:{database_file:{name:'editor.db',mimeType:'application/octet-stream',buffer:snapshot}}}));
|
||||
const restore=()=>request.post('/prompt_manager/restore',{multipart:{database_file:{name:'editor.db',mimeType:'application/octet-stream',buffer:snapshot}}});
|
||||
await openHistory(page,f.positive_id);
|
||||
await page.route(`**/prompt_manager/library/${f.positive_id}`,async route=>{
|
||||
if(route.request().method()!=='PATCH')return route.continue();
|
||||
@@ -617,7 +615,7 @@ test('restored storage after a confirmed save or before an archive offers an exp
|
||||
const DISCARDS='Leaving this dialog discards your entries.';
|
||||
test('restored storage before an Edit or Add negative save offers only the Library reload exit',async({page,request})=>{
|
||||
const f=page.fixture;const snapshot=await(await request.get('/__test__/library-editor/snapshot')).body();
|
||||
const restore=()=>unseenRestore(page,()=>request.post('/prompt_manager/restore',{multipart:{database_file:{name:'editor.db',mimeType:'application/octet-stream',buffer:snapshot}}}));
|
||||
const restore=()=>request.post('/prompt_manager/restore',{multipart:{database_file:{name:'editor.db',mimeType:'application/octet-stream',buffer:snapshot}}});
|
||||
await openHistory(page,f.positive_id);
|
||||
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();let dialog=editorDialog(page);await ready(dialog);
|
||||
await expect(await restore()).toBeOK();
|
||||
|
||||
@@ -516,7 +516,7 @@ test('a rollover after the review deletes nothing and Activity offers Review aga
|
||||
test('Clear all logs states both counts, confirms first and reads everything again', async ({ page, request }) => {
|
||||
await seedLogs(request, LOG_FOLDER);
|
||||
const truncates = [];
|
||||
page.on('request', item => { if (item.url().endsWith('/prompt_manager/logs/truncate')) truncates.push(item.postDataJSON()); });
|
||||
page.on('request', item => { if (item.url().endsWith('/prompt_manager/logs/truncate')) truncates.push(item.url()); });
|
||||
await openDiagnostics(page);
|
||||
await expect(lines(page)).toContainText('Database backup verified');
|
||||
const details = await openFiles(page);
|
||||
@@ -535,10 +535,6 @@ test('Clear all logs states both counts, confirms first and reads everything aga
|
||||
await dialog(page).getByRole('button', { name: 'Clear', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('Logs cleared · 3 rotated copies deleted.');
|
||||
expect(truncates).toHaveLength(1);
|
||||
// #389: Clear sends back exactly the files the dialog was built from, each with its identity string.
|
||||
expect(truncates[0].expected.map(row => row.name).sort()).toEqual(['prompt_manager.log', 'prompt_manager.log.1',
|
||||
'prompt_manager.log.2', 'prompt_manager.log.3']);
|
||||
for (const row of truncates[0].expected) expect(row.identity).toMatch(/^\d+:\d+:\d+$/);
|
||||
// Read again: the viewer, the file line and the list.
|
||||
await expect(lines(page)).not.toContainText('Database backup verified');
|
||||
await expect(details.locator(':scope > p.small').first()).toHaveText(/rotated copies: 0$/);
|
||||
@@ -568,23 +564,6 @@ test('Clear all logs reads the files again before it states what it deletes', as
|
||||
expect(await names(request)).toEqual(['prompt_manager.log', 'prompt_manager.log.1', 'prompt_manager.log.2', 'prompt_manager.log.3']);
|
||||
});
|
||||
|
||||
test('a rollover while Clear all logs is open deletes nothing and says why (#389)', async ({ page, request }) => {
|
||||
await seedLogs(request, { rotated: [1024, 1024], active: 100 });
|
||||
await openDiagnostics(page);
|
||||
await openFiles(page);
|
||||
await operation(page, 'Clear all logs').getByRole('button', { name: 'Clear all logs…', exact: true }).click();
|
||||
await expect(dialog(page).locator('#modal-body > p')).toHaveText(/^Empties prompt_manager\.log, deletes 2 rotated copies /);
|
||||
// The log rolls over after the confirmation was built: the copies it listed are not the ones there now.
|
||||
await expect(await request.post('/__test__/logs/rotate')).toBeOK();
|
||||
const rolled = await names(request);
|
||||
const answer = page.waitForResponse(response => response.url().endsWith('/prompt_manager/logs/truncate'));
|
||||
await dialog(page).getByRole('button', { name: 'Clear', exact: true }).click();
|
||||
expect((await answer).status()).toBe(409);
|
||||
await expect(toast(page)).toHaveText('The log files changed since you confirmed; nothing was cleared. '
|
||||
+ 'Read the list again and clear again.');
|
||||
expect(await names(request)).toEqual(rolled);
|
||||
});
|
||||
|
||||
test('each listed file downloads by its returned name and a link has no Download', async ({ page, request }) => {
|
||||
await seedLogs(request, LOG_FOLDER);
|
||||
await openDiagnostics(page);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {armUnseenRestores,unseenRestore} from './unseen-restore.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {pageInventoryOf,inventoryOf,diffInventory,mapVocabularyConcept,mapVocabularyDialog} from './vocabulary-canonical-comparison.mjs';
|
||||
|
||||
@@ -41,7 +40,7 @@ async function expectLibrary(page,{tags='',category='',count}) {
|
||||
|
||||
let manifest;
|
||||
test.beforeEach(async({page,request,browserName})=>{
|
||||
await startCoverage(page,browserName);await armUnseenRestores(page);manifest=await seed(request);
|
||||
await startCoverage(page,browserName);manifest=await seed(request);
|
||||
page.vocabularyErrors=[];page.on('pageerror',error=>page.vocabularyErrors.push(error.message));
|
||||
});
|
||||
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.vocabularyErrors).toEqual([]);});
|
||||
@@ -265,8 +264,7 @@ const toast=page=>page.locator('#workspace-toast');
|
||||
const actionNames=scope=>scope.locator('.dialog-actions button').evaluateAll(nodes=>nodes.map(node=>node.textContent.trim()));
|
||||
const snapshot=async request=>{const response=await request.get('/__test__/vocabulary/snapshot');await expect(response).toBeOK();return response.json();};
|
||||
const backup=async request=>{const response=await request.get('/__test__/vocabulary/backup');await expect(response).toBeOK();return response.body();};
|
||||
// A restore the open page has not seen, so its dialog stays to report it (#413).
|
||||
const restore=async(page,request,buffer)=>expect(await unseenRestore(page,()=>request.post('/prompt_manager/restore',{multipart:{database_file:{name:'vocabulary.db',mimeType:'application/octet-stream',buffer}}}))).toBeOK();
|
||||
const restore=async(request,buffer)=>expect(await request.post('/prompt_manager/restore',{multipart:{database_file:{name:'vocabulary.db',mimeType:'application/octet-stream',buffer}}})).toBeOK();
|
||||
const tagNames=rows=>rows.tags.map(([,name])=>name);
|
||||
function trackWrites(page) {
|
||||
const writes=[];page.on('request',item=>{if(item.method()!=='GET')writes.push({method:item.method(),url:new URL(item.url()).pathname,body:item.postDataJSON()});});return writes;
|
||||
@@ -484,7 +482,7 @@ test('conflict and restore states keep the dialog and write nothing',async({page
|
||||
|
||||
const before=await snapshot(request);
|
||||
box=await openDialog(page,main(page).getByRole('button',{name:'+ Create tag',exact:true}),'Create tag');
|
||||
await restore(page,request,saved);
|
||||
await restore(request,saved);
|
||||
await field(box,'Tag name').fill('sunrise');await button(box,'Apply').click();
|
||||
await expect(note(box)).toHaveText('Prompt storage was restored since this dialog opened. Nothing was changed.');
|
||||
await expect(button(box,'Apply')).toBeDisabled();
|
||||
@@ -624,7 +622,7 @@ test('delete confirmation read failure, missing tag and restore keep Delete disa
|
||||
await expect(button(box,'Delete')).toBeDisabled();await expect(tagRow(page,'calm').locator('td:nth-child(2)')).toHaveText('2');
|
||||
await button(box,'Cancel').click();
|
||||
|
||||
box=await openRename(page,'mist');await restore(page,request,saved);
|
||||
box=await openRename(page,'mist');await restore(request,saved);
|
||||
await button(box,'Delete tag…').click();
|
||||
await expect(note(box)).toHaveText('Prompt storage was restored since this dialog opened. Nothing was changed.');
|
||||
await expect(button(box,'Delete')).toBeDisabled();expect(await actionNames(box)).toEqual(['Reload','Cancel','Delete']);
|
||||
|
||||
@@ -123,34 +123,6 @@ def _touch(path: Path, size: int, moment: datetime.datetime) -> None:
|
||||
os.utime(path, (stamp, stamp))
|
||||
|
||||
|
||||
def _append_active(manager, log_dir: Path, size: int) -> None:
|
||||
"""Grow the active log as this process's handler does (#389).
|
||||
|
||||
A second handle appending to the file the handler writes is what Clear
|
||||
all logs refuses as another program writing to it, so the bytes go
|
||||
through the handler's own stream; with file logging off, to the file.
|
||||
"""
|
||||
|
||||
def same(path) -> bool:
|
||||
return os.path.normcase(os.path.realpath(path)) == os.path.normcase(
|
||||
os.path.realpath(log_dir / ACTIVE)
|
||||
)
|
||||
|
||||
for handler in manager._file_handlers():
|
||||
stream = getattr(handler, "stream", None)
|
||||
if stream is None or not same(handler.baseFilename):
|
||||
continue
|
||||
handler.acquire()
|
||||
try:
|
||||
stream.write("x" * size)
|
||||
stream.flush()
|
||||
finally:
|
||||
handler.release()
|
||||
return
|
||||
with open(log_dir / ACTIVE, "ab") as handle:
|
||||
handle.write(b"x" * size)
|
||||
|
||||
|
||||
def _bounded(value, name, top=MAX_SEED_BYTES):
|
||||
if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= top:
|
||||
raise web.HTTPBadRequest(text=f"{name} must be an integer 0..{top}")
|
||||
@@ -197,7 +169,8 @@ def register_diagnostics_commands(app, routes, api, root: Path):
|
||||
for index, (time_text, level, message) in enumerate(CONCEPT_RECORDS):
|
||||
record = _record(time_text, level, message, index)
|
||||
manager.add_to_buffer(record, record.getMessage())
|
||||
_append_active(manager, log_dir, CONCEPT_ACTIVE)
|
||||
with open(log_dir / ACTIVE, "ab") as handle:
|
||||
handle.write(b"x" * CONCEPT_ACTIVE)
|
||||
for number, size in enumerate(CONCEPT_ROTATED, start=1):
|
||||
moment = ROTATED_TIME - datetime.timedelta(hours=number)
|
||||
_touch(log_dir / f"{ACTIVE}.{number}", size, moment)
|
||||
@@ -221,7 +194,8 @@ def register_diagnostics_commands(app, routes, api, root: Path):
|
||||
manager.add_to_buffer(record, record.getMessage())
|
||||
active = _bounded(data.get("active", 0), "active")
|
||||
if active:
|
||||
_append_active(manager, log_dir, active)
|
||||
with open(log_dir / ACTIVE, "ab") as handle:
|
||||
handle.write(b"x" * active)
|
||||
rotated = data.get("rotated", [])
|
||||
if not isinstance(rotated, list) or len(rotated) > 20:
|
||||
raise web.HTTPBadRequest(text="rotated must be a list of sizes")
|
||||
|
||||
@@ -175,28 +175,6 @@ test('Clear all logs states both counts and its toast says what was done', async
|
||||
assert.equal(clearToast(undefined), 'Logs cleared · 0 rotated copies deleted.');
|
||||
});
|
||||
|
||||
test('Clear all logs sends back exactly the files its confirmation showed (#389)', async () => {
|
||||
const { clearBody, clearFailureText, COPY } = await diagnostics;
|
||||
const files = [
|
||||
{ filename: 'prompt_manager.log', kind: 'active', identity: '10:1761000000123456789:9007199254740993' },
|
||||
{ filename: 'prompt_manager.log.1', kind: 'rotated', identity: '20:1761000000123456789:42' },
|
||||
{ filename: 'prompt_manager.log.4', kind: 'link', identity: '5:1:7' },
|
||||
{ filename: 'prompt_manager.log.bak', kind: 'other', identity: '1:1:8' },
|
||||
{ filename: 'prompt_manager.log.2', kind: 'rotated' },
|
||||
];
|
||||
// Identities stay strings: mtime_ns and large inodes are not exact JS numbers.
|
||||
assert.deepEqual(clearBody(files), { expected: [
|
||||
{ name: 'prompt_manager.log', identity: '10:1761000000123456789:9007199254740993' },
|
||||
{ name: 'prompt_manager.log.1', identity: '20:1761000000123456789:42' },
|
||||
] });
|
||||
assert.deepEqual(clearBody([]), { expected: [] });
|
||||
assert.deepEqual(clearBody(null), {});
|
||||
const refused = Object.assign(new Error('The log files changed since you confirmed; nothing was cleared.'), { status: 409 });
|
||||
assert.equal(clearFailureText(refused), refused.message);
|
||||
assert.equal(clearFailureText(Object.assign(new Error('boom'), { status: 500 })), COPY.clearFailed);
|
||||
assert.equal(clearFailureText(undefined), COPY.clearFailed);
|
||||
});
|
||||
|
||||
test('the test-link dialog names the prompt and how Missing-file links removes the record', async () => {
|
||||
const { testLinkSentence, promptName } = await diagnostics;
|
||||
assert.equal(testLinkSentence({ id: 812, text: 'Misty harbor' }), 'Writes a placeholder image record '
|
||||
|
||||
@@ -415,74 +415,5 @@ class TestApply(LogCleanupTestCase):
|
||||
self.assertEqual(job.status_line, "The log cleanup failed.")
|
||||
|
||||
|
||||
TRUNCATE = "/prompt_manager/logs/truncate"
|
||||
FILES = "/prompt_manager/logs/files"
|
||||
|
||||
|
||||
class TestClearAllLogsConfirmed(LogCleanupTestCase):
|
||||
"""#389: POST /logs/truncate deletes no more than the confirmation listed."""
|
||||
|
||||
async def confirmed(self):
|
||||
response = await self.client.request("GET", FILES)
|
||||
data = await response.json()
|
||||
return [
|
||||
{"name": row["filename"], "identity": row["identity"]}
|
||||
for row in data["files"]
|
||||
if row["kind"] in ("active", "rotated")
|
||||
]
|
||||
|
||||
def handler(self):
|
||||
return next(
|
||||
h
|
||||
for h in self.manager.logger.handlers
|
||||
if isinstance(h, logging.FileHandler)
|
||||
)
|
||||
|
||||
async def test_a_rollover_after_the_confirmation_is_refused(self):
|
||||
self.write("prompt_manager.log.1", b"a" * 100)
|
||||
self.write("prompt_manager.log.2", b"b" * 200)
|
||||
expected = await self.confirmed()
|
||||
self.app_logger.info("written after the confirmation")
|
||||
self.handler().doRollover()
|
||||
before = self.snapshot()
|
||||
body = await self.post(TRUNCATE, {"expected": expected}, 409)
|
||||
self.assertEqual(body["success"], False)
|
||||
self.assertEqual(body["code"], "logs-changed")
|
||||
self.assertIn("nothing was cleared", body["error"])
|
||||
self.assertEqual(self.snapshot(), before)
|
||||
|
||||
async def test_the_confirmed_files_are_cleared(self):
|
||||
self.write("prompt_manager.log.1", b"a" * 100)
|
||||
expected = await self.confirmed()
|
||||
body = await self.post(TRUNCATE, {"expected": expected}, 200)
|
||||
self.assertEqual(
|
||||
sorted(body["results"]["truncated"]),
|
||||
["prompt_manager.log", "prompt_manager.log.1"],
|
||||
)
|
||||
self.assertEqual(self.names(), ["prompt_manager.log"])
|
||||
|
||||
async def test_the_legacy_call_without_a_body_still_clears(self):
|
||||
self.write("prompt_manager.log.1", b"a" * 100)
|
||||
response = await self.client.request("POST", TRUNCATE)
|
||||
self.assertEqual(response.status, 200, await response.text())
|
||||
self.assertEqual(self.names(), ["prompt_manager.log"])
|
||||
|
||||
async def test_a_foreign_writer_is_refused_with_a_clear_message(self):
|
||||
self.write("prompt_manager.log.1", b"a" * 100)
|
||||
with open(self.path("prompt_manager.log"), "ab") as other:
|
||||
other.write(b"line from another ComfyUI\n")
|
||||
body = await self.post(TRUNCATE, {"expected": await self.confirmed()}, 409)
|
||||
self.assertEqual(body["code"], "logs-foreign-writer")
|
||||
self.assertIn("Another program is writing", body["error"])
|
||||
self.assertIn("prompt_manager.log.1", self.names())
|
||||
|
||||
async def test_a_malformed_confirmation_is_400(self):
|
||||
self.write("prompt_manager.log.1", b"a" * 100)
|
||||
for expected in ("all", [{"name": "prompt_manager.log.1", "identity": 5}]):
|
||||
with self.subTest(expected=expected):
|
||||
await self.post(TRUNCATE, {"expected": expected}, 400)
|
||||
self.assertIn("prompt_manager.log.1", self.names())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1040,8 +1040,8 @@ class TestApplyRefusals(ReviewRoutesTestCase):
|
||||
_path, item, detail, observed, cat = await self._opened()
|
||||
real = review_capture_module.resolve_prompt_pair
|
||||
|
||||
def reworded(metadata, **kwargs):
|
||||
resolution = real(metadata, **kwargs)
|
||||
def reworded(metadata):
|
||||
resolution = real(metadata)
|
||||
return {
|
||||
**resolution,
|
||||
"reason": "a reworded reason from a newer resolver",
|
||||
@@ -1065,8 +1065,8 @@ class TestApplyRefusals(ReviewRoutesTestCase):
|
||||
_path, item, detail, observed, cat = await self._opened()
|
||||
real = review_capture_module.resolve_prompt_pair
|
||||
|
||||
def without_cat(metadata, **kwargs):
|
||||
resolution = real(metadata, **kwargs)
|
||||
def without_cat(metadata):
|
||||
resolution = real(metadata)
|
||||
kept = [
|
||||
c
|
||||
for c in resolution["candidates"]
|
||||
|
||||
@@ -298,21 +298,19 @@ class TestQwenEditBatchIsQueued(InspectionTestCase):
|
||||
|
||||
class TestDefaultProfileStillDecides(InspectionTestCase):
|
||||
async def test_ambiguous_file_with_texts_keeps_the_default_profile(self):
|
||||
"""A default-ambiguous file with a choice is recorded from the
|
||||
default profile, even when the inspection profile would offer more
|
||||
candidates. That profile only checks the saver anchor (#390): no
|
||||
saver names this file, so it anchors nothing."""
|
||||
"""A default-ambiguous file with a choice never runs the inspection
|
||||
profile, even when that profile would offer more candidates."""
|
||||
self._roots()
|
||||
graph = {
|
||||
**_prefixed(qwen_edit_graph("make it night"), "q"),
|
||||
**unlinkable_graph("cat", "dog"),
|
||||
}
|
||||
self._write("unsaved_00001_.png", graph=graph)
|
||||
self._write(graph=graph)
|
||||
|
||||
with patch.object(
|
||||
review_capture,
|
||||
"inspection_choice",
|
||||
side_effect=AssertionError("inspection choices were asked"),
|
||||
"resolve_inspection",
|
||||
side_effect=AssertionError("inspection profile ran"),
|
||||
):
|
||||
await self._scan()
|
||||
|
||||
@@ -323,24 +321,17 @@ class TestDefaultProfileStillDecides(InspectionTestCase):
|
||||
self.assertEqual(item["resolver_version"], review_capture.RESOLVER_VERSION)
|
||||
self.assertEqual(_texts(evidence), ["cat", "dog"])
|
||||
|
||||
async def test_apply_of_a_default_item_inspects_only_for_the_anchor(
|
||||
self,
|
||||
):
|
||||
async def test_apply_of_a_default_item_never_runs_the_inspection_profile(self):
|
||||
self._roots()
|
||||
write_png(self.output_dir / "two.png", prompt=unlinkable_graph("cat", "dog"))
|
||||
await self._scan()
|
||||
with (
|
||||
_counted(review_capture, "resolve_inspection") as calls,
|
||||
patch.object(
|
||||
review_capture,
|
||||
"inspection_choice",
|
||||
side_effect=AssertionError("inspection choices were asked"),
|
||||
),
|
||||
with patch.object(
|
||||
review_capture,
|
||||
"resolve_inspection",
|
||||
side_effect=AssertionError("inspection profile ran"),
|
||||
):
|
||||
_item, (status, data) = await self._apply("cat")
|
||||
self.assertEqual(status, 200, data)
|
||||
# The saver anchor check (#390): with the file's name, nothing matches.
|
||||
self.assertEqual([c[1] for c in calls], ["two.png"])
|
||||
self.assertEqual(self._linked_text(self.output_dir / "two.png"), "cat")
|
||||
|
||||
|
||||
@@ -378,16 +369,15 @@ class TestApplyInspectionCandidate(InspectionTestCase):
|
||||
calls = []
|
||||
real = review_capture.resolve_inspection
|
||||
|
||||
def spy(metadata, file_name=None):
|
||||
calls.append((metadata, file_name))
|
||||
return real(metadata, file_name)
|
||||
def spy(metadata):
|
||||
calls.append(metadata)
|
||||
return real(metadata)
|
||||
|
||||
with patch.object(review_capture, "resolve_inspection", side_effect=spy):
|
||||
_item, (status, data) = await self._apply("make it night")
|
||||
|
||||
self.assertEqual(status, 200, data)
|
||||
self.assertEqual(len(calls), 1)
|
||||
self.assertEqual(calls[0][1], NAME) # anchored on its saver (#390)
|
||||
|
||||
async def test_table_change_keeps_the_item_applicable(self):
|
||||
"""A table change for a class the graph uses moves the item's
|
||||
@@ -863,8 +853,8 @@ class TestInspectionResolverVersion(InspectionTestCase):
|
||||
version = _version()
|
||||
real = review_capture.resolve_inspection
|
||||
|
||||
def newer(metadata, file_name=None):
|
||||
resolution = real(metadata, file_name)
|
||||
def newer(metadata):
|
||||
resolution = real(metadata)
|
||||
return {
|
||||
**resolution,
|
||||
"candidates": [
|
||||
@@ -909,10 +899,10 @@ class TestScanContainment(InspectionTestCase):
|
||||
self._write("b.png", graph=qwen_edit_graph("snow", "rain"))
|
||||
real = review_capture.resolve_inspection
|
||||
|
||||
def fails_for_a(metadata, file_name=None):
|
||||
def fails_for_a(metadata):
|
||||
if "make it night" in json.dumps(metadata):
|
||||
raise RuntimeError("inspection blew up")
|
||||
return real(metadata, file_name)
|
||||
return real(metadata)
|
||||
|
||||
with (
|
||||
patch.object(review_capture, "resolve_inspection", side_effect=fails_for_a),
|
||||
|
||||
@@ -1,333 +0,0 @@
|
||||
"""The scan and Needs review anchor a file on the saver that wrote it (#390).
|
||||
|
||||
The workspace inspector (since #383) passes the file's name to the resolver,
|
||||
which anchors the file on the one saver whose ``filename_prefix`` names it
|
||||
(``_GraphResolver.saver_stages``). The scan and Needs review use the same
|
||||
rule. Maintainer ruling: a file never falls through both the link and the
|
||||
queue, so when only that anchor resolves a file the default profile and the
|
||||
parser floor do not link, it is queued with the anchor's pair as its one
|
||||
choice (never linked automatically). No match, several matches or an
|
||||
unfinished walk anchor nothing, so such a file is queued as before. Apply
|
||||
re-resolves with the file's name, so a choice the anchor contradicts is
|
||||
refused as stale evidence.
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
try:
|
||||
from tests.test_review import unlinkable_graph, write_png
|
||||
from tests.test_review_inspection import (
|
||||
PROMPTS,
|
||||
_counted,
|
||||
InspectionTestCase,
|
||||
qwen_edit_graph,
|
||||
)
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from test_review import unlinkable_graph, write_png
|
||||
from test_review_inspection import (
|
||||
PROMPTS,
|
||||
_counted,
|
||||
InspectionTestCase,
|
||||
qwen_edit_graph,
|
||||
)
|
||||
|
||||
from utils import review_capture
|
||||
|
||||
|
||||
def branch_graph(*prompts, prefixes=None):
|
||||
"""A Qwen-edit batch whose every SaveImage has its own literal prefix."""
|
||||
graph = qwen_edit_graph(*prompts)
|
||||
savers = [n for n in graph.values() if n["class_type"] == "SaveImage"]
|
||||
names = prefixes or [f"branch_{index}" for index in range(len(savers))]
|
||||
for saver, prefix in zip(savers, names):
|
||||
saver["inputs"]["filename_prefix"] = prefix
|
||||
return graph
|
||||
|
||||
|
||||
def saved_unlinkable_graph(*positives):
|
||||
"""``unlinkable_graph`` whose branches save under their own prefix.
|
||||
|
||||
The default profile finds it ambiguous with a choice and the scan's
|
||||
browser-parser floor links nothing, so it is queued from the default
|
||||
profile unless the saver that wrote the file anchors its branch.
|
||||
"""
|
||||
graph = unlinkable_graph(*positives)
|
||||
samplers = [
|
||||
node_id
|
||||
for node_id, node in graph.items()
|
||||
if node["class_type"] == "KSampler" and node_id != "3"
|
||||
]
|
||||
for index, sampler in enumerate(samplers):
|
||||
graph[f"{sampler}1"] = {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": [sampler, 0], "vae": ["1", 2]},
|
||||
}
|
||||
graph[f"{sampler}2"] = {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"images": [f"{sampler}1", 0],
|
||||
"filename_prefix": f"side_{index}",
|
||||
},
|
||||
}
|
||||
return graph
|
||||
|
||||
|
||||
def _without_name(real):
|
||||
"""``resolve_inspection`` as it was before #390: the file name ignored."""
|
||||
|
||||
def unnamed(metadata, file_name=None):
|
||||
return real(metadata)
|
||||
|
||||
return unnamed
|
||||
|
||||
|
||||
def _only_candidate(test, item):
|
||||
"""The positive text of an anchored item's one candidate."""
|
||||
evidence = json.loads(item["evidence"])
|
||||
test.assertEqual(evidence.get("anchor"), "saver")
|
||||
test.assertEqual(evidence.get("profile"), "inspection")
|
||||
(candidate,) = evidence["candidates"]
|
||||
return candidate["positive"]["text"]
|
||||
|
||||
|
||||
class TestInspectionItemsAnchor(InspectionTestCase):
|
||||
async def test_file_its_saver_names_is_queued_with_the_anchor_choice(self):
|
||||
self._roots()
|
||||
path = self._write("branch_1_00001_.png", graph=branch_graph(*PROMPTS))
|
||||
|
||||
job = await self._scan()
|
||||
|
||||
(item,) = self._items()
|
||||
self.assertEqual(_only_candidate(self, item), "add a red hat")
|
||||
self.assertEqual(item["candidate_count"], 1)
|
||||
self.assertEqual(
|
||||
item["resolver_version"],
|
||||
review_capture.inspection_review_version(
|
||||
{"prompt": branch_graph(*PROMPTS)}
|
||||
),
|
||||
)
|
||||
(row,) = job["outcomes"]["review"]
|
||||
self.assertEqual(row["path"], "branch_1_00001_.png")
|
||||
# Queued, never linked: ingestion stays on the default profile.
|
||||
self.assertIsNone(self._linked_text(path))
|
||||
|
||||
async def test_anchored_item_stays_on_rescan(self):
|
||||
self._roots()
|
||||
self._write("branch_1_00001_.png", graph=branch_graph(*PROMPTS))
|
||||
await self._scan()
|
||||
(item,) = self._items()
|
||||
|
||||
job = await self._scan("all")
|
||||
|
||||
self.assertEqual([i["id"] for i in self._items()], [item["id"]])
|
||||
self.assertEqual(job["scope"]["review_untraced"], 0)
|
||||
|
||||
async def test_file_no_saver_names_stays_queued(self):
|
||||
self._roots()
|
||||
self._write("elsewhere_00001_.png", graph=branch_graph(*PROMPTS))
|
||||
|
||||
await self._scan()
|
||||
|
||||
(item,) = self._items()
|
||||
self.assertEqual(item["candidate_count"], len(PROMPTS))
|
||||
self.assertNotIn("anchor", json.loads(item["evidence"]))
|
||||
|
||||
async def test_file_several_savers_name_stays_queued(self):
|
||||
self._roots()
|
||||
graph = branch_graph(*PROMPTS, prefixes=["same", "same", "other"])
|
||||
self._write("same_00001_.png", graph=graph)
|
||||
|
||||
await self._scan()
|
||||
|
||||
(item,) = self._items()
|
||||
self.assertEqual(item["candidate_count"], len(PROMPTS))
|
||||
|
||||
async def test_old_rule_item_is_replaced_by_the_anchor_choice(self):
|
||||
self._roots()
|
||||
self._write("branch_1_00001_.png", graph=branch_graph(*PROMPTS))
|
||||
real = review_capture.resolve_inspection
|
||||
with (
|
||||
patch.object(review_capture, "REVIEW_RULE_VERSION", 0),
|
||||
patch.object(
|
||||
review_capture, "resolve_inspection", side_effect=_without_name(real)
|
||||
),
|
||||
):
|
||||
await self._scan()
|
||||
(old,) = self._items() # queued under the old rule with every branch
|
||||
self.assertEqual(old["candidate_count"], len(PROMPTS))
|
||||
|
||||
await self._scan("all")
|
||||
|
||||
(item,) = self._items()
|
||||
self.assertNotEqual(item["id"], old["id"])
|
||||
self.assertEqual(_only_candidate(self, item), "add a red hat")
|
||||
|
||||
async def test_apply_links_the_anchor_choice(self):
|
||||
self._require_routes()
|
||||
self._roots()
|
||||
path = self._write("branch_1_00001_.png", graph=branch_graph(*PROMPTS))
|
||||
await self._scan()
|
||||
|
||||
_item, (status, data) = await self._apply("add a red hat")
|
||||
|
||||
self.assertEqual(status, 200, data)
|
||||
self.assertEqual(self._linked_text(path), "add a red hat")
|
||||
|
||||
async def test_apply_refuses_an_old_choice_the_anchor_contradicts(self):
|
||||
self._require_routes()
|
||||
self._roots()
|
||||
path = self._write("branch_1_00001_.png", graph=branch_graph(*PROMPTS))
|
||||
real = review_capture.resolve_inspection
|
||||
with (
|
||||
patch.object(review_capture, "REVIEW_RULE_VERSION", 0),
|
||||
patch.object(
|
||||
review_capture, "resolve_inspection", side_effect=_without_name(real)
|
||||
),
|
||||
):
|
||||
await self._scan()
|
||||
item, detail, observed = await self._open()
|
||||
# branch_1 is the saver of "add a red hat"; choose another branch.
|
||||
wrong = self._candidate(detail, "make it night")
|
||||
|
||||
status, data = await self._decide(
|
||||
item["id"], self._body(detail, observed, wrong["hash"])
|
||||
)
|
||||
|
||||
self.assertEqual(status, 409, data)
|
||||
self.assertEqual(data["code"], "review-conflict", data)
|
||||
self.assertIsNone(self._linked_text(path))
|
||||
|
||||
|
||||
class TestDefaultItemsAnchor(InspectionTestCase):
|
||||
async def test_anchor_resolves_and_nothing_links_so_it_is_queued(self):
|
||||
"""Maintainer ruling: never unlinked and unqueued. The anchor resolves
|
||||
the file, the default profile and the parser floor link nothing, so
|
||||
it is queued with the anchor's pair as its choice."""
|
||||
self._roots()
|
||||
path = self.output_dir / "side_0_00001_.png"
|
||||
graph = saved_unlinkable_graph("cat", "dog")
|
||||
write_png(path, prompt=graph)
|
||||
self.assertEqual(
|
||||
review_capture.resolve_prompt_pair({"prompt": graph})["state"], "ambiguous"
|
||||
)
|
||||
|
||||
job = await self._scan()
|
||||
|
||||
(item,) = self._items()
|
||||
self.assertEqual(_only_candidate(self, item), "cat")
|
||||
(row,) = job["outcomes"]["review"]
|
||||
self.assertIn("more than one prompt could match", row["detail"])
|
||||
self.assertIsNone(self._linked_text(path))
|
||||
|
||||
async def test_default_ambiguous_file_no_saver_names_stays_queued(self):
|
||||
self._roots()
|
||||
write_png(
|
||||
self.output_dir / "unsaved_00001_.png",
|
||||
prompt=saved_unlinkable_graph("cat", "dog"),
|
||||
)
|
||||
|
||||
await self._scan()
|
||||
|
||||
(item,) = self._items()
|
||||
evidence = json.loads(item["evidence"])
|
||||
self.assertNotIn("profile", evidence)
|
||||
self.assertNotIn("anchor", evidence)
|
||||
self.assertEqual(item["resolver_version"], review_capture.RESOLVER_VERSION)
|
||||
|
||||
async def test_file_the_parser_floor_links_is_not_queued(self):
|
||||
"""Already linked by the default path: unchanged by the anchor."""
|
||||
self._roots()
|
||||
graph = saved_unlinkable_graph("cat", "dog")
|
||||
del graph["3"] # the parser floor now reads a real sampler
|
||||
path = self.output_dir / "side_0_00001_.png"
|
||||
write_png(path, prompt=graph)
|
||||
|
||||
await self._scan()
|
||||
|
||||
self.assertEqual(self._items(), [])
|
||||
self.assertIsNotNone(self._linked_text(path))
|
||||
|
||||
async def test_apply_refuses_an_old_default_choice_the_anchor_contradicts(self):
|
||||
self._require_routes()
|
||||
self._roots()
|
||||
path = self.output_dir / "side_0_00001_.png"
|
||||
write_png(path, prompt=saved_unlinkable_graph("cat", "dog"))
|
||||
with patch.object(review_capture, "_captured_inspection", return_value=None):
|
||||
await self._scan() # queued from the default profile, as before #390
|
||||
item, detail, observed = await self._open()
|
||||
# side_0 is the saver of "cat"; choose the other branch.
|
||||
wrong = self._candidate(detail, "dog")
|
||||
|
||||
status, data = await self._decide(
|
||||
item["id"], self._body(detail, observed, wrong["hash"])
|
||||
)
|
||||
|
||||
self.assertEqual(status, 409, data)
|
||||
self.assertEqual(data["code"], "review-conflict", data)
|
||||
self.assertIsNone(self._linked_text(path))
|
||||
|
||||
async def test_old_default_item_is_replaced_by_the_anchor_choice(self):
|
||||
self._roots()
|
||||
write_png(
|
||||
self.output_dir / "side_0_00001_.png",
|
||||
prompt=saved_unlinkable_graph("cat", "dog"),
|
||||
)
|
||||
with patch.object(review_capture, "_captured_inspection", return_value=None):
|
||||
await self._scan() # queued from the default profile, as before #390
|
||||
(old,) = self._items()
|
||||
self.assertEqual(old["resolver_version"], review_capture.RESOLVER_VERSION)
|
||||
|
||||
await self._scan("all")
|
||||
|
||||
(item,) = self._items()
|
||||
self.assertNotEqual(item["id"], old["id"])
|
||||
self.assertEqual(_only_candidate(self, item), "cat")
|
||||
|
||||
with _counted(review_capture, "resolve_inspection") as calls:
|
||||
await self._scan("all") # now queued under its version: no re-read
|
||||
self.assertEqual(calls, [])
|
||||
self.assertEqual([i["id"] for i in self._items()], [item["id"]])
|
||||
|
||||
async def test_decided_anchored_item_is_kept(self):
|
||||
self._require_routes()
|
||||
self._roots()
|
||||
write_png(
|
||||
self.output_dir / "side_0_00001_.png",
|
||||
prompt=saved_unlinkable_graph("cat", "dog"),
|
||||
)
|
||||
await self._scan()
|
||||
item, detail, observed = await self._open()
|
||||
status, data = await self._decide(
|
||||
item["id"], self._body(detail, observed, "unresolved")
|
||||
)
|
||||
self.assertEqual(status, 200, data)
|
||||
before = (self._items(), self._decisions())
|
||||
|
||||
await self._scan("all")
|
||||
|
||||
self.assertEqual((self._items(), self._decisions()), before)
|
||||
|
||||
async def test_apply_accepts_an_old_default_choice_the_anchor_names(self):
|
||||
self._require_routes()
|
||||
self._roots()
|
||||
path = self.output_dir / "side_0_00001_.png"
|
||||
write_png(path, prompt=saved_unlinkable_graph("cat", "dog"))
|
||||
with patch.object(review_capture, "_captured_inspection", return_value=None):
|
||||
await self._scan()
|
||||
|
||||
_item, (status, data) = await self._apply("cat")
|
||||
|
||||
self.assertEqual(status, 200, data)
|
||||
self.assertEqual(self._linked_text(path), "cat")
|
||||
|
||||
|
||||
class TestSaverAnchored(InspectionTestCase):
|
||||
def test_only_the_anchor_counts(self):
|
||||
anchored = {"prompt": saved_unlinkable_graph("cat", "dog")}
|
||||
self.assertTrue(review_capture.saver_anchored(anchored, "side_1_00001_.png"))
|
||||
self.assertFalse(review_capture.saver_anchored(anchored, "nope_00001_.png"))
|
||||
self.assertFalse(review_capture.saver_anchored(anchored, None))
|
||||
# One sampler resolves without any name: the anchor decides nothing.
|
||||
single = {"prompt": branch_graph("make it night")}
|
||||
self.assertFalse(review_capture.saver_anchored(single, "branch_0_00001_.png"))
|
||||
@@ -709,8 +709,7 @@ class TestRotatedCopyRule(IsolatedLoggerTestCase):
|
||||
self.assertNotEqual(link["size"], os.path.getsize(target))
|
||||
for row in rows.values():
|
||||
self.assertEqual(
|
||||
set(row),
|
||||
{"filename", "path", "size", "modified", "is_main", "kind", "identity"},
|
||||
set(row), {"filename", "path", "size", "modified", "is_main", "kind"}
|
||||
)
|
||||
|
||||
def test_an_unreadable_timestamp_does_not_break_the_scan(self):
|
||||
@@ -764,217 +763,6 @@ class TestRotatedCopyRule(IsolatedLoggerTestCase):
|
||||
self.manager.log_file_entry(name)
|
||||
|
||||
|
||||
class TestClearOnlyWhatWasConfirmed(IsolatedLoggerTestCase):
|
||||
"""#389: Clear all logs empties or deletes no more than its confirmation showed."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.logger.info("active log line") # the active log exists
|
||||
self.outside = os.path.join(self.tmp.name, "outside")
|
||||
os.makedirs(self.outside)
|
||||
|
||||
def _path(self, name):
|
||||
return os.path.join(self.log_dir, name)
|
||||
|
||||
def _write(self, name, data=b"rotated\n"):
|
||||
with open(self._path(name), "wb") as handle:
|
||||
handle.write(data)
|
||||
|
||||
def _read(self, path):
|
||||
with open(path, "rb") as handle:
|
||||
return handle.read()
|
||||
|
||||
def _snapshot(self):
|
||||
return {name: self._read(self._path(name)) for name in os.listdir(self.log_dir)}
|
||||
|
||||
def _assert_unchanged(self, before, growing="prompt_manager.log"):
|
||||
"""Nothing deleted or emptied (the refusal itself logs to ``growing``)."""
|
||||
after = self._snapshot()
|
||||
self.assertEqual(sorted(after), sorted(before))
|
||||
for name, data in before.items():
|
||||
if name == growing:
|
||||
self.assertTrue(after[name].startswith(data), name)
|
||||
else:
|
||||
self.assertEqual(after[name], data, name)
|
||||
|
||||
def _confirmed(self):
|
||||
"""What the workspace dialog showed: the listed active and rotated rows."""
|
||||
return [
|
||||
{"name": row["filename"], "identity": row["identity"]}
|
||||
for row in self.manager.get_log_files()
|
||||
if row["kind"] in ("active", "rotated")
|
||||
]
|
||||
|
||||
def _file_handler(self):
|
||||
return next(
|
||||
h
|
||||
for h in self.manager.logger.handlers
|
||||
if isinstance(h, logging.FileHandler)
|
||||
)
|
||||
|
||||
def test_listed_identity_is_a_string_that_survives_json_numbers(self):
|
||||
# mtime_ns (~1.7e18) and many inodes exceed 2**53: as JSON numbers a
|
||||
# browser would round them and every confirmed clear would be refused.
|
||||
self._write("prompt_manager.log.1")
|
||||
for row in self.manager.get_log_files():
|
||||
with self.subTest(name=row["filename"]):
|
||||
self.assertIsInstance(row["identity"], str)
|
||||
self.assertRegex(row["identity"], r"^\d+:-?\d+:\d+$")
|
||||
|
||||
def test_a_copy_dated_before_1970_can_be_confirmed(self):
|
||||
self._write("prompt_manager.log.1")
|
||||
try:
|
||||
os.utime(self._path("prompt_manager.log.1"), ns=(0, -(10**9)))
|
||||
except (OSError, OverflowError, ValueError) as error:
|
||||
self.skipTest(f"this filesystem cannot store a time before 1970: {error}")
|
||||
confirmed = self._confirmed()
|
||||
if not any(":-" in row["identity"] for row in confirmed):
|
||||
self.skipTest("the filesystem did not keep the negative time")
|
||||
result = self.manager.truncate_logs(confirmed)
|
||||
self.assertIn("prompt_manager.log.1", result["truncated"])
|
||||
|
||||
def test_a_rollover_after_the_confirmation_deletes_nothing(self):
|
||||
self._write("prompt_manager.log.1", b"one" * 10)
|
||||
self._write("prompt_manager.log.2", b"two" * 10)
|
||||
confirmed = self._confirmed()
|
||||
self.logger.info("written after the confirmation")
|
||||
self._file_handler().doRollover() # .2 -> .3, .1 -> .2, log -> .1
|
||||
before = self._snapshot()
|
||||
with self.assertRaises(logging_config.ClearRefused) as caught:
|
||||
self.manager.truncate_logs(confirmed)
|
||||
self.assertEqual(caught.exception.code, "logs-changed")
|
||||
self._assert_unchanged(before)
|
||||
|
||||
def test_a_copy_that_appeared_after_the_confirmation_is_kept(self):
|
||||
self._write("prompt_manager.log.1", b"one")
|
||||
confirmed = self._confirmed()
|
||||
self._write("prompt_manager.log.5", b"not shown")
|
||||
result = self.manager.truncate_logs(confirmed)
|
||||
self.assertEqual(result["errors"], [])
|
||||
self.assertEqual(
|
||||
sorted(result["truncated"]), ["prompt_manager.log", "prompt_manager.log.1"]
|
||||
)
|
||||
self.assertEqual(
|
||||
sorted(os.listdir(self.log_dir)),
|
||||
["prompt_manager.log", "prompt_manager.log.5"],
|
||||
)
|
||||
|
||||
def _clear(self, confirmed=None):
|
||||
"""Run a clear and return what it raised (None), so the files are
|
||||
asserted first: on the old code the test fails on lost bytes."""
|
||||
try:
|
||||
if confirmed is None:
|
||||
self.manager.truncate_logs() # the legacy page's call
|
||||
else:
|
||||
self.manager.truncate_logs(confirmed)
|
||||
except Exception as error: # noqa: BLE001 - asserted below
|
||||
return error
|
||||
return None
|
||||
|
||||
def test_a_foreign_append_to_the_active_log_is_refused(self):
|
||||
# A second ComfyUI sharing the folder writes the same active log.
|
||||
self._write("prompt_manager.log.1")
|
||||
listings = (None, "listed")
|
||||
for listing in listings:
|
||||
with self.subTest(listed=listing is not None):
|
||||
confirmed = self._confirmed() if listing else None
|
||||
with open(self._path("prompt_manager.log"), "ab") as other:
|
||||
other.write(b"line from another process\n")
|
||||
before = self._snapshot()
|
||||
error = self._clear(confirmed)
|
||||
self._assert_unchanged(before)
|
||||
self.assertEqual(getattr(error, "code", None), "logs-foreign-writer")
|
||||
self.logger.info("our own line") # our stream catches up again
|
||||
|
||||
@unittest.skipIf(os.name == "nt", "Windows cannot rename a log that is open")
|
||||
def test_a_foreign_rotation_of_our_log_is_refused(self):
|
||||
# Another process rolled the shared log over: our handler still writes
|
||||
# the file now named .1, and a new active log is the other's.
|
||||
os.rename(self._path("prompt_manager.log"), self._path("prompt_manager.log.1"))
|
||||
self._write("prompt_manager.log", b"the other process's log\n")
|
||||
before = self._snapshot()
|
||||
with self.assertRaises(logging_config.ClearRefused) as caught:
|
||||
self.manager.truncate_logs()
|
||||
self.assertEqual(caught.exception.code, "logs-foreign-writer")
|
||||
# Our handler still writes the renamed file, so the refusal lands there.
|
||||
self._assert_unchanged(before, growing="prompt_manager.log.1")
|
||||
|
||||
def test_a_copy_that_changes_mid_clear_stops_the_clear(self):
|
||||
# The identity is checked again just before each unlink: another
|
||||
# process rotating while we delete stops the clear there.
|
||||
for n in (1, 2):
|
||||
self._write(f"prompt_manager.log.{n}", b"r" * n)
|
||||
confirmed = self._confirmed()
|
||||
real_unlink = os.unlink
|
||||
calls = []
|
||||
|
||||
def unlink_then_rotate(path, *args, **kwargs):
|
||||
real_unlink(path, *args, **kwargs)
|
||||
if not calls:
|
||||
with open(self._path("prompt_manager.log.2"), "ab") as handle:
|
||||
handle.write(b"rotated in by another process")
|
||||
calls.append(os.path.basename(path))
|
||||
|
||||
active_before = self._read(self._path("prompt_manager.log"))
|
||||
with mock.patch.object(logging_config.os, "unlink", unlink_then_rotate):
|
||||
result = self.manager.truncate_logs(confirmed)
|
||||
self.assertEqual(calls, ["prompt_manager.log.1"])
|
||||
self.assertEqual(result["truncated"], ["prompt_manager.log.1"])
|
||||
self.assertEqual(len(result["errors"]), 1)
|
||||
self.assertIn("prompt_manager.log.2", result["errors"][0])
|
||||
self.assertTrue(os.path.exists(self._path("prompt_manager.log.2")))
|
||||
# The active log is emptied last, so it is kept too.
|
||||
self.assertTrue(
|
||||
self._read(self._path("prompt_manager.log")).startswith(active_before)
|
||||
)
|
||||
|
||||
def _require_hard_link(self, source, link):
|
||||
try:
|
||||
os.link(source, link)
|
||||
except (OSError, NotImplementedError, AttributeError) as error:
|
||||
self.skipTest(f"this filesystem cannot make hard links: {error}")
|
||||
|
||||
def test_a_hard_linked_active_log_is_never_emptied(self):
|
||||
other_name = os.path.join(self.outside, "kept-by-the-user.log")
|
||||
self._require_hard_link(self._path("prompt_manager.log"), other_name)
|
||||
kept = self._read(other_name)
|
||||
self.assertTrue(kept)
|
||||
for listed in (False, True):
|
||||
with self.subTest(listed=listed):
|
||||
error = self._clear(self._confirmed() if listed else None)
|
||||
self.assertTrue(self._read(other_name).startswith(kept))
|
||||
self.assertEqual(getattr(error, "code", None), "logs-hard-link")
|
||||
|
||||
def test_a_hard_linked_active_log_with_file_logging_off_is_kept(self):
|
||||
self.manager.update_config({"file_logging": False})
|
||||
other_name = os.path.join(self.outside, "kept-by-the-user.log")
|
||||
self._require_hard_link(self._path("prompt_manager.log"), other_name)
|
||||
kept = self._read(other_name)
|
||||
error = self._clear()
|
||||
self.assertEqual(self._read(other_name), kept)
|
||||
self.assertEqual(getattr(error, "code", None), "logs-hard-link")
|
||||
|
||||
def test_a_malformed_confirmation_is_refused_before_anything_changes(self):
|
||||
self._write("prompt_manager.log.1")
|
||||
before = self._snapshot()
|
||||
for expected in (
|
||||
"prompt_manager.log.1",
|
||||
[{"name": "prompt_manager.log.1"}],
|
||||
[{"name": "prompt_manager.log.1", "identity": 7}],
|
||||
[{"name": "prompt_manager.log.1", "identity": "1:2"}],
|
||||
[{"name": 1, "identity": "1:2:3"}],
|
||||
[
|
||||
{"name": "prompt_manager.log.1", "identity": "1:2:3"},
|
||||
{"name": "prompt_manager.log.1", "identity": "1:2:3"},
|
||||
],
|
||||
["prompt_manager.log.1"],
|
||||
):
|
||||
with self.subTest(expected=expected):
|
||||
with self.assertRaises(ValueError):
|
||||
self.manager.truncate_logs(expected)
|
||||
self._assert_unchanged(before)
|
||||
|
||||
|
||||
class TestConfiguration(IsolatedLoggerTestCase):
|
||||
def test_in_flight_record_cannot_reopen_retired_file_handler(self) -> None:
|
||||
handler = next(
|
||||
|
||||
Vendored
+25
-120
@@ -67,45 +67,6 @@ const browserJobIds = [...allJobs].filter(([, body]) => /npm run --prefix tools\
|
||||
const mergeJob = () => allJobs.get('frontend-coverage-merge');
|
||||
const verifyStep = () => steps(mergeJob()).find(step => /name: Verify every shard's raw coverage arrived/.test(step));
|
||||
|
||||
// Jobs that run only in the merge queue, on main and on demand (#420): skipped on pull_request.
|
||||
const QUEUE_ONLY_IF = /^ {4}if: github\.event_name != 'pull_request'$/m;
|
||||
const EVENTS = ['pull_request', 'merge_group', 'push', 'workflow_dispatch'];
|
||||
|
||||
function gateVariables(jobText) {
|
||||
return new Map([...jobText.matchAll(/^ {10}([A-Z_]+): \$\{\{ needs\.([\w-]+)\.result \}\}$/gm)]
|
||||
.map(match => [match[2], match[1]]));
|
||||
}
|
||||
|
||||
function runGate(jobText, env) {
|
||||
const script = runScript(steps(jobText).find(step => /run: \|/.test(step)));
|
||||
try {
|
||||
execFileSync('bash', ['--noprofile', '--norc', '-eo', 'pipefail', '-c', script], { env: { PATH: process.env.PATH, ...env }, stdio: 'pipe' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// A gate passes only when every always-on job succeeded and every queue-only job is skipped on a
|
||||
// pull request and succeeded on every other event. Anything else fails it.
|
||||
function assertGateBehaviour(jobText, queueOnly) {
|
||||
assert.match(jobText, /^ {10}EVENT_NAME: \$\{\{ github\.event_name \}\}$/m, 'the gate knows the event');
|
||||
const variables = gateVariables(jobText);
|
||||
for (const id of queueOnly) assert.ok(variables.has(id), `${id} reaches the gate`);
|
||||
for (const event of EVENTS) {
|
||||
const expected = id => (queueOnly.includes(id) && event === 'pull_request' ? 'skipped' : 'success');
|
||||
const green = Object.fromEntries([...variables].map(([id, name]) => [name, expected(id)]));
|
||||
assert.equal(runGate(jobText, { ...green, EVENT_NAME: event }), true, `${event}: the expected results pass`);
|
||||
for (const [id, name] of variables) {
|
||||
for (const result of ['success', 'failure', 'cancelled', 'skipped']) {
|
||||
if (result === expected(id)) continue;
|
||||
assert.equal(runGate(jobText, { ...green, EVENT_NAME: event, [name]: result }), false,
|
||||
`${event}: ${id}=${result} must fail the gate`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('viewerjs keeps its name and needs every frontend job', () => {
|
||||
const viewerjs = allJobs.get('viewerjs');
|
||||
assert.ok(viewerjs, 'the required context is the job id viewerjs');
|
||||
@@ -115,17 +76,32 @@ test('viewerjs keeps its name and needs every frontend job', () => {
|
||||
assert.deepEqual([...needs].sort(), [...others].sort(), 'viewerjs needs every other job in the workflow');
|
||||
assert.deepEqual([...needs].sort(), ['browser-workflows', 'frontend-coverage', 'frontend-coverage-merge']);
|
||||
assert.match(viewerjs, /^ {4}if: always\(\)$/m, 'viewerjs runs (and fails) when a needed job failed or was skipped');
|
||||
assert.deepEqual([...gateVariables(viewerjs).keys()].sort(), [...needs].sort(), 'every needed result reaches the check');
|
||||
const script = runScript(steps(viewerjs).find(step => /run: \|/.test(step)));
|
||||
for (const id of needs) {
|
||||
const variable = new RegExp(`^ {10}([A-Z_]+): \\$\\{\\{ needs\\.${id}\\.result \\}\\}$`, 'm').exec(viewerjs);
|
||||
assert.ok(variable, `${id}'s result reaches the check`);
|
||||
assert.match(script, new RegExp(`test "\\$${variable[1]}" = success`), `${id} must be exactly success`);
|
||||
}
|
||||
});
|
||||
|
||||
test('viewerjs requires the browser shards everywhere but on a pull request', () => {
|
||||
assertGateBehaviour(allJobs.get('viewerjs'), ['browser-workflows']);
|
||||
});
|
||||
|
||||
test('Firefox/WebKit page workflows run only outside pull requests; coverage runs on every event', () => {
|
||||
assert.match(allJobs.get('browser-workflows'), QUEUE_ONLY_IF);
|
||||
for (const id of ['frontend-coverage', 'frontend-coverage-merge']) {
|
||||
assert.doesNotMatch(allJobs.get(id), /^ {4}if:/m, `${id} runs on every event`);
|
||||
test('viewerjs fails unless every needed job succeeded', () => {
|
||||
const viewerjs = allJobs.get('viewerjs');
|
||||
const script = runScript(steps(viewerjs).find(step => /run: \|/.test(step)));
|
||||
const variables = [...viewerjs.matchAll(/^ {10}([A-Z_]+): \$\{\{ needs\.[\w-]+\.result \}\}$/gm)].map(match => match[1]);
|
||||
const run = env => {
|
||||
try {
|
||||
execFileSync('bash', ['--noprofile', '--norc', '-eo', 'pipefail', '-c', script], { env: { PATH: process.env.PATH, ...env }, stdio: 'pipe' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const allSuccess = Object.fromEntries(variables.map(name => [name, 'success']));
|
||||
assert.equal(run(allSuccess), true);
|
||||
for (const name of variables) {
|
||||
for (const result of ['failure', 'cancelled', 'skipped']) {
|
||||
assert.equal(run({ ...allSuccess, [name]: result }), false, `${name}=${result} must fail viewerjs`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -248,8 +224,7 @@ test('browser job timeouts keep a real margin over measured runs', () => {
|
||||
});
|
||||
|
||||
// Every job is bounded (#350): a hang ends at a measured limit instead of GitHub's 6-hour default.
|
||||
const GATING_WORKFLOWS = ['test.yml', 'code-quality.yml', 'viewerjs-check.yml'];
|
||||
const BOUNDED_WORKFLOWS = [...GATING_WORKFLOWS, 'portability.yml'];
|
||||
const BOUNDED_WORKFLOWS = ['test.yml', 'code-quality.yml', 'viewerjs-check.yml'];
|
||||
const readWorkflow = name => readFileSync(new URL(`../../.github/workflows/${name}`, import.meta.url), 'utf8');
|
||||
|
||||
test('every job in the CI workflows has a numeric timeout-minutes', () => {
|
||||
@@ -301,73 +276,3 @@ test('the bandit pre-commit hook fails at the severity CI enforces and passes ev
|
||||
assert.doesNotMatch(hook, /true|exit-zero|bash -c/, 'nothing masks the exit code or drops a file');
|
||||
assert.doesNotMatch(hook, /pass_filenames: false/, 'the hook scans the staged files');
|
||||
});
|
||||
|
||||
// Event split (#420): pull requests run a fast subset, the merge queue and main run everything.
|
||||
const testJobs = () => jobs(readWorkflow('test.yml'));
|
||||
|
||||
test('the Python matrix is Linux 3.12 on a pull request and Linux 3.12-3.14 on every other event', () => {
|
||||
const body = testJobs().get('test');
|
||||
assert.deepEqual(matrixList(body, 'os'), ['ubuntu-latest'], 'Windows and macOS never gate a merge');
|
||||
const match = /^ {8}python-version: \$\{\{ github\.event_name == 'pull_request' && fromJSON\('(\[[^\]]*\])'\) \|\| fromJSON\('(\[[^\]]*\])'\) \}\}$/m.exec(body);
|
||||
assert.ok(match, 'python-version is chosen by event');
|
||||
assert.deepEqual({ pr: JSON.parse(match[1]), other: JSON.parse(match[2]) }, { pr: ['3.12'], other: ['3.12', '3.13', '3.14'] });
|
||||
assert.match(body, /^ {6}fail-fast: false$/m);
|
||||
assert.match(body, /^ {4}name: py\$\{\{ matrix\.python-version \}\} on \$\{\{ matrix\.os \}\}$/m);
|
||||
});
|
||||
|
||||
test('Windows and macOS run 3.12-3.14 on main, nightly and on demand, never on a PR or in the queue', () => {
|
||||
const text = readWorkflow('portability.yml');
|
||||
const triggers = text.slice(text.indexOf('\non:'), text.indexOf('\nconcurrency:'));
|
||||
for (const event of ['push', 'schedule', 'workflow_dispatch']) assert.match(triggers, new RegExp(`^ {2}${event}:`, 'm'));
|
||||
for (const event of ['pull_request', 'merge_group']) assert.doesNotMatch(triggers, new RegExp(`^ {2}${event}`, 'm'));
|
||||
assert.match(triggers, /^ {4}branches: \[main\]$/m, 'push runs only for main');
|
||||
const body = jobs(text).get('test');
|
||||
assert.deepEqual(matrixList(body, 'os'), ['windows-latest', 'macos-latest']);
|
||||
assert.deepEqual(matrixList(body, 'python-version'), ['"3.12"', '"3.13"', '"3.14"']);
|
||||
assert.match(body, /^ {6}fail-fast: false$/m);
|
||||
assert.match(body, /^ {8}run: python -m tests\.run_suite -v$/m);
|
||||
assert.doesNotMatch(text, /cancel-in-progress: true|cancel-in-progress: \$/, 'no portability run is cancelled');
|
||||
});
|
||||
|
||||
test('real ComfyUI runs only outside pull requests; the other test.yml checks run on every event', () => {
|
||||
const found = testJobs();
|
||||
assert.match(found.get('comfyui'), QUEUE_ONLY_IF);
|
||||
for (const id of ['test', 'coverage', 'lint', 'js-test']) {
|
||||
assert.doesNotMatch(found.get(id), /^ {4}if:/m, `${id} runs on every event`);
|
||||
}
|
||||
});
|
||||
|
||||
test('all tests needs every gating test.yml job and requires the full set outside pull requests', () => {
|
||||
const found = testJobs();
|
||||
const gate = found.get('all-tests');
|
||||
assert.ok(gate, 'the aggregate gate exists');
|
||||
assert.match(gate, /^ {4}name: all tests$/m, 'the required context is "all tests"');
|
||||
assert.match(gate, /^ {4}if: always\(\)$/m);
|
||||
const gating = [...found.keys()].filter(id => !['all-tests', 'perf-budget'].includes(id));
|
||||
assert.deepEqual([...needsOf(gate)].sort(), gating.sort(), 'the gate needs every job but the non-gating perf budget');
|
||||
assert.deepEqual([...gateVariables(gate).keys()].sort(), gating.sort(), 'every needed result reaches the gate');
|
||||
assertGateBehaviour(gate, ['comfyui']);
|
||||
});
|
||||
|
||||
test('the CI workflows run on pull_request, merge_group and push without path filters', () => {
|
||||
for (const name of GATING_WORKFLOWS) {
|
||||
const text = readWorkflow(name);
|
||||
const triggers = text.slice(text.indexOf('\non:'), text.indexOf('\n', text.indexOf('\non:') + 1) + 400);
|
||||
for (const event of ['merge_group', 'pull_request', 'push']) {
|
||||
assert.match(triggers, new RegExp(`^ {2}${event}:`, 'm'), `${name} runs on ${event}`);
|
||||
}
|
||||
assert.doesNotMatch(text, /^ {4}paths(-ignore)?:/m, `${name} has no path filter that would leave a required check pending`);
|
||||
}
|
||||
assert.doesNotMatch(jobs(readWorkflow('code-quality.yml')).get('security'), /^ {4}if:/m, 'bandit runs on every event');
|
||||
});
|
||||
|
||||
test('every required check reports on a pull request and the gates carry the full matrix', () => {
|
||||
const contexts = JSON.parse(readRepoFile('.github/branch-protection.json')).required_status_checks.contexts;
|
||||
assert.deepEqual([...contexts].sort(), [
|
||||
'all tests', 'bandit (high severity)', 'black and flake8', 'coverage gate (85%)', 'frontend modules',
|
||||
'py3.12 on ubuntu-latest', 'real ComfyUI compatibility', 'viewerjs',
|
||||
].sort());
|
||||
assert.equal(contexts.filter(context => /^py/.test(context)).length, 1,
|
||||
'a dropped matrix leg never reports on a pull request, so it cannot be required');
|
||||
assert.ok(!contexts.some(context => /windows|macos/.test(context)), 'Windows and macOS never gate a merge');
|
||||
});
|
||||
|
||||
+53
-239
@@ -39,35 +39,6 @@ class RotatedChanged(Exception):
|
||||
self.name = name
|
||||
|
||||
|
||||
# Clear all logs (#389): why it changed nothing, as the API and the pages say it.
|
||||
CLEAR_CHANGED = (
|
||||
"The log files changed since you confirmed; nothing was cleared."
|
||||
" Read the list again and clear again."
|
||||
)
|
||||
CLEAR_FOREIGN_WRITER = (
|
||||
"Another program is writing to prompt_manager.log, such as a second ComfyUI"
|
||||
" sharing this log folder; nothing was cleared. Stop it and clear again."
|
||||
)
|
||||
CLEAR_HARD_LINK = (
|
||||
"prompt_manager.log has another name (a hard link); emptying it would empty"
|
||||
" that file too, so nothing was cleared. Remove the other name, then clear"
|
||||
" again."
|
||||
)
|
||||
# A confirmed row's identity: "<size>:<mtime_ns>:<ino>", a string because
|
||||
# mtime_ns and many inodes are larger than a browser's exact integers.
|
||||
# mtime_ns is negative for a time before 1970 (a restored archive, say).
|
||||
_IDENTITY = re.compile(r"([0-9]{1,20}):(-?[0-9]{1,20}):([0-9]{1,40})")
|
||||
MAX_CONFIRMED_ROWS = 10000
|
||||
|
||||
|
||||
class ClearRefused(Exception):
|
||||
"""Clear all logs changed nothing; ``code`` names why, the message says it."""
|
||||
|
||||
def __init__(self, code: str, message: str):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
def _name_key(name: str) -> str:
|
||||
"""How a name compares: case-folded where the filesystem folds it (Windows)."""
|
||||
return os.path.normcase(name)
|
||||
@@ -116,37 +87,35 @@ def scan_log_folder(log_dir) -> List[Dict[str, Any]]:
|
||||
be read is left out. Never logs: the cleanup calls it while it holds the
|
||||
file handler's lock.
|
||||
"""
|
||||
rows = []
|
||||
try:
|
||||
names = [entry.name for entry in os.scandir(log_dir)]
|
||||
except OSError:
|
||||
return []
|
||||
rows = (scan_log_entry(log_dir, name) for name in names)
|
||||
return [row for row in rows if row is not None]
|
||||
|
||||
|
||||
def scan_log_entry(log_dir, name) -> Optional[Dict[str, Any]]:
|
||||
"""One entry's scan row (``lstat``), or None when it cannot be read."""
|
||||
path = os.path.join(log_dir, name)
|
||||
try:
|
||||
st = os.lstat(path)
|
||||
kind = _kind(name, path, st)
|
||||
except OSError:
|
||||
return None
|
||||
try:
|
||||
modified = datetime.fromtimestamp(st.st_mtime).isoformat()
|
||||
except (OSError, OverflowError, ValueError): # a time before 1970 on Windows
|
||||
modified = ""
|
||||
return {
|
||||
"name": name,
|
||||
"kind": kind,
|
||||
"size": st.st_size,
|
||||
"mtime_ns": st.st_mtime_ns,
|
||||
"ino": st.st_ino,
|
||||
"modified": modified,
|
||||
"_dir": stat.S_ISDIR(st.st_mode) and kind != "link",
|
||||
"_regular": stat.S_ISREG(st.st_mode) and kind != "link",
|
||||
"_nlink": st.st_nlink,
|
||||
}
|
||||
return rows
|
||||
for name in names:
|
||||
path = os.path.join(log_dir, name)
|
||||
try:
|
||||
st = os.lstat(path)
|
||||
kind = _kind(name, path, st)
|
||||
except OSError:
|
||||
continue
|
||||
try:
|
||||
modified = datetime.fromtimestamp(st.st_mtime).isoformat()
|
||||
except (OSError, OverflowError, ValueError): # a time before 1970 on Windows
|
||||
modified = ""
|
||||
rows.append(
|
||||
{
|
||||
"name": name,
|
||||
"kind": kind,
|
||||
"size": st.st_size,
|
||||
"mtime_ns": st.st_mtime_ns,
|
||||
"ino": st.st_ino,
|
||||
"modified": modified,
|
||||
"_dir": stat.S_ISDIR(st.st_mode) and kind != "link",
|
||||
"_regular": stat.S_ISREG(st.st_mode) and kind != "link",
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def public_row(row: Dict[str, Any]) -> Dict[str, Any]:
|
||||
@@ -158,37 +127,6 @@ def rotated_sort_key(row: Dict[str, Any]) -> int:
|
||||
return _rotated_number(row["name"]) or 0
|
||||
|
||||
|
||||
def file_identity(row: Dict[str, Any]) -> str:
|
||||
"""A listed file's identity as the client sends it back: size, mtime_ns, ino."""
|
||||
return f"{row['size']}:{row['mtime_ns']}:{row['ino']}"
|
||||
|
||||
|
||||
def parse_confirmed(expected) -> List[Dict[str, Any]]:
|
||||
"""The rows a Clear all logs confirmation showed, as review rows.
|
||||
|
||||
``expected`` is a list of ``{name, identity}`` objects from
|
||||
``/logs/files``; anything else raises ValueError.
|
||||
"""
|
||||
if not isinstance(expected, list) or len(expected) > MAX_CONFIRMED_ROWS:
|
||||
raise ValueError("expected must be a list of listed log files")
|
||||
rows, seen = [], set()
|
||||
for item in expected:
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError("each expected file must be an object")
|
||||
name, identity = item.get("name"), item.get("identity")
|
||||
if not isinstance(name, str) or not isinstance(identity, str):
|
||||
raise ValueError("each expected file needs a name and an identity")
|
||||
match = _IDENTITY.fullmatch(identity)
|
||||
if match is None:
|
||||
raise ValueError("an expected file's identity is not size:mtime_ns:ino")
|
||||
size, mtime_ns, ino = (int(part) for part in match.groups())
|
||||
if _name_key(name) in seen:
|
||||
raise ValueError("an expected file is listed twice")
|
||||
seen.add(_name_key(name))
|
||||
rows.append({"name": name, "size": size, "mtime_ns": mtime_ns, "ino": ino})
|
||||
return rows
|
||||
|
||||
|
||||
def log_file_entry(log_dir, name) -> Path:
|
||||
"""The path of a downloadable log file named ``name`` (blocking).
|
||||
|
||||
@@ -219,11 +157,6 @@ def _stream_is(stream, ino: int) -> bool:
|
||||
return not (ino and open_ino and open_ino != ino)
|
||||
|
||||
|
||||
def _stream_ino_is(opened: os.stat_result, ino: int) -> bool:
|
||||
"""Whether a stream's ``fstat`` is the file with inode ``ino`` (0: unknown)."""
|
||||
return not (ino and opened.st_ino and opened.st_ino != ino)
|
||||
|
||||
|
||||
def _error_text(error: BaseException) -> str:
|
||||
"""An OSError's reason and file name, without the folder it is in."""
|
||||
if isinstance(error, OSError):
|
||||
@@ -499,7 +432,6 @@ class PromptManagerLogger:
|
||||
"modified": row["modified"],
|
||||
"is_main": row["kind"] == "active",
|
||||
"kind": row["kind"],
|
||||
"identity": file_identity(row),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -598,14 +530,8 @@ class PromptManagerLogger:
|
||||
finally:
|
||||
handler.release()
|
||||
if not handled:
|
||||
flags = os.O_WRONLY | getattr(os, "O_NOFOLLOW", 0)
|
||||
fd = os.open(str(log_file), flags)
|
||||
try:
|
||||
if os.fstat(fd).st_nlink > 1: # emptying it would empty the other
|
||||
raise ClearRefused("logs-hard-link", CLEAR_HARD_LINK)
|
||||
os.ftruncate(fd, 0)
|
||||
finally:
|
||||
os.close(fd)
|
||||
flags = os.O_WRONLY | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0)
|
||||
os.close(os.open(str(log_file), flags))
|
||||
|
||||
def delete_rotated(self, expected: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""Delete exactly the reviewed rotated copies, or none of them (blocking).
|
||||
@@ -656,126 +582,47 @@ class PromptManagerLogger:
|
||||
)
|
||||
return {"removed": removed, "failed": failed}
|
||||
|
||||
def _active_streams(self, active: Optional[Dict[str, Any]]):
|
||||
"""``fstat`` and offset of every handler stream writing the active name."""
|
||||
target = os.path.normcase(
|
||||
os.path.realpath(os.path.join(self.log_dir, ACTIVE_LOG_NAME))
|
||||
)
|
||||
for handler in self._file_handlers():
|
||||
stream = getattr(handler, "stream", None)
|
||||
base = getattr(handler, "baseFilename", None)
|
||||
if stream is None or base is None:
|
||||
continue
|
||||
if os.path.normcase(os.path.realpath(base)) != target:
|
||||
continue
|
||||
try:
|
||||
stream.flush()
|
||||
fd = stream.fileno()
|
||||
yield os.fstat(fd), os.lseek(fd, 0, os.SEEK_CUR)
|
||||
except (OSError, ValueError, AttributeError):
|
||||
continue
|
||||
def truncate_logs(self) -> Dict[str, Any]:
|
||||
"""Empty the active log and delete its rotated copies (Rule R).
|
||||
|
||||
def _check_clear_is_safe(self, rows, active) -> None:
|
||||
"""Refuse a clear that would empty or delete what nobody confirmed.
|
||||
|
||||
Called under the files lock and every handler lock, so nothing of this
|
||||
process writes or rolls over meanwhile; what changes is another
|
||||
program's doing. Our handler's stream is flushed: a file larger than
|
||||
the stream's own offset was appended to by someone else, and a stream
|
||||
whose file is now a rotated copy was rolled over by someone else.
|
||||
Either is a second writer (another ComfyUI sharing the folder). This
|
||||
is best effort, not a lock: writes before our own last write, or any
|
||||
write while this process has file logging off, go unseen. An
|
||||
active log with more than one name (a hard link) is never emptied: the
|
||||
other name would be emptied too.
|
||||
"""
|
||||
rotated = {row["ino"] for row in rows if row["kind"] == "rotated"}
|
||||
rotated.discard(0)
|
||||
nlink = active["_nlink"] if active is not None else 1
|
||||
for opened, offset in self._active_streams(active):
|
||||
if (opened.st_ino and opened.st_ino in rotated) or opened.st_size > offset:
|
||||
raise ClearRefused("logs-foreign-writer", CLEAR_FOREIGN_WRITER)
|
||||
if active is not None and _stream_ino_is(opened, active["ino"]):
|
||||
nlink = max(nlink, opened.st_nlink)
|
||||
if nlink > 1:
|
||||
raise ClearRefused("logs-hard-link", CLEAR_HARD_LINK)
|
||||
|
||||
@staticmethod
|
||||
def _confirmed_targets(confirmed, current, active) -> List[Dict[str, Any]]:
|
||||
"""The rotated copies a confirmation listed, each as it was listed.
|
||||
|
||||
Refuses (``logs-changed``) when any listed copy is missing or differs
|
||||
in kind, size, ``mtime_ns`` or inode, or when the listed active log is
|
||||
now another file (it was rolled over). A copy nobody listed is kept.
|
||||
"""
|
||||
targets = []
|
||||
for reviewed in confirmed:
|
||||
key = _name_key(reviewed["name"])
|
||||
row = current.get(key)
|
||||
if key == _name_key(ACTIVE_LOG_NAME):
|
||||
ino = reviewed["ino"]
|
||||
if active is None or (ino and active["ino"] and active["ino"] != ino):
|
||||
raise ClearRefused("logs-changed", CLEAR_CHANGED)
|
||||
continue
|
||||
if _rotated_number(reviewed["name"]) is None:
|
||||
continue # never deleted, listed or not
|
||||
if not _matches_review(row, reviewed):
|
||||
raise ClearRefused("logs-changed", CLEAR_CHANGED)
|
||||
targets.append(row)
|
||||
return targets
|
||||
|
||||
def truncate_logs(self, expected=None) -> Dict[str, Any]:
|
||||
"""Empty the active log and delete its rotated copies (Rule R, #389).
|
||||
|
||||
``expected`` is what the confirmation showed: ``{name, identity}``
|
||||
rows of ``/logs/files`` (``parse_confirmed``; ValueError when
|
||||
malformed). Then only the listed rotated copies are deleted, and only
|
||||
when each still has the listed size, ``mtime_ns`` and inode and the
|
||||
active log is still the listed file; otherwise ClearRefused
|
||||
(``logs-changed``) with nothing changed. Without it (the legacy admin
|
||||
page, whose confirmation lists nothing) every rotated copy present now
|
||||
is deleted.
|
||||
|
||||
Either way, a second writer of the active log (``logs-foreign-writer``)
|
||||
or an active log with a hard link (``logs-hard-link``) refuses the
|
||||
whole clear first. Each copy's identity is checked again just before
|
||||
its unlink; one that changed (another process rolling over meanwhile)
|
||||
stops the clear there, keeping it, the rest and the active log, which
|
||||
is emptied last. Every other file in the log folder, and every link,
|
||||
is kept. Also clears the memory buffer. Runs under ``_files_lock`` and
|
||||
the file handlers' locks and logs only once they are released.
|
||||
Every other file in the log folder, and every link, is kept. Also
|
||||
clears the memory buffer. Runs under ``_files_lock`` and the file
|
||||
handlers' locks, so no rollover recreates a just-deleted copy, and
|
||||
logs only once they are released.
|
||||
|
||||
Returns:
|
||||
Dictionary with:
|
||||
- truncated: List of successfully truncated files
|
||||
- errors: List of error messages for failed operations
|
||||
"""
|
||||
confirmed = None if expected is None else parse_confirmed(expected)
|
||||
results = {"truncated": [], "errors": []}
|
||||
refused = None
|
||||
|
||||
with self._files_lock:
|
||||
held = self._acquire_file_handlers()
|
||||
try:
|
||||
rows = scan_log_folder(self.log_dir)
|
||||
current = {_name_key(row["name"]): row for row in rows}
|
||||
active = next((row for row in rows if row["kind"] == "active"), None)
|
||||
try:
|
||||
if confirmed is None:
|
||||
targets = [row for row in rows if row["kind"] == "rotated"]
|
||||
else:
|
||||
targets = self._confirmed_targets(confirmed, current, active)
|
||||
self._check_clear_is_safe(rows, active)
|
||||
except ClearRefused as error:
|
||||
refused = error
|
||||
else:
|
||||
self._clear(sorted(targets, key=rotated_sort_key), active, results)
|
||||
active = [row for row in rows if row["kind"] == "active"]
|
||||
rotated = sorted(
|
||||
(row for row in rows if row["kind"] == "rotated"),
|
||||
key=rotated_sort_key,
|
||||
)
|
||||
for row in active + rotated:
|
||||
path = os.path.join(self.log_dir, row["name"])
|
||||
try:
|
||||
if row["kind"] == "active":
|
||||
self._truncate_main_log(Path(path), row["ino"])
|
||||
else:
|
||||
os.unlink(path)
|
||||
results["truncated"].append(row["name"])
|
||||
except OSError as error:
|
||||
results["errors"].append(
|
||||
f"Failed to truncate {row['name']}: {_error_text(error)}"
|
||||
)
|
||||
with self._buffer_lock:
|
||||
self._log_buffer.clear()
|
||||
finally:
|
||||
self._release_handlers(held)
|
||||
|
||||
if refused is not None:
|
||||
self.logger.warning("Clear all logs refused (%s)", refused.code)
|
||||
raise refused
|
||||
if results["truncated"]:
|
||||
self.logger.info("Truncated log files: %s", ", ".join(results["truncated"]))
|
||||
for error_msg in results["errors"]:
|
||||
@@ -783,39 +630,6 @@ class PromptManagerLogger:
|
||||
|
||||
return results
|
||||
|
||||
def _clear(self, targets, active, results) -> None:
|
||||
"""Delete ``targets`` (each re-checked) then empty ``active`` (locks held)."""
|
||||
for reviewed in targets:
|
||||
name = reviewed["name"]
|
||||
if not _matches_review(scan_log_entry(self.log_dir, name), reviewed):
|
||||
results["errors"].append(
|
||||
f"{name} changed while clearing (another program may be writing"
|
||||
" to this log folder); it, the copies after it and the active"
|
||||
" log were kept"
|
||||
)
|
||||
return
|
||||
try:
|
||||
os.unlink(os.path.join(self.log_dir, name))
|
||||
results["truncated"].append(name)
|
||||
except OSError as error:
|
||||
results["errors"].append(
|
||||
f"Failed to truncate {name}: {_error_text(error)}"
|
||||
)
|
||||
if active is not None:
|
||||
try:
|
||||
self._truncate_main_log(
|
||||
Path(self.log_dir, active["name"]), active["ino"]
|
||||
)
|
||||
results["truncated"].append(active["name"])
|
||||
except ClearRefused as error:
|
||||
results["errors"].append(str(error))
|
||||
except OSError as error:
|
||||
results["errors"].append(
|
||||
f"Failed to truncate {active['name']}: {_error_text(error)}"
|
||||
)
|
||||
with self._buffer_lock:
|
||||
self._log_buffer.clear()
|
||||
|
||||
def update_config(self, new_config: Dict[str, Any]):
|
||||
"""Update logging configuration.
|
||||
|
||||
|
||||
+31
-176
@@ -40,19 +40,6 @@ while it is pending and undecided (never beside a decision, see
|
||||
profile the item recorded (``captured_review(..., item_profile(evidence))``).
|
||||
The inspection profile only ever produces review choices; ingestion's own
|
||||
links stay on the default profile, which never loads the table (D1).
|
||||
|
||||
The saver anchor (#390): the inspection profile resolves a file with its
|
||||
name, as the workspace inspector does since #383, so the one saver whose
|
||||
``filename_prefix`` names the file anchors its branch. When that anchor alone
|
||||
resolves a file the default profile left ambiguous and nothing links
|
||||
(``saver_anchored``), the scan queues it with the anchor's pair as its one
|
||||
candidate (evidence ``anchor: "saver"``), never linking it: a file never
|
||||
falls through both the link and the queue (maintainer ruling). No match,
|
||||
several matches or an unfinished walk anchor nothing and the file is queued
|
||||
as before. Apply re-resolves with the file's name, so only the anchored
|
||||
branch can be applied. ``REVIEW_RULE_VERSION`` is folded into inspection
|
||||
item versions, so inspection items recorded before the anchor are recorded
|
||||
again by the next scan.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -117,16 +104,10 @@ MAX_CANDIDATE_DEPTH = 8
|
||||
# The resolver profile an item's evidence was built with (``profile``).
|
||||
DEFAULT = "default"
|
||||
INSPECTION = "inspection"
|
||||
# The ``anchor`` an item recorded from the saver anchor names (#390).
|
||||
SAVER_ANCHOR = "saver"
|
||||
ANCHOR_REASON = "The saver that wrote this file names its branch"
|
||||
# Inspection item versions: above every plausible ``RESOLVER_VERSION`` and,
|
||||
# with 40 bits of the folded digest, below 2**53 (exact as a JSON number).
|
||||
INSPECTION_VERSION_BASE = 1 << 40
|
||||
_INSPECTION_VERSION_HEX = 10
|
||||
# How the scan and Apply use the inspection profile, folded into inspection
|
||||
# item versions. 1: they resolve with the file's name (the saver anchor, #390).
|
||||
REVIEW_RULE_VERSION = 1
|
||||
|
||||
|
||||
# Above this many distinct node classes the whole table is the fingerprint.
|
||||
@@ -173,92 +154,38 @@ def inspection_review_version(metadata: Any = None) -> int | None:
|
||||
"""The ``resolver_version`` of an item recorded from the inspection profile.
|
||||
|
||||
Folds the profile with its budgets, ``RESOLVER_VERSION``,
|
||||
``INSPECTION_RESOLVER_VERSION``, ``REVIEW_RULE_VERSION`` and
|
||||
``semantics_version(metadata)``, so a change of either resolver, of how
|
||||
review uses the profile, of the profile's bounds or of the table about
|
||||
the graph's classes moves it. None when the table cannot be loaded.
|
||||
``INSPECTION_RESOLVER_VERSION`` and ``semantics_version(metadata)``, so
|
||||
a change of either resolver, of the profile's bounds or of the table
|
||||
about the graph's classes moves it. None when the table cannot be loaded.
|
||||
"""
|
||||
table = semantics_version(metadata)
|
||||
if table is None:
|
||||
return None
|
||||
profile = repr(astuple(INSPECTION_PROFILE))
|
||||
seed = f"{profile}:{RESOLVER_VERSION}:{INSPECTION_RESOLVER_VERSION}:{table}"
|
||||
if REVIEW_RULE_VERSION: # 0 is the seed items had before the rule existed
|
||||
seed += f":review-rule-{REVIEW_RULE_VERSION}"
|
||||
folded = hashlib.sha256(seed.encode("utf-8")).hexdigest()
|
||||
return INSPECTION_VERSION_BASE + int(folded[:_INSPECTION_VERSION_HEX], 16)
|
||||
|
||||
|
||||
def resolve_inspection(metadata: dict, file_name: str | None = None) -> dict:
|
||||
"""``metadata`` resolved with the inspection profile (bounded work).
|
||||
|
||||
``file_name`` (the file's last path part) anchors the file on the one
|
||||
saver that wrote it, as in the workspace inspector (#390).
|
||||
"""
|
||||
return resolve_prompt_pair(
|
||||
metadata, profile=INSPECTION_PROFILE, file_name=file_name
|
||||
)
|
||||
def resolve_inspection(metadata: dict) -> dict:
|
||||
"""``metadata`` resolved with the inspection profile (bounded work)."""
|
||||
return resolve_prompt_pair(metadata, profile=INSPECTION_PROFILE)
|
||||
|
||||
|
||||
def inspection_choice(metadata: dict, file_name: str | None = None) -> dict | None:
|
||||
"""The inspection profile's result when the file is a review case.
|
||||
def inspection_choice(metadata: dict) -> dict | None:
|
||||
"""The inspection profile's ambiguous result when it offers a choice.
|
||||
|
||||
The scan asks only for a file the default profile found ambiguous
|
||||
without a choice that nothing links. The result is ambiguous with a
|
||||
choice, or ``resolved`` when ``file_name`` anchors the file on its saver
|
||||
and only that anchor resolves it (``saver_anchored``): the item then
|
||||
offers the anchor's pair. None otherwise.
|
||||
without a choice that nothing links.
|
||||
"""
|
||||
if inspection_review_version(metadata) is None:
|
||||
return None
|
||||
resolution = resolve_inspection(metadata, file_name)
|
||||
if resolution.get("state") == "resolved":
|
||||
return _anchor_of(metadata, file_name, resolution)
|
||||
resolution = resolve_inspection(metadata)
|
||||
if resolution.get("state") != "ambiguous" or not reviewable(resolution):
|
||||
return None
|
||||
return resolution
|
||||
|
||||
|
||||
def _anchor_of(metadata: Any, file_name: str | None, named: dict) -> dict | None:
|
||||
"""``named`` tagged ``anchor: "saver"`` when only the saver anchor
|
||||
resolves the file, to a pair with a positive text to choose; else None.
|
||||
|
||||
``named`` is ``metadata`` resolved with ``file_name``; the profile runs
|
||||
once more without the name only when it is resolved and choosable.
|
||||
"""
|
||||
if not file_name or named.get("state") != "resolved":
|
||||
return None
|
||||
try:
|
||||
offered = choosable(_projection(named))
|
||||
except (KeyError, TypeError):
|
||||
return None
|
||||
if not offered or resolve_inspection(metadata).get("state") == "resolved":
|
||||
return None
|
||||
return {**named, "anchor": SAVER_ANCHOR}
|
||||
|
||||
|
||||
def saver_anchored(metadata: Any, file_name: str | None) -> bool:
|
||||
"""True when the saver that wrote ``file_name`` resolves its branch (#390).
|
||||
|
||||
The inspection profile resolves ``metadata`` with the file's name, and
|
||||
without it the file does not resolve: the anchor alone decides it. A file
|
||||
the default profile left ambiguous is then queued with the anchor's pair
|
||||
as its one candidate. No name, an unusable table, no matching saver,
|
||||
several, or an unfinished walk anchor nothing (False). The profile
|
||||
without the name runs only when the named run resolved.
|
||||
"""
|
||||
return _anchored(metadata, file_name) is not None
|
||||
|
||||
|
||||
def _anchored(metadata: Any, file_name: str | None) -> dict | None:
|
||||
"""The anchored result ``saver_anchored`` describes, else None."""
|
||||
if not file_name or not isinstance(metadata, dict):
|
||||
return None
|
||||
if inspection_review_version(metadata) is None:
|
||||
return None
|
||||
return _anchor_of(metadata, file_name, resolve_inspection(metadata, file_name))
|
||||
|
||||
|
||||
def item_profile(evidence: Any) -> str:
|
||||
"""The profile an item's stored evidence was built with."""
|
||||
if isinstance(evidence, dict) and evidence.get("profile") == INSPECTION:
|
||||
@@ -319,9 +246,7 @@ def record_ambiguous_file(
|
||||
"""
|
||||
versions = item_versions(scanned[0] if scanned is not None else None)
|
||||
spelled_path, queued = _queued_spelling(db, root, relative, versions)
|
||||
if queued is not None and not (
|
||||
inspection and _default_item_to_anchor(db, queued, scanned, relative)
|
||||
):
|
||||
if queued is not None:
|
||||
return _already_queued(db, *queued, versions)
|
||||
epoch = db.get_execution_epoch()
|
||||
try:
|
||||
@@ -347,24 +272,24 @@ def record_ambiguous_file(
|
||||
if resolution is None or resolution.get("state") != "ambiguous":
|
||||
return "skipped"
|
||||
profile, version = DEFAULT, RESOLVER_VERSION
|
||||
named = _captured_inspection(captured[0], relative, scanned) if inspection else None
|
||||
anchored = named is not None and named[0].get("anchor") == SAVER_ANCHOR
|
||||
if anchored:
|
||||
# Only the saver anchor resolves it (#390): offer that one pair.
|
||||
profile, version = INSPECTION, named[1]
|
||||
resolution = {
|
||||
"state": "ambiguous",
|
||||
"reason": ANCHOR_REASON,
|
||||
"candidates": [named[0]],
|
||||
}
|
||||
elif not reviewable(resolution):
|
||||
if named is None:
|
||||
if not reviewable(resolution):
|
||||
version = inspection_review_version(captured[0]) if inspection else None
|
||||
if version is None:
|
||||
return "untraced"
|
||||
profile, (resolution, version) = INSPECTION, named
|
||||
profile = INSPECTION
|
||||
if (
|
||||
scanned is not None
|
||||
and scanned[1] is not None
|
||||
and _same_records(captured[0], scanned[0])
|
||||
):
|
||||
resolution = scanned[1] # the same records: no second run
|
||||
else:
|
||||
# Heavier, so only for a file ambiguous with nothing to choose.
|
||||
resolution = resolve_inspection(captured[0])
|
||||
if resolution.get("state") != "ambiguous" or not reviewable(resolution):
|
||||
return "untraced"
|
||||
evidence, count, limited = review_evidence(
|
||||
resolution, profile, version, captured[0], anchored=anchored
|
||||
resolution, profile, version, captured[0]
|
||||
)
|
||||
try:
|
||||
inserted = db.record_review_item(
|
||||
@@ -388,60 +313,6 @@ def record_ambiguous_file(
|
||||
return "limited" if limited else "recorded"
|
||||
|
||||
|
||||
def _default_item_to_anchor(db, queued: tuple, scanned, relative: str) -> bool:
|
||||
"""True when the bytes' only pending item is a default item recorded
|
||||
before #390 and the saver anchor alone resolves the scanned records.
|
||||
|
||||
The file is then captured again so the anchored item supersedes that
|
||||
pending, undecided item. Decided bytes and bytes already queued under
|
||||
the current inspection version are never re-read.
|
||||
"""
|
||||
if scanned is None or scanned[0] is None:
|
||||
return False
|
||||
version = inspection_review_version(scanned[0])
|
||||
if version is None or db.review_pending_at(*queued, version):
|
||||
return False
|
||||
if not db.review_pending_at(*queued, RESOLVER_VERSION):
|
||||
return False
|
||||
return _anchored(scanned[0], _file_name(relative)) is not None
|
||||
|
||||
|
||||
def _captured_inspection(metadata: dict, relative: str, scanned) -> tuple | None:
|
||||
"""``(resolution, item_version)`` of the captured records under the
|
||||
inspection profile, anchored on the file's name; None when the table is
|
||||
unusable.
|
||||
|
||||
A result tagged ``anchor: "saver"`` means the saver anchor alone
|
||||
resolves the file (#390); any other resolved result is returned as
|
||||
``unresolved`` (no review case, as before). The scan's result is reused
|
||||
when the capture holds the same records (``inspection_choice`` applies
|
||||
the same rule).
|
||||
"""
|
||||
version = inspection_review_version(metadata)
|
||||
if version is None:
|
||||
return None
|
||||
if (
|
||||
scanned is not None
|
||||
and scanned[1] is not None
|
||||
and _same_records(metadata, scanned[0])
|
||||
):
|
||||
return scanned[1], version # the same records: no second run
|
||||
name = _file_name(relative)
|
||||
resolution = resolve_inspection(metadata, name)
|
||||
if resolution.get("state") == "resolved":
|
||||
# Resolved without the anchor too: no review case, as before.
|
||||
resolution = _anchor_of(metadata, name, resolution) or {
|
||||
**resolution,
|
||||
"state": "unresolved",
|
||||
}
|
||||
return resolution, version
|
||||
|
||||
|
||||
def _file_name(relative: str) -> str:
|
||||
"""The last part of a ``/``-separated relative path (the saved name)."""
|
||||
return relative.rsplit("/", 1)[-1]
|
||||
|
||||
|
||||
def _already_queued(db, locator: str, size: int, mtime_ns: int, versions) -> str:
|
||||
"""``duplicate`` while an existing item is pending, else ``decided``."""
|
||||
for version in versions:
|
||||
@@ -483,19 +354,14 @@ def _queued_spelling(db, root, relative: str, versions) -> tuple[str, tuple | No
|
||||
return spelled, None
|
||||
|
||||
|
||||
def captured_review(
|
||||
store, capture, profile: str = DEFAULT, file_name: str | None = None
|
||||
) -> tuple[dict, dict] | None:
|
||||
def captured_review(store, capture, profile: str = DEFAULT) -> tuple[dict, dict] | None:
|
||||
"""``(metadata, resolution)`` of the capture's own bytes, else None.
|
||||
|
||||
``metadata`` is what the scan hands the ingester: prompt and workflow
|
||||
JSON-decoded and ``file_info`` (size, dimensions, format) from the
|
||||
capture. ``resolution`` comes from ``profile`` (``DEFAULT`` or
|
||||
``INSPECTION``; the latter anchored on ``file_name``, the file's own
|
||||
name, as the item was recorded). With ``file_name`` (Apply), a default
|
||||
result its saver anchor alone resolves (``saver_anchored``) is that
|
||||
anchored result, so only the anchored branch is still offered. None
|
||||
when the capture is gone or carries no prompt metadata.
|
||||
``INSPECTION``). None when the capture is gone or carries no prompt
|
||||
metadata.
|
||||
"""
|
||||
if store.acquire(capture.token) is None:
|
||||
return None
|
||||
@@ -523,15 +389,8 @@ def captured_review(
|
||||
info["format"] = read.format
|
||||
canonical["file_info"] = info
|
||||
if profile == INSPECTION:
|
||||
named = resolve_inspection(canonical, file_name)
|
||||
return canonical, _anchor_of(canonical, file_name, named) or named
|
||||
resolution = resolve_prompt_pair(canonical)
|
||||
if file_name is not None and resolution.get("state") == "ambiguous":
|
||||
anchored = _anchored(canonical, file_name)
|
||||
if anchored is not None:
|
||||
# Its saver names its branch (#390): only that one is offered.
|
||||
return canonical, anchored
|
||||
return canonical, resolution
|
||||
return canonical, resolve_inspection(canonical)
|
||||
return canonical, resolve_prompt_pair(canonical)
|
||||
|
||||
|
||||
def reviewable(resolution: Any) -> bool:
|
||||
@@ -557,14 +416,12 @@ def review_evidence(
|
||||
profile: str = DEFAULT,
|
||||
version: int | None = None,
|
||||
metadata: Any = None,
|
||||
anchored: bool = False,
|
||||
) -> tuple[dict, int, bool]:
|
||||
"""``(evidence, candidate_count, limited)`` for one ambiguous result.
|
||||
|
||||
Inspection evidence also names its ``profile`` and ``semantics``, the
|
||||
table fingerprint of ``metadata``'s graph it was resolved with;
|
||||
``version`` is the item's version. ``anchored`` evidence (the saver
|
||||
anchor's one pair, #390) also names ``anchor: "saver"``.
|
||||
``version`` is the item's version.
|
||||
"""
|
||||
base = {
|
||||
"resolver_version": RESOLVER_VERSION if version is None else version,
|
||||
@@ -573,8 +430,6 @@ def review_evidence(
|
||||
if profile == INSPECTION:
|
||||
base["profile"] = INSPECTION
|
||||
base["semantics"] = semantics_version(metadata)
|
||||
if anchored:
|
||||
base["anchor"] = SAVER_ANCHOR
|
||||
candidates: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
total = 0
|
||||
|
||||
@@ -225,23 +225,6 @@ export function clearSentence(files) {
|
||||
return `Empties ${ACTIVE_LOG}, deletes ${number(rotated.length)} rotated ${noun(rotated.length, 'copy', 'copies')} (${sizeText(size)}) ${tail}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* #389: the `/logs/truncate` body for the files a confirmation showed. Their active and rotated rows go back with the
|
||||
* server's `identity`, so the server deletes only those and refuses (409) when any changed meanwhile. A list that could
|
||||
* not be read (`null`, the general sentence) sends no list: the server then clears what is there, as the sentence says.
|
||||
*/
|
||||
export function clearBody(files) {
|
||||
if (!Array.isArray(files)) return {};
|
||||
const expected = files.filter(row => (row?.kind === 'active' || row?.kind === 'rotated') && typeof row.filename === 'string'
|
||||
&& typeof row.identity === 'string').map(row => ({ name: row.filename, identity: row.identity }));
|
||||
return { expected };
|
||||
}
|
||||
|
||||
/** A refused clear (409) says why in the server's words; any other failure the general line. */
|
||||
export function clearFailureText(error) {
|
||||
return error?.status === 409 && typeof error.message === 'string' && error.message ? error.message : COPY.clearFailed;
|
||||
}
|
||||
|
||||
/** CUJ 7: the toast for `/logs/truncate`'s `results` ({truncated: [names], errors: [messages]}). */
|
||||
export function clearToast(results) {
|
||||
const truncated = Array.isArray(results?.truncated) ? results.truncated : [];
|
||||
@@ -486,14 +469,13 @@ function logsSurface({ api, activity, scope, drafts, navigate, notify, latestOnl
|
||||
const list = el('div', undefined, 'operation-list');
|
||||
let listed = null;
|
||||
// The confirmation names what Clear deletes now: the files are read again before it opens (a read that fails
|
||||
// gives the general sentence, never an old count). Clear sends exactly the files the dialog was built from (#389).
|
||||
// gives the general sentence, never an old count).
|
||||
const clear = operationRow({ scope, title: 'Clear all logs', description: COPY.clearDescription, label: 'Clear all logs…',
|
||||
danger: true, onClick: button => pressing(button, async () => {
|
||||
await readFiles();
|
||||
if (scope.signal.aborted) return;
|
||||
const shown = listed;
|
||||
confirmDialog({ scope, trigger: button, title: 'Clear all logs', sentence: clearSentence(shown), label: 'Clear',
|
||||
danger: true, onConfirm: () => clearLogs(shown) });
|
||||
confirmDialog({ scope, trigger: button, title: 'Clear all logs', sentence: clearSentence(listed), label: 'Clear',
|
||||
danger: true, onConfirm: clearLogs });
|
||||
}) });
|
||||
files.append(el('summary', 'Log files & retention'), line, review, retention, list, clear);
|
||||
const runtime = runtimeRows({ scope, drafts });
|
||||
@@ -580,12 +562,12 @@ function logsSurface({ api, activity, scope, drafts, navigate, notify, latestOnl
|
||||
readConfig();
|
||||
}
|
||||
|
||||
async function clearLogs(shown) {
|
||||
async function clearLogs() {
|
||||
let answer;
|
||||
try {
|
||||
answer = await api.post(TRUNCATE_URL, clearBody(shown));
|
||||
} catch (error) {
|
||||
if (!scope.signal.aborted) notify(clearFailureText(error));
|
||||
answer = await api.post(TRUNCATE_URL, {});
|
||||
} catch {
|
||||
if (!scope.signal.aborted) notify(COPY.clearFailed);
|
||||
}
|
||||
if (scope.signal.aborted) return;
|
||||
if (answer) notify(clearToast(answer.results));
|
||||
|
||||
Reference in New Issue
Block a user