Skip to content

Workspace Store & Project File Layout

dazeb edited this page Sep 17, 2026 · 2 revisions

Workspace Store & Project File Layout

The workspace store is the disk-backed half of workspace state. The renderer keeps React Flow as the live source of truth; WorkspaceStore is the main-process service addressed over IPC that owns project metadata and node persistence. It is constructed with a CorePlatform seam (platform.userDataPath) and does not import Electron, so the same store can run under the server entrypoint.

src/core/workspace-files.ts is the pure file-layout and serialization layer. src/core/workspace-store.ts is the stateful orchestration layer that calls it.

Runtime model

WorkspaceStore holds two pieces of in-memory state:

  • index: WorkspaceIndex — project metadata plus cleanup/tombstone queues.
  • revs: Map<string, number> — per-project revision numbers. The constructor seeds each entry from the project file’s rev (0 when no file exists). addProject sets 0; deleteProject deletes the entry. The excerpt does not show the saveNodes body, so the exact read/update of revs during node saves is outside this page.

Nodes are not cached in the store. snapshot() reloads every project file and returns { index, projects }; a project whose file is missing or unreadable comes back as [].

IPC is the boundary to the renderer. Channel names and handler wiring live in the preload/IPC contract; this page covers the store side.

sequenceDiagram
    participant R as Renderer (React Flow live state)
    participant S as WorkspaceStore
    participant F as workspace-files
    participant D as Disk

    Note over S: constructor(platform)
    S->>F: loadIndex(userDataPath)
    F->>D: read workspace.json
    D-->>F: WorkspaceIndex or empty fallback
    loop each indexed project
        S->>F: loadProjectFile(userDataPath, project)
        F->>D: read folder/inline project file
        D-->>F: ProjectFile or null
        F-->>S: file.rev or 0
    end

    R->>S: snapshot()
    S->>F: loadProjectFile per project
    F-->>S: nodes or []
    S-->>R: { index, projects }

    R->>S: save path over IPC
    S->>F: saveProjectFile(..., baseRev, links?)
    F->>D: atomic temp write, then rename
    F-->>S: new rev
Loading

Key nodes: loadIndex is the first disk read and silently falls back to an empty index on any parse/validation error. loadProjectFile seeds revs during construction and later supplies nodes to snapshot. snapshot is intentionally reload-based, not cache-based. saveProjectFile is the file-level write path; the store’s saveNodes wrapper is referenced elsewhere but its body is outside the excerpt.

On-disk layout

Path Format Writer Shareable
<userData>/workspace.json WorkspaceIndex saveIndex No — machine-local
<cwd>/.termsprawl/project.json ProjectFile saveProjectFile Yes — inside the project repo
<userData>/projects/<id>.json ProjectFile saveProjectFile No — for cwd-less/remote projects
<path>.<pid>-<timestamp>-<random>.tmp temporary complete file atomicWriteFile No — transient
<basename>.*.delete staged deletion stageProjectFileRemoval (not shown) No — transient/crash-recovery

Folder projects store nodes inside the project folder so the canvas layout can be committed and shared with the repository. Cwd-less projects, including remote projects whose cwd is null, use the inline userData path instead.

Formats

WorkspaceIndex is version 1 and contains projects, plus optional pendingTerminalCleanup, pendingTerminalNodeCleanup, and terminalTombstones arrays.

ProjectMeta stores id, name, cwd (null means cwd-less inline canvas), optional remote, closed, optional archived, and optional settings. ProjectSettings currently carries accent.

ProjectFile is version 1 and contains rev, nodes, and optional links. Links are optional so pre-link files load unchanged.

SerializedNode has required id, type, and position, with optional width, height, style, and an opaque data: Record<string, unknown> payload.

Read path

Startup and snapshot() both use the same file layer.

  1. loadIndex(userDataPath) reads <userData>/workspace.json. It accepts only version === 1 with a projects array. Missing optional cleanup arrays are defaulted; projects and cleanup entries with unsafe ids are filtered out. Any parse or validation error returns { version: 1, projects: [] }.
  2. For each indexed project, the constructor calls loadProjectFile(userDataPath, project) to seed revs. snapshot() calls it again to collect nodes.
  3. loadProjectFile chooses <cwd>/.termsprawl/project.json when project.cwd is set, otherwise <userData>/projects/<id>.json.
  4. If the chosen file is missing, loadProjectFile looks for staged deletion files in the same directory whose name starts with ${basename(path)}. and ends with .delete, picks the last name after sorting, and renames it back to the live path. This is crash recovery for the window after deletion is staged but before workspace.json is committed. It only restores when the index still references the project.
  5. The file must parse as version === 1 with a nodes array. Links are optional and normalized through parseNodeLinks from shared/node-links. Any error returns null; snapshot() turns null into [].

This parsing is migration-tolerant only in a narrow sense: optional fields can be absent and invalid ids are dropped. There is no version migration. A future format version is treated as a bad file: loadIndex returns an empty index and loadProjectFile returns null.

Write path

saveIndex and saveProjectFile are the two file-level writers.

  • saveIndex validates every project id and every cleanup/tombstone terminal id against isSafeProjectId, creates userDataPath if needed, and writes workspace.json through atomicWriteFile.
  • saveProjectFile computes rev = baseRev + 1. When links is undefined, it reads the existing file and preserves its links; if that read fails, it falls back to []. It then writes ProjectFile as JSON.
    • Folder project: creates <cwd>/.termsprawl and writes <cwd>/.termsprawl/project.json. mkdir failures are wrapped with Cannot save the project into <cwd>: ... so the user sees the location, not a raw EACCES/EROFS.
    • Inline project: creates <userData>/projects and writes <userData>/projects/<id>.json.
    • Returns the new rev.
  • atomicWriteFile writes the entire content to a sibling temp path (<path>.<pid>-<timestamp>-<random>.tmp), then renameSyncs it over the live path. On failure it removes the temp file and rethrows the original error. The temp writer is injectable for tests.

Index-only lifecycle methods (closeProject, archiveProject, reopenProject, renameProject, updateSettings) mutate WorkspaceIndex and persist it; they do not touch project node files. addProject only writes the index; the first node save creates the project file.

updateSettings merges a patch into project.settings, deletes keys whose value is undefined, and collapses an empty settings object to undefined. This keeps in-memory state equal to what a relaunch loads.

Deletion transaction

deleteProject is the only multi-file mutation in the store excerpt. It builds a nextIndex that:

  • removes the project from projects;
  • appends the supplied pendingTerminalIds plus any pendingTerminalNodeCleanup entries for that project into pendingTerminalCleanup, deduplicating (projectId, terminalId);
  • drops pendingTerminalNodeCleanup and terminalTombstones entries for that project.

It then stages removal of the project file (stageProjectFileRemoval), saves the new index, and, on success, commits the staged removal. If saveIndex or commit fails, it rolls back the index when it was already saved, rolls back the staged removal, and throws the original error or an AggregateError if rollback also failed.

flowchart TD
    A[deleteProject(id)] --> B[find ProjectMeta]
    B --> C[build nextIndex: remove project, merge cleanup queues]
    C --> D{project found?}
    D -->|yes| E[stageProjectFileRemoval]
    D -->|no| F[stagedRemoval = null]
    E --> G[saveIndex(nextIndex)]
    F --> G
    G -->|success| H[stagedRemoval.commit]
    G -->|failure| I[rollback index if it was saved]
    H -->|success| L[set in-memory index, delete rev]
    H -->|failure| I
    I --> J[stagedRemoval.rollback]
    J --> K[throw original error or AggregateError]
Loading

Key nodes: stageProjectFileRemoval is the staged deletion seam; its implementation is outside the excerpt. saveIndex commits metadata removal. commit deletes the staged file; rollback restores it. The rollback path aggregates errors if both the original operation and rollback fail.

Note: deleteProject does not early-return for an unknown id. It still writes nextIndex; project-file removal is only staged when project was found.

Terminal cleanup queues

The index carries three optional arrays used to keep terminal teardown consistent across restarts:

  • pendingTerminalCleanup: terminal sessions that still need cleanup after a project is deleted. pendingTerminalIdsForProject reads it; completeTerminalCleanup removes completed terminal ids and persists.
  • pendingTerminalNodeCleanup: terminal nodes that still need to be removed from a project file. stageTerminalNodeClose validates the terminal id and project, adds an entry if missing, and persists. removeTerminalNode reads the project via snapshot(), filters out the node, and calls saveNodes. completeTerminalNodeClose removes the entry and persists.
  • terminalTombstones: tombstones for closed terminal nodes. stageTerminalNodeClose writes them alongside pendingTerminalNodeCleanup. retireCompletedTerminalTombstones is intended for fresh-process startup: it keeps only tombstones whose node/session cleanup is still pending, because a fresh process cannot receive delayed renderer saves from the prior run.

Boundaries and failure modes

  • Unsafe ids: isSafeProjectId is ^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$. It is enforced in addProject, stageTerminalNodeClose, inlineProjectPath, saveIndex, and filtered in loadIndex. This prevents path traversal in inline project files.
  • Duplicate ids: addProject throws if the id already exists. closeProject, archiveProject, reopenProject, renameProject, and updateSettings silently no-op when the project is not found.
  • Corrupt index: loadIndex swallows all errors and returns an empty index. Project files may still exist on disk, but the store will not surface them.
  • Corrupt project file: loadProjectFile returns null; snapshot() reports that project as []. A subsequent node-only save can also fall back to links: [] if it cannot read the existing file to preserve links.
  • Version handling: both workspace.json and project files require version === 1. Adding fields is safe only if they are optional or have a defaulting/filtering path in load.
  • Atomicity: temp files are sibling files and are renamed into place. A crash leaves .tmp files that readers ignore. Staged .delete files are recovered only when the index still references the missing project.
  • Remote projects: addProject stores cwd: null and a remote descriptor. Because loadProjectFile branches on project.cwd, a remote project’s nodes use the inline <userData>/projects/<id>.json path, not a remote .termsprawl/project.json.
  • Folder projects: saveProjectFile creates .termsprawl on save. folderHasProject(cwd) detects an existing project file for adoption. ensureFolderProjectRoot(cwd) is the pre-add validation seam for typed folder paths; the excerpt only shows its documented purpose and the start of its implementation.

Extension points

  • Add project metadata: extend ProjectMeta with an optional field. loadIndex passes unknown fields through from JSON, so old files remain loadable; add validation only if the field is security-sensitive.
  • Add per-project settings: extend ProjectSettings. updateSettings already handles undefined-valued patch keys and empty-object collapse.
  • Add node-type state: SerializedNode.data is Record<string, unknown>. Most node-specific state can live there without changing the file format; required geometry is position, with optional width, height, and style.
  • Add node links: follow the optional links field pattern. Parse/normalize rules belong in shared/node-links so the renderer can import them without pulling in node:fs; workspace-files re-exports the parse helpers for callers that go through the file layer.
  • Add workspace-level bookkeeping: add an optional array to WorkspaceIndex, then update loadIndex filtering and saveIndex validation. Missing arrays must default to empty.
  • Add project-file fields: add an optional field to ProjectFile, validate/normalize it in loadProjectFile, and decide whether saveProjectFile should preserve it when a partial save omits it. links is the existing example.
  • Test atomic writes: atomicWriteFile accepts an AtomicTempWriter, so tests can inject write failures without touching real rename behavior.

Limits of this page

This page is based on the provided excerpts of src/core/workspace-store.ts and src/core/workspace-files.ts. It does not cover the IPC channel table, the private persistIndex/saveNodes bodies, stageProjectFileRemoval implementation, shared/node-links parsing rules, SSH remote file operations, bundle export/import, or worktree registry. Where those are referenced, they are treated as boundaries of this module rather than described in detail.

Sources: src/core/workspace-store.ts, src/core/workspace-files.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