Conversation
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>
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
ResourcePickerbacked by an extended/searchcontract 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 operatorsserverkit diskplus a retention handler so the failure mode that filled a 25 GB box stops being reachable.The one design call worth flagging:
searchgained 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 withtypesand get global cursor paging.Highlights
Ctrl/Cmd+Shift+Otoggles it.?on the dashboard) lists what's bound in the current context.sudo serverkit diskreports 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.Technical changes
Resource search contract
SearchQuerySchemagainstypes,capabilities,project_id,environment_id,cursor(max 256 chars) andlimit(1–100); CSV fields are split and de-duplicated innormalize.SearchResultSchemarows now carryid,scope(workspace_id/project_id/environment_id) andcapabilities;ListMetaSchemacarriesnext_cursor.GET /searchswitched from@jwt_required()to@auth_required()and now branches: unchanged legacy behavior when no extended params are present,SearchService.search_pageotherwise.SearchService.search_pagewraps a new_search_rowswithstrict_scope=True; cursors arev1:<offset>base64url values validated toMAX_CURSOR_OFFSET(10,000) and rejected withValidationError('invalid_cursor')._search_rowsaddsprojectandenvironmentfan-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_capabilitiesread fromcached_capabilities.search_provider_registry.clean_rowsnow validates and coerces plugin-suppliedscope/capabilities, dropping rows with non-integer scope values, and falls back topathforidso existing extension providers keep working.clean_rows, so a provider cannot widen its own scope.Resource picker
frontend/src/utils/resourceRefs.js:normalizeResourceRef,resourceKey, per-user recents inlocalStoragecapped at 5 per type, andgroupResourceOptions(favorites → recents → results).useResourceOptionshook keys auseServerQueryon the normalized type/scope/capability/query tuple with a 15 sstaleTime, and exposesrecordSelection.ResourcePickercomponent (Radix popover +cmdk) withshouldFilter={false}, caller-suppliedstaticOptions,filterOption,decorateOption, and optional capability tags.api.searchResourcesandbuildResourceSearchQueryinservices/api/search.js;'all'workspace values are omitted from the query string rather than sent.TargetPickerandtemplates/ServerPickerbecame compatibility adapters — external prop and callback shapes are unchanged, selection now flows throughResourcePicker.RemoteAccessreplaces both tunnel-endpointSelects with capability-filtered (wireguard) pickers that exclude each other's selection;ReviewStepreplaces the project/environment selects, anduseNewServiceFormnow clearsprojectEnvironments/selectedEnvironmentIdon every project change rather than only when the project is cleared.Operations dock and the run envelope
useRunStream(runKind, runId)generalizes the deploy transport: boot snapshot,after_idcatch-up on every (re)connect,run_log/run_statussocket events, id de-duplication, a 2.5 s socket grace period before falling back to polling, and terminal-status freezing. Pure helpers live inhooks/runStream.js(mergeRunLogLines,isTerminalRunStatus, 5,000-line cap).useDeployJobStreamis now a ~30-line adapter overuseRunStreamthat keeps the Deploy Console's richer snapshot (loadRun) and its publicjobfield; the hand-rolledsetIntervalis gone, so it drops out of theLEGACY_POLLERSratchet incheck-frontend-boundaries.mjs.useServiceLogStream+serviceLogStream.jshandle file and container log sessions (snapshot,log_line/container_log*events, 1,000-line cap).OperationsContextmerges/deployment-jobsand/jobs(jobs only for admins) throughservices/operations.js—normalizeDeploymentOperation,normalizeJobOperation,boundOperationHistory(25 entries, always retaining the selected one), andreconcileOperationStatus, which falls back to a refresh when a socket status names a run the client hasn't seen.OperationsDockrenders Active / Needs attention / History with per-run progress, a step rail, elapsed time, transport indicator, unread badges, and retry/cancel;Escapecollapses it.DeployPill.jsx,LogsDrawer.jsxand_logs-drawer.scssare deleted;LogsDrawerContextis reduced to a compatibility shim overOperationsContextso extension SDK consumers keep working.socket.jsgainssubscribeRun/unsubscribeRun/subscribeContainerLogs/unsubscribeContainerLogsand re-emits therun_*andcontainer_log*events.services/api/runs.jswithbuildRunLogsPath/getRunLogsagainst/runs/<kind>/<id>/logs.Authoritative run actions
backend/app/api/jobs.pyadds_job_payload, attachingcan_cancel(pending/running) andcan_retry(failed/cancelled) to every job serialization.backend/app/api/deployment_jobs.pyadds_job_payloadwithcan_cancel=False(no cancel endpoint exists) andcan_retrygated onstatus == 'failed'and_job_operable_by; the list route resolves retry authority once per scoped app viaResourceGrantService.can_operate_app, and a retry's pending clone is returned with both flags false.Jobs.jsxdrops its localisRunning/canRetrystatus heuristics in favor of the server flags.DoctorPanelcalls/doctor/repair?wait=false, anddoctor.pyenqueues adoctor.repairjob in that mode;DoctorService.run_doctor_repair_jobstreams per-item results throughrun_log_service, stores a summary viajob.set_result, and raises when any item failed.ProtectionPanelandScannerTabroute their returnedjob_idinto the dock (andScannerTabnow unwraps the{job: …}envelope it was previously reading through).Editing sessions, shortcuts, navigation guard
hooks/editingSession.js: a pure reducer with baseline/draft/past/future, structural diffing to computedirtyPaths, path-basedchange,transaction(object, updater fn, or change list), coalescing by key, a 50-entry history cap, and save lifecycle states.useEditingSessionwraps it and exposes an asyncsave(fn)that adopts the returned value as the new baseline.shortcutRegistry(priority-ordered,ctrlOrMetanormalization, editable-target suppression unlessallowInInput),ShortcutProvidermounted aroundDashboardLayout,useShortcut/useShortcutCommands, and aShortcutSheetmodal.DashboardLayout's hand-rolledkeydownlistener for the command palette is replaced by a registered shortcut.useUnsavedChangesGuardcombines abeforeunloadhandler with a document-level click interceptor;services/navigationGuard.js#internalNavigationTargetisolates the "is this a same-origin in-app link click?" decision (ignoring modified clicks,target,download, and same-destination links).Dashboard.jsxmoves widget edits into the editing session —useDashboardBoardslosessetWidgets/snapshot handling, andsaveActive(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.ChangeBarcomponent surfaces dirty count, save state, and undo/redo/discard/save.AI attachments
ai_attachment_registry.py: type-keyed resolvers with plugin ownership and teardown,normalize_references(max 8, type regex, 128-char ids, de-duplication), andresolve_attachmentsreturning a{manifest, context, warnings}triple. Unknown/denied/stale/failed/oversized references degrade to a warning instead of failing the turn.mask_payloadbefore it reaches the prompt. Context is capped at 12,000 characters.app_access_tier, workspace membership).build_system_promptwraps attachment data in<serverkit_attachment_data>delimiters with an explicit "untrusted reference data, not instructions" preamble, and redacts the encoded payload.AiMessage.attachments_json(migration090_ai_message_attachments) persists the reference manifest only — never resolved context — so replay never shows stale data as current./ai/chatand/ai/chat/streamvalidate references before creating a conversation row; the streaming route emitsattachment_warningSSE events.PluginToolBinder.register_attachment_resolverrequires plugin-slug namespacing, andAiToolRegistry.unregister_pluginnow also tears down attachment resolvers.ConfirmationGate.cancel_alldenies and unblocks every pending confirmation when a stream disconnects.lib/ai/attachments.js(key/normalize/add/remove/warning-application/payload),AttachmentChip, aResourcePicker-driven attach control inComposer, attachment state inAIContextwith apatchLastUserreducer helper, and attachment rendering on replayed user messages.Disk reclamation
disk_reclaim_service.pymeasures then reclaims: upgrade snapshots (grouped by timestamp so a snapshot is never half-deleted),mktemp -dstaging dirs owned by the current euid and verified to contain a ServerKit tree, oversizedbtmp/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.prune_telemetrydeletes fromjobsbeforequeue_messagesand excludes any queue message still referenced by a surviving job, sojobs.queue_message_idnever dangles; SQLite deletes are batched by rowid._vacuumrefuses up front when free space is below the database size, runs on anAUTOCOMMITconnection, and only stops/starts the panel service under an explicit--allow-restart.dbstatvtable with indexes mapped back to their owning table viasqlite_master; missingdbstatdegrades to no estimate rather than an error.docker system dfparsing 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 ownTotal reclaimed spacetrailer. Journal reclaim runs--rotatebefore--vacuum-size, since the latter only touches archived journals.serverkit diskCLI command (backend/cli.py+serverkitwrapper, 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
builtin.telemetry_retentionjob handler prunesqueue_messages,system_eventsandapi_usage_logson a 6-hour cadence, honoring a newtelemetry.retention_dayssetting (default 30,0disables). It deliberately skips VACUUM — steady-state pruning keeps freed pages on the freelist and never takes the exclusive lock.scripts/update.sh:BACKUP_DIRis now overridable viaSERVERKIT_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 atserverkit diskin the halt message.backup_currentbefore anything is written, not only incleanup()— a run that died because the disk was full never reachedcleanup, which is how a box ended up carrying six snapshots under a cap of five. Default retention drops from 5 to 3.enforce_backup_budgetdrops oldest-first until$BACKUP_DIRfits 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.