-
Notifications
You must be signed in to change notification settings - Fork 0
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.
| 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.
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>
}-
workspaceisSnapshotWorkspacefromspace-snapshots.ts:index.projectsmetadata (id, name, cwd,closed?,archived?, plus arbitrary extra keys),projectsas aprojectId → SerializedNode[]map, optionalcurrentProjectId, and the per-projectrevsmap. -
revsare required by contract in a bundle: restore paths skip rev-less projects as "older", soBundleSourceDeps.revFormust answer for every project (0 when unknown). The tests assert that a rev of0is still written into the map rather than dropped. -
filesis declared in the shape and always emitted as{}bybuildBundle— it exists for envelope compatibility with the space payload, which does populate it (${cwd}/.termsprawl/project.jsoninbuildProjectPushPayloadwhen the project has a folder and the file was readable). -
scrollbacksmaps terminal node id → capped text.
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"]
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.
isValidBundle is a total function: it never throws, because callers hand it arbitrary parsed JSON. It rejects, in order:
- Anything that is not a non-array object.
- A missing/non-object
bundleheader. -
bundle.format !== BUNDLE_FORMAT. -
bundle.versionnot a number, or not exactlyBUNDLE_VERSION— newer versions are rejected, not tolerated. - A missing
workspace, missingworkspace.index, or non-arrayindex.projects. - An empty project list: there is nothing to import.
- A missing/non-object
workspace.projects. - 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.
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).
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.
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 --> [*]
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.
- Every imported project gets a fresh project id from
opts.newProjectId()— bundle project ids are never reused. -
cwdis forced tonull, 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 isString(meta.name ?? freshId).trim() || freshId, and each accepted name is added to the runningtakenNamesset — so two projects inside the same bundle that share a name also get suffixed. -
revis carried through asbundle.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-1andp-2appear in the plan).
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.
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
Two ordering constraints are explicit in the source:
-
Validate before planning.
applyBundlePlanre-runsisValidBundleand throwsInvalid workspace bundle (bad format, unsupported version, or truncated)for a bad payload; it also throwsThe workspace bundle has no projects to bring inif the index is empty. Callers are expected to gate withisValidBundlefirst so that user-facing failures are reported as validation, not exceptions. -
Projects before scrollbacks.
pendingScrollbacksis annotated as "hand toScrollbackStore.importSnapshotafter 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.
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
pendingScrollbacksthrough 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 |
-
Empty workspace is not exportable-importable:
isValidBundlerequires at least one index project. -
Missing nodes key ≠ empty nodes.
undefinedfails validation;[]passes. -
Terminal nodes without usable ids are silently skipped by
terminalIdsIn, so they are never remapped and never receive scrollback. -
filesis always{}frombuildBundle. A bundle produced here does not carry.termsprawl/project.jsoncontents, 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:
?? 0means "unknown", and restore paths on the space side treat rev-less/older snapshots as stale and skip them. -
Forward compatibility is closed:
version !== BUNDLE_VERSIONfails, so introducing a v2 requires a deliberate reader.
-
Bump
BUNDLE_VERSIONtogether withisValidBundle'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 (buildProjectPushPayloadwrites${cwd}/.termsprawl/project.jsonwhen available); a futureBundleSourceDepsreader can fill it without changing the import plan shape. -
Extend
BundleSourceDepswith 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'sexistingNamesset — the plan is the only place identity is minted. -
Reuse
uniqueNameWithSuffixfor any new import path;space-snapshots.tsimplements the same<base> 2,<base> 3loop 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
terminalIdsInand 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
Generated from termsprawl at 0d4393be54c6200beedd91bb636e5296c30472c5.
App Shell & Platform Foundations
- Electron Main Process & Window Lifecycle
- Preload Bridge & IPC Contract
- Shared Domain Types and File/URL Helpers
- Renderer Bootstrap & App Composition
- Build Targets & TypeScript Configuration
Canvas, Nodes & Renderer State
- Infinite Canvas Surface & Viewport Interaction
- Workspace, Project & Tab State
- Node Links, Edges & Link Inspector
- Sticky, Group, Editor & Diff Nodes
- Keyboard Canvas Navigation & Cross-Panel Requests
- Theme, Accent & Visual Language
- Boot Overlay, Onboarding & Shared UI Kit
Terminals & Session Continuity
- PTY Lifecycle & Terminal Sessions
- tmux Session Naming & Reattach
- Scrollback Snapshots & Cold Replay
- Terminal Node Rendering (xterm.js)
- SSH Remote Projects, Terminals & Files
Persistence, Projects & Files
- Workspace Store & Project File Layout
- Project Scope, Deletion & Worktree Registry
- Workspace Bundle Export/Import
- File Service & File Tree UI
Agent Runtime & Tooling
- Agent Status Model & Hook Normalization
- Hook Server & CLI Hook Installers
- Agent Launch, CLI Probing & Managed Accounts
- Agent Tool Protocol & In-Process Server
- Agent Tool Client, CLI & MCP Entry
- Transcripts, Context Discovery & Context CLI
- Agent Canvas State & Status Badges
Chat Nodes & Model Providers
- Chat Runtime, Conversation & Cost
- Model Provider Adapters & Streaming
- Chat Tool Calling & Project Tools
- Chat Node UI
Git & Source Control
Embedded Browser Nodes
- Browser Manager & Guest Runtime
- CDP Facade & Browser Agent Server
- Browser Navigation Policy & Node UI
Server Edition
- Server Bootstrap & HTTP/WebSocket Entry
- RPC Dispatch, Handlers & Service Bridges
- Renderer Shim & Server Boundary
- Server Auth & Security Boundary
Relay & Remote Access
- Relay Hub & WebSocket Frame Routing
- Relay End-to-End Cryptography
- Relay Auth, Invites, Store & Admin API
- Relay Client, Pairing & Terminal Tunneling
- Relay Trust UI
Integrations & Secondary Surfaces
- Telegram Bot, Commands & Pairing
- A2A Peers: Protocol, Client & Server
- Node Link Engine, Registry & Scheduler
- Cloud Spaces, Snapshots & Sync
Settings, Updates & Maintenance