-
Notifications
You must be signed in to change notification settings - Fork 0
Cloud Spaces, Snapshots & Sync
This area is split into three layers:
| Layer | Main file | Responsibility |
|---|---|---|
| Electron-free cloud client | src/core/cloud.ts |
Typed HTTP calls to Termsprawl Cloud, cookie injection, nullable “no content” handling, error shaping, space-open URL construction. |
| Electron main runtime | src/main/cloud.ts |
Session-cookie persistence, browser hand-off, backup polling, space wrappers, GitHub repo listing and clone paths. |
| Pure snapshot helpers | src/core/space-snapshots.ts |
Project selection from a snapshot, imported-project naming, push payload contract, rev semantics. |
The design keeps secrets and side effects in the main process, while the pure client can be reused by tests and the Server Edition.
flowchart TB
subgraph ElectronMain["Electron main process"]
Runtime["createCloudRuntime()<br/>src/main/cloud.ts"]
Session["userData/cloud-session<br/>mode 0600"]
Shell["shell.openExternal()"]
Snapshot["opts.snapshot()<br/>WorkspaceSnapshot"]
end
subgraph Core["Electron-free core"]
Client["CloudClient<br/>src/core/cloud.ts"]
SnapHelpers["space-snapshots.ts<br/>project selection, naming, push payload"]
end
Cloud["Termsprawl Cloud<br/>/api/v1/*"]
Runtime --> Client
Runtime --> SnapHelpers
Runtime --> Snapshot
Runtime <--> Session
Runtime --> Shell
Client <--> Cloud
CloudClient is constructed with apiBase, fetchFn, keepCookie, and getCookie, so all I/O and credential storage are injected. The main runtime supplies Electron-specific pieces: a persisted session file, system-browser opens, and workspace snapshots for backups. space-snapshots.ts stays pure and only encodes decisions about what the active project is, what an imported project should be named, and what the push envelope must carry.
The core client exposes both OAuth-code and device-flow paths:
-
oauthStartUrl()returns the GitHub OAuth start URL. -
exchangeCode(code)exchanges a GitHub OAuth code for a session cookie. -
deviceStart()starts GitHub Device Flow. -
devicePoll(deviceCode)polls until the user approves in the browser. -
me()reads the signed-in cloud user. -
signOut()posts to/api/v1/auth/logoutand clears the cookie.
In the main process, sign-in state is a cookie held in memory and mirrored to <userData>/cloud-session with mode 0600. keepCookie writes or removes that file; if the settings directory is not writable, the in-memory cookie still works for the current run. getUser() converts a 401 into null, starts backup polling on success, and rethrows other errors. signOut() stops polling before clearing the server session.
sequenceDiagram
participant Main as main/cloud.ts
participant Client as core/cloud.ts
participant Cloud as Cloud API
participant Browser as System browser
Main->>Client: deviceStart()
Client->>Cloud: POST /api/v1/auth/device
Cloud-->>Client: CloudDeviceStart
Main->>Browser: shell.openExternal(verification_uri)
loop poll
Main->>Client: devicePoll(deviceCode)
Client->>Cloud: POST /api/v1/auth/device/poll
Cloud-->>Client: pending or ok
end
Main->>Main: startBackupPolling() when ok
The cookie is never exposed to the renderer. Every request goes through CloudClient.request, which adds credentials: 'include', injects the stored Cookie header when present, reads Set-Cookie, and stores only the first segment before ;. Error responses are parsed for { error: { code, message } } and surfaced as CloudError; non-JSON bodies fall back to unknown and Request failed (<status>).
Space provisioning and access are thin wrappers over the cloud API:
-
getSpace()callsGET /api/v1/spaces/mineand unwraps{ space }. -
provisionSpace()callsPOST /api/v1/spaces; free plans surface403 upgrade_required. -
spaceAccessToken()mints a short-lived token, roughly five minutes. -
openSpace()callsspaceAccessToken()lazily, builds the browser URL withspaceOpenUrl, validates the scheme, and opens it throughshell.openExternal.
spaceOpenUrl appends the token as ?t=<token> to the API-returned space URL. If the URL is malformed, it is returned untouched. Because openSpace calls shell.openExternal directly, it repeats a scheme guard before opening: the final URL must match ^https?://, otherwise it throws CloudError(0, 'bad_space_url', ...). This prevents a hostile or malformed API URL from reaching the OS through the direct path.
Pull and push use the same space endpoints for both snapshot payloads and whole-workspace bundles:
-
pullSpaceContent()callsGET /api/v1/spaces/pulland maps404 no_contenttonull. -
pullWorkspaceContent()calls the same endpoint and uses the same nullable behavior. -
pushSpaceContent(payload)callsPOST /api/v1/spaces/pushand resolves{ ok: true, bytes }. -
pushWorkspaceContent(payload)calls the same push endpoint with the bundle envelope.
The bundle payload is structurally declared in core/cloud.ts rather than imported from workspace-bundle.ts, because tsconfig.web.json’s core whitelist does not include workspace-bundle.ts. The shape is identical, so a real WorkspaceBundle is assignable; validation is the caller’s job via isValidBundle(). The server stores whatever was pushed.
flowchart LR
W["Local workspace / active project"] --> B["space-snapshots.ts<br/>PushProjectInput -> payload"]
B --> P["CloudClient.pushSpaceContent / pushWorkspaceContent"]
P --> E["POST /api/v1/spaces/push"]
E --> S["Stored space content"]
S --> G["GET /api/v1/spaces/pull"]
G --> N{"404 no_content?"}
N -- yes --> Null["null"]
N -- no --> C["SpaceSnapshotPayload / WorkspaceBundlePayload"]
The pure snapshot helper layer defines SnapshotWorkspace: an index of projects, a projects map of serialized nodes, optional currentProjectId, and optional per-project revs. snapshotCurrentProject resolves the current project by currentProjectId, then the first open/non-archived project, then the first project. uniqueOnlineSnapshotName builds <name> (online, <date>) and appends 2, 3, etc., never overwriting an existing project name.
PushProjectInput describes the single active project that gets pushed: id, name, cwd, serialized nodes, monotonic rev, and optional projectFile. The comments make the reconciliation rule explicit: boot restore applies only strictly newer projects, so a push without revs is silently ignored on the space’s next boot. The payload assembly therefore must travel with the rev and the folder-project file when present, plus terminal scrollbacks.
backupNow() snapshots the current workspace through opts.snapshot(), chooses the first non-closed/non-archived project or the first project at all, then calls createBackup with:
-
project: project cwd or name, falling back totermsprawl. -
name:auto. -
workspace:{ id, at, nodes }. -
files:{ nodes }.
This is a best-effort backup of the active project’s nodes, not a full workspace bundle.
A background poller starts after successful sign-in. Every POLL_INTERVAL_MS (30 seconds), maybeRunPendingBackup() calls syncStatus() and checks whether backup_requested_at is newer than last_backup_at. If so, it runs backupNow(). The backupPolling boolean prevents overlapping runs. Transient network or auth errors are swallowed; the next poll retries. startBackupPolling() is idempotent, and signOut() stops it.
The runtime also owns the cloud-backed GitHub import path:
-
githubRepos()callsGET /api/v1/github/repos, normalizes{ repos }, and stripscloneUrlfrom every entry so no URL shape crosses IPC. -
githubClone(req)validatesfullNameagainstowner/repo, derives a fallback name frombasename(fullName), mints the credential-bearing import URL in the main process, creates the destination underprojectsRoot, and callscloneRepo. - The minted URL goes directly into the local clone. It is never logged, persisted, or sent to the renderer.
-
githubDisconnect()callsDELETE /api/v1/github/connectionto wipe the cloud vault token.
| State | Location | Lifecycle / constraint |
|---|---|---|
| Session cookie | Main-process memory plus <userData>/cloud-session
|
Loaded on runtime creation; written mode 0600; cleared on sign-out; in-memory fallback if write fails. |
| Backup poller |
backupPoller, backupPolling
|
Started after me() succeeds or device poll returns ok; 30-second interval; stopped on sign-out. |
| Space access token | Transient in openSpace
|
Minted lazily before browser open; lives roughly five minutes. |
| Project revs | SnapshotWorkspace.revs |
Per-project monotonic values; boot restore applies only strictly newer projects. |
| Imported project naming | uniqueOnlineSnapshotName |
Appends (online, date) and numeric suffixes; never overwrites. |
| Bundle payload | WorkspaceBundlePayload |
Structural type kept in core/cloud.ts; caller validates via isValidBundle(). |
Important edge cases:
-
requestNullableonly maps the configured empty status and code tonull; all other errors still throwCloudError. -
requestreturnsundefinedfor204 No Content. -
CloudClient.isUnauthorized(e)is true only for status401. - The session-cookie write path catches failures so an unwritable settings directory does not break the current run.
-
openSpacerejects non-http(s) URLs even ifspaceOpenUrlleaves a malformed URL untouched. -
githubClonerejects anything that is notowner/repobefore making a network call. -
backupNowfalls back totermsprawl,id: null, and empty nodes when no project exists.
- Add new cloud API calls on
CloudClientfirst; the main runtime can then wrap them inCloudRuntime. - Keep new secret-bearing operations in the main runtime. The renderer-facing surface should receive names, ids, and statuses, not cookies, tokens, or clone URLs.
- Extend snapshot behavior in
core/space-snapshots.tswhen the payload decision is pure; callers continue to supply I/O and persistence. - Reuse the existing
/api/v1/spaces/pushand/api/v1/spaces/pullendpoints for superset envelopes by adding fields to the payload; the server stores the body verbatim. - Adjust backup polling cadence at
POLL_INTERVAL_MSif the dashboard-triggered backup path needs a different responsiveness trade-off.
Sources:
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