From 91d749f82c8a4388521b47966e6c2f8297097386 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 22 Oct 2026 19:48:34 +0700 Subject: [PATCH] fix(server): preserve workspace PR status across reconnects (#7638) Workspace runtime snapshots lived inside refcounted watch targets and vanished when the last session disconnected. Keep the last observed git and forge data in daemon memory so reconnecting clients receive it before refresh completes, while watch lifetime continues to govern refresh only. --- docs/architecture.md | 7 + .../opencode/bridge-input-errors.test.ts | 4 +- packages/server/src/server/bootstrap.ts | 17 +- .../src/server/checkout-diff-manager.test.ts | 7 +- .../src/server/checkout-diff-manager.ts | 12 +- .../src/server/paseo-worktree-service.test.ts | 2 +- packages/server/src/server/session.test.ts | 133 +++++---- packages/server/src/server/session.ts | 13 +- .../session.workspace-git-watch.test.ts | 6 +- ...on.workspace-resolution-invariants.test.ts | 3 +- .../src/server/session.workspaces.test.ts | 256 +++++++++++++++++- .../workspace-provisioning-service.test.ts | 5 - .../workspace-provisioning-service.ts | 2 +- .../workspace-scripts-service.test.ts | 14 +- .../workspace-scripts-service.ts | 10 +- .../snapshot-mutation-ownership.test.ts | 2 + .../test-utils/workspace-git-service-stub.ts | 2 +- .../websocket-server.browser-tools.test.ts | 2 + .../websocket-server.notifications.test.ts | 2 + .../websocket-server.relay-reconnect.test.ts | 2 + ...cket-server.terminal-notifications.test.ts | 2 + .../server/src/server/websocket-server.ts | 4 +- .../server/src/server/wire-compat.test.ts | 6 +- ...it-service.observation.integration.test.ts | 6 +- .../workspace-git-service.observation.test.ts | 41 ++- .../workspace-git-service.primitive.test.ts | 19 +- .../src/server/workspace-git-service.test.ts | 44 ++- .../src/server/workspace-git-service.ts | 11 +- .../src/server/workspace-registry.test.ts | 10 + .../server/src/server/workspace-registry.ts | 2 +- .../workspace-runtime-store/index.test.ts | 18 ++ .../server/workspace-runtime-store/index.ts | 23 ++ 32 files changed, 552 insertions(+), 135 deletions(-) create mode 100644 packages/server/src/server/workspace-runtime-store/index.test.ts create mode 100644 packages/server/src/server/workspace-runtime-store/index.ts diff --git a/docs/architecture.md b/docs/architecture.md index c98f3825af..8e63bacaf6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -74,6 +74,13 @@ server to capability-aware sessions. It deliberately does not use the broad recu watcher or the per-session Git observer: those are checkout/status mechanisms and intentionally do not retain non-Git directories. +Workspace git and forge runtime data belongs to one daemon-level, directory-keyed store. +Workspace descriptors, checkout diff subscriptions, and script metadata read the last known +snapshot from that store, so reconnecting clients receive it before refresh completes. +Refcounted git watches feed the store through snapshot-updated events; their lifetime controls +refresh only. Session cleanup and workspace archive retain the data. Removing the final active +workspace for a directory clears it. The store lives in memory and resets on daemon restart. + **Key modules:** | Module | Responsibility | diff --git a/packages/server/src/server/agent/providers/opencode/bridge-input-errors.test.ts b/packages/server/src/server/agent/providers/opencode/bridge-input-errors.test.ts index f9a776d168..1396d1f784 100644 --- a/packages/server/src/server/agent/providers/opencode/bridge-input-errors.test.ts +++ b/packages/server/src/server/agent/providers/opencode/bridge-input-errors.test.ts @@ -47,8 +47,8 @@ test("bridge preserves tool failure messages and classifies invalid requests as logger, ), projectRegistry: new FileBackedProjectRegistry(path.join(paseoHome, "projects.json"), logger), - workspaceGitService: { - peekSnapshot: () => { + workspaceRuntimeStore: { + get: () => { throw new Error("Unexpected git lookup"); }, }, diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 22bf93b625..201702444c 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -1,3 +1,4 @@ +import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import type { PluginRegistries } from "@getpaseo/protocol/plugin-registry"; import { describeHookWorkspace } from "./plugins/lifecycle/index.js"; import express from "express"; @@ -905,7 +906,17 @@ export async function createPaseoDaemon( forgeOverrides: { github }, }, }); - workspaceRegistry.subscribeToMutations((mutation) => { + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + workspaceGitService.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); + workspaceRegistry.subscribeToMutations(async (mutation) => { + if (mutation.kind === "remove" && mutation.workspace) { + const cwd = path.resolve(mutation.workspace.cwd); + const workspaces = await workspaceRegistry.list(); + const hasActiveWorkspace = workspaces.some( + (workspace) => !workspace.archivedAt && path.resolve(workspace.cwd) === cwd, + ); + if (!hasActiveWorkspace) workspaceRuntimeStore.remove(cwd); + } if (mutation.kind === "archive" && mutation.workspace) { pluginRuntime.emit("workspace.archived", { workspace: describeHookWorkspace(mutation.workspace), @@ -1016,6 +1027,7 @@ export async function createPaseoDaemon( logger, paseoHome: config.paseoHome, workspaceGitService, + workspaceRuntimeStore, }); const archiveWorkspaceRecordExternal = async ( workspaceId: string, @@ -1434,7 +1446,7 @@ export async function createPaseoDaemon( terminalManager, workspaceRegistry, projectRegistry, - workspaceGitService, + workspaceRuntimeStore, getDaemonTcpPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), getDaemonTcpHost: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.host : null), serviceProxyPublicBaseUrl, @@ -1710,6 +1722,7 @@ export async function createPaseoDaemon( startPaused: true, }, workspaceAutoName, + workspaceRuntimeStore, daemonAuth, speechService, terminalManager, diff --git a/packages/server/src/server/checkout-diff-manager.test.ts b/packages/server/src/server/checkout-diff-manager.test.ts index 1d3f2e598d..d55171831b 100644 --- a/packages/server/src/server/checkout-diff-manager.test.ts +++ b/packages/server/src/server/checkout-diff-manager.test.ts @@ -1,3 +1,4 @@ +import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; const { toCheckoutErrorMock } = vi.hoisted(() => ({ @@ -67,7 +68,7 @@ function createPendingManager() { const workspaceGitService = { getCheckoutDiff: async () => ({ diff: "", structured: [] }), getSnapshot: async () => createWorkspaceSnapshot(), - peekSnapshot: () => null, + registerWorkspace: () => ({ unsubscribe: () => {} }), requestWorkingTreeWatch: (cwd: string, onChange: () => void) => { const pending = createDeferred<{ repoRoot: string | null; unsubscribe: () => void }>(); @@ -90,6 +91,7 @@ function createPendingManager() { }; const logger = { child: () => logger, warn: () => {} }; const manager = new CheckoutDiffManager({ + workspaceRuntimeStore: createWorkspaceRuntimeStore(), logger: logger as unknown as pino.Logger, paseoHome: "/tmp/paseo-test", workspaceGitService, @@ -125,7 +127,7 @@ describe("CheckoutDiffManager", () => { const workspaceGitService = { subscribe: vi.fn(), - peekSnapshot: vi.fn(), + registerWorkspace: vi.fn( (_params: { cwd: string }, listener: (snapshot: WorkspaceGitRuntimeSnapshot) => void) => { onWorkspaceSnapshot = listener; @@ -147,6 +149,7 @@ describe("CheckoutDiffManager", () => { }; const manager = new CheckoutDiffManager({ + workspaceRuntimeStore: createWorkspaceRuntimeStore(), logger: logger as unknown as pino.Logger, paseoHome: "/tmp/paseo-test", workspaceGitService: workspaceGitService as unknown as WorkspaceGitService, diff --git a/packages/server/src/server/checkout-diff-manager.ts b/packages/server/src/server/checkout-diff-manager.ts index 6dc437297d..917813d587 100644 --- a/packages/server/src/server/checkout-diff-manager.ts +++ b/packages/server/src/server/checkout-diff-manager.ts @@ -1,3 +1,4 @@ +import { type WorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import type pino from "pino"; import type { SubscribeCheckoutDiffRequest, SessionOutboundMessage } from "./messages.js"; import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js"; @@ -8,11 +9,7 @@ const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150; type CheckoutDiffWorkspace = Pick< WorkspaceGitService, - | "getCheckoutDiff" - | "getSnapshot" - | "peekSnapshot" - | "registerWorkspace" - | "requestWorkingTreeWatch" + "getCheckoutDiff" | "getSnapshot" | "registerWorkspace" | "requestWorkingTreeWatch" >; export type CheckoutDiffCompareInput = SubscribeCheckoutDiffRequest["compare"]; @@ -63,14 +60,17 @@ export interface CheckoutDiffSubscription { export class CheckoutDiffManager { private readonly workspaceGitService: CheckoutDiffWorkspace; + private readonly workspaceRuntimeStore: WorkspaceRuntimeStore; private readonly targets = new Map(); constructor(options: { logger: pino.Logger; paseoHome: string; workspaceGitService: CheckoutDiffWorkspace; + workspaceRuntimeStore: WorkspaceRuntimeStore; }) { this.workspaceGitService = options.workspaceGitService; + this.workspaceRuntimeStore = options.workspaceRuntimeStore; } async read( @@ -366,7 +366,7 @@ export class CheckoutDiffManager { private async openTarget(target: CheckoutDiffWatchTarget): Promise { if (target.compare.mode !== "uncommitted") { const snapshot = - this.workspaceGitService.peekSnapshot(target.cwd) ?? + this.workspaceRuntimeStore.get(target.cwd) ?? (await this.workspaceGitService.getSnapshot(target.cwd, { includeForge: false })); target.diffCwd = snapshot.git.repoRoot ?? target.cwd; if (this.targets.get(target.key) !== target || target.listeners.size === 0) { diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index 0e0ccf34f7..66740d2ae2 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -1221,7 +1221,7 @@ function createWorkspaceGitServiceStub(): WorkspaceGitService { registerWorkspace: () => ({ unsubscribe: () => {}, }), - peekSnapshot: (cwd) => createWorkspaceGitSnapshot(cwd), + getCheckout: async (cwd) => { try { const snapshot = createWorkspaceGitSnapshot(cwd); diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 7d881286fb..2fc05d0131 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -1,3 +1,7 @@ +import { + createWorkspaceRuntimeStore, + type WorkspaceRuntimeStore, +} from "./workspace-runtime-store/index.js"; import { createAgentMessageDeliveryStub } from "./test-utils/session-stubs.js"; import { createMessageReceiptsStub, @@ -289,6 +293,7 @@ vi.mock("./worktree-bootstrap.js", async (importOriginal) => { }); interface SessionForTestOptions { + workspaceRuntimeStore?: WorkspaceRuntimeStore; sendToSource?: SessionOptions["onMessageToSource"]; clientId?: string; permissions?: readonly DaemonPermission[]; @@ -302,7 +307,6 @@ interface SessionForTestOptions { getSnapshot?: ReturnType; suggestBranchesForCwd?: ReturnType; listStashes?: ReturnType; - peekSnapshot?: ReturnType; validateBranchRef?: ReturnType; hasLocalBranch?: ReturnType; resolveRepoRemoteUrl?: ReturnType; @@ -353,7 +357,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { getSnapshot: vi.fn(), suggestBranchesForCwd: vi.fn(), listStashes: vi.fn(), - peekSnapshot: vi.fn(), + validateBranchRef: vi.fn(), hasLocalBranch: vi.fn(), resolveRepoRemoteUrl: vi.fn(), @@ -369,6 +373,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { const messages = options.messages ?? []; const sessionOptions: SessionOptions = { + workspaceRuntimeStore: options.workspaceRuntimeStore ?? createWorkspaceRuntimeStore(), messageReceipts: createMessageReceiptsStub(), agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), @@ -3898,11 +3903,11 @@ describe("session checkout refresh handling", () => { describe("session checkout status handling", () => { test("returns checkout status from the workspace git service snapshot", async () => { const messages: unknown[] = []; + const workspaceRuntimeStore = { ...createWorkspaceRuntimeStore(), get: vi.fn() }; const workspaceGitService = { getSnapshot: vi.fn().mockResolvedValue(createWorkspaceGitSnapshot("/tmp/service-worktree")), - peekSnapshot: vi.fn(), }; - const session = createSessionForTest({ workspaceGitService, messages }); + const session = createSessionForTest({ workspaceRuntimeStore, workspaceGitService, messages }); await session.handleMessage({ type: "checkout_status_request", @@ -3938,6 +3943,7 @@ describe("session checkout status handling", () => { test("returns fresh service data on the first checkout status read for a cwd", async () => { const messages: unknown[] = []; + const workspaceRuntimeStore = { ...createWorkspaceRuntimeStore(), get: vi.fn(() => null) }; const workspaceGitService = { getSnapshot: vi.fn().mockResolvedValue( createWorkspaceGitSnapshot("/tmp/cold-worktree", { @@ -3950,9 +3956,8 @@ describe("session checkout status handling", () => { }, }), ), - peekSnapshot: vi.fn(() => null), }; - const session = createSessionForTest({ workspaceGitService, messages }); + const session = createSessionForTest({ workspaceRuntimeStore, workspaceGitService, messages }); await session.handleMessage({ type: "checkout_status_request", @@ -3960,7 +3965,7 @@ describe("session checkout status handling", () => { requestId: "request-cold-status", }); - expect(workspaceGitService.peekSnapshot).not.toHaveBeenCalled(); + expect(workspaceRuntimeStore.get).not.toHaveBeenCalled(); expect(workspaceGitService.getSnapshot).toHaveBeenCalledTimes(1); expect(messages).toContainEqual({ type: "checkout_status_response", @@ -4000,15 +4005,9 @@ describe("session workspace descriptors", () => { archivedAt: null, }; const session = createSessionForTest({ - messages, - workspaceRegistry: { get: vi.fn(), list: vi.fn().mockResolvedValue([workspace]) }, - projectRegistry: { - list: vi.fn().mockResolvedValue([project]), - get: vi.fn().mockResolvedValue(project), - }, - workspaceGitService: { - getSnapshot: vi.fn(), - peekSnapshot: vi.fn(() => + workspaceRuntimeStore: { + ...createWorkspaceRuntimeStore(), + get: vi.fn(() => createWorkspaceGitSnapshot("/repo/app", { git: { remoteUrl: "https://github.com/acme/app.git", @@ -4018,6 +4017,16 @@ describe("session workspace descriptors", () => { }, }), ), + }, + messages, + workspaceRegistry: { get: vi.fn(), list: vi.fn().mockResolvedValue([workspace]) }, + projectRegistry: { + list: vi.fn().mockResolvedValue([project]), + get: vi.fn().mockResolvedValue(project), + }, + workspaceGitService: { + getSnapshot: vi.fn(), + registerWorkspace: vi.fn(() => () => {}), }, }); @@ -4076,12 +4085,9 @@ describe("session workspace descriptors", () => { archivedAt: null, }; const session = createSessionForTest({ - messages, - workspaceRegistry: { get: vi.fn(), list: vi.fn().mockResolvedValue([workspace]) }, - projectRegistry: { list: vi.fn().mockResolvedValue([project]), get: vi.fn() }, - workspaceGitService: { - getSnapshot: vi.fn(), - peekSnapshot: vi.fn(() => + workspaceRuntimeStore: { + ...createWorkspaceRuntimeStore(), + get: vi.fn(() => createWorkspaceGitSnapshot("/repo/local", { git: { remoteUrl: null, @@ -4091,6 +4097,13 @@ describe("session workspace descriptors", () => { }, }), ), + }, + messages, + workspaceRegistry: { get: vi.fn(), list: vi.fn().mockResolvedValue([workspace]) }, + projectRegistry: { list: vi.fn().mockResolvedValue([project]), get: vi.fn() }, + workspaceGitService: { + getSnapshot: vi.fn(), + registerWorkspace: vi.fn(() => () => {}), }, }); @@ -4127,16 +4140,19 @@ describe("session workspace descriptors", () => { }); }); - test("reads descriptor diff stat from the workspace git service snapshot", async () => { - const workspaceGitService = { - getSnapshot: vi.fn(), - peekSnapshot: vi.fn(() => + test("reads descriptor diff stat from the workspace runtime store", async () => { + const workspaceRuntimeStore = { + ...createWorkspaceRuntimeStore(), + get: vi.fn(() => createWorkspaceGitSnapshot("/tmp/workspace", { git: { diffStat: { additions: 7, deletions: 2 } }, }), ), }; - const session = createSessionForTest({ workspaceGitService }); + const workspaceGitService = { + getSnapshot: vi.fn(), + }; + const session = createSessionForTest({ workspaceRuntimeStore, workspaceGitService }); checkoutGitMocks.getCachedCheckoutShortstat.mockReturnValue({ additions: 99, deletions: 88, @@ -4158,7 +4174,7 @@ describe("session workspace descriptors", () => { }, ); - expect(workspaceGitService.peekSnapshot).toHaveBeenCalledWith("/tmp/workspace"); + expect(workspaceRuntimeStore.get).toHaveBeenCalledWith("/tmp/workspace"); expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); expect(checkoutGitMocks.getCachedCheckoutShortstat).not.toHaveBeenCalled(); expect(checkoutGitMocks.warmCheckoutShortstatInBackground).not.toHaveBeenCalled(); @@ -4166,11 +4182,11 @@ describe("session workspace descriptors", () => { }); test("does not cold-load git data while describing a workspace", async () => { + const workspaceRuntimeStore = { ...createWorkspaceRuntimeStore(), get: vi.fn(() => null) }; const workspaceGitService = { getSnapshot: vi.fn().mockResolvedValue(createWorkspaceGitSnapshot("/tmp/workspace")), - peekSnapshot: vi.fn(() => null), }; - const session = createSessionForTest({ workspaceGitService }); + const session = createSessionForTest({ workspaceRuntimeStore, workspaceGitService }); const descriptor = await asSessionInternals(session).describeWorkspaceRecordWithGitData( { @@ -4188,7 +4204,7 @@ describe("session workspace descriptors", () => { }, ); - expect(workspaceGitService.peekSnapshot).toHaveBeenCalledWith("/tmp/workspace"); + expect(workspaceRuntimeStore.get).toHaveBeenCalledWith("/tmp/workspace"); expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); expect(descriptor.diffStat).toBeNull(); expect(descriptor.gitRuntime).toBeUndefined(); @@ -4198,14 +4214,15 @@ describe("session workspace descriptors", () => { describe("session branch validation", () => { test("validates branches through the workspace git service", async () => { const messages: unknown[] = []; + const workspaceRuntimeStore = { ...createWorkspaceRuntimeStore(), get: vi.fn() }; const workspaceGitService = { getSnapshot: vi.fn(), - peekSnapshot: vi.fn(), + validateBranchRef: vi .fn() .mockResolvedValue({ kind: "remote-only", name: "feature", remoteRef: "origin/feature" }), }; - const session = createSessionForTest({ workspaceGitService, messages }); + const session = createSessionForTest({ workspaceRuntimeStore, workspaceGitService, messages }); await session.handleMessage({ type: "validate_branch_request", @@ -4243,12 +4260,17 @@ describe("session branch validation", () => { execSync("git tag v1", { cwd: repoDir }); const messages: unknown[] = []; + const workspaceRuntimeStore = { ...createWorkspaceRuntimeStore(), get: vi.fn() }; const workspaceGitService = { getSnapshot: vi.fn(), - peekSnapshot: vi.fn(), + validateBranchRef: vi.fn().mockResolvedValue({ kind: "not-found" }), }; - const session = createSessionForTest({ workspaceGitService, messages }); + const session = createSessionForTest({ + workspaceRuntimeStore, + workspaceGitService, + messages, + }); await session.handleMessage({ type: "validate_branch_request", @@ -4323,11 +4345,11 @@ describe("session checkout switch branch handling", () => { describe("session checkout rename branch handling", () => { test("rejects invalid branch slugs without renaming", async () => { const messages: unknown[] = []; + const workspaceRuntimeStore = { ...createWorkspaceRuntimeStore(), get: vi.fn() }; const workspaceGitService = { getSnapshot: vi.fn(), - peekSnapshot: vi.fn(), }; - const session = createSessionForTest({ workspaceGitService, messages }); + const session = createSessionForTest({ workspaceRuntimeStore, workspaceGitService, messages }); await session.handleMessage({ type: "checkout.rename_branch.request", @@ -4356,11 +4378,11 @@ describe("session checkout rename branch handling", () => { test("reports null current branch when branch rename fails", async () => { const messages: unknown[] = []; + const workspaceRuntimeStore = { ...createWorkspaceRuntimeStore(), get: vi.fn() }; const workspaceGitService = { getSnapshot: vi.fn(), - peekSnapshot: vi.fn(), }; - const session = createSessionForTest({ workspaceGitService, messages }); + const session = createSessionForTest({ workspaceRuntimeStore, workspaceGitService, messages }); checkoutGitMocks.renameCurrentBranch.mockRejectedValue(new Error("branch already exists")); await session.handleMessage({ @@ -4374,7 +4396,7 @@ describe("session checkout rename branch handling", () => { "/tmp/repo", "feature/new-name", ); - expect(workspaceGitService.peekSnapshot).not.toHaveBeenCalled(); + expect(workspaceRuntimeStore.get).not.toHaveBeenCalled(); expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); expect(messages).toContainEqual({ type: "checkout.rename_branch.response", @@ -4394,6 +4416,14 @@ describe("session checkout rename branch handling", () => { test("forces workspace git refresh after renaming the current branch", async () => { const messages: unknown[] = []; const github = { invalidate: vi.fn() }; + const workspaceRuntimeStore = { + ...createWorkspaceRuntimeStore(), + get: vi.fn(() => + createWorkspaceGitSnapshot("/tmp/repo", { + git: { currentBranch: "feature/old-name" }, + }), + ), + }; const workspaceGitService = { getSnapshot: vi.fn().mockResolvedValue( createWorkspaceGitSnapshot("/tmp/repo", { @@ -4403,13 +4433,13 @@ describe("session checkout rename branch handling", () => { }, }), ), - peekSnapshot: vi.fn(() => - createWorkspaceGitSnapshot("/tmp/repo", { - git: { currentBranch: "feature/old-name" }, - }), - ), }; - const session = createSessionForTest({ github, workspaceGitService, messages }); + const session = createSessionForTest({ + workspaceRuntimeStore, + github, + workspaceGitService, + messages, + }); checkoutGitMocks.renameCurrentBranch.mockResolvedValue({ previousBranch: "feature/old-name", currentBranch: "feature/new-name", @@ -4528,12 +4558,12 @@ describe("session branch suggestions handling", () => { const branchDetails = [ { name: "feature/service", committerDate: 10, hasLocal: true, hasRemote: false }, ]; + const workspaceRuntimeStore = { ...createWorkspaceRuntimeStore(), get: vi.fn() }; const workspaceGitService = { getSnapshot: vi.fn(), suggestBranchesForCwd: vi.fn().mockResolvedValue(branchDetails), - peekSnapshot: vi.fn(), }; - const session = createSessionForTest({ workspaceGitService, messages }); + const session = createSessionForTest({ workspaceRuntimeStore, workspaceGitService, messages }); await session.handleMessage({ type: "branch_suggestions_request", @@ -4572,12 +4602,12 @@ describe("session stash list handling", () => { isPaseo: true, }, ]; + const workspaceRuntimeStore = { ...createWorkspaceRuntimeStore(), get: vi.fn() }; const workspaceGitService = { getSnapshot: vi.fn(), listStashes: vi.fn().mockResolvedValue(entries), - peekSnapshot: vi.fn(), }; - const session = createSessionForTest({ workspaceGitService, messages }); + const session = createSessionForTest({ workspaceRuntimeStore, workspaceGitService, messages }); await session.handleMessage({ type: "stash_list_request", @@ -4723,8 +4753,8 @@ describe("session workspace script handling", () => { remoteUrl: "https://github.com/getpaseo/paseo.git", }, }); + const workspaceRuntimeStore = { ...createWorkspaceRuntimeStore(), get: vi.fn(() => snapshot) }; const workspaceGitService = { - peekSnapshot: vi.fn(() => snapshot), getProjectSlug: vi.fn().mockResolvedValue("paseo"), }; const workspaceRegistry = { @@ -4738,6 +4768,7 @@ describe("session workspace script handling", () => { terminalId: "terminal-1", }); const session = createSessionForTest({ + workspaceRuntimeStore, workspaceGitService, workspaceRegistry, terminalManager: { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 221d3148c8..14495fedec 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1,3 +1,4 @@ +import { type WorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import type { AgentMessageDelivery } from "./agent/agent-messages/index.js"; import { AgentDirectory } from "./agent/directory.js"; import { ConnectedHostSession } from "./connected-hosts/index.js"; @@ -477,6 +478,7 @@ export interface SessionOptions { // defaults to the real checkout-git implementation. renameCurrentBranch?: typeof renameCurrentBranchDefault; workspaceGitService: WorkspaceGitService; + workspaceRuntimeStore: WorkspaceRuntimeStore; workspaceAutoName: WorkspaceAutoName; daemonConfigStore: DaemonConfigStore; pluginRuntime?: { @@ -747,6 +749,7 @@ export class Session { private readonly github: ForgeService; private readonly renameCurrentBranch: typeof renameCurrentBranchDefault; private readonly workspaceGitService: WorkspaceGitService; + private readonly workspaceRuntimeStore: WorkspaceRuntimeStore; private readonly workspaceAutoName: WorkspaceAutoName; private readonly gitMutation: GitMutationService; private readonly workspaceProvisioning: WorkspaceProvisioningService; @@ -850,6 +853,7 @@ export class Session { github, renameCurrentBranch, workspaceGitService, + workspaceRuntimeStore, workspaceAutoName, daemonConfigStore, pluginRuntime, @@ -930,6 +934,7 @@ export class Session { this.github = github ?? createGitHubService(); this.renameCurrentBranch = renameCurrentBranch ?? renameCurrentBranchDefault; this.workspaceGitService = workspaceGitService; + this.workspaceRuntimeStore = workspaceRuntimeStore; this.gitMutation = createGitMutationService({ workspaceGitService: this.workspaceGitService, logger: this.sessionLogger, @@ -1202,7 +1207,7 @@ export class Session { terminalManager: this.terminalManager, workspaceRegistry: this.workspaceRegistry, projectRegistry: this.projectRegistry, - workspaceGitService: this.workspaceGitService, + workspaceRuntimeStore: this.workspaceRuntimeStore, getDaemonTcpPort: this.getDaemonTcpPort, getDaemonTcpHost: this.getDaemonTcpHost, serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl, @@ -2141,7 +2146,7 @@ export class Session { if (!project) { throw new Error(`Project not found for workspace ${workspace.workspaceId}`); } - const snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd); + const snapshot = this.workspaceRuntimeStore.get(workspace.cwd); const checkout = checkoutFromPersistedWorkspacePlacement({ workspace, // COMPAT(workspacePlacementBackfill): added in v0.1.107, remove after 2027-01-15. @@ -5611,7 +5616,7 @@ export class Session { projectRecord ?? (await this.projectRegistry.get(workspace.projectId)); let diffStat: { additions: number; deletions: number } | null = null; - const snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd); + const snapshot = this.workspaceRuntimeStore.get(workspace.cwd); if (snapshot?.git.diffStat) { diffStat = snapshot.git.diffStat; } @@ -5687,7 +5692,7 @@ export class Session { projectRecord?: PersistedProjectRecord | null, ): Promise { const base = await this.describeWorkspaceRecord(workspace, projectRecord); - const snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd); + const snapshot = this.workspaceRuntimeStore.get(workspace.cwd); if (!snapshot) { return base; } diff --git a/packages/server/src/server/session.workspace-git-watch.test.ts b/packages/server/src/server/session.workspace-git-watch.test.ts index c836e6db5d..f13b90d2c8 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -1,3 +1,4 @@ +import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { createAgentMessageDeliveryStub } from "./test-utils/session-stubs.js"; import { createMessageReceiptsStub, @@ -135,7 +136,7 @@ function createSessionForWorkspaceGitWatchTests(options?: { workspaces: Map>; workspaceGitService: WorkspaceGitService & { registerWorkspace: ReturnType; - peekSnapshot: ReturnType; + getSnapshot: ReturnType; refresh: ReturnType; requestWorkingTreeWatch: ReturnType; @@ -177,7 +178,7 @@ function createSessionForWorkspaceGitWatchTests(options?: { unsubscribe, }; }), - peekSnapshot: vi.fn((cwd: string) => createWorkspaceRuntimeSnapshot(cwd)), + getSnapshot: vi.fn(async (cwd: string) => createWorkspaceRuntimeSnapshot(cwd)), refresh: vi.fn(async () => {}), requestWorkingTreeWatch: vi.fn(async (cwd: string) => ({ @@ -189,6 +190,7 @@ function createSessionForWorkspaceGitWatchTests(options?: { }; const session = new Session({ + workspaceRuntimeStore: createWorkspaceRuntimeStore(), messageReceipts: createMessageReceiptsStub(), agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), diff --git a/packages/server/src/server/session.workspace-resolution-invariants.test.ts b/packages/server/src/server/session.workspace-resolution-invariants.test.ts index 079dadc26f..2a5402a0c3 100644 --- a/packages/server/src/server/session.workspace-resolution-invariants.test.ts +++ b/packages/server/src/server/session.workspace-resolution-invariants.test.ts @@ -1,3 +1,4 @@ +import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { createAgentMessageDeliveryStub } from "./test-utils/session-stubs.js"; import { createMessageReceiptsStub, @@ -79,7 +80,6 @@ function createHarness(input: { mainRepoRoot: null, }; }, - peekSnapshot: () => null, }); const emitted: SessionOutboundMessage[] = []; @@ -93,6 +93,7 @@ function createHarness(input: { }; const session = new Session({ + workspaceRuntimeStore: createWorkspaceRuntimeStore(), messageReceipts: createMessageReceiptsStub(), agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 71759d33bf..2dfddef55e 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -1,3 +1,7 @@ +import { + createWorkspaceRuntimeStore, + type WorkspaceRuntimeStore, +} from "./workspace-runtime-store/index.js"; import { createAgentMessageDeliveryStub } from "./test-utils/session-stubs.js"; import { createMessageReceiptsStub, @@ -48,7 +52,12 @@ import { writePaseoWorktreeFirstAgentBranchAutoNameMetadata, writePaseoWorktreeMetadata, } from "../utils/worktree-metadata.js"; -import type { WorkspaceGitRuntimeSnapshot } from "./workspace-git-service.js"; +import { + WorkspaceGitServiceImpl, + type WorkspaceGitRuntimeSnapshot, +} from "./workspace-git-service.js"; +import type { CheckoutSnapshotFacts, CheckoutStatusGit } from "../utils/checkout-git.js"; +import type pino from "pino"; import type { GeneratedWorkspaceName } from "./worktree-branch-name-generator.js"; import { WorkspaceAutoName } from "./workspace-auto-name.js"; import type { ForgeService } from "../services/forge-service.js"; @@ -184,10 +193,10 @@ interface SessionTestAccess { killTerminal(id: string): unknown; clearTerminalAttention?(id: string): Promise; } | null; + workspaceRuntimeStore: WorkspaceRuntimeStore; workspaceGitService: { getCheckout: (cwd: string) => Promise; getSnapshot: (cwd: string, options?: unknown) => Promise; - peekSnapshot: (cwd: string) => WorkspaceGitRuntimeSnapshot | null; registerWorkspace: (params: { cwd: string }, listener: unknown) => { unsubscribe: () => void }; }; filesystem: { @@ -559,6 +568,7 @@ function createSessionForWorkspaceTests( appVersion?: string | null; onMessage?: (message: SessionOutboundMessage) => void; onWorkspaceRecovered?: SessionOptions["onWorkspaceRecovered"]; + workspaceRuntimeStore?: WorkspaceRuntimeStore; workspaceGitService?: ReturnType; terminalManager?: TerminalManager | null; agentManager?: { [K in keyof SessionOptions["agentManager"]]?: unknown }; @@ -647,6 +657,7 @@ function createSessionForWorkspaceTests( const session = asTestSession( new Session({ + workspaceRuntimeStore: options.workspaceRuntimeStore ?? createWorkspaceRuntimeStore(), messageReceipts: createMessageReceiptsStub(), agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), @@ -1010,6 +1021,7 @@ test("create_agent_request keeps requested child cwd when grouped under an exist const emitted: SessionOutboundMessage[] = []; const session = asTestSession( new Session({ + workspaceRuntimeStore: createWorkspaceRuntimeStore(), messageReceipts: createMessageReceiptsStub(), agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), @@ -1166,6 +1178,7 @@ test("create_agent_request launches from an exact subdirectory in a created work const emitted: SessionOutboundMessage[] = []; const session = new Session({ + workspaceRuntimeStore: createWorkspaceRuntimeStore(), messageReceipts: createMessageReceiptsStub(), agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), @@ -1306,6 +1319,7 @@ test("create_agent_request does not title an existing workspace from the agent p let generateCalls = 0; const session = asTestSession( new Session({ + workspaceRuntimeStore: createWorkspaceRuntimeStore(), messageReceipts: createMessageReceiptsStub(), agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), @@ -1449,7 +1463,6 @@ test("agent_update placement does not refresh git snapshots", async () => { const workspaceGitService = { ...createNoopWorkspaceGitService(), getSnapshot, - peekSnapshot: vi.fn(() => null), }; const session = asTestSession( createSessionForWorkspaceTests({ @@ -1516,7 +1529,6 @@ test("agent_update emits remove when the agent has no workspaceId", async () => workspaceGitService: { ...createNoopWorkspaceGitService(), getSnapshot, - peekSnapshot: vi.fn(() => null), }, }), ); @@ -1578,6 +1590,7 @@ test("archive emits an authoritative agent_update upsert for subscribed clients" const session = asTestSession( new Session({ + workspaceRuntimeStore: createWorkspaceRuntimeStore(), messageReceipts: createMessageReceiptsStub(), agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), @@ -2063,6 +2076,7 @@ test("close_items_request archives agents and kills terminals in one batch", asy const cancelAgentRun = vi.fn(async () => ({ status: "settled" as const })); const session = asTestSession( new Session({ + workspaceRuntimeStore: createWorkspaceRuntimeStore(), messageReceipts: createMessageReceiptsStub(), agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), @@ -2234,6 +2248,7 @@ test("close_items_request archives stored agents that are not currently loaded", const session = asTestSession( new Session({ + workspaceRuntimeStore: createWorkspaceRuntimeStore(), messageReceipts: createMessageReceiptsStub(), agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), @@ -2396,6 +2411,7 @@ test("close_items_request continues after an archive failure", async () => { const killTerminalBestEffort = vi.fn(); const session = asTestSession( new Session({ + workspaceRuntimeStore: createWorkspaceRuntimeStore(), messageReceipts: createMessageReceiptsStub(), agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), @@ -2606,7 +2622,7 @@ test("workspace placements preserve checkout facts independently from the projec (workspace) => workspace.workspaceId === workspaceId, ) ?? null; session.projectRegistry.get = async () => project; - session.workspaceGitService.peekSnapshot = (cwd: string) => + session.workspaceRuntimeStore.get = (cwd: string) => cwd === paseoSubdirectory.cwd ? createWorkspaceRuntimeSnapshot(cwd, { git: { repoRoot: "/tmp/paseo-worktree" }, @@ -3654,6 +3670,7 @@ test("workspace update stream keeps persisted workspace visible after agents sto const session = asTestSession( new Session({ + workspaceRuntimeStore: createWorkspaceRuntimeStore(), messageReceipts: createMessageReceiptsStub(), agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), @@ -4624,7 +4641,7 @@ test("open_project_request emits a workspace_update with githubRuntime once the isPaseoOwnedWorktree: false, mainRepoRoot: null, }); - session.workspaceGitService.peekSnapshot = () => peeked.value; + session.workspaceRuntimeStore.get = () => peeked.value; session.workspaceGitService.registerWorkspace = ( _params, incomingListener: (snapshot: WorkspaceGitRuntimeSnapshot) => void, @@ -6428,7 +6445,7 @@ test("lists Git runtime for a checkout explicitly owned by a non-Git project", a session.listAgentPayloads = async () => []; session.projectRegistry.list = async () => [project]; session.workspaceRegistry.list = async () => [workspace]; - session.workspaceGitService.peekSnapshot = () => createWorkspaceRuntimeSnapshot(REPO_CWD); + session.workspaceRuntimeStore.get = () => createWorkspaceRuntimeSnapshot(REPO_CWD); const descriptors = Array.from( (await session.buildWorkspaceDescriptorMap({ includeGitData: true })).values(), @@ -6982,9 +6999,10 @@ test("fetch_workspaces_response reads runtime fields from passive workspace git }, }, }); - const peekSnapshotRuntimeFetch = vi.fn(() => runtimeSnapshot); + const getRuntimeSnapshotForFetch = vi.fn(() => runtimeSnapshot); const workspaceGitService = createNoopWorkspaceGitService(); - workspaceGitService.peekSnapshot = peekSnapshotRuntimeFetch; + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + workspaceRuntimeStore.get = getRuntimeSnapshotForFetch; workspaceGitService.registerWorkspace = vi.fn(() => ({ unsubscribe: () => {}, })); @@ -6992,6 +7010,7 @@ test("fetch_workspaces_response reads runtime fields from passive workspace git const session = asTestSession( createSessionForWorkspaceTests({ workspaceGitService, + workspaceRuntimeStore, }), ); const project = createPersistedProjectRecord({ @@ -7041,7 +7060,7 @@ test("fetch_workspaces_response reads runtime fields from passive workspace git | { type: "fetch_workspaces_response"; payload: Record } | undefined; - expect(peekSnapshotRuntimeFetch).toHaveBeenCalledWith(REPO_CWD); + expect(getRuntimeSnapshotForFetch).toHaveBeenCalledWith(REPO_CWD); expect(response?.payload.entries).toEqual([ expect.objectContaining({ id: "ws-runtime-fetch", @@ -7146,13 +7165,15 @@ test("workspace_update includes updated runtime fields", async () => { }, }, }); - const peekSnapshotRuntimeUpdate = vi.fn(() => runtimeSnapshot); + const getRuntimeSnapshotForUpdate = vi.fn(() => runtimeSnapshot); const workspaceGitService = createNoopWorkspaceGitService(); - workspaceGitService.peekSnapshot = peekSnapshotRuntimeUpdate; + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + workspaceRuntimeStore.get = getRuntimeSnapshotForUpdate; const session = asTestSession( createSessionForWorkspaceTests({ workspaceGitService, + workspaceRuntimeStore, onMessage: (message) => emitted.push(message), }), ); @@ -7199,7 +7220,7 @@ test("workspace_update includes updated runtime fields", async () => { await session.emitWorkspaceUpdateForCwd(REPO_CWD); - expect(peekSnapshotRuntimeUpdate).toHaveBeenCalledWith(REPO_CWD); + expect(getRuntimeSnapshotForUpdate).toHaveBeenCalledWith(REPO_CWD); expect(emitted).toContainEqual({ type: "workspace_update", payload: { @@ -9692,3 +9713,212 @@ test.each(["failed", "blocked"] as const)( expect(findByType(emitted, "fetch_workspaces_response")!.payload.entries).toEqual([]); }, ); + +// Diagnostic reproduction: PR status on workspace descriptors must survive a +// session teardown (client reconnect after the 90s grace, or any routed-session +// disconnect) when the daemon already observed it. +test("workspace PR status survives session cleanup and reconnect", async () => { + const cwd = path.resolve("/tmp/repro-pr-repo"); + const project = createPersistedProjectRecord({ + projectId: "proj-repro", + rootPath: cwd, + kind: "git", + displayName: "repro", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-repro", + projectId: project.projectId, + cwd, + kind: "worktree", + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + displayName: "repro", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const projectRegistry: SessionOptions["projectRegistry"] = { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [project], + get: async (id) => (id === project.projectId ? project : null), + getOrCreateActiveByRoot: async () => project, + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + }; + const workspaceRegistry: SessionOptions["workspaceRegistry"] = { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [workspace], + get: async (id) => (id === workspace.workspaceId ? workspace : null), + update: async (id, updater) => (id === workspace.workspaceId ? updater(workspace) : null), + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + }; + + const checkoutStatus: CheckoutStatusGit = { + isGit: true, + repoRoot: cwd, + mainRepoRoot: null, + currentBranch: "feature", + isDirty: false, + baseRef: "main", + aheadBehind: { ahead: 0, behind: 0 }, + aheadOfOrigin: 0, + behindOfOrigin: 0, + hasRemote: true, + remoteUrl: "https://github.com/acme/repo.git", + isPaseoOwnedWorktree: false, + }; + const facts: CheckoutSnapshotFacts = { + isGit: true, + worktreeRoot: cwd, + currentBranch: "feature", + remoteUrl: "https://github.com/acme/repo.git", + absoluteGitDir: path.join(cwd, ".git"), + gitCommonDir: path.join(cwd, ".git"), + paseoWorktree: { isPaseoOwnedWorktree: false }, + storedBaseRef: null, + resolvedBaseRef: "main", + mainRepoRoot: null, + comparisonBaseRef: null, + branchRemoteName: "origin", + branchMergeRef: "refs/heads/feature", + pullRequestLookupTarget: { headRef: "feature" }, + }; + const githubStub = { + listPullRequests: async () => [], + listIssues: async () => [], + searchIssuesAndPrs: async () => ({ + items: [], + featuresEnabled: true, + githubFeaturesEnabled: true, + }), + getPullRequest: async () => { + throw new Error("unused"); + }, + getPullRequestHeadRef: async () => "feature", + getPullRequestCheckoutTarget: async () => { + throw new Error("unused"); + }, + getCurrentPullRequestStatus: async () => null, + createPullRequest: async () => ({ url: "https://github.com/acme/repo/pull/1", number: 1 }), + mergePullRequest: async () => ({ success: true }), + isAuthenticated: async () => true, + authProbeCanThrow: true, + invalidate: () => {}, + } as unknown as ForgeService; + const gitService = new WorkspaceGitServiceImpl({ + logger: createTestLogger() as unknown as pino.Logger, + paseoHome: "/tmp/paseo-test", + deps: { + subscribe: async () => ({ updateIgnore: async () => {}, unsubscribe: async () => {} }), + getCheckoutSnapshotFacts: async () => facts, + getCheckoutRefDerivedState: async (_cwd, f, current) => ({ + ...current, + upstreamStatus: f.upstreamStatus, + }), + getCheckoutStatus: async () => checkoutStatus, + getCheckoutShortstat: async () => ({ additions: 1, deletions: 0 }), + getCheckoutWorktreeState: async () => ({ + isDirty: false, + diffStat: { additions: 1, deletions: 0 }, + }), + getPullRequestStatus: async () => ({ + status: { + url: "https://github.com/acme/repo/pull/123", + title: "Merged feature", + state: "merged", + baseRefName: "main", + headRefName: "feature", + isMerged: true, + checksStatus: "success", + }, + authState: "authenticated", + featuresEnabled: true, + githubFeaturesEnabled: true, + }), + forgeOverrides: { github: githubStub }, + resolveAbsoluteGitDir: async () => path.join(cwd, ".git"), + hasOriginRemote: async () => false, + runGitFetch: async () => ({ changes: [], error: null }), + runGitCommand: async () => ({ + stdout: `${cwd}\n`, + stderr: "", + truncated: false, + exitCode: 0, + signal: null, + }), + getWorkspaceGitSelfHealPhaseMs: () => 30_000, + createWatcherLivenessCanary: () => ({ + path: "", + filterEvents: (events) => events, + verify: async () => {}, + }), + now: () => new Date(), + } as never, + }); + + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + gitService.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); + + const emitted1: SessionOutboundMessage[] = []; + const session1 = createSessionForWorkspaceTests({ + onMessage: (m) => emitted1.push(m), + workspaceGitService: gitService, + workspaceRuntimeStore, + projectRegistry, + workspaceRegistry, + }); + await session1.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-1", + subscribe: { subscriptionId: "sub-1" }, + }); + expect(filterByType(emitted1, "rpc_error")).toEqual([]); + const prUrlFrom = (m: SessionOutboundMessage) => + m.type === "workspace_update" && m.payload.kind === "upsert" + ? (m.payload.workspace.githubRuntime?.pullRequest?.url ?? null) + : null; + await vi.waitFor( + () => { + expect(emitted1.map(prUrlFrom)).toContain("https://github.com/acme/repo/pull/123"); + }, + { timeout: 5_000 }, + ); + expect(workspaceRuntimeStore.get(cwd)?.forge.pullRequest?.url).toBe( + "https://github.com/acme/repo/pull/123", + ); + + // Client disconnect past the reconnect grace (or any routed-session drop). + await session1.cleanup(); + + const emitted2: SessionOutboundMessage[] = []; + const session2 = createSessionForWorkspaceTests({ + onMessage: (m) => emitted2.push(m), + workspaceGitService: gitService, + workspaceRuntimeStore, + projectRegistry, + workspaceRegistry, + }); + await session2.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-2", + subscribe: { subscriptionId: "sub-2" }, + }); + const response = filterByType(emitted2, "fetch_workspaces_response")[0]; + const entry = response?.payload.entries.find((e) => e.id === workspace.workspaceId); + const reconnectEntryPrUrl = entry?.githubRuntime?.pullRequest?.url; + try { + // Expected: the daemon already knew this PR; the reconnecting client should get it back + // in the directory listing instead of a descriptor with no githubRuntime at all. + expect(entry?.githubRuntime).toBeDefined(); + expect(reconnectEntryPrUrl).toBe("https://github.com/acme/repo/pull/123"); + } finally { + await session2.cleanup(); + await gitService.dispose(); + } +}); diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts index 2692250b49..0a3b8cb257 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts @@ -53,7 +53,6 @@ let provisioning: WorkspaceProvisioningService; function gitService() { return createNoopWorkspaceGitService({ - peekSnapshot: () => null, getSnapshot: async (cwd: string) => { const snapshot = createNoGitWorkspaceRuntimeSnapshot(cwd); if (!restoredMergedChangeRequestUrl) return snapshot; @@ -242,7 +241,6 @@ test("persists manual worktree ownership separately from its workspace kind", as projectRegistry, isDirectory, workspaceGitService: createNoopWorkspaceGitService({ - peekSnapshot: () => null, getCheckout: async () => ({ cwd, isGit: true, @@ -330,7 +328,6 @@ test("reopening archived exact-root records restores the fresh Git project", asy projectRegistry, isDirectory, workspaceGitService: createNoopWorkspaceGitService({ - peekSnapshot: () => null, getCheckout: async () => ({ cwd, isGit: true, @@ -886,7 +883,6 @@ test("a failed import keeps an archived worktree's placement when its directory logger, isDirectory, workspaceGitService: createNoopWorkspaceGitService({ - peekSnapshot: () => null, getCheckout: async (cwd: string) => { const status = await getCheckoutStatus(cwd, { logger }); return checkoutLiteFromGitSnapshot( @@ -960,7 +956,6 @@ test("a directory that goes away while the git read is in flight keeps its place isDirectory, logger, workspaceGitService: createNoopWorkspaceGitService({ - peekSnapshot: () => null, getCheckout: async (cwd: string) => { rmSync(repo, { recursive: true, force: true }); return { diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts index 6dbcabdda6..3ac91a834a 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts @@ -103,7 +103,7 @@ export function createWorkspaceProvisioningService(deps: { serverId?: string; workspaceRegistry: WorkspaceRegistry; projectRegistry: ProjectRegistry; - workspaceGitService: Pick; + workspaceGitService: Pick; isDirectory: (path: string) => Promise; logger: Logger; lifecycle?: PluginLifecycle; diff --git a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts index aa366a3495..18ae1f1366 100644 --- a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts +++ b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts @@ -22,11 +22,11 @@ import type { SpawnWorkspaceScriptOptions, WorktreeScriptResult, } from "../../worktree-bootstrap.js"; -import type { WorkspaceGitService } from "../../workspace-git-service.js"; +import type { WorkspaceRuntimeStore } from "../../workspace-runtime-store/index.js"; import { createWorkspaceScriptsService } from "./workspace-scripts-service.js"; import { deriveProjectServiceSlug } from "../../workspace-git-metadata.js"; -// The production module reads only WorkspaceGitService.{peekSnapshot,getProjectSlug}, +// The production module reads only WorkspaceRuntimeStore.get, // WorkspaceRegistry.get, and forwards the launcher + opaque managers to the injected // spawnWorkspaceScript port. The fakes below implement exactly that slice; the service proxy and // runtime store are the real in-memory implementations, and spawning is injected so no process runs. @@ -51,7 +51,7 @@ function fakeProjectRegistry(record: PersistedProjectRecord | null): Pick; + runtimeStore?: Pick; automationError?: Error; } @@ -106,7 +106,7 @@ function buildService(options: BuildOptions = {}) { options.terminalManager === undefined ? availableTerminalManager : options.terminalManager, workspaceRegistry: fakeWorkspaceRegistry(workspace), projectRegistry: fakeProjectRegistry(options.project ?? null), - workspaceGitService: options.gitService ?? fakeGitService(), + workspaceRuntimeStore: options.runtimeStore ?? fakeRuntimeStore(), getDaemonTcpPort: () => 6767, getDaemonTcpHost: () => "127.0.0.1", serviceProxyPublicBaseUrl: null, @@ -204,7 +204,7 @@ describe("buildSnapshot", () => { workspace, project, serviceProxy, - gitService: { peekSnapshot: () => undefined }, + runtimeStore: { get: () => null }, }); expect(service.buildSnapshot(workspace, project)[0]?.hostname).toBe( diff --git a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts index d449a48855..103c3ed16b 100644 --- a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts +++ b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts @@ -1,3 +1,4 @@ +import { type WorkspaceRuntimeStore } from "../../workspace-runtime-store/index.js"; import type pino from "pino"; import type { ScriptStatusUpdateMessage, @@ -10,7 +11,6 @@ import type { TerminalManager } from "../../../terminal/terminal-manager.js"; import type { ServiceProxySubsystem } from "../../service-proxy.js"; import type { WorkspaceScriptRuntimeStore } from "../../workspace-script-runtime-store.js"; import type { ScriptHealthState } from "../../script-health-monitor.js"; -import type { WorkspaceGitService } from "../../workspace-git-service.js"; import type { PersistedProjectRecord, PersistedWorkspaceRecord, @@ -51,15 +51,13 @@ export interface WorkspaceScriptsService { start(request: StartWorkspaceScriptRequest): Promise; } -type WorkspaceScriptsGitSource = Pick; - export function createWorkspaceScriptsService(deps: { serviceProxy: ServiceProxySubsystem | null; scriptRuntimeStore: WorkspaceScriptRuntimeStore | null; terminalManager: TerminalManager | null; workspaceRegistry: Pick; projectRegistry: Pick; - workspaceGitService: WorkspaceScriptsGitSource; + workspaceRuntimeStore: Pick; getDaemonTcpPort: (() => number | null) | null; getDaemonTcpHost: (() => string | null) | null; serviceProxyPublicBaseUrl: string | null; @@ -78,7 +76,7 @@ export function createWorkspaceScriptsService(deps: { terminalManager, workspaceRegistry, projectRegistry, - workspaceGitService, + workspaceRuntimeStore, getDaemonTcpPort, getDaemonTcpHost, serviceProxyPublicBaseUrl, @@ -95,7 +93,7 @@ export function createWorkspaceScriptsService(deps: { workspace: PersistedWorkspaceRecord, project: { projectId: string; rootPath: string } | null, ) { - const snapshot = workspaceGitService.peekSnapshot(workspace.cwd); + const snapshot = workspaceRuntimeStore.get(workspace.cwd); const currentBranch = snapshot?.git.currentBranch ?? workspace.branch ?? null; if (project) { return { diff --git a/packages/server/src/server/snapshot-mutation-ownership.test.ts b/packages/server/src/server/snapshot-mutation-ownership.test.ts index 4af5e45c5b..2917d0fec9 100644 --- a/packages/server/src/server/snapshot-mutation-ownership.test.ts +++ b/packages/server/src/server/snapshot-mutation-ownership.test.ts @@ -1,3 +1,4 @@ +import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { createAgentMessageDeliveryStub } from "./test-utils/session-stubs.js"; import { createMessageReceiptsStub, @@ -104,6 +105,7 @@ describe("snapshot mutation ownership boundary", () => { const session = asInternals( new Session({ + workspaceRuntimeStore: createWorkspaceRuntimeStore(), messageReceipts: createMessageReceiptsStub(), agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), diff --git a/packages/server/src/server/test-utils/workspace-git-service-stub.ts b/packages/server/src/server/test-utils/workspace-git-service-stub.ts index 53b8a44c1e..0027173ab7 100644 --- a/packages/server/src/server/test-utils/workspace-git-service-stub.ts +++ b/packages/server/src/server/test-utils/workspace-git-service-stub.ts @@ -38,7 +38,7 @@ export function createNoopWorkspaceGitService( onSnapshotUpdated: () => ({ unsubscribe: () => {}, }), - peekSnapshot: () => null, + getCheckout: async (cwd: string) => ({ cwd, isGit: false, diff --git a/packages/server/src/server/websocket-server.browser-tools.test.ts b/packages/server/src/server/websocket-server.browser-tools.test.ts index df0b02ad43..313d82ff7b 100644 --- a/packages/server/src/server/websocket-server.browser-tools.test.ts +++ b/packages/server/src/server/websocket-server.browser-tools.test.ts @@ -1,3 +1,4 @@ +import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { createServer, type Server as HTTPServer } from "node:http"; import type { AddressInfo } from "node:net"; @@ -306,6 +307,7 @@ function createVoiceAssistantWebSocketServer(params: { null, { allowedOrigins: new Set(["*"]) }, createWorkspaceAutoNameStub(), + createWorkspaceRuntimeStore(), undefined, undefined, undefined, diff --git a/packages/server/src/server/websocket-server.notifications.test.ts b/packages/server/src/server/websocket-server.notifications.test.ts index 8ff692d580..c16a079628 100644 --- a/packages/server/src/server/websocket-server.notifications.test.ts +++ b/packages/server/src/server/websocket-server.notifications.test.ts @@ -1,3 +1,4 @@ +import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { SessionDelivery } from "./session/owned-subscriptions/index.js"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { Server as HTTPServer } from "http"; @@ -117,6 +118,7 @@ function createServer(agentManagerOverrides?: Record) { null, { allowedOrigins: new Set() }, createWorkspaceAutoNameStub(), + createWorkspaceRuntimeStore(), undefined, undefined, undefined, diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index a3c821abd1..859b5007fb 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -1,3 +1,4 @@ +import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { SessionDelivery } from "./session/owned-subscriptions/index.js"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import type { Server as HTTPServer } from "http"; @@ -285,6 +286,7 @@ function createServer(options?: { null, { allowedOrigins: new Set(), startPaused: options?.startPaused }, createWorkspaceAutoNameStub(), + createWorkspaceRuntimeStore(), options?.auth, speechReadiness ? { diff --git a/packages/server/src/server/websocket-server.terminal-notifications.test.ts b/packages/server/src/server/websocket-server.terminal-notifications.test.ts index 3f77a0482e..e22e5bf9a7 100644 --- a/packages/server/src/server/websocket-server.terminal-notifications.test.ts +++ b/packages/server/src/server/websocket-server.terminal-notifications.test.ts @@ -1,3 +1,4 @@ +import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { SessionDelivery } from "./session/owned-subscriptions/index.js"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { Server as HTTPServer } from "http"; @@ -148,6 +149,7 @@ function createServer(terminalManager: TerminalManager, workspaceRegistry?: Work null, { allowedOrigins: new Set() }, createWorkspaceAutoNameStub(), + createWorkspaceRuntimeStore(), undefined, undefined, terminalManager, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 214fc8c9ce..8a28f25d5a 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1,3 +1,4 @@ +import { type WorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { createAgentMessageDelivery, type AgentMessageDelivery, @@ -241,7 +242,6 @@ function createFallbackWorkspaceGitService(): WorkspaceGitService { onSnapshotUpdated: () => ({ unsubscribe: () => {}, }), - peekSnapshot: () => null, getCheckout: async (cwd: string) => ({ cwd, isGit: false, @@ -649,6 +649,7 @@ export class VoiceAssistantWebSocketServer { mcpBaseUrl: string | null, wsConfig: WebSocketServerConfig, workspaceAutoName: WorkspaceAutoName, + private readonly workspaceRuntimeStore: WorkspaceRuntimeStore, auth?: DaemonAuthConfig, speech?: SpeechService | null, terminalManager?: TerminalManager | null, @@ -1606,6 +1607,7 @@ export class VoiceAssistantWebSocketServer { checkoutDiffManager: this.checkoutDiffManager, github: this.github, workspaceGitService: this.workspaceGitService, + workspaceRuntimeStore: this.workspaceRuntimeStore, workspaceAutoName: this.workspaceAutoName, daemonConfigStore: this.daemonConfigStore, pluginRuntime: this.pluginRuntime, diff --git a/packages/server/src/server/wire-compat.test.ts b/packages/server/src/server/wire-compat.test.ts index c6896c3fb0..00faf1f441 100644 --- a/packages/server/src/server/wire-compat.test.ts +++ b/packages/server/src/server/wire-compat.test.ts @@ -1,3 +1,4 @@ +import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { createAgentMessageDeliveryStub } from "./test-utils/session-stubs.js"; import { createMessageReceiptsStub, @@ -214,6 +215,7 @@ function createSessionForWireCompatTest(options?: { ]; const session = new Session({ + workspaceRuntimeStore: createWorkspaceRuntimeStore(), messageReceipts: createMessageReceiptsStub(), agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), @@ -262,9 +264,7 @@ function createSessionForWireCompatTest(options?: { async listStashes() { return []; }, - peekSnapshot() { - return null; - }, + async validateBranchRef() { return { ok: false, error: "not found" }; }, diff --git a/packages/server/src/server/workspace-git-service.observation.integration.test.ts b/packages/server/src/server/workspace-git-service.observation.integration.test.ts index 8ac1ef0018..a8a1f11dfb 100644 --- a/packages/server/src/server/workspace-git-service.observation.integration.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.integration.test.ts @@ -1,3 +1,4 @@ +import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { execFileSync } from "node:child_process"; import { tmpdir } from "node:os"; @@ -204,7 +205,10 @@ test("recursive observation updates tracked state and prunes ignored storms", as runGitCommand, } as never, }); + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); const diffManager = new CheckoutDiffManager({ + workspaceRuntimeStore: workspaceRuntimeStore, logger: createLogger(), paseoHome: path.join(tempDir, "paseo-home"), workspaceGitService: service, @@ -229,7 +233,7 @@ test("recursive observation updates tracked state and prunes ignored storms", as await vi.waitFor( () => { expect(activeWatcherCount).toBe(2); - expect(service.peekSnapshot(repoDir)).not.toBeNull(); + expect(workspaceRuntimeStore.get(repoDir)).not.toBeNull(); expect(service.getMetrics()).toMatchObject({ workspaceObservationSetupInFlightCount: 0, workspaceRefreshInFlightCount: 0, diff --git a/packages/server/src/server/workspace-git-service.observation.test.ts b/packages/server/src/server/workspace-git-service.observation.test.ts index 8285304570..f91a96e464 100644 --- a/packages/server/src/server/workspace-git-service.observation.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.test.ts @@ -1,3 +1,4 @@ +import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import path from "node:path"; import { pathToFileURL } from "node:url"; import type pino from "pino"; @@ -379,11 +380,13 @@ describe("WorkspaceGitService checkout observation", () => { getCheckoutStatus, runGitCommand, }); + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); const first = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); const second = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); await vi.waitFor(() => { - expect(service.peekSnapshot(REPO_CWD)).not.toBeNull(); + expect(workspaceRuntimeStore.get(REPO_CWD)).not.toBeNull(); expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0); }); @@ -442,7 +445,10 @@ describe("WorkspaceGitService checkout observation", () => { getPullRequestStatus, getCheckoutDiff, }); + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); const diffManager = new CheckoutDiffManager({ + workspaceRuntimeStore: workspaceRuntimeStore, logger: createLogger(), paseoHome: "/tmp/paseo-home", workspaceGitService: service, @@ -509,10 +515,12 @@ describe("WorkspaceGitService checkout observation", () => { }, ); const service = createService(watcher, { getCheckoutDiff }); + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); await vi.waitFor(() => { - expect(service.peekSnapshot(REPO_CWD)).not.toBeNull(); + expect(workspaceRuntimeStore.get(REPO_CWD)).not.toBeNull(); expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); }); const initialUncommitted = await service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" }); @@ -963,12 +971,14 @@ describe("WorkspaceGitService checkout observation", () => { return { changes: [], nonRemoteRefsChanged: false, error: null }; }), }); + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); await fetchSnapshotRead.promise; // Observation setup also reads facts. Wait for the initial snapshot to be // published before injecting an event that must produce a second refresh. await vi.waitFor(() => { - expect(service.peekSnapshot(REPO_CWD)?.git.currentBranch).toBe("feature"); + expect(workspaceRuntimeStore.get(REPO_CWD)?.git.currentBranch).toBe("feature"); expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0); }); expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1); @@ -1233,7 +1243,10 @@ describe("WorkspaceGitService checkout observation", () => { getCheckoutShortstat, getCheckoutDiff, }); + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); const diffManager = new CheckoutDiffManager({ + workspaceRuntimeStore: workspaceRuntimeStore, logger: createLogger(), paseoHome: "/tmp/paseo-home", workspaceGitService: service, @@ -1288,7 +1301,10 @@ describe("WorkspaceGitService checkout observation", () => { diffStat: { additions: 4, deletions: 2 }, })); const service = createService(watcher, { getCheckoutDiff, getCheckoutWorktreeState }); + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); const diffManager = new CheckoutDiffManager({ + workspaceRuntimeStore: workspaceRuntimeStore, logger: createLogger(), paseoHome: "/tmp/paseo-home", workspaceGitService: service, @@ -1518,6 +1534,8 @@ describe("WorkspaceGitService checkout observation", () => { getCheckoutStatus, getPullRequestStatus, }); + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); await vi.waitFor(() => { @@ -1529,7 +1547,7 @@ describe("WorkspaceGitService checkout observation", () => { expect(service.getMetrics().workingTreeWatchTargetCount).toBe(1); expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0); - expect(service.peekSnapshot(REPO_CWD)).not.toBeNull(); + expect(workspaceRuntimeStore.get(REPO_CWD)).not.toBeNull(); expect(getCheckoutStatus).toHaveBeenCalledTimes(1); }); await vi.advanceTimersByTimeAsync(7_000); @@ -1677,10 +1695,12 @@ describe("WorkspaceGitService checkout observation", () => { getWorkspaceGitSelfHealPhaseMs: () => 60_000, runGitCommand, }); + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); const summarySubscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); await vi.waitFor(() => { - expect(service.peekSnapshot(REPO_CWD)?.git.isGit).toBe(false); + expect(workspaceRuntimeStore.get(REPO_CWD)?.git.isGit).toBe(false); expect(service.getMetrics()).toMatchObject({ workspaceObservationSetupInFlightCount: 0, workspaceRefreshInFlightCount: 0, @@ -1691,7 +1711,7 @@ describe("WorkspaceGitService checkout observation", () => { isGit = true; await vi.advanceTimersByTimeAsync(5_000); await vi.waitFor(() => { - expect(service.peekSnapshot(REPO_CWD)?.git.isGit).toBe(true); + expect(workspaceRuntimeStore.get(REPO_CWD)?.git.isGit).toBe(true); expect(service.getMetrics()).toMatchObject({ repositoryTargetCount: 1, repositoryWorkspaceLinkCount: 1, @@ -1728,10 +1748,12 @@ describe("WorkspaceGitService checkout observation", () => { getWorkspaceGitSelfHealPhaseMs: () => 1_000_000, runGitCommand, }); + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); await vi.waitFor(() => { - expect(service.peekSnapshot(REPO_CWD)).not.toBeNull(); + expect(workspaceRuntimeStore.get(REPO_CWD)).not.toBeNull(); expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); }); const checkoutWatcher = watcher.records.find((record) => record.directory === REPO_CWD); @@ -2743,7 +2765,10 @@ describe("WorkspaceGitService checkout observation", () => { getCheckoutShortstat, getCheckoutDiff, }); + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); const diffManager = new CheckoutDiffManager({ + workspaceRuntimeStore: workspaceRuntimeStore, logger: createLogger(), paseoHome: "/tmp/paseo-home", workspaceGitService: service, @@ -2757,7 +2782,7 @@ describe("WorkspaceGitService checkout observation", () => { ); await vi.waitFor(() => { - expect(service.peekSnapshot(REPO_CWD)).not.toBeNull(); + expect(workspaceRuntimeStore.get(REPO_CWD)).not.toBeNull(); expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); }); isDirty = true; diff --git a/packages/server/src/server/workspace-git-service.primitive.test.ts b/packages/server/src/server/workspace-git-service.primitive.test.ts index ce052cdd7c..9a3599740c 100644 --- a/packages/server/src/server/workspace-git-service.primitive.test.ts +++ b/packages/server/src/server/workspace-git-service.primitive.test.ts @@ -1,3 +1,4 @@ +import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { execFileSync } from "node:child_process"; import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -566,19 +567,19 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { expect(subscription).toEqual({ unsubscribe: expect.any(Function) }); expect(getCheckoutStatus).not.toHaveBeenCalled(); expect(listener).not.toHaveBeenCalled(); - expect(service.peekSnapshot(REPO_CWD)).toBeNull(); + expect(listener.mock.lastCall?.[0] ?? null).toBeNull(); await vi.waitFor(() => { expect(getCheckoutStatus).toHaveBeenCalledTimes(1); }); await flushPromises(); expect(getCheckoutStatus).toHaveBeenCalledTimes(1); - expect(service.peekSnapshot(REPO_CWD)).toBeNull(); + expect(listener.mock.lastCall?.[0] ?? null).toBeNull(); checkoutStatusDeferred.resolve(createCheckoutStatus(REPO_CWD)); await expect(service.getSnapshot(REPO_CWD)).resolves.toEqual(createSnapshot(REPO_CWD)); - expect(service.peekSnapshot(REPO_CWD)).toEqual(createSnapshot(REPO_CWD)); + expect(listener.mock.lastCall?.[0] ?? null).toEqual(createSnapshot(REPO_CWD)); subscription.unsubscribe(); service.dispose(); @@ -703,7 +704,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { refreshStatus.resolve(createCheckoutStatus(REPO_CWD, { currentBranch: "feature" })); await refresh; - expect(service.peekSnapshot(REPO_CWD)).toEqual( + expect(await service.getSnapshot(REPO_CWD)).toEqual( createSnapshot(REPO_CWD, { git: { currentBranch: "feature", @@ -1385,23 +1386,25 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { getPullRequestStatus: vi.fn(async () => createPullRequestStatusResult("Visible PR")), }); + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); try { - await service.getSnapshot(REPO_CWD); + workspaceRuntimeStore.set(await service.getSnapshot(REPO_CWD)); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); - expect(service.peekSnapshot(REPO_CWD)?.forge.pullRequest?.title).toBe("Visible PR"); + expect(workspaceRuntimeStore.get(REPO_CWD)?.forge.pullRequest?.title).toBe("Visible PR"); headSha = "2222222222222222222222222222222222222222"; nowMs = 3_000; await service.refresh(REPO_CWD); - expect(service.peekSnapshot(REPO_CWD)?.forge.pullRequest).toBeNull(); + expect(workspaceRuntimeStore.get(REPO_CWD)?.forge.pullRequest).toBeNull(); expect(forge.getCurrentPullRequestStatus).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(0); await flushPromises(); expect(forge.getCurrentPullRequestStatus).toHaveBeenCalledTimes(1); - expect(service.peekSnapshot(REPO_CWD)?.forge.pullRequest?.title).toBe("MR self-healed"); + expect(workspaceRuntimeStore.get(REPO_CWD)?.forge.pullRequest?.title).toBe("MR self-healed"); subscription.unsubscribe(); } finally { service.dispose(); diff --git a/packages/server/src/server/workspace-git-service.test.ts b/packages/server/src/server/workspace-git-service.test.ts index a1d6b392e7..7d547b75e6 100644 --- a/packages/server/src/server/workspace-git-service.test.ts +++ b/packages/server/src/server/workspace-git-service.test.ts @@ -1,3 +1,4 @@ +import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import os from "node:os"; import path, { join } from "node:path"; @@ -334,6 +335,8 @@ describe("WorkspaceGitServiceImpl", () => { test("registerWorkspace returns a subscription without an initial snapshot contract", async () => { const service = createService(); + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); const listener = vi.fn(); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener); @@ -341,12 +344,46 @@ describe("WorkspaceGitServiceImpl", () => { expect(subscription).toEqual({ unsubscribe: expect.any(Function) }); expect("initial" in subscription).toBe(false); expect(listener).not.toHaveBeenCalled(); - expect(service.peekSnapshot(REPO_CWD)).toBeNull(); + expect(workspaceRuntimeStore.get(REPO_CWD)).toBeNull(); subscription.unsubscribe(); service.dispose(); }); + test("shared watches publish to the runtime store before listeners and retain data after teardown", async () => { + const subscribe = vi.fn(async () => createAsyncSubscription()); + const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult()); + const service = createService({ subscribe, getPullRequestStatus }); + const store = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => store.set(snapshot)); + const observed: WorkspaceGitRuntimeSnapshot[] = []; + const listener = (snapshot: WorkspaceGitRuntimeSnapshot) => { + expect(store.get(REPO_CWD)).toBe(snapshot); + observed.push(snapshot); + }; + const first = service.registerWorkspace({ cwd: REPO_CWD }, listener); + const second = service.registerWorkspace({ cwd: join(REPO_CWD, ".") }, (snapshot) => + listener(snapshot), + ); + await service.getSnapshot(REPO_CWD, { force: true, reason: "runtime-store" }); + await flushPromises(); + expect(observed.length).toBeGreaterThan(0); + expect(service.getMetrics().workspaceTargetCount).toBe(1); + const subscriptionCount = subscribe.mock.calls.length; + const snapshot = store.get(REPO_CWD); + expect(snapshot?.forge.pullRequest).not.toBeNull(); + first.unsubscribe(); + expect(service.getMetrics().workspaceListenerCount).toBe(1); + second.unsubscribe(); + expect(service.getMetrics().workspaceTargetCount).toBe(0); + const pollCount = getPullRequestStatus.mock.calls.length; + await vi.advanceTimersByTimeAsync(360_000); + expect(getPullRequestStatus).toHaveBeenCalledTimes(pollCount); + expect(subscribe).toHaveBeenCalledTimes(subscriptionCount); + expect(store.get(REPO_CWD)).toBe(snapshot); + await service.dispose(); + }); + test("onSnapshotUpdated emits only for observed workspace snapshots and can unsubscribe", async () => { const service = createService(); const snapshotListener = vi.fn(); @@ -516,7 +553,6 @@ describe("WorkspaceGitServiceImpl", () => { expect(getCheckoutStatus).toHaveBeenCalledTimes(1); expect(getPullRequestStatus).toHaveBeenCalledTimes(1); expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(0); - expect(service.peekSnapshot(REPO_CWD)).toEqual(createSnapshot(REPO_CWD)); await expect(service.getSnapshot(REPO_CWD)).resolves.toEqual(createSnapshot(REPO_CWD)); expect(getCheckoutStatus).toHaveBeenCalledTimes(1); @@ -834,13 +870,15 @@ describe("WorkspaceGitServiceImpl", () => { resolveAbsoluteGitDir, now: () => new Date(nowMs), }); + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); const subscription = service.registerWorkspace({ cwd: join(REPO_CWD, ".") }, vi.fn()); await expect(service.getSnapshot(join(REPO_CWD, "."))).resolves.toEqual( createSnapshot(REPO_CWD), ); - expect(service.peekSnapshot(REPO_CWD)).toEqual(createSnapshot(REPO_CWD)); + expect(workspaceRuntimeStore.get(REPO_CWD)).toEqual(createSnapshot(REPO_CWD)); nowMs += 3_000; await service.refresh(REPO_CWD); diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index be62376130..74c6f8f18d 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -209,7 +209,6 @@ export interface WorkspaceGitService { ): WorkspaceGitSubscription; onSnapshotUpdated(listener: WorkspaceGitSnapshotUpdatedListener): WorkspaceGitSubscription; - peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null; getCheckout(cwd: string): Promise; getSnapshot( cwd: string, @@ -765,7 +764,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { worktreesRoot: this.worktreesRoot, logger: this.logger, }); - const cached = this.peekSnapshot(normalizedCwd); + const cached = this.getCachedSnapshot(normalizedCwd); if (cached && cached.git.isGit !== status.isGit) { // A fresh checkout read discovered a repository transition. Keep the // shared snapshot current before exposing the new project capability. @@ -795,7 +794,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { }); } - peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null { + private getCachedSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null { cwd = resolve(cwd); return this.workspaceTargets.get(cwd)?.latestSnapshot ?? null; } @@ -3518,9 +3517,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { if (!options?.notify || target.listeners.size === 0) { return; } - for (const listener of target.listeners) { - listener(snapshot); - } for (const listener of this.snapshotUpdatedListeners) { try { listener(snapshot); @@ -3531,6 +3527,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { ); } } + for (const listener of target.listeners) { + listener(snapshot); + } } private async runRepoFetch(target: RepoGitTarget): Promise { diff --git a/packages/server/src/server/workspace-registry.test.ts b/packages/server/src/server/workspace-registry.test.ts index 04ffc1d7ac..6c27027edc 100644 --- a/packages/server/src/server/workspace-registry.test.ts +++ b/packages/server/src/server/workspace-registry.test.ts @@ -13,6 +13,7 @@ import { FileBackedWorkspaceRegistry, resolveWorkspaceDisplayName, resolveWorkspaceName, + type WorkspaceMutation, } from "./workspace-registry.js"; describe("resolveWorkspaceName", () => { @@ -430,6 +431,10 @@ describe("workspace registries", () => { test("creates, updates, archives, deletes, and lists workspace records", async () => { await workspaceRegistry.initialize(); + const mutations: WorkspaceMutation[] = []; + workspaceRegistry.subscribeToMutations((mutation) => { + mutations.push(mutation); + }); await workspaceRegistry.upsert( createPersistedWorkspaceRecord({ workspaceId: "/tmp/repo", @@ -460,6 +465,11 @@ describe("workspace registries", () => { expect(archived?.archivedAt).toBe("2026-03-03T00:00:00.000Z"); await workspaceRegistry.remove("/tmp/repo"); + expect(mutations.at(-1)).toEqual({ + kind: "remove", + workspaceId: "/tmp/repo", + workspace: archived, + }); expect(await workspaceRegistry.get("/tmp/repo")).toBeNull(); expect(await workspaceRegistry.list()).toEqual([]); }); diff --git a/packages/server/src/server/workspace-registry.ts b/packages/server/src/server/workspace-registry.ts index 417af353fe..0e0fb4e62c 100644 --- a/packages/server/src/server/workspace-registry.ts +++ b/packages/server/src/server/workspace-registry.ts @@ -585,7 +585,7 @@ export class FileBackedWorkspaceRegistry override async remove(workspaceId: string): Promise { const workspace = await this.removeIfPresent(workspaceId); if (!workspace) return; - await this.notifyMutation({ kind: "remove", workspaceId, workspace: null }); + await this.notifyMutation({ kind: "remove", workspaceId, workspace }); } async commitWorkspaceLabelMutation(input: { diff --git a/packages/server/src/server/workspace-runtime-store/index.test.ts b/packages/server/src/server/workspace-runtime-store/index.test.ts new file mode 100644 index 0000000000..ade1820506 --- /dev/null +++ b/packages/server/src/server/workspace-runtime-store/index.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from "vitest"; +import { resolve } from "node:path"; +import { createNoGitWorkspaceRuntimeSnapshot } from "../test-utils/workspace-git-service-stub.js"; +import { createWorkspaceRuntimeStore } from "./index.js"; + +test("retains, replaces, and removes snapshots by resolved workspace directory", () => { + const store = createWorkspaceRuntimeStore(); + const cwd = "./workspace"; + const snapshot = createNoGitWorkspaceRuntimeSnapshot(cwd); + expect(store.get(cwd)).toBeNull(); + store.set(snapshot); + expect(store.get(resolve(cwd))).toBe(snapshot); + const replacement = createNoGitWorkspaceRuntimeSnapshot(cwd); + store.set(replacement); + expect(store.get(cwd)).toBe(replacement); + store.remove(resolve(cwd)); + expect(store.get(cwd)).toBeNull(); +}); diff --git a/packages/server/src/server/workspace-runtime-store/index.ts b/packages/server/src/server/workspace-runtime-store/index.ts new file mode 100644 index 0000000000..778939bf93 --- /dev/null +++ b/packages/server/src/server/workspace-runtime-store/index.ts @@ -0,0 +1,23 @@ +import { resolve } from "node:path"; +import type { WorkspaceGitRuntimeSnapshot } from "../workspace-git-service.js"; + +export interface WorkspaceRuntimeStore { + get(cwd: string): WorkspaceGitRuntimeSnapshot | null; + set(snapshot: WorkspaceGitRuntimeSnapshot): void; + remove(cwd: string): void; +} + +export function createWorkspaceRuntimeStore(): WorkspaceRuntimeStore { + const snapshots = new Map(); + return { + get(cwd) { + return snapshots.get(resolve(cwd)) ?? null; + }, + set(snapshot) { + snapshots.set(resolve(snapshot.cwd), snapshot); + }, + remove(cwd) { + snapshots.delete(resolve(cwd)); + }, + }; +}