Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion .plans/21-roaming-workspace.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Roaming workspace — local-first plan

> **Status:** M0 complete 2026-07-04 — both spikes GO (see [M0 results](#m0-results-2026-07-04)); M1 next.
> **Status:** M1 complete 2026-07-04 — exit criteria pass on the harness
> (`scripts/roaming/accept-m1.mjs`; see [M1 results](#m1-results-2026-07-04)); M2 next.
> **Decisions log:** 2026-07-04 — v1 transport for small state = machine-to-machine
> mirror (user decision); cloud store backend (private git repo or T3 relay)
> deferred to explicit milestone M7 behind the same interface.
Expand Down Expand Up @@ -575,6 +576,44 @@ the plan says.
connectivity suffices: mirror RPCs reconcile manifests both ways per
contact, so A→B credentials give bidirectional data flow.

## M1 results (2026-07-04)

Landed as four reviewed PRs into `feature/roaming`: contracts (#2), blob
store (#3), enrollment + PeerMirror (#4), client shell/UI (#5). Exit
criteria verified end-to-end by `scripts/roaming/accept-m1.mjs` on the M0
harness: enroll on A → registry entry (title, repository, per-machine root)
on B after a mirror pass → A killed → B still serves its local copy.

Decisions/deviations recorded during implementation and review:

- **Enrollment is administrative.** The enrollment/mint HTTP routes require
`access:write`, not `orchestration:operate` (any standard client could
otherwise mint 365-day mirror credentials). Consequently the D4 handshake
needs an admin-scoped pairing credential: `t3 auth pairing create --admin`
(new flag). Mirror RPCs require `roaming:mirror`, which is granted nowhere
by default.
- **Peer records are tamper-resistant.** A caller of the machine-credential
route is recorded insert-only (`RoamingPeers.ensurePeer`) and its
advertised base URLs are ignored — overwriting a credentialed peer's URLs
would have redirected our authenticated mirror traffic to an attacker.
- **Enroll ordering:** `project.roaming.enroll` dispatches before the
registry blob write, so the decider gates concurrent double-enrolls and no
orphan blob can mirror out; the idempotent re-enroll path self-heals a
missing blob.
- **Honest staleness:** `last_contact_at` is written only by a completed
mirror pass. Accepted M1 simplification: `lastMirrorContactAt` in the
shell is a global max across peers, not per-project (revisit ~M4).
- **Flag-off behavior:** shell snapshots hide `roamingProjects` and the live
roaming stream while the `roaming` setting is off, consistent with the
routes 404ing. Local blob data is retained.
- **Roaming shell stream events carry `sequence: 0`** (they ride outside the
event log); the client reducer applies them by key and owns all sequencing
rules (the redundant outer gate in shell sync was removed).
- **Reconciliation hardening from review:** the blob store serializes its
read-modify-write behind a semaphore (fiber interleaving could defeat
equal-version conflict detection) and verifies ingested `contentHash`
against the payload before applying.

## Execution process

**Branching (fork discipline):** `main` tracks upstream and receives their
Expand Down
57 changes: 57 additions & 0 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ import {
readThreadShell,
useProject,
useProjects,
useRoamingProjects,
useServerConfigs,
useThreadShells,
useThreadShellsForProjectRefs,
Expand Down Expand Up @@ -2831,6 +2832,61 @@ const SidebarChromeFooter = memo(function SidebarChromeFooter() {
);
});

/**
* Registry entries mirrored from other machines that are not materialized
* here. Read-only in M1 (materialize arrives with M2); rendered greyed with
* an honest staleness label — the data is only as fresh as the last mirror
* contact.
*/
function SidebarRoamingProjects() {
const roamingProjects = useRoamingProjects();
const remoteOnly = roamingProjects.filter(
(entry) => entry.roamingProject.localProjectId === null,
);
if (remoteOnly.length === 0) {
return null;
}
return (
<SidebarGroup className="px-2 pb-2">
<div className="mb-1 pl-2">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60">
Roaming
</span>
</div>
<SidebarMenu>
{remoteOnly.map(({ environmentId, roamingProject }) => {
const repository =
roamingProject.repository.displayName ??
roamingProject.repository.name ??
roamingProject.repository.locator.remoteUrl;
const staleness =
roamingProject.lastMirrorContactAt === null
? "never synced"
: `synced ${formatRelativeTimeLabel(roamingProject.lastMirrorContactAt)}`;
return (
<SidebarMenuItem key={`${environmentId}:${roamingProject.workspaceProjectId}`}>
<div
className="flex items-center gap-2 rounded-md px-2 py-1.5 opacity-60"
title={`${roamingProject.title} — on another machine (${staleness})`}
>
<CloudIcon className="size-3.5 shrink-0 text-muted-foreground/60" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-muted-foreground">
{roamingProject.title}
</div>
<div className="truncate text-[10px] text-muted-foreground/60">
{repository} · {staleness}
</div>
</div>
</div>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroup>
);
}

interface SidebarProjectsContentProps {
showArm64IntelBuildWarning: boolean;
arm64IntelBuildWarningDescription: string | null;
Expand Down Expand Up @@ -3098,6 +3154,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
</div>
)}
</SidebarGroup>
<SidebarRoamingProjects />
</SidebarContent>
);
});
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/state/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { Atom } from "effect/unstable/reactivity";
import { useMemo } from "react";
import { appAtomRegistry } from "../rpc/atomRegistry";
import { environmentProjects } from "./projects";
import type { EnvironmentRoamingProject } from "@t3tools/client-runtime/state/projects";
import { environmentServerConfigsAtom } from "./server";
import { environmentThreadDetails, environmentThreadShells } from "./threads";

Expand Down Expand Up @@ -105,6 +106,10 @@ export function useProjects(): ReadonlyArray<EnvironmentProject> {
return useAtomValue(environmentProjects.projectsAtom);
}

export function useRoamingProjects(): ReadonlyArray<EnvironmentRoamingProject> {
return useAtomValue(environmentProjects.roamingProjectsAtom);
}

export function useServerConfigs(): ReadonlyMap<EnvironmentId, ServerConfig> {
return useAtomValue(environmentServerConfigsAtom);
}
Expand Down
38 changes: 38 additions & 0 deletions packages/client-runtime/src/state/projectEntities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
OrchestrationProjectShell,
OrchestrationShellSnapshot,
ProjectId,
RoamingProjectShell,
ScopedProjectRef,
} from "@t3tools/contracts";
import { Atom } from "effect/unstable/reactivity";
Expand All @@ -13,6 +14,7 @@ import type { EnvironmentCatalogState } from "./connections.ts";
import { arrayElementsEqual, parseProjectKey, projectKey, projectRefsEqual } from "./entities.ts";

const EMPTY_PROJECTS: ReadonlyArray<OrchestrationProjectShell> = Object.freeze([]);
const EMPTY_ROAMING_PROJECTS: ReadonlyArray<RoamingProjectShell> = Object.freeze([]);
const EMPTY_PROJECT_INDEX: ReadonlyMap<ProjectId, OrchestrationProjectShell> = new Map();

export function createEnvironmentProjectAtoms(input: {
Expand Down Expand Up @@ -94,12 +96,48 @@ export function createEnvironmentProjectAtoms(input: {
return previousProjects;
}).pipe(Atom.withLabel("environment-project-list"));

const environmentRoamingProjectsAtom = Atom.family((environmentId: EnvironmentId) =>
Atom.make(
(get): ReadonlyArray<RoamingProjectShell> =>
get(input.snapshotAtom(environmentId))?.roamingProjects ?? EMPTY_ROAMING_PROJECTS,
).pipe(Atom.withLabel(`environment-roaming-projects:${environmentId}`)),
);

let previousRoamingProjects: ReadonlyArray<EnvironmentRoamingProject> = [];
const roamingProjectsAtom = Atom.make((get) => {
const next: EnvironmentRoamingProject[] = [];
for (const environmentId of get(input.catalogValueAtom).entries.keys()) {
for (const roamingProject of get(environmentRoamingProjectsAtom(environmentId))) {
next.push({ environmentId, roamingProject });
}
}
const unchanged =
previousRoamingProjects.length === next.length &&
next.every(
(entry, index) =>
previousRoamingProjects[index]?.environmentId === entry.environmentId &&
previousRoamingProjects[index]?.roamingProject === entry.roamingProject,
);
if (unchanged) {
return previousRoamingProjects;
}
previousRoamingProjects = next;
return previousRoamingProjects;
}).pipe(Atom.withLabel("environment-roaming-project-list"));

return {
environmentProjectsAtom,
environmentProjectIndexAtom,
environmentProjectRefsAtom,
projectRefsAtom,
projectsAtom,
projectAtom: (ref: ScopedProjectRef) => projectAtomFamily(projectKey(ref)),
environmentRoamingProjectsAtom,
roamingProjectsAtom,
};
}

export interface EnvironmentRoamingProject {
readonly environmentId: EnvironmentId;
readonly roamingProject: RoamingProjectShell;
}
8 changes: 4 additions & 4 deletions packages/client-runtime/src/state/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,10 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")
? item.snapshot
: Option.match(current.snapshot, {
onNone: () => null,
onSome: (snapshot) =>
item.sequence > snapshot.snapshotSequence
? applyShellStreamEvent(snapshot, item)
: snapshot,
// Sequencing rules live in the reducer (roaming events ride
// sequence 0 and are applied by key; sequenced events are
// ignored when stale and return the same reference).
onSome: (snapshot) => applyShellStreamEvent(snapshot, item),
});
if (nextSnapshot === null) {
return;
Expand Down
53 changes: 52 additions & 1 deletion packages/client-runtime/src/state/shellReducer.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vite-plus/test";

import { ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId, WorkspaceProjectId } from "@t3tools/contracts";
import type { OrchestrationShellSnapshot, OrchestrationShellStreamEvent } from "@t3tools/contracts";

import { applyShellStreamEvent } from "./shellReducer.ts";
Expand Down Expand Up @@ -44,7 +44,58 @@ const stubThread = {
session: null,
} as const;

const stubRoamingProject = {
workspaceProjectId: WorkspaceProjectId.make("wp-1"),
title: "Roaming Project",
repository: {
canonicalKey: "github.com/acme/app",
locator: {
source: "git-remote" as const,
remoteName: "origin",
remoteUrl: "git@github.com:acme/app.git",
},
},
localProjectId: null,
authorEnvironmentId: EnvironmentId.make("env-desktop"),
perMachineRoots: {},
lastMirrorContactAt: null,
updatedAt: "2026-04-01T00:00:00.000Z",
} as const;

describe("applyShellStreamEvent", () => {
it("applies roaming upserts and removals by key without touching snapshotSequence", () => {
const withHighSequence: OrchestrationShellSnapshot = {
...baseSnapshot,
snapshotSequence: 10,
};

// Roaming events carry sequence 0 and must not be dropped by the guard.
const upserted = applyShellStreamEvent(withHighSequence, {
kind: "roaming-project-upserted",
sequence: 0,
roamingProject: stubRoamingProject,
});
expect(upserted.roamingProjects).toEqual([stubRoamingProject]);
expect(upserted.snapshotSequence).toBe(10);

const replaced = applyShellStreamEvent(upserted, {
kind: "roaming-project-upserted",
sequence: 0,
roamingProject: { ...stubRoamingProject, title: "Renamed" },
});
expect(replaced.roamingProjects).toHaveLength(1);
expect(replaced.roamingProjects[0]?.title).toBe("Renamed");

const removed = applyShellStreamEvent(replaced, {
kind: "roaming-project-removed",
sequence: 0,
workspaceProjectId: stubRoamingProject.workspaceProjectId,
});
expect(removed.roamingProjects).toEqual([]);
expect(removed.snapshotSequence).toBe(10);
});


it("ignores stale project upserts without mutating the snapshot", () => {
const snapshotWithProject: OrchestrationShellSnapshot = {
...baseSnapshot,
Expand Down
27 changes: 27 additions & 0 deletions packages/client-runtime/src/state/shellReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,33 @@ export function applyShellStreamEvent(
snapshot: OrchestrationShellSnapshot,
event: OrchestrationShellStreamEvent,
): OrchestrationShellSnapshot {
// Roaming events ride outside the event-log sequence (the server emits
// them with sequence 0): apply by key and leave snapshotSequence alone.
if (event.kind === "roaming-project-upserted") {
const exists = snapshot.roamingProjects.some(
(entry) => entry.workspaceProjectId === event.roamingProject.workspaceProjectId,
);
return {
...snapshot,
roamingProjects: exists
? Arr.map(snapshot.roamingProjects, (entry) =>
entry.workspaceProjectId === event.roamingProject.workspaceProjectId
? event.roamingProject
: entry,
)
: Arr.append(snapshot.roamingProjects, event.roamingProject),
};
}
if (event.kind === "roaming-project-removed") {
return {
...snapshot,
roamingProjects: Arr.filter(
snapshot.roamingProjects,
(entry) => entry.workspaceProjectId !== event.workspaceProjectId,
),
};
}

if (event.sequence <= snapshot.snapshotSequence) return snapshot;

switch (event.kind) {
Expand Down
Loading