Skip to content

Workspace Bundle Export Import

dazeb edited this page Sep 17, 2026 · 2 revisions

Workspace Bundle Export/Import

A workspace bundle is the entire workspace as one JSON file: the workspace index, every project's serialized nodes, and the captured terminal scrollbacks. The module that defines it, src/core/workspace-bundle.ts, is deliberately pure and dependency-injected — it contains no electron import and no fs usage. Callers (Electron main, the workspace store) supply readers for index/nodes/revs/scrollback and perform all I/O; the module only knows how to assemble, validate, and plan the landing of the bundle.

The body of the bundle is exactly the spaces-sync envelope (SnapshotWorkspace, as used by SpaceSnapshotPayload). That is the central design decision: file export, cloud sync, and the space boot path all speak one format, so an import path only ever has to be written once.

Files in scope

File Role
src/core/workspace-bundle.ts Bundle format constants, WorkspaceBundle shape, buildBundle, isValidBundle, terminalIdsIn, uniqueNameWithSuffix, applyBundlePlan. Pure, no I/O.
src/core/space-snapshots.ts Defines SnapshotWorkspace (the bundle body), snapshotCurrentProject, uniqueOnlineSnapshotName, buildProjectPushPayload. The same envelope used for single-project pushes to a cloud space.
src/core/workspace-bundle.test.ts Unit tests for build / validate / plan: header + envelope shape, rev inclusion, collision-safe names, remap rules, distinct remapped ids for multi-terminal projects.
src/core/workspace-bundle.roundtrip.test.ts End-to-end round trip: seed a fake workspace, build a bundle, compute a plan against simulated local state (colliding project name and colliding terminal id), land pendingScrollbacks through the real ScrollbackStore and read the text back.
src/core/workspace-bundle.proto-probe.test.ts Present in the suite; its assertions were not part of the read evidence for this page.

The header comment points at the design record: .hermes/plans/2026-08-30_123805-workspace-bundle.md.

Bundle shape

const BUNDLE_FORMAT = 'termsprawl-workspace'
const BUNDLE_VERSION = 1

interface WorkspaceBundle {
  bundle: { format: 'termsprawl-workspace'; version: number; savedAt: string }
  workspace: SnapshotWorkspace      // index + projects + revs + currentProjectId?
  files: Record<string, unknown>
  scrollbacks: Record<string, string>
}
  • workspace is SnapshotWorkspace from space-snapshots.ts: index.projects metadata (id, name, cwd, closed?, archived?, plus arbitrary extra keys), projects as a projectId → SerializedNode[] map, optional currentProjectId, and the per-project revs map.
  • revs are required by contract in a bundle: restore paths skip rev-less projects as "older", so BundleSourceDeps.revFor must answer for every project (0 when unknown). The tests assert that a rev of 0 is still written into the map rather than dropped.
  • files is declared in the shape and always emitted as {} by buildBundle — it exists for envelope compatibility with the space payload, which does populate it (${cwd}/.termsprawl/project.json in buildProjectPushPayload when the project has a folder and the file was readable).
  • scrollbacks maps terminal node id → capped text.

Export path: buildBundle

flowchart TD
  A["caller: store index + project files + scrollback store"] --> B["BundleSourceDeps<br/>index, nodesFor, revFor, scrollbacksFor, currentProjectId?, now?"]
  B --> C["buildBundle(deps)"]
  C --> C1["for each meta of index.projects:<br/>projects[id] = nodesFor(id)<br/>revs[id] = revFor(id)<br/>collect terminalIdsIn(nodes)"]
  C1 --> D["scrollbacksFor(allTerminalIds)"]
  D --> E["WorkspaceBundle<br/>bundle header + workspace envelope + files: {} + scrollbacks"]
  E --> F["caller serializes to JSON"]
Loading

buildBundle walks deps.index.projects once. For each project metadata it materialises projects[meta.id] (falling back to []), records revs[meta.id], and appends that project's terminal ids into one flat list. Only after the walk does it call deps.scrollbacksFor(allTerminalIds), so the scrollback reader is invoked exactly once with the full id set — the caller decides what to return (the tests return only ids that actually have stored text).

savedAt is the only non-deterministic value, and it is injectable (deps.now). The serialized JSON keeps bundle as the first key, so a reader can cheaply peek at format/version before parsing the rest of the payload.

terminalIdsIn is the shared id extractor: keep nodes whose type === 'terminal', coerce the id via String(id ?? ''), drop empties. Order is stable.

Validation: isValidBundle

isValidBundle is a total function: it never throws, because callers hand it arbitrary parsed JSON. It rejects, in order:

  1. Anything that is not a non-array object.
  2. A missing/non-object bundle header.
  3. bundle.format !== BUNDLE_FORMAT.
  4. bundle.version not a number, or not exactly BUNDLE_VERSION — newer versions are rejected, not tolerated.
  5. A missing workspace, missing workspace.index, or non-array index.projects.
  6. An empty project list: there is nothing to import.
  7. A missing/non-object workspace.projects.
  8. Any index project lacking an array entry in workspace.projects[p.id] — a missing key means a truncated or corrupt bundle, while an empty array is valid (an archived project with no nodes imports as empty).

Note what is not checked: node-level shape, scrollback contents, revs, and currentProjectId. Validation is an envelope-and-integrity gate, not a node schema gate.

Import plan: applyBundlePlan

The import side never mutates a store. It computes a BundleImportPlan — fresh project identities, remapped nodes, and scrollback text keyed by the post-remap terminal ids — and the caller lands it.

interface BundleImportPlan {
  projects: Array<{ id: string; name: string; cwd: null; nodes: SerializedNode[]; rev: number }>
  pendingScrollbacks: Map<string, string>   // keyed by POST-remap terminal id
}

Inputs: existingNames, existingTerminalIds, newProjectId(), newTerminalId(ordinal).

Why terminal ids are load-bearing

The module states the invariant directly: pty session id == tmux key == scrollback file == persisted node id. A terminal id is therefore not a cosmetic label; if two machines import each other's bundles and end up sharing an id, their tmux keys and scrollback files collide. Non-terminal ids carry no such weight and are always kept as-is.

The remap rule

stateDiagram-v2
  [*] --> Inspect: for each index project
  Inspect --> KeepIds: no terminal id intersects existingTerminalIds
  Inspect --> RemapAll: ANY terminal id intersects existingTerminalIds
  KeepIds --> Emit: nodes unchanged, scrollback keys unchanged
  RemapAll --> Assign: idMap = every terminal id → newTerminalId(ordinal++)
  Assign --> Emit: nodes spread with fresh id, scrollback keys remapped in lockstep
  Emit --> [*]
Loading

The remap decision is per project and all-or-nothing within that project: if even one of a project's terminal ids collides, every terminal in that project is remapped. Mixing kept and fresh identities across machines is described in the source as exactly how duplicate-tmux-key bugs happen. A second project in the same bundle that has no collisions keeps its original terminal ids.

Non-terminal nodes are returned untouched even when an id map exists — the remap only fires when the node's id is a string present in idMap.

Project identity and naming

  • Every imported project gets a fresh project id from opts.newProjectId() — bundle project ids are never reused.
  • cwd is forced to null, because the bundle's cwd belonged to another machine.
  • Names are made collision-safe through uniqueNameWithSuffix(base, taken), the shared <base>, <base> 2, <base> 3… scheme used across import paths. The base is String(meta.name ?? freshId).trim() || freshId, and each accepted name is added to the running takenNames set — so two projects inside the same bundle that share a name also get suffixed.
  • rev is carried through as bundle.workspace.revs?.[meta.id] ?? 0; import paths re-save to bump it.
  • Archived/closed projects are imported like any other project (the unit test asserts both p-1 and p-2 appear in the plan).

Scrollbacks travel in lockstep

For each terminal id in the project, the bundle's text is looked up by the original id, and written into pendingScrollbacks under idMap.get(id) ?? id. Non-string entries are skipped entirely (a terminal with no captured text simply produces no map entry). This is the mechanism that keeps the remapped id and its scrollback file aligned.

Rehydrate order

sequenceDiagram
  participant U as Caller (main / IPC)
  participant V as isValidBundle
  participant P as applyBundlePlan
  participant W as workspace:import/add + node save
  participant S as ScrollbackStore.importSnapshot

  U->>V: parsed JSON from file
  V-->>U: type guard (never throws)
  U->>P: bundle + existingNames + existingTerminalIds + id factories
  P-->>U: BundleImportPlan (projects + pendingScrollbacks)
  U->>W: create each project, save remapped nodes
  W-->>U: projects landed
  U->>S: pendingScrollbacks (POST-remap keys)
  S-->>U: scrollback available for rehydrated terminals
Loading

Two ordering constraints are explicit in the source:

  1. Validate before planning. applyBundlePlan re-runs isValidBundle and throws Invalid workspace bundle (bad format, unsupported version, or truncated) for a bad payload; it also throws The workspace bundle has no projects to bring in if the index is empty. Callers are expected to gate with isValidBundle first so that user-facing failures are reported as validation, not exceptions.
  2. Projects before scrollbacks. pendingScrollbacks is annotated as "hand to ScrollbackStore.importSnapshot after the projects land" — the store is addressed by terminal id, so the ids must already exist locally.

The caller-facing operations referenced by the plan comment are workspace:import/add (project creation) and the ordinary node-save path; cwd: null means the imported projects are canvas-only until the user re-points them at a local folder.

Round-trip integrity

workspace-bundle.roundtrip.test.ts is the guarantee test, and it is intentionally narrow: pure fs plus the real ScrollbackStore, with no Electron, tmux, or PTY. It

  • seeds a fake workspace (two projects, terminal + non-terminal nodes, known scrollback text),
  • builds a bundle from it,
  • imports against simulated local state containing both a colliding project name and a colliding terminal id, forcing the remap-all path,
  • lands pendingScrollbacks through the real store and reads the text back from disk.

The property under test is the one the design cares about: the remapped terminal id carries the original scrollback text. Combined with the unit tests, the integrity contract is:

Guarantee Evidence
Envelope survives a JSON.stringify/parse cycle with the bundle header first and the format string intact workspace-bundle.test.ts
Corrupt/truncated bundles are rejected without throwing junk list + missing projects[p.id] case
Unsupported (including newer) versions are rejected version-mutation test
Every index project becomes a fresh local project, with cwd: null and the bundle's rev plan test
Non-colliding terminal ids are kept (cheap path) plan test
One collision remaps all terminals of that project plan test with existingTerminalIds = {'n-1'}
Multi-terminal projects get pairwise-distinct remapped ids multi-terminal plan test
Name collisions resolve to <base> 2, <base> 3 deterministically suffix tests, both unit and shared helper
Remapped id ↔ scrollback text stays paired across a real store round trip round-trip test

Boundary conditions to watch

  • Empty workspace is not exportable-importable: isValidBundle requires at least one index project.
  • Missing nodes key ≠ empty nodes. undefined fails validation; [] passes.
  • Terminal nodes without usable ids are silently skipped by terminalIdsIn, so they are never remapped and never receive scrollback.
  • files is always {} from buildBundle. A bundle produced here does not carry .termsprawl/project.json contents, whereas a space push payload can. This asymmetry is deliberate-format-compatible but not content-equivalent.
  • Project id collisions are impossible by construction (newProjectId() per project), but non-terminal node id collisions are possible and tolerated — those ids are not load-bearing.
  • Rev is advisory on import: ?? 0 means "unknown", and restore paths on the space side treat rev-less/older snapshots as stale and skip them.
  • Forward compatibility is closed: version !== BUNDLE_VERSION fails, so introducing a v2 requires a deliberate reader.

Extension points

  • Bump BUNDLE_VERSION together with isValidBundle's equality gate. Because the version check is strict, adding a v2 means deciding whether v1 bundles are migrated before planning or rejected.
  • Populate files. The field already travels through the same envelope the space payload uses (buildProjectPushPayload writes ${cwd}/.termsprawl/project.json when available); a future BundleSourceDeps reader can fill it without changing the import plan shape.
  • Extend BundleSourceDeps with another injected reader (the module's documented contract is that main/the store own I/O), rather than importing fs or electron here.
  • Swap id policy through newProjectId / newTerminalId, and name policy through the caller's existingNames set — the plan is the only place identity is minted.
  • Reuse uniqueNameWithSuffix for any new import path; space-snapshots.ts implements the same <base> 2, <base> 3 loop for online snapshot names and is the sibling entry point for single-project pulls.
  • Add terminal-like node kinds carefully. Anything whose id becomes a pty/tmux/scrollback key must route through terminalIdsIn and the remap branch, or the load-bearing invariant breaks.

Sources: src/core/workspace-bundle.ts, src/core/space-snapshots.ts, src/core/workspace-bundle.test.ts, src/core/workspace-bundle.roundtrip.test.ts

termsprawl

App Shell & Platform Foundations

Canvas, Nodes & Renderer State

Terminals & Session Continuity

Persistence, Projects & Files

Agent Runtime & Tooling

Chat Nodes & Model Providers

Git & Source Control

Embedded Browser Nodes

Server Edition

Relay & Remote Access

Integrations & Secondary Surfaces

Settings, Updates & Maintenance

Clone this wiki locally