Keep VACUUM snapshots private until permissions and durability are established. Refuse to recreate a missing rollback backup after import has begun, and preserve secondary cleanup failures in startup logs.
The activation module described itself as a temporary compatibility
bridge but bypassed the structure the migrations strategy prescribes: no
dated migrations/ file, no src/migrations.rs registry entry, no
REMOVAL_DEADLINE, and no removal_deadline log field. The strategy doc's
removal checklist (grep REMOVAL_DEADLINE, explicit registry ordering)
would never have surfaced it, letting the bridge silently outlive its
window as a second, parallel migration mechanism in serve.rs.
The module now lives at migrations/2026082301_sqlite_blob_activation.rs,
is registered and re-exported through src/migrations.rs like the two
existing server migrations, carries a REMOVAL_DEADLINE eligibility floor
(removal still requires the evidence and explicit approval in the module
docs), and logs removal_deadline on every activation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Neither the pre-activation backup nor the pre-migration snapshot fsynced
the staged file contents or the parent directory around the publishing
rename. A crash after the import committed could lose the retained
'.pre-blob-activation.bak' (whose directory entry was never made
durable), and the next activation would then write a new backup that
already contains the imported blobs, silently breaking the documented
pre-activation rollback boundary; a torn staging file could likewise
wedge later boots in backup validation.
write_snapshot_to_staging now syncs the staged file before handing it to
the caller, and both publishers sync the destination's parent directory
after their rename (fabro-db on a blocking task, activation inside its
existing blocking publication task).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
create_backup re-implemented the staging half of fabro-db's
pre-migration snapshot (remove stale staging file, UTF-8 check,
VACUUM INTO, private permissions), and remove_file_if_exists and
set_private_permissions had been made pub precisely to hand-copy that
sequence. Any future hardening of snapshot staging would have had to
land in two crates and could drift.
fabro-db now exposes write_snapshot_to_staging with a typed
SnapshotStagingError; both the pre-migration snapshot and the
pre-activation backup stage through it, and the hand-copied helpers are
private again. The publish halves stay separate on purpose: migrations
overwrite their snapshot, activation publishes with persist_noclobber
plus integrity validation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PRAGMA wal_checkpoint(TRUNCATE) returning busy=1 aborted server startup.
Any external reader that outlives the pool's five-second busy timeout (a
replication agent, a backup tool, an operator sqlite3 shell) would crash
the boot, and a supervisor restart would loop into the same abort while
the reader persisted, over a condition that threatens no data integrity.
A busy truncate now logs a warning and startup continues; a later
checkpoint truncates the WAL once the reader is gone. Adds the
failure-path coverage the relocated checkpoint lost: a held read
snapshot blocks the truncate and activation still succeeds.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
available_space_for_path returning None aborted startup with a fatal
UnknownFilesystem error, even on a fresh install with zero legacy rows.
Hosts with tmpfs or squashfs roots, network-filesystem data paths, or an
unreadable mount table would fail every boot with no operator override,
while the resource sampler already treats the identical condition as
benign (supported: false) and keeps running.
The preflight now logs a warning and is skipped when free space cannot
be determined; the import, verification, and integrity checks still run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The preflight demanded ~1.5x the full legacy inventory bytes free on
every startup, with no credit for rows already imported. Because the
first activation itself consumes about twice the legacy bytes (the
SQLite copy plus the retained backup) and the legacy keyspace stays in
place for the whole retention window, a successfully activated server
could fall below the requirement and become unable to restart until an
operator freed space the server would never write.
The legacy inventory now checks each row's hash against the SQLite blobs
table and reports pending rows and bytes, and the preflight requires
1.5x only the pending bytes plus the backup reserve and fixed headroom.
A warm restart with nothing left to import needs only the headroom.
Also updates the server operations doc for this and for the
verification pass now running only on boots that import rows.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Startup previously scanned the legacy SlateDB keyspace three times and
SHA-256-hashed every value in each pass (inventory, import,
verification), then read and rehashed every row of the live SQLite blobs
table — on every boot, even a warm restart with nothing to import. With
a large object-store-backed legacy keyspace that makes restart time
proportional to total blob bytes for the whole retention window.
The inventory pass now only validates key shapes and sizes the keyspace;
digests are still validated by the import pass before any row persists.
The independent verification sweep now runs only on boots whose import
actually inserted rows: the import pass itself byte-compares every
already-present legacy row each boot, so a no-op restart is already
fully cross-checked without a third scan or a full-table rehash.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
import_legacy_blobs_into and verify_legacy_blobs_in took a &BlobStore and
extracted its pool through sqlite_pool_for_legacy_import, an Option that
was statically always Some in production (the None arm existed only for
the test-only Slate backend). That accessor forced a clippy
unnecessary_wraps suppression and two WrongTargetBackend error variants
no production caller could ever hit, and the activation path round-tripped
a pool it already owned through a BlobStore it had just built.
Both functions now take &SqlitePool, deleting the accessor, the
suppression, both unreachable variants, and their rejection test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fabro-workflow's and fabro-server's src/test_support.rs import
fabro_store::test_support, but their test-support features never enabled
fabro-store/test-support. Workspace builds passed only through feature
unification from other members' dev-dependencies, while per-crate builds
such as `cargo check -p fabro-cli --tests` or
`cargo check -p fabro-server --features test-support` failed with E0432.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Blob activation cleanups:
- Reuse fabro-db's append_to_path, remove_file_if_exists, and
set_private_permissions instead of local duplicates.
- Return the store directly from activate_blob_storage; the report
wrapper existed only to be logged internally and then discarded.
- Collapse compute_disk_preflight to return the required free bytes
instead of echoing its inputs back through a struct.
- Deduplicate the "exactly one ok row" PRAGMA integrity_check protocol
into one executor-generic helper used by the backup and live checks.
- Skip re-validating a freshly published backup; the staging copy was
validated immediately before the atomic rename, so only a
concurrently published file needs its own validation.
- Replace the manual anyhow wrapping plus duplicate error log in
serve.rs with a plain .context(), matching other startup errors.
- Extract the disk-candidate enumeration in resource_sampler.rs that
available_space_for_path had copy-pasted from sample_disk_resources.
Test fixture cleanups:
- Route all hand-assembled Database::new(..., test_blob_store()) test
fixtures (32 sites) through fabro_store::test_support::test_database,
and make that helper infallible instead of returning an unconditional
Ok.
- Install the test blob schema from fabro_db::BLOBS_MIGRATION_SQL via a
test-support-gated optional dependency instead of a four-level
relative include_str! into fabro-db's migrations directory.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Apply cleanups from a reuse/simplification/efficiency review of the
bounded-tool-output changes:
- Share one MAX_RUN_EVENT_BODY_BYTES constant in fabro-types; the server
body limit, the agent's serialized-output reservation, and the event
headroom test all derive from it.
- Rework truncation.rs around one split_head_tail helper: drop the
hand-rolled ceil_char_boundary (std's is stable), the duplicate
truncate_plain_output splitter and its dead Tail arm, and the
head_bytes field with its sentinel values.
- Return Cow from preview_tool_output and take retain_tool_output's
input by value, so untruncated output crosses the pipeline without
full copies. Measure serialized JSON size with a counting writer
instead of materializing the payload.
- Reuse fabro-llm's byte-token estimate (now public) instead of a third
copy of the 4-bytes-per-token heuristic.
- Take retain_tool_result's ToolResult by value and mutate content in
place; extract the triplicated error retain-emit-truncate block into
finish_error_result.
- Share the shell retain-and-record sequence between the native and
kimi shell tools as retain_shell_output.
- Move OutputCaptureBuffer::into_parts to reuse the head allocation,
skip the buffer round-trip in replay_exec_result when output fits,
and replace daytona's byte-iterator suffix matching with contiguous
slice comparisons behind one retained_slices accessor.
- Make SessionBoundEmitter's fields private.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TK3QTWQHiXhRbFwTr57LzX
Cap closure expansion at 256 distinct workflow mounts. Mounts are keyed
by rebased path, so a small chain of stored versions that mounts a
shared dependency along two paths per level expands exponentially; a
single authenticated create request could stall the server before any
error was returned. The check also bounds the recursion depth.
Resolve file-form run goals through the certified version: expose
ValidatedWorkflowVersion::resolved_goal_file_content, which reuses the
exact grammar store validation certified, and drop the parallel
resolution (and its unreachable-for-stored-versions error variants) the
server had re-implemented. The certified entrypoint-presence invariant
replaces the MissingEntrypoint error the same way.
Destructure both environment layer types without `..` when pinning
server environment authority, so a new server-owned field becomes a
compile-time decision instead of silently escaping the pin.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Lowering and compiler rejections now carry the top-level error message
in the 422 detail, matching the diagnostic depth the legacy manifest
lane already returns for identical defects; the full source chain stays
in the server log.
Pre-persistence store failures stop claiming run_persistence_failed:
credential-store reads return credential_store_error and run-variable
snapshots return variable_store_error, so alerting keyed on codes
triages the failing subsystem instead of a persistence outage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both lanes now deserialize the raw request bytes directly instead of
round-tripping through a serde_json::Value, which silently collapsed
duplicate JSON keys to last-key-wins on the legacy manifest lane and
stripped line/column locations from manifest parse errors.
When neither lane accepts the body, attribution now recognizes a
defective manifest by its required keys, so a legacy manifest carrying a
stray workflow_version_id keeps its 400 manifest error instead of being
misrouted to a 422 run_intent_invalid describing a schema the caller
never used.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The create-run dispatcher deep-cloned the parsed JSON body once to
attempt the RunIntent shape and again for the RunManifest fallback,
so every legacy manifest request paid two full copies of a body that
carries entire workflow bundles. Deserialize both shapes from a
reference to the parsed value instead; routing and error attribution
are unchanged.
Also bind the lowered goal slot once in inline_goal_file rather than
re-navigating the settings layer and asserting the goal is still there.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The intent and legacy-manifest create handlers each carried a full copy
of the same post-admission sequence: LLM readiness resolution, graph
compilation and model pinning, persistence, summary read, managed-run
registration, title-generation spawn, and the 201 response. The copies
had already drifted on when the run ID is resolved (before compilation
in one lane, after in the other).
Extract one finalize_created_run tail, with a small CreatedRunErrorStyle
carrying each lane's pinned error mapping and log lines so the wire
contracts are unchanged. Both lanes now resolve identity before
compilation and share the parent-link validation, which lets the
PinnedRun copy of PreparedRun's identity accessors be deleted.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Git-target grammar (slug, branch, and SHA rules plus the derived
origin URL) was implemented twice with no shared code path: once in
server admission and again in sandbox start, so the two could drift and
disagree about which persisted targets are valid.
Own it once as RunTarget::validate() in fabro-types, next to the
primitives it uses, returning the canonical target together with its
derived GitContext projection. Admission consumes it directly, and the
start path re-derives the expected clone source from the same rules
before checking the persisted projection against it. The start path now
also moves the derived strings into the sandbox spec instead of cloning
them.
While reordering admission around the shared validator, run the pure,
in-memory checks (target grammar, environment id) before the blob-store
closure fetch and lowering so malformed requests no longer pay for
version-store I/O.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Preserve the SQLite auth-session release notes alongside main's July 26 fixes and retain all current changelog navigation entries. Make the refresh-token rotation timestamp assertion deterministic after the merged suite exposed its wall-clock race.
Consolidate the copies that review found across the feature:
- One GITHUB_CREDENTIAL_HELPER / GITHUB_CREDENTIAL_HELPER_KEY pair in
fabro-github, with apply_probe_git_env() for probe commands; the runtime
git bridge, server preflight probe, and live contract test all consume it
so the probes exercise exactly what the bridge configures.
- GitHubRepositoryAccess::resolve_verified_token() owns the
resolve-installations-then-mint choreography shared by server preflight,
workflow initialization, and the live test.
- A shared lookup_installation() helper backs both the shared-installation
resolution and the mint's installation lookup.
- The contents = read|write rule lives once as
RunIntegrationsGithubSettings::contents_permission_allows_repository_access.
- The preflight probe paces retries with fabro-sandbox's exported
replication_backoff() (3s/9s) instead of a contradicting 1s/2s loop, and
shares one run_ls_remote() runner with the existing remote-ref check.
Also: collapse the dead Ok(None) arm and repeated error blocks in the
preflight token check, drop the derivable bridge_entry_count(), privatize
resolve_permissions() behind resolve_integration(), make
GitHubRepositorySlug ordering/hashing allocation-free, and use EnvVars
constants for env names.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Brave stays the default. Shops that already vault VENICE_API_KEY
can drop BRAVE_SEARCH_API_KEY by setting
[server.integrations.search] provider = "venice".
Co-authored-by: Cursor <cursoragent@cursor.com>
When a run declares additional repositories, preflight now proves the
whole effective set works instead of treating a minted token as proof:
- It constructs the same validated `GitHubRepositoryAccess` used by
runtime initialization, so the two paths cannot disagree.
- In App mode it first resolves every repository's installation with
the App JWT and requires one shared installation ID, naming any
repository the App cannot see before the mint; then it mints the one
scoped token, failing with the raw error on rejection.
- Every effective repository gets a non-interactive
`git ls-remote <url> HEAD` probe through a shared helper that keeps
the token out of the URL, argv, and errors (a credential helper reads
GITHUB_TOKEN from the child environment), retries auth-shaped
failures with the same token to cover replication lag (classified
via fabro_sandbox::classify_failure), and reports one check per
repository in deterministic primary-first order under bounded
concurrency.
- A resolved run environment that defines GH_TOKEN produces a warning
(gh prefers it over the managed token) without failing preflight.
- With no additional repositories declared, the primary-only mint
check is byte-for-byte unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Carry the resolved GitHub integration (permissions plus declared
additional repositories) as one value from run materialization into
workflow startup, and make the sandbox environment reach every declared
repository through the single managed GITHUB_TOKEN.
- `StartServices.github_permissions` becomes
`github_integration: ResolvedGithubIntegration`; CLI and server
workers build it with `resolve_integration()` after interpolation and
pass it through `SandboxEnvSpec` as one unit.
- `build_sandbox_env` constructs the validated
`GitHubRepositoryAccess` and scopes the App token source to the whole
effective set. Missing credentials or a missing origin are hard
initialization errors when additional repositories are declared;
legacy permissions-only configuration keeps its best-effort behavior.
- When additional repositories are declared, initialization eagerly
resolves each repository's App installation (naming any repository
the App cannot see) and the token itself, so an inaccessible declared
repository fails before the first workflow stage.
- A new `git_bridge` module injects secret-free `GIT_CONFIG_*` entries
into the stage environment: a github.com credential helper that reads
`$GITHUB_TOKEN` at invocation time, per-repository SSH-to-HTTPS
`insteadOf` rewrites, and `GIT_TERMINAL_PROMPT=0`. Entries append
after a valid user-provided Git config overlay and fail clearly on a
malformed one. Contract tests drive the installed git binary against
local fixtures for the rewrite, credential, prefix-collision, and
overlay-preservation behaviors.
- The long-running ACP notice now says all declared repository access
expires together.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add `additional_repositories` to `[run.integrations.github]`: a list of
full `owner/repository` slugs, beyond the implicit run origin, that the
minted GITHUB_TOKEN must cover.
- `GitHubRepositorySlug` gains FromStr, Display, string serde, and
case-insensitive Eq/Ord/Hash identity while preserving the submitted
spelling for display and serialization.
- The config layer keeps raw strings; the higher-precedence list
replaces the lower one wholesale, with `[]` as an explicit clear,
resolving independently from the `permissions` map.
- Resolution validates each entry with indexed error paths: slug
grammar, case-insensitive duplicates, one shared owner, the
499-repository cap, and a required `contents = "read"|"write"`
permission (templated values are re-checked at the runtime boundary).
- `RunIntegrationsGithubSettings` resolves permissions and repositories
together through `resolve_integration()` so consumers cannot pick up
one without the other; the field is omitted from serialization when
empty, keeping single-repository settings byte-identical.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fabro can mint a scoped sandbox GITHUB_TOKEN via
[run.integrations.github.permissions], but apps registered through the
manifest flow could not grant packages = "read" because the manifest
never requested it. Add Packages (read-only) so freshly registered apps
can download private GitHub Packages (for example npm registry
dependencies) inside sandboxes, mirroring how GitHub Actions workflows
use their built-in GITHUB_TOKEN for registry reads.
Existing apps still need the permission added manually in the app's
settings, as the docs already describe.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the public stored-token row with an initial-token input that carries only token-specific facts. Bind the token to the session and initialize it as unused inside AuthSessionStore so callers cannot create mismatched session/token rows.
Delete the owning auth session inside the refresh-token rotation transaction when a spent token is replayed. Return the replay outcome only after the revocation commits, and propagate database failures without claiming the chain was revoked.
The lineage field's `skip_serializing_if` behavior was asserted five times
across three crates. Keep the two assertions in fabro-types, which owns the
attribute, and drop the duplicates:
- Delete `run_created_omits_absent_workflow_version_id` from event/convert.rs,
a copy of the test above it that re-checked another crate's serde attribute.
convert.rs's own responsibility is covered by the existing field assertion.
- Delete `legacy_create_input_persists_without_workflow_version_id`, which ran
the full create() pipeline to prove a hardcoded `None` literal is `None`.
`CreateRunInput` has no such field, so no input could change the result.
- Fold `run_spec_omits_absent_workflow_version_id` into the adjacent legacy-spec
test, which already holds an all-`None` record.
- Drop the off-topic spec re-serialization from run_state.rs's retried_from test.
Add `test_support::test_workflow_version_id()` alongside `test_run_provenance()`
and use it everywhere, replacing eight copies of the same magic seed across five
crates plus two assertion sites that recomputed the hash inline. This also
subsumes retry.rs's private helper of the same shape.
Revert the `run_spec_json` parameterization in the projection round-trip test:
`RunProjection` is a `with_replacement` alias for the canonical type, so the
`Some` and `None` call sites exercise identical code.
Have the two run.created literals that mirror a `RunSpec` read the spec's
lineage field instead of hardcoding `None`, so the mirrors stay accurate once a
producer populates it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
RunId is a ULID, so its embedded timestamp is truncated to whole
milliseconds, while Variable.updated_at comes from Utc::now() with
sub-millisecond precision. When the variable write and the run creation
landed in the same millisecond, the run id compared as earlier and the
assertion failed. Truncate the variable timestamp to milliseconds so
both sides use the same precision.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>