---
url: 'https://adk.nht.io/batteries/dev-tools/workspace.md'
description: >-
  Eight fields, three path maps and two baselines — because a file has three
  identities the moment a rename meets a write, and a final-state diff can
  recover none of them.
---

# The Workspace

## LLM summary — Dev Tools workspace model

* `DevWorkspace` fields: `files: Map<string, WorkspaceFile>` (`{text, mimeType}`), `unreadable: Set<string>`, `token: DevWorkspaceToken`, `diagnostics: Diagnostic[]`, `renames: Map<destination, acquisitionPath>`, `persistedPaths: Map<workspacePath, onDiskPath>`, `pendingDeletions: Map<lastWorkspacePath, onDiskPath>`, `recreated: Set<string>`.
* `renames` answers "what should the summary call this"; `persistedPaths` answers "where is it on disk now". They diverge after the first write — that is the reason both exist.
* Two EXECUTION-PRIVATE baselines: `acquisitionBaseline` (frozen; `DevResult.changes` diffs against it) and `persistedBaseline` (refreshed per write/re-read; `write` diffs against it). Not workspace fields — middleware can replace the workspace via `shortCircuit`.
* `changes` is final-state vs acquisition, one row per path. Reverted → no row. `recreated` → `added`. Renamed+edited → one `renamed` row with `from`. `unreadable` → no row.
* Acquisition: literal if no `*`, else glob. Patterns validated lexically, enumerated, then each CONCRETE match runs the path gauntlet — `toRelative` cannot take a pattern.
* Glob grammar (hand-written, one matcher for matching + overlap + `mkdir` satisfiability): `*` within a segment, `**` only as a whole segment, case-sensitive, no `?`/`[]`/`{}`/`!`/escapes, dot-leading needs a dot-leading pattern segment, symlinks not followed, results deduped and sorted.
* MIME: whole-filename map → configured `mimeResolver` → extension resolver. Still unmapped is refused. Binaries refused.
* Bounds enforced AS COLLECTED at acquisition (throws). At a post-write re-read they NEVER throw: pre-existing files admitted first, then new ones, remainder reported as error diagnostics.

A file has one name right up until the moment it does not. Rename it and there are two: the one the caller knows and the one it now answers to. Persist that rename and there are three, because disk has an opinion too. Rename it again and any structure that kept only one of those is now lying to you.

`DevWorkspace` keeps eight fields, three path maps and two baselines. Each exists because a final-state diff cannot answer a question the runtime has to answer anyway — and the failure mode of guessing is not a crash, it is a wrong file on disk and a change summary that says everything is fine.

## The eight-field workspace

The workspace is the state passed across pipeline stages. Every field exists because omitting it forces the runtime into heuristic recovery:

| Field | Type | Purpose |
| :--- | :--- | :--- |
| `files` | `Map<string, WorkspaceFile>` | Current in-memory state. Each `WorkspaceFile` holds `{ text, mimeType }`. |
| `unreadable` | `Set<string>` | Paths previously acquired but dropped after a failed re-read. The file remains on disk. |
| `token` | `DevWorkspaceToken` | Runtime-minted opaque token, unique per execution and write operation. |
| `diagnostics` | `Diagnostic[]` | Diagnostic messages accumulated across pipeline steps. |
| `renames` | `Map<string, string>` | Destination path mapped to the *original acquisition path*. |
| `persistedPaths` | `Map<string, string>` | Current workspace path mapped to the path the file *currently occupies on disk*. |
| `pendingDeletions` | `Map<string, string>` | Last workspace path mapped to the on-disk path awaiting physical removal. |
| `recreated` | `Set<string>` | Set of original acquisition paths that were vacated and subsequently created anew. |

This structure makes ambiguity impossible. If a file is unreadable on disk after a write, it does not disappear from existence; it lands in `unreadable` so the runtime knows disk still holds it. If a stage needs to verify that the workspace state has not drifted across asynchronous writes, the opaque `token` enforces that alignment.

## Three path maps: why identity splits under mutation

Treating a file's name as a single mutable string is an architectural trap. At any point in a multi-step pipeline, a file has three distinct identities: what the caller called it, what the workspace calls it, and what the filesystem calls it. Collapsing these into one map guarantees corruption the moment intermediate writes occur.

::: info The three questions

* `renames` answers: **What should the change summary call this file?** Its value is always the initial acquisition path that the caller provided.
* `persistedPaths` answers: **What path does this file occupy on disk right now?**
* `pendingDeletions` answers: **What disk path must be removed when a previously renamed file is deleted?**
  :::

Consider a sequence across multiple steps:

1. Acquire `a.ts`.
2. A patch renames `a.ts` to `b.ts`.
3. A write step persists changes to disk. The disk now holds `b.ts`.
4. A subsequent patch renames `b.ts` to `c.ts`.

At step 4, `renames` holds `c.ts → a.ts`. That is correct for reporting to the caller, but issuing an operating system rename of `a.ts → c.ts` will fail because `a.ts` no longer exists on disk. Without `persistedPaths` recording that the file is physically located at `b.ts`, the runtime would have to fall back to deleting `a.ts` and writing `c.ts` from scratch, throwing away the atomicity of the filesystem adapter's rename operation.

`pendingDeletions` prevents a companion failure: if you acquire `a.ts`, rename it to `b.ts`, and then delete `b.ts`, simply dropping the `persistedPaths` entry destroys the only record that the physical file requiring deletion on disk is `a.ts`.

`recreated` solves the replacement paradox. If an acquired path `src/index.ts` is deleted or renamed away, and a subsequent stage writes a brand new file into `src/index.ts`, the final workspace contains a path that exists in the acquisition baseline. A naive final-state diff would compare the new content against the old content and declare the file `modified`—or report no change at all if the generated text happened to match. Both conclusions are false. The file that ends at that path is not the file that started there. `recreated` forces the change summary to report `added`.

## Two baselines: execution-private truth

The pipeline tracks two separate baselines throughout execution:

```
[Acquisition] ──> acquisitionBaseline (frozen) ──────────────────> Diff for DevResult.changes
              └──> persistedBaseline   ───(refreshed on write)───> Diff for FS write calls
```

* `acquisitionBaseline` is captured exactly once during initial acquisition and is **never mutated**. The final `DevResult.changes` diffs against this baseline.
* `persistedBaseline` starts as a clone of the acquisition baseline, but is **refreshed after every write step and post-write re-read**. The write step diffs against this baseline to calculate the minimal filesystem delta to persist.

If you use a single refreshed baseline, a post-write fixer re-read empties your change summary, reporting zero modifications to the caller. If you use a single frozen baseline, every write step re-persists the entire accumulated delta over and over again.

::: info The round-trip edit guarantee
If a file is edited, persisted to disk, and then edited back to its exact acquisition text in a later stage, it **is** written to disk again—because disk currently holds the intermediate version. Yet the final summary correctly reports **no row** for that file, because its final state matches its acquisition state. One necessary filesystem write, zero reported changes.
:::

These baselines are strictly **execution-private** and are never exposed as fields on `DevWorkspace`. Middleware stages can replace the entire workspace object via `shortCircuit`. Storing baselines on the workspace would allow an errant stage to drop or overwrite them, silently corrupting change summaries and write calculations.

## Final-state change accounting

`DevResult.changes` produces a clean, reconciled final-state report computed once at the end of the pipeline. It is not an append-only event stream:

* **Edited then reverted:** Emits no row.
* **Path in `recreated`:** Emits `added`, never `modified`.
* **Edited, persisted, and edited again by a fixer:** Emits a single row containing cumulative line and byte counts.
* **Added then deleted within the run:** Emits no row.
* **File present in `unreadable`:** Emits no row, because the file remains on disk and was not deleted.
* **Renamed and edited:** Emits a single `renamed` row with `from` populated, never a disjoint rename plus a modify.

`changes` reports the state of the **managed workspace**, not the underlying repository. If an engine runs a subprocess that alters unacquired files on disk, those modifications do not appear in `changes`. The workspace does not pretend to be `git status`.

## Acquisition and the path gauntlet

Acquisition resolves matching files without ever executing raw globs against the filesystem adapter. A pattern is never translated directly into a relative path; its concrete matches are. Attempting to `stat('src/**/*.ts')` will fail on any real filesystem because `*` is a literal path component. The engine validates patterns lexically, enumerates entries under the translated root, and passes each concrete match through the path validation gauntlet.

```typescript
// Glob grammar: minimal by strict rejection
const isValidGlob = (pattern: string) => {
  // Disallowed: character classes [abc], braces {a,b}, negation !, '?', escapes \
  // Allowed: '*' within a segment, '**' only as a standalone segment
}
```

The glob grammar is hand-written rather than imported:

* `*` is permitted only within a single path segment.
* `**` is permitted only as an entire, standalone path segment.
* No brace expansion (`{a,b}`), no negation (`!`), no character classes (`[a-z]`), no single-character wildcards (`?`), and no escape sequences.
* Matching is strictly case-sensitive across all platforms.
* Hidden files and directories are excluded unless the pattern segment explicitly begins with `.`.
* Symlinks are never followed.
* Results are deduplicated and sorted deterministically.

This grammar is intentionally minimal by rejection rather than silent interpretation. A pattern like `src/[abc]/index.ts` could be three literal characters or a character set; the difference dictates whether an authorization scope permits a destructive write. The engine rejects ambiguous syntax during validation rather than delegating it to third-party library heuristics. A single hand-written matcher simultaneously powers glob matching, glob overlap analysis, and `mkdir` satisfiability—three calculations that must always agree.

Binary files are refused at acquisition. The workspace operates exclusively on text. MIME resolution follows a strict three-tier precedence:

1. **Whole-filename map:** Exact names like `Makefile`, `Dockerfile`, `.eslintrc`, and `.gitignore` resolve directly to `text/plain`.
2. **Configured resolver:** Any explicit custom resolver supplied to the pipeline.
3. **Extension resolver:** Standard extension-to-MIME mapping.

Any file that remains unmapped after all three tiers is **refused**. Extensionless binaries in directories like `bin/` are standard; attempting to sniff arbitrary byte headers is guesswork that this battery declines to perform.

## Bounding and the re-read inversion

Acquisition enforces workspace bounds eagerly **as entries are collected**. If a glob matches a million files, the collector aborts the moment it crosses the threshold. Collecting an unbounded list of paths into memory and validating the size afterward is an unmitigated memory leak.

At a post-write re-read, this bounding rule **inverts**:

::: warning Re-read bounding never throws
An overage during a post-write re-read is caused by an external tool or engine execution, not by the user's initial acquisition request. Aborting the pipeline at this stage would destroy an otherwise successful run and discard valid execution results.
:::

When a post-write re-read exceeds capacity:

1. **Pre-existing files** already in the workspace are admitted first, sorted lexically.
2. **Newly created files** are admitted next into any remaining capacity, sorted lexically.
3. Any files that cannot fit are dropped from the workspace and reported as error diagnostics.

Losing a file the workspace already held is strictly worse than declining to load a newly spawned artifact. The workspace preserves baseline continuity first, surfaces diagnostics for the overflow, and completes execution safely.
