Skip to content

Cloud Spaces, Snapshots & Sync

dazeb edited this page Sep 17, 2026 · 2 revisions

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
Loading

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.

Authentication and session state

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/logout and 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
Loading

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>).

Spaces, provisioning, and browser hand-off

Space provisioning and access are thin wrappers over the cloud API:

  • getSpace() calls GET /api/v1/spaces/mine and unwraps { space }.
  • provisionSpace() calls POST /api/v1/spaces; free plans surface 403 upgrade_required.
  • spaceAccessToken() mints a short-lived token, roughly five minutes.
  • openSpace() calls spaceAccessToken() lazily, builds the browser URL with spaceOpenUrl, validates the scheme, and opens it through shell.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.

Snapshots, bundles, and the push/pull contract

Pull and push use the same space endpoints for both snapshot payloads and whole-workspace bundles:

  • pullSpaceContent() calls GET /api/v1/spaces/pull and maps 404 no_content to null.
  • pullWorkspaceContent() calls the same endpoint and uses the same nullable behavior.
  • pushSpaceContent(payload) calls POST /api/v1/spaces/push and 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"]
Loading

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.

Backup flow and dashboard-triggered backups

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 to termsprawl.
  • 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.

GitHub repo import path

The runtime also owns the cloud-backed GitHub import path:

  • githubRepos() calls GET /api/v1/github/repos, normalizes { repos }, and strips cloneUrl from every entry so no URL shape crosses IPC.
  • githubClone(req) validates fullName against owner/repo, derives a fallback name from basename(fullName), mints the credential-bearing import URL in the main process, creates the destination under projectsRoot, and calls cloneRepo.
  • The minted URL goes directly into the local clone. It is never logged, persisted, or sent to the renderer.
  • githubDisconnect() calls DELETE /api/v1/github/connection to wipe the cloud vault token.

Key state and boundaries

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:

  • requestNullable only maps the configured empty status and code to null; all other errors still throw CloudError.
  • request returns undefined for 204 No Content.
  • CloudClient.isUnauthorized(e) is true only for status 401.
  • The session-cookie write path catches failures so an unwritable settings directory does not break the current run.
  • openSpace rejects non-http(s) URLs even if spaceOpenUrl leaves a malformed URL untouched.
  • githubClone rejects anything that is not owner/repo before making a network call.
  • backupNow falls back to termsprawl, id: null, and empty nodes when no project exists.

Extension points

  • Add new cloud API calls on CloudClient first; the main runtime can then wrap them in CloudRuntime.
  • 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.ts when the payload decision is pure; callers continue to supply I/O and persistence.
  • Reuse the existing /api/v1/spaces/push and /api/v1/spaces/pull endpoints for superset envelopes by adding fields to the payload; the server stores the body verbatim.
  • Adjust backup polling cadence at POLL_INTERVAL_MS if the dashboard-triggered backup path needs a different responsiveness trade-off.

Sources:

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