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)); + }, + }; +}