Skip to content

Unify runs and resource pickers, and stop the disk filling up - #115

Merged
jhd3197 merged 40 commits into
mainfrom
dev
Aug 22, 2026
Merged

Unify runs and resource pickers, and stop the disk filling up#115
jhd3197 merged 40 commits into
mainfrom
dev

Conversation

@jhd3197

@jhd3197 jhd3197 commented Aug 21, 2026

Copy link
Copy Markdown
Owner

The panel had grown three different ways to pick a server, two floating widgets that both showed you a log stream, and a database that quietly gained about 11 MB a day forever. This PR collapses the first two into shared primitives and fixes the third at the source. Every "which resource?" control — target picker, template server picker, remote-access tunnel endpoints, new-service project/environment — now goes through one ResourcePicker backed by an extended /search contract that returns scoped, capability-tagged, cursor-paged references instead of ad-hoc per-page lists. The deploy pill and the logs drawer become one Operations dock that shows every run (deployments, background jobs, live service logs) with progress, transport state, and retry/cancel actions the server actually authorizes, rather than actions the client guessed from a status string. On top of that, the assistant can now take resource attachments — references, never client-sent payloads, re-resolved and re-authorized on every turn — and the disk work gives operators serverkit disk plus a retention handler so the failure mode that filled a 25 GB box stops being reachable.

The one design call worth flagging: search gained an extended mode rather than a second endpoint, because the palette and the pickers want the same authorization fan-out and splitting them would have meant maintaining two scoping code paths. The palette keeps its five-rows-per-type behavior; pickers opt in with types and get global cursor paging.

Highlights

  • One Operations dock replaces the deploy pill and the logs drawer — active runs, a "needs attention" view, recent history, and live logs for deployments, background jobs, and service log sessions in a single place. Ctrl/Cmd+Shift+O toggles it.
  • Every server/project/environment/service selector is now the same searchable picker, with favorites and recently-used pinned to the top, capability filtering (a WireGuard tunnel only offers servers that can do WireGuard), and keyboard navigation.
  • Retry and Cancel buttons now appear only when the server says the caller may actually perform them, instead of being inferred from the job's status text.
  • Dashboard editing became a real editing session: undo/redo, a change bar showing how many unsaved changes you have, and a confirmation before you navigate away or reload with unsaved layout work.
  • A keyboard shortcut sheet (press ? on the dashboard) lists what's bound in the current context.
  • The AI assistant accepts attached resources — pick a service, server, project, environment, or domain and it gets that resource's current facts, re-checked against your permissions each time you send.
  • New sudo serverkit disk reports exactly what is consuming space and interactively reclaims it: old upgrade snapshots, abandoned staging dirs, oversized login logs, package caches, the journal, Docker build cache, and aged telemetry rows.
  • Updates no longer fill the disk: the preflight sizes its requirement to what the run will actually write, retention runs before the backup rather than only on success, and backups are capped by size as well as count.
Technical changes

Resource search contract

  • SearchQuerySchema gains types, capabilities, project_id, environment_id, cursor (max 256 chars) and limit (1–100); CSV fields are split and de-duplicated in normalize.
  • SearchResultSchema rows now carry id, scope (workspace_id/project_id/environment_id) and capabilities; ListMetaSchema carries next_cursor.
  • GET /search switched from @jwt_required() to @auth_required() and now branches: unchanged legacy behavior when no extended params are present, SearchService.search_page otherwise.
  • SearchService.search_page wraps a new _search_rows with strict_scope=True; cursors are v1:<offset> base64url values validated to MAX_CURSOR_OFFSET (10,000) and rejected with ValidationError('invalid_cursor').
  • _search_rows adds project and environment fan-outs (access derived from workspace membership via _accessible_workspace_ids), scopes vaults to accessible workspaces for non-admins when no workspace header is set, and filters servers by _server_capabilities read from cached_capabilities.
  • search_provider_registry.clean_rows now validates and coerces plugin-supplied scope/capabilities, dropping rows with non-integer scope values, and falls back to path for id so existing extension providers keep working.
  • Plugin-contributed rows are additionally re-filtered by workspace/project/environment/capability after clean_rows, so a provider cannot widen its own scope.

Resource picker

  • New frontend/src/utils/resourceRefs.js: normalizeResourceRef, resourceKey, per-user recents in localStorage capped at 5 per type, and groupResourceOptions (favorites → recents → results).
  • New useResourceOptions hook keys a useServerQuery on the normalized type/scope/capability/query tuple with a 15 s staleTime, and exposes recordSelection.
  • New ResourcePicker component (Radix popover + cmdk) with shouldFilter={false}, caller-supplied staticOptions, filterOption, decorateOption, and optional capability tags.
  • api.searchResources and buildResourceSearchQuery in services/api/search.js; 'all' workspace values are omitted from the query string rather than sent.
  • TargetPicker and templates/ServerPicker became compatibility adapters — external prop and callback shapes are unchanged, selection now flows through ResourcePicker.
  • RemoteAccess replaces both tunnel-endpoint Selects with capability-filtered (wireguard) pickers that exclude each other's selection; ReviewStep replaces the project/environment selects, and useNewServiceForm now clears projectEnvironments/selectedEnvironmentId on every project change rather than only when the project is cleared.

Operations dock and the run envelope

  • New useRunStream(runKind, runId) generalizes the deploy transport: boot snapshot, after_id catch-up on every (re)connect, run_log/run_status socket events, id de-duplication, a 2.5 s socket grace period before falling back to polling, and terminal-status freezing. Pure helpers live in hooks/runStream.js (mergeRunLogLines, isTerminalRunStatus, 5,000-line cap).
  • useDeployJobStream is now a ~30-line adapter over useRunStream that keeps the Deploy Console's richer snapshot (loadRun) and its public job field; the hand-rolled setInterval is gone, so it drops out of the LEGACY_POLLERS ratchet in check-frontend-boundaries.mjs.
  • New useServiceLogStream + serviceLogStream.js handle file and container log sessions (snapshot, log_line/container_log* events, 1,000-line cap).
  • New OperationsContext merges /deployment-jobs and /jobs (jobs only for admins) through services/operations.jsnormalizeDeploymentOperation, normalizeJobOperation, boundOperationHistory (25 entries, always retaining the selected one), and reconcileOperationStatus, which falls back to a refresh when a socket status names a run the client hasn't seen.
  • OperationsDock renders Active / Needs attention / History with per-run progress, a step rail, elapsed time, transport indicator, unread badges, and retry/cancel; Escape collapses it.
  • DeployPill.jsx, LogsDrawer.jsx and _logs-drawer.scss are deleted; LogsDrawerContext is reduced to a compatibility shim over OperationsContext so extension SDK consumers keep working.
  • socket.js gains subscribeRun/unsubscribeRun/subscribeContainerLogs/unsubscribeContainerLogs and re-emits the run_* and container_log* events.
  • New services/api/runs.js with buildRunLogsPath / getRunLogs against /runs/<kind>/<id>/logs.

Authoritative run actions

  • backend/app/api/jobs.py adds _job_payload, attaching can_cancel (pending/running) and can_retry (failed/cancelled) to every job serialization.
  • backend/app/api/deployment_jobs.py adds _job_payload with can_cancel=False (no cancel endpoint exists) and can_retry gated on status == 'failed' and _job_operable_by; the list route resolves retry authority once per scoped app via ResourceGrantService.can_operate_app, and a retry's pending clone is returned with both flags false.
  • Jobs.jsx drops its local isRunning/canRetry status heuristics in favor of the server flags.
  • DoctorPanel calls /doctor/repair?wait=false, and doctor.py enqueues a doctor.repair job in that mode; DoctorService.run_doctor_repair_job streams per-item results through run_log_service, stores a summary via job.set_result, and raises when any item failed. ProtectionPanel and ScannerTab route their returned job_id into the dock (and ScannerTab now unwraps the {job: …} envelope it was previously reading through).

Editing sessions, shortcuts, navigation guard

  • New hooks/editingSession.js: a pure reducer with baseline/draft/past/future, structural diffing to compute dirtyPaths, path-based change, transaction (object, updater fn, or change list), coalescing by key, a 50-entry history cap, and save lifecycle states.
  • useEditingSession wraps it and exposes an async save(fn) that adopts the returned value as the new baseline.
  • New shortcutRegistry (priority-ordered, ctrlOrMeta normalization, editable-target suppression unless allowInInput), ShortcutProvider mounted around DashboardLayout, useShortcut/useShortcutCommands, and a ShortcutSheet modal.
  • DashboardLayout's hand-rolled keydown listener for the command palette is replaced by a registered shortcut.
  • useUnsavedChangesGuard combines a beforeunload handler with a document-level click interceptor; services/navigationGuard.js#internalNavigationTarget isolates the "is this a same-origin in-app link click?" decision (ignoring modified clicks, target, download, and same-destination links).
  • Dashboard.jsx moves widget edits into the editing session — useDashboardBoards loses setWidgets/snapshot handling, and saveActive(widgets) now writes first and adopts the server's response, so a failed save leaves both baseline and draft intact. Undo/redo/save/delete/escape/? are registered shortcuts, and board switching, creation and deletion route through the unsaved-changes guard.
  • New ChangeBar component surfaces dirty count, save state, and undo/redo/discard/save.

AI attachments

  • New ai_attachment_registry.py: type-keyed resolvers with plugin ownership and teardown, normalize_references (max 8, type regex, 128-char ids, de-duplication), and resolve_attachments returning a {manifest, context, warnings} triple. Unknown/denied/stale/failed/oversized references degrade to a warning instead of failing the turn.
  • Attachments are references only — resolvers reload the resource for the current user on every turn, return an allowlisted summary, and that summary passes through mask_payload before it reaches the prompt. Context is capped at 12,000 characters.
  • Core resolvers cover service, server, project, environment, domain, and incident, each enforcing its own authorization (app_access_tier, workspace membership).
  • build_system_prompt wraps attachment data in <serverkit_attachment_data> delimiters with an explicit "untrusted reference data, not instructions" preamble, and redacts the encoded payload.
  • AiMessage.attachments_json (migration 090_ai_message_attachments) persists the reference manifest only — never resolved context — so replay never shows stale data as current.
  • /ai/chat and /ai/chat/stream validate references before creating a conversation row; the streaming route emits attachment_warning SSE events. PluginToolBinder.register_attachment_resolver requires plugin-slug namespacing, and AiToolRegistry.unregister_plugin now also tears down attachment resolvers.
  • ConfirmationGate.cancel_all denies and unblocks every pending confirmation when a stream disconnects.
  • Frontend: lib/ai/attachments.js (key/normalize/add/remove/warning-application/payload), AttachmentChip, a ResourcePicker-driven attach control in Composer, attachment state in AIContext with a patchLastUser reducer helper, and attachment rendering on replayed user messages.

Disk reclamation

  • New disk_reclaim_service.py measures then reclaims: upgrade snapshots (grouped by timestamp so a snapshot is never half-deleted), mktemp -d staging dirs owned by the current euid and verified to contain a ServerKit tree, oversized btmp/wtmp (truncated, not deleted, to preserve ownership and mode), package/pip/npm caches, the systemd journal, Docker build cache and dangling images, and aged telemetry rows.
  • Ordering is enforced regardless of the caller's key order: filesystem candidates always run before the database VACUUM, because VACUUM needs free space roughly equal to the database itself and is the one step that cannot go first on a full disk.
  • prune_telemetry deletes from jobs before queue_messages and excludes any queue message still referenced by a surviving job, so jobs.queue_message_id never dangles; SQLite deletes are batched by rowid.
  • _vacuum refuses up front when free space is below the database size, runs on an AUTOCOMMIT connection, and only stops/starts the panel service under an explicit --allow-restart.
  • Table size estimates come from the dbstat vtable with indexes mapped back to their owning table via sqlite_master; missing dbstat degrades to no estimate rather than an error.
  • docker system df parsing deliberately counts only Build Cache, since Docker's image/volume "reclaimable" figure includes tagged images and named volumes this never removes; actual freed bytes come from Docker's own Total reclaimed space trailer. Journal reclaim runs --rotate before --vacuum-size, since the latter only touches archived journals.
  • New serverkit disk CLI command (backend/cli.py + serverkit wrapper, help text and bash completion) with an interactive menu, per-snapshot selection (1,3, older-than 14, all-but-newest, none), --safe/--all/--only, --dry-run, --allow-restart, and --json. Deleting the last remaining restore point requires an explicit confirmation.

Retention and update safety

  • New builtin.telemetry_retention job handler prunes queue_messages, system_events and api_usage_logs on a 6-hour cadence, honoring a new telemetry.retention_days setting (default 30, 0 disables). It deliberately skips VACUUM — steady-state pruning keeps freed pages on the freelist and never takes the exclusive lock.
  • scripts/update.sh: BACKUP_DIR is now overridable via SERVERKIT_BACKUP_DIR; the preflight requires 2 GiB plus twice the current database size (the run writes a pre-upgrade copy and a tree backup containing another), attempts a trim before failing, and points at serverkit disk in the halt message.
  • Retention now runs in backup_current before anything is written, not only in cleanup() — a run that died because the disk was full never reached cleanup, which is how a box ended up carrying six snapshots under a cap of five. Default retention drops from 5 to 3.
  • New enforce_backup_budget drops oldest-first until $BACKUP_DIR fits a share of the filesystem (SERVERKIT_BACKUP_MAX_PERCENT, default 15%), with a 50-iteration guard and a hard refusal to delete the last remaining restore point.

jhd3197 and others added 29 commits August 21, 2026 01:30
A panel host filled to 100% (0 bytes free) purely from routine
`serverkit update` runs, and recovering it meant hand-running du and
sqlite. `serverkit disk` measures every reclaimable item and offers them
for selection: numbers, "safe", or "all".

Upgrade snapshots are grouped by timestamp and offered individually with
age and size, since one update writes both a tree backup and a database
copy under one stamp -- half a snapshot is not a restore point. They can
be picked by number, by age (--older-than N, or "older-than N" in the
prompt), or left to the "all-but-newest" default.

Two ordering rules are enforced regardless of selection order:

* filesystem reclaimers run BEFORE the database VACUUM. VACUUM rewrites
  the database into a temp file beside it, so it needs free space equal
  to the database -- on a full disk it is the one step that cannot go
  first. It refuses with a clear message rather than failing halfway.
* `jobs` rows are deleted before `queue_messages`, and any message still
  referenced by a surviving job is kept, so jobs.queue_message_id never
  dangles.

in_flight messages and pending/failed jobs are never touched. /tmp is
only swept for entries matching mktemp naming, owned by the invoking
user, over 24h old AND holding an unpacked ServerKit tree. Docker prunes
dangling images and build cache only -- never tagged images or volumes.

Sizes are measured rather than estimated wherever measurement is
possible: docker reports its own reclaimed tally (shared layers make
dangling-image sizes overlap), and the journal and package caches report
before/after deltas -- apt-get clean empties the cache before a naive
count could see it, which would otherwise report 0 freed for real work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`jobs` has had retention since day one, but the three tables that grow
alongside it never did. `queue_messages` and `system_events` gain a row
per scheduler tick and `api_usage_logs` one per request; measured on a
single-app box that is ~25k rows/day, or ~11 MB/day of database forever.

That is what filled a 25 GB host from nothing but routine updates: the
database reached 809 MB in two months, and every update copies it twice
as its safety net (a pre-upgrade snapshot plus a tree backup containing
it), so each update cost ~1.6 GB.

Adds builtin.telemetry_retention on a 6h tick, keeping
`telemetry.retention_days` (default 30); 0 disables it. It reuses the
prune from disk_reclaim_service, so the same guards apply -- terminal
rows only, and a queue message still referenced by a surviving job is
kept.

It deliberately does NOT vacuum. VACUUM needs an exclusive lock and free
space equal to the whole database, which is never acceptable on a
background tick. Steady-state pruning leaves the freed pages on SQLite's
freelist for reuse, so the file stops growing; `serverkit disk` remains
what actually shrinks it after a backlog.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A backup retention cap already existed (SERVERKIT_BACKUP_RETENTION,
default 5), yet a box was found holding six snapshots and 0 bytes free.
Three separate defects, each sufficient on its own:

1. cleanup() is the LAST phase of the update, so retention never ran on
   a run that died earlier -- including the run that died BECAUSE the
   disk was full. Retention that only executes on success cannot bound a
   failure loop. backup_current now trims BEFORE writing, which also
   makes peak usage `keep` snapshots rather than `keep + 1`.

2. The preflight required a flat 2 GiB, but the update is about to write
   two full copies of the database. With an 800 MB database a box at
   2.1 GiB free passed the check and then filled mid-update. The
   requirement is now 2 GiB + 2x the database, it trims stale snapshots
   and re-checks before giving up, and the halt message points at
   `serverkit disk`.

3. A count cap is not a disk guarantee: five snapshots of an 800 MB
   database is 8 GB, a third of a 25 GB droplet. The default drops to 3
   and enforce_backup_budget caps backups at a share of the filesystem
   (SERVERKIT_BACKUP_MAX_PERCENT, default 15), dropping oldest-first and
   always keeping at least one restore point -- an update with no way
   back is worse than a full disk.

BACKUP_DIR also becomes SERVERKIT_BACKUP_DIR-overridable so it can agree
with the reclaim service, which scans the same directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Brings the disk-space work (serverkit disk, telemetry retention, updater
hardening) plus the AI attachment composer, operations run consolidation
and remote-access pickers already on the branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The raw subprocess census caught disk_reclaim_service as a 25th call
site against a ceiling of 24. The ceiling only goes down, and this is a
case where the shared door is genuinely the better call rather than a
box to tick.

_run's own `except OSError` turned a missing binary into an empty
result, which is exactly the sbin-PATH failure mode: a unit whose $PATH
omits sbin cannot exec a bare `journalctl`, and this sweep would have
reported "nothing to reclaim" on a host that had plenty to reclaim --
a false fact about the operator's disk rather than a visible error.

run_checked resolves argv[0] the way privileged_cmd does, and separates
"never ran" (returncode None) from "answered no". _run keeps its
(ok, stdout, stderr) shape, so callers and tests are unchanged; a
missing binary still degrades to a zero-sized candidate instead of
aborting the scan, but now the reason reaches stderr.

Census back to 24/24; all six ratchets pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ratchet only fails on a DROP, so a floor left un-bumped never
complains -- it just quietly stops protecting anything. At 4417 against
4530 collected, 113 tests could have been deleted with CI still green.

113 new tests since the floor was last set: ~35 from the disk-space work
(test_disk_reclaim.py plus the disk --json case) and the rest from the
branch merged into dev.

Verified trustworthy before committing: `pytest tests --collect-only`
(what backend-ci.yml runs) and the bare `pytest` that check_test_count.py
uses both report 4530 -- backend/dev-data/ errors out of collection
rather than adding to it, so the two agree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 21, 2026 23:26
jhd3197 and others added 5 commits August 21, 2026 19:30
check-frontend-boundaries failed on dev: _ai-assistant.scss carried 11
colour literals against a legacy baseline of 10.

The extra one came in with the attachment composer as
`var(--warning, <amber literal>)`. `--warning` is not a defined token
anywhere in the tree, so that declaration always fell through to the
literal -- a hardcoded amber that runtime skins could never recolour,
which is precisely what the boundary check exists to catch. (_doctor.scss
reaches for the same undefined token with a *different* fallback, so the
two rendered slightly different ambers.)

$warning resolves to var(--amber, …), the real token. The adjacent line
already uses $warning-raw, so the variables are in scope; $warning itself
must not be fed to fade() since it expands to a var() rather than a
compile-time colour.

File back to 10 literals, matching the baseline without moving it.
Verified: boundary check, theme-token check, npm run lint and npm run
build all pass.

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

The floor was --update'd from a dev box again, so CI collected 4516 against
a floor of 4530 and the ratchet went red for a suite that lost nothing.
Same 14 cases as last time (4431 -> 4417): test_builtin_extension_drift.py
parametrises over the on-disk intersection of builtin-extensions/<ext>/backend
and app/plugins/<ext>, and four live copies here are gitignored --
cloud-provision (3), ftp (3), remote-access (5), status (3).

Fixing the number alone would queue up a third occurrence, so the
machine-dependence is closed instead: check_test_count.py now collects with
SERVERKIT_CLEAN_COLLECT=1, and under that flag the drift test pairs only
git-TRACKED live copies. Ordinary runs are untouched and still compare the
gitignored copies on the machine that has them.

Verified with the WSL venv: the ratchet now reports 4518 locally, exactly
CI's 4516 plus the two tests added here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot stopped reviewing on behalf of jhd3197 due to an error August 21, 2026 23:47
jhd3197 and others added 6 commits August 21, 2026 20:14
The attachment round added two hand-shaped `jsonify({'error': str(exc)}), 400`
returns in api/ai.py, which pushed the error-shape census to 1146 against a
ceiling of 1144 and turned shard 2 red. The census policy is not "raise the
ceiling" -- new endpoints raise, and these are new.

AttachmentValidationError now subclasses app.exceptions.ValidationError, so the
global handler owns the 400 body, the `code`, and the X-Request-ID correlation.
It is still a ValueError (ValidationError subclasses one), so every existing
`except ValueError` caller and the pytest.raises assertions in
test_ai_attachments.py are unchanged, and the two try/except blocks in the
routes disappear entirely.

Census back to 1144 = ceiling. Verified: test_ai_attachments, test_ai_assistant,
test_error_shape_ratchet all green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The plan-77 G3 guard grepped raw file text for `create_app(`, so it counted
mentions. test_disk_reclaim.py failed CI for a docstring reading "Stand-in for
create_app() -- the disk command only needs a context": that file monkeypatches
the factory away and never boots anything. The same false positive had already
put test_api_error_shape.py into the baseline, for a comment.

Detection is now AST-based -- a real call, direct or attributed. String
literals still count, because test_ai_lazy_import.py boots its probe app from an
embedded script; docstrings and comments never boot anything, and an
unparseable file falls back to the old blunt check rather than passing free.

Baseline drops test_api_error_shape.py (it uses the shared `app` fixture) and
gains nothing. test_boot_detection_reads_code_not_prose proves the scanner
against both false positives and all three true positives, so the next docstring
does not turn CI red. BASELINE_COUNT +1 for it.

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

Two more shards were red on generated artifacts nobody regenerated:

- api_controller_boundary_baseline.json: the attachment round rewrote
  _persist_user_message to build the row before adding it, so the fingerprint
  moved from `db.session.add(AiMessage(...))` to `db.session.add(message)`.
  Same crossing, same category, count unchanged at 509 -- one line.

- docs/MIGRATION_INVENTORY.md: three measured numbers had moved without a
  regeneration -- bare @jwt_required() routes 577 -> 576, and the two frontend
  rows whose totals come from maps inside check-frontend-boundaries.mjs
  (setInterval pollers 10 -> 9, hex colour literals 151 -> 150). All three are
  deterministic AST/constant counts, so these are CI's numbers, not this box's.

The reason nobody caught the inventory locally is the third fix here: the
generator hardcoded `node`, and under WSL the Windows nvm directory is on PATH
but exposes only `node.exe`, so test_migration_inventory.py skipped on the dev
box. Both the generator and the test now resolve $NODE / node / node.exe, and
the test runs instead of skipping.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five red CI rounds in one day, none of them a broken feature: a floor measured
on a dev box, a fingerprint baseline left unregenerated after a refactor, a
ceiling that moved with the code, a generated doc gone stale, and a text-grep
guard that matched a docstring. Every one is an AST or text scan over the tree
that needs no shard and no push to run.

preflight.sh runs the test-count ratchet, every backend ratchet/census/guard
test (matched by filename, so a new ratchet is covered the day it lands rather
than the day someone remembers this script), the frontend boundary check, and
the migration-inventory freshness check. About two minutes against a five-minute
push-and-wait.

It resolves node.exe through WSL interop, so the one WSL invocation in the
header comment covers both halves on this dev box. The inventory check compares
against a snapshot of the file rather than against HEAD, so an uncommitted but
correct regeneration is a pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jhd3197
jhd3197 merged commit 5ff4a4e into main Aug 22, 2026
37 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant