Astrolabe / ajxpublic /korvus
Repository

Workspace Sync, Checkout and Materialization

reference scope internal/workspace Verification failed
  • internal/workspace/sync.go
  • internal/workspace/materialize.go
  • internal/workspace/client.go
  • Syncer
  • Revision
  • Status
  • Options
  • MaterializeResult
  • seededPrefixes
  • Materialize
Relevant source files · 10

The workspace repository is Korvus's source of truth for the material an investigation runs on: the skills that shape the agent's prompt, the documentation it can search, and the report templates it writes from. Every session is pinned to one commit of that repository, and the pin is recorded on the session row so that a past run can be explained later. This page covers the machinery that turns a remote git repository into that pin: the Syncer that keeps a local checkout in step with the remote, and Materialize, which copies the pinned commit's trees into a session's scratch directory.

The package is internal/workspace. It uses go-git as a library and never shells out to a git binary — the configuration type that drives it says so explicitly, because the git binary would mean os/exec Workspace. The go-git packages are imported directly for the repository, config, plumbing and HTTP transport layers imports.

How it works

Constructing a Syncer

NewSyncer validates the Options and returns a Syncer that has not synced yet. It deliberately does not touch the network: startup ordering decides when the first sync happens, and a constructor that blocked on a remote would turn a dead git host into a boot failure NewSyncer. Only URL and Path are required; Ref empty means the remote's default branch, and Username/Token carry HTTP basic auth for a private repository Options.

Options mirrors the workspace block of the configuration file rather than taking config.Workspace directly, so the package stays testable without a configuration object; the wiring that connects the two lives in the process start-up path startWorkspaceSync. See Configuration Loading and Validation for how the YAML block is read and validated.

The Syncer holds the remote URL, the ref, the checkout path and the credentials, plus two locks and a swappable clock. The clock (now func() time.Time) is injected so tests can pin timestamps without sleeping Syncer.

One sync, step by step

Sync is the only mutating entry point. It takes the sync lock, checks that the caller's context is still alive, resolves the target ref, and then delegates the git work to fetch Sync. A context that died while queued is not an attempt: no request was made against the remote, so recording a failure would mark a healthy deployment stale with an error naming a host that was never contacted.

fetch performs the git work and returns the resulting revision without touching any Syncer state, so a failure anywhere inside it cannot leave the served revision half-updated fetch. Its sequence is:

  1. Open or clone. openOrClone tries git.PlainOpen at the checkout path and, when there is no repository there, clones into a temporary sibling directory and renames it into place only after the clone has fully succeeded. The clone uses NoCheckout, because the default checkout would write the remote HEAD's tree to disk before the commit had been validated openOrClone.
  2. Fetch. fetchRemote updates the remote-tracking refs with explicit refspecs for heads and tags, so a checkout cloned at one branch still sees a tag or another branch the admin RPC asks for. NoErrAlreadyUpToDate is the ordinary outcome and not a failure fetchRemote.
  3. Resolve the ref. resolve tries the remote-tracking branch first, then a tag, then a local branch, and only last treats the string as a revision such as a SHA. The remote branch comes first on purpose: after a fetch, the local refs/heads/main still points at whatever the clone saw, so a resolver that tried it first would report success while serving the previous revision resolve.
  4. Pick the default branch. When no ref is configured, remoteHead reads the remote's HEAD, falling back to the fetched remote-tracking branches. It must never consult the local HEAD, because every successful sync ends in a detached checkout and the local HEAD would then resolve to the commit already checked out remoteHead. When the remote advertises several branches and none is named main or master, it refuses rather than picking one, and tells the operator to set workspace.ref remoteHead.
  5. Validate the commit's symlinks. The commit's tree is scanned before anything is written to the working tree fetch.
  6. Check out. The tree is force-checked-out at the resolved hash. Force is correct here: the checkout is Korvus-owned and read-only to sessions, so there is no user edit to preserve, and a half-written tree from an interrupted sync must not survive into the next one fetch.

Authentication is HTTP basic auth, and a token configured without a username is given the placeholder oauth2, because go-git's BasicAuth rejects an empty username auth.

validateSymlinks runs at sync time, before the commit is checked out or pinned, because the runtime checks are keyed on path names: a denied name symlinked onto a tree stored elsewhere makes the canonical spelling writable, and only a look at the whole tree can see that. A link is refused when it sits inside a deny-listed top-level directory, has an absolute target, or resolves outside the checkout or to nothing when followed through the tree the way open(2) would follow it validateSymlinks.

The commit is refused as a whole rather than with the offending links dropped, because a pinned commit must be exactly what the session's workspace ref names validateSymlinks. The error message lists at most ten offending links and appends a count of the remainder, so a repository with a million links cannot turn the sync log line or Status.LastError into a megabyte maxReportedSymlinks. The same four read-only prefixes are enforced at runtime by the sandbox tools denyWritePrefixes; the sibling page describes that side Workspace Sandbox Client and Built-in Tools.

Stale versus never synced

The state a reader sees is Status: the revision being served, whether the last refresh failed while a good revision was still in place, the most recent error, and when the last attempt happened Status. Current returns just the revision, and ErrNeverSynced when nothing has ever synced Current ErrNeverSynced.

A failure only marks the deployment stale when there is a good revision to keep serving. A deployment that has never synced is not stale, it is unprovisioned, and the two are recorded differently so that an operator is not sent looking for a checkout that was never there recordFailure. A success clears the error and the stale flag and records the new revision recordSuccess.

Revision.Ref is always the full 40-character commit SHA, never a branch name: a branch moves, and a session stamped with a branch name would not identify what it ran against Revision.

Periodic refresh and the admin RPC

Run refreshes the checkout on every tick of the configured interval until the context is cancelled. It does not perform an initial sync — the caller does that one synchronously — and every failure inside the loop is logged and swallowed, so a transient git outage cannot end the loop and freeze the revision until the next restart Run.

The transport layer sees the syncer through a two-method interface, Status and Sync, declared in the package that consumes it so the transport's own tests need no real git repository WorkspaceSyncer. GetSystemStatus fills the workspace fields of its response from Status applyWorkspaceStatus, and the SyncWorkspace RPC passes a caller-supplied git ref straight into Sync, returning Unavailable rather than Internal when it fails, because the previous revision is still being served and the caller may retry SyncWorkspace. See Connect API Server and Service Handlers for the RPC surface.

The after-sync hook

Options.AfterSync runs after every successful sync while the sync lock is still held, so the working tree at the checkout path stays at the new revision for the whole call and no other sync can rewrite it underneath. The hook owns its own duration and its own errors: a hook failure never turns a successful sync into a failed one AfterSync Sync.

In the shipped wiring that hook re-indexes the documentation base, so the first session already has a docs_search over the revision it runs against docsAfterSync. The indexer side of that is described in Documentation Chunking, Indexing and Search.

Materialization into a session's scratch directory

Materialize writes the repository's skills/, docs/ and templates/ trees, as they are at a given commit, into a destination directory that must already exist Materialize. The set of trees is the package-level seededPrefixes list seededPrefixes. The runtime deny list has one more entry, context/, whose content is the incident's inherited artifacts rather than anything from the repository denyWritePrefixes.

Three decisions shape the implementation:

  • It reads the commit's tree out of the object store, not the live checkout. A concurrent sync force-checks-out a different commit into that directory, and a copy taken during one would mix two revisions while the session row names only one. Objects are immutable once written, so the read takes no lock against a fetch and a session start never queues behind a network round-trip Materialize.
  • It opens the object store directly rather than through git.PlainOpen, which reads HEAD first; a checkout rewrites HEAD in place, and an open that lands mid-rewrite fails with "repository does not exist" Materialize.
  • It writes through os.Root, so a tree entry whose name a hostile repository crafted cannot land outside the destination: containment is checked by the kernel on every path component rather than by a prefix comparison Materialize.

Files land read-only, mode 0444, because the workspace tools already refuse writes under these prefixes and the mode makes the same promise to anything else that opens the directory writeBlob. The write uses O_EXCL: the destination is a fresh directory, so an existing file means two tree entries mapped to one path, which must fail rather than let the second silently replace the first writeBlob.

Entries that are not regular files — symlinks and submodules — are counted in MaterializeResult.Skipped rather than copied. A symlink is not followed even when it points inside the tree: sync already refuses links that leave the tree or alias a read-only directory, but a copy that followed one would publish a file at a path the repository never contained MaterializeResult.

Who calls it

The session manager depends on an interface rather than the concrete type, so the session package's tests can substitute a fake seeder Seeder. *workspace.Syncer satisfies it Materialize, and the manager calls it with the session's pinned commit and its scratch root; a failure here is logged and the run continues without a workspace rather than failing the session seedScratch. The scratch directory, the inherited context/ files and the removal of the directory afterwards are covered in Session Persistence, Reopen and Scratch Seeding, and the session's workspace ref itself is recorded in the Storage Domain Model and Store Interfaces.

Concurrency model

Two locks, with deliberately different jobs. The read-write mutex guards current and the status fields only, so Status and Current readers never block behind a network round-trip. The sync mutex serialises the git work — the fetch into .git/refs and the forced checkout that rewrites the whole working directory — and is held across the whole of Sync rather than only the fetch, so the status fields land in the same order the checkouts did Syncer.

Calls queue rather than fail. Two concurrent syncs would run git against one .git directory, and the loser could leave the working tree at a commit other than the one Current reports, with Stale false and no error; the admin RPC landing on an interval tick is the ordinary case, not an error Sync.

Relationships

flowchart TD
    cfg["internal/config/config.go"] --> opt["Options"]
    opt --> syncer["Syncer"]
    syncer --> rev["Revision"]
    syncer --> stat["Status"]
    syncer --> fetch["fetch"]
    fetch --> clone["openOrClone"]
    fetch --> fremote["fetchRemote"]
    fetch --> resolve["resolve"]
    fetch --> symlinks["internal/workspace/symlinks.go"]
    syncer --> mat["Materialize"]
    mat --> prefixes["seededPrefixes"]
    mat --> result["MaterializeResult"]
    syncer --> run["Run"]
    syncer --> client["internal/workspace/client.go"]
    stat --> tstatus["applyWorkspaceStatus"]
    rev --> tstatus
    syncer --> tstatus
    syncer --> tsync["SyncWorkspace"]
    mat --> seeder["internal/session/scratch.go"]
    prefix2["seededPrefixes"] --> seeder

Reference

Symbol Kind What it does Location
ErrNeverSynced var Sentinel returned by Current when no sync has ever succeeded src/internal/workspace/sync.go#L26-L26
Revision struct One successful checkout: the full commit SHA and when it was fetched src/internal/workspace/sync.go#L31-L39
Status struct What is served now, whether the last refresh failed, the last error and attempt time src/internal/workspace/sync.go#L44-L61
Syncer struct The workspace checkout state machine, its two locks and injected clock src/internal/workspace/sync.go#L73-L110
Options struct Syncer configuration: URL, ref, path, credentials, clock, after-sync hook src/internal/workspace/sync.go#L116-L143
NewSyncer func Validates the options and returns a syncer that has not synced yet src/internal/workspace/sync.go#L149-L169
Syncer.Current func The revision being served, or ErrNeverSynced src/internal/workspace/sync.go#L175-L182
Syncer.Status func A snapshot of the served revision and sync health src/internal/workspace/sync.go#L185-L189
Syncer.Sync func Brings the checkout to a ref and returns the revision now served src/internal/workspace/sync.go#L207-L236
Syncer.recordFailure func Records a failed attempt; marks stale only if a good revision exists src/internal/workspace/sync.go#L242-L250
Syncer.recordSuccess func Records the new revision and clears the stale flag and error src/internal/workspace/sync.go#L252-L263
Syncer.fetch func Clone, fetch, resolve, symlink validation and forced checkout src/internal/workspace/sync.go#L268-L311
Syncer.openOrClone func Opens the checkout, or clones into a staging directory and renames it into place src/internal/workspace/sync.go#L323-L364
Syncer.fetchRemote func Updates remote-tracking refs with explicit head and tag refspecs src/internal/workspace/sync.go#L368-L385
Syncer.resolve func Turns a ref, tag, branch or SHA into a commit hash src/internal/workspace/sync.go#L395-L423
Syncer.remoteHead func The remote's default branch, never the local HEAD src/internal/workspace/sync.go#L436-L466
Syncer.remoteBranches func Lists the fetched remote-tracking branch names src/internal/workspace/sync.go#L470-L496
Syncer.auth func HTTP basic auth credentials, or nil for an anonymous clone src/internal/workspace/sync.go#L503-L512
Syncer.Run func Periodic refresh loop that swallows and logs tick failures src/internal/workspace/run.go#L25-L49
validateSymlinks func Refuses a commit whose tree carries a symlink the sandbox cannot treat as harmless src/internal/workspace/symlinks.go#L34-L67
seededPrefixes var The repository trees a session's scratch directory is given src/internal/workspace/materialize.go#L24-L24
MaterializeResult struct Counts of files written and non-regular entries skipped src/internal/workspace/materialize.go#L27-L35
Syncer.Materialize func Writes the seeded trees of a commit into a destination directory src/internal/workspace/materialize.go#L55-L106
writeBlob func Writes one blob read-only through an os.Root src/internal/workspace/materialize.go#L108-L129
Client struct The in-process MCP client serving file tools over a scratch directory src/internal/workspace/client.go#L49-L52

Generated by astrolabe from commit 0c2dca73 on branch main.