From 2dd77aae04e95b58b7a0535bee140931329ac6f8 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 14 May 2026 18:08:48 +0800 Subject: [PATCH] Auto-archive worktrees after PR merge (#1004) Add a daemon setting `autoArchiveAfterMerge` (default off). When on, the daemon's existing GitHub poll triggers archive once the PR is merged, but only if the worktree is clean (not dirty, not ahead of origin). Skips silently otherwise. --- .../settings/providers-section.test.tsx | 2 +- .../archive-if-safe.test.ts | 290 ++++++++++++++++++ .../auto-archive-on-merge/archive-if-safe.ts | 131 ++++++++ .../src/server/auto-archive-on-merge/index.ts | 25 ++ packages/server/src/server/bootstrap.ts | 114 ++++--- packages/server/src/server/config.ts | 4 +- .../server/src/server/daemon-config-store.ts | 1 + .../server/src/server/persisted-config.ts | 1 + .../server/src/server/websocket-server.ts | 3 + .../src/server/workspace-git-service.test.ts | 24 ++ .../src/server/workspace-git-service.ts | 26 +- packages/server/src/shared/messages.ts | 2 + 12 files changed, 571 insertions(+), 52 deletions(-) create mode 100644 packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts create mode 100644 packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts create mode 100644 packages/server/src/server/auto-archive-on-merge/index.ts diff --git a/packages/app/src/screens/settings/providers-section.test.tsx b/packages/app/src/screens/settings/providers-section.test.tsx index e01d0af1b..61095e834 100644 --- a/packages/app/src/screens/settings/providers-section.test.tsx +++ b/packages/app/src/screens/settings/providers-section.test.tsx @@ -202,7 +202,7 @@ const disabledCodexEntry: ProviderSnapshotEntry = { }; function makeConfig(providers: MutableDaemonConfig["providers"] = {}): MutableDaemonConfig { - return { mcp: { injectIntoAgents: false }, providers }; + return { mcp: { injectIntoAgents: false }, providers, autoArchiveAfterMerge: false }; } function descendants(el: HTMLElement): HTMLElement[] { diff --git a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts new file mode 100644 index 000000000..ced4719fc --- /dev/null +++ b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts @@ -0,0 +1,290 @@ +import type { Logger } from "pino"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { + archiveIfSafe, + type ArchiveIfSafeDependencies, + type AutoArchiveArchiveOptions, +} from "./archive-if-safe.js"; +import type { WorkspaceGitRuntimeSnapshot } from "../workspace-git-service.js"; + +const CWD = "/tmp/paseo/worktrees/repo/branch"; +const PASEO_HOME = "/tmp/paseo"; +const WORKTREES_ROOT = "/tmp/paseo/worktrees/repo"; + +function createPullRequest( + overrides?: Partial>, +): NonNullable { + return { + url: "https://github.com/acme/repo/pull/123", + title: "Merge me", + state: "open", + baseRefName: "main", + headRefName: "feature", + isMerged: true, + ...overrides, + }; +} + +function createSnapshot(overrides?: { + git?: Partial; + pullRequest?: WorkspaceGitRuntimeSnapshot["github"]["pullRequest"]; +}): WorkspaceGitRuntimeSnapshot { + return { + cwd: CWD, + git: { + isGit: true, + repoRoot: "/tmp/repo", + mainRepoRoot: "/tmp/repo", + currentBranch: "feature", + remoteUrl: "https://github.com/acme/repo.git", + isPaseoOwnedWorktree: true, + isDirty: false, + baseRef: "main", + aheadBehind: { ahead: 0, behind: 0 }, + aheadOfOrigin: 0, + behindOfOrigin: 0, + hasRemote: true, + diffStat: { additions: 0, deletions: 0 }, + ...overrides?.git, + }, + github: { + featuresEnabled: true, + pullRequest: + overrides && "pullRequest" in overrides + ? (overrides.pullRequest ?? null) + : createPullRequest(), + error: null, + }, + }; +} + +function createLogger(): Logger { + const logger = { + child: () => logger, + info: vi.fn(), + warn: vi.fn(), + }; + return logger as unknown as Logger; +} + +function createHarness(overrides?: { + autoArchiveAfterMerge?: boolean; + getSnapshot?: () => Promise; + isPaseoOwnedWorktreeCwd?: ArchiveIfSafeDependencies["isPaseoOwnedWorktreeCwd"]; + archivePaseoWorktree?: ArchiveIfSafeDependencies["archivePaseoWorktree"]; +}) { + const getConfig = vi.fn(() => ({ + autoArchiveAfterMerge: overrides?.autoArchiveAfterMerge ?? true, + })); + const getSnapshot = vi.fn( + overrides?.getSnapshot ?? (async () => createSnapshot()), + ) as unknown as AutoArchiveArchiveOptions["workspaceGitService"]["getSnapshot"]; + const workspaceGitService = { + getSnapshot, + } as unknown as AutoArchiveArchiveOptions["workspaceGitService"]; + const options: AutoArchiveArchiveOptions = { + paseoHome: PASEO_HOME, + daemonConfigStore: { + get: getConfig, + } as unknown as AutoArchiveArchiveOptions["daemonConfigStore"], + workspaceGitService, + github: {} as AutoArchiveArchiveOptions["github"], + agentManager: {} as AutoArchiveArchiveOptions["agentManager"], + agentStorage: {} as AutoArchiveArchiveOptions["agentStorage"], + terminalManager: {} as AutoArchiveArchiveOptions["terminalManager"], + archiveWorkspaceRecord: vi.fn(), + markWorkspaceArchiving: vi.fn(), + clearWorkspaceArchiving: vi.fn(), + emitWorkspaceUpdatesForWorkspaceIds: vi.fn(), + emitSessionMessage: vi.fn(), + }; + const archivePaseoWorktree = vi.fn( + overrides?.archivePaseoWorktree ?? (async () => undefined), + ) as unknown as ArchiveIfSafeDependencies["archivePaseoWorktree"]; + const isPaseoOwnedWorktreeCwd = vi.fn( + overrides?.isPaseoOwnedWorktreeCwd ?? + (async () => ({ + allowed: true, + repoRoot: "/tmp/repo", + worktreeRoot: WORKTREES_ROOT, + worktreePath: CWD, + })), + ) as unknown as ArchiveIfSafeDependencies["isPaseoOwnedWorktreeCwd"]; + const deps: ArchiveIfSafeDependencies = { + archivePaseoWorktree, + isPaseoOwnedWorktreeCwd, + killTerminalsUnderPath: vi.fn(), + isPathWithinRoot: vi.fn(() => true), + }; + const log = createLogger(); + const inFlight = new Set(); + + return { + deps, + getConfig, + getSnapshot, + inFlight, + log, + options, + }; +} + +async function runArchiveIfSafe( + harness: ReturnType, + overrides?: { + cwd?: string; + pullRequest?: WorkspaceGitRuntimeSnapshot["github"]["pullRequest"]; + }, +): Promise { + await archiveIfSafe({ + cwd: overrides?.cwd ?? CWD, + pullRequest: + overrides && "pullRequest" in overrides + ? (overrides.pullRequest ?? null) + : createPullRequest(), + inFlight: harness.inFlight, + options: harness.options, + log: harness.log, + deps: harness.deps, + }); +} + +describe("archiveIfSafe", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("does nothing when the pull request is not merged", async () => { + const harness = createHarness(); + + await runArchiveIfSafe(harness, { pullRequest: createPullRequest({ isMerged: false }) }); + + expect(harness.getConfig).not.toHaveBeenCalled(); + expect(harness.getSnapshot).not.toHaveBeenCalled(); + expect(harness.deps.archivePaseoWorktree).not.toHaveBeenCalled(); + }); + + test("does nothing when auto-archive-after-merge is disabled", async () => { + const harness = createHarness({ autoArchiveAfterMerge: false }); + + await runArchiveIfSafe(harness); + + expect(harness.getConfig).toHaveBeenCalledTimes(1); + expect(harness.getSnapshot).not.toHaveBeenCalled(); + expect(harness.deps.archivePaseoWorktree).not.toHaveBeenCalled(); + }); + + test("does nothing when the cwd already has an archive in flight", async () => { + const harness = createHarness(); + harness.inFlight.add(CWD); + + await runArchiveIfSafe(harness); + + expect(harness.getSnapshot).not.toHaveBeenCalled(); + expect(harness.deps.archivePaseoWorktree).not.toHaveBeenCalled(); + expect(harness.inFlight.has(CWD)).toBe(true); + }); + + test("logs and skips when reading the snapshot fails", async () => { + const harness = createHarness({ + getSnapshot: async () => { + throw new Error("snapshot failed"); + }, + }); + + await runArchiveIfSafe(harness); + + expect(harness.log.warn).toHaveBeenCalledWith( + { err: expect.any(Error), cwd: CWD }, + "Failed to read snapshot for auto-archive; skipping", + ); + expect(harness.deps.archivePaseoWorktree).not.toHaveBeenCalled(); + expect(harness.inFlight.has(CWD)).toBe(false); + }); + + test("does nothing when there is no snapshot", async () => { + const harness = createHarness({ getSnapshot: async () => null }); + + await runArchiveIfSafe(harness); + + expect(harness.deps.isPaseoOwnedWorktreeCwd).not.toHaveBeenCalled(); + expect(harness.deps.archivePaseoWorktree).not.toHaveBeenCalled(); + }); + + test("does nothing when the worktree is dirty", async () => { + const harness = createHarness({ + getSnapshot: async () => createSnapshot({ git: { isDirty: true } }), + }); + + await runArchiveIfSafe(harness); + + expect(harness.deps.isPaseoOwnedWorktreeCwd).not.toHaveBeenCalled(); + expect(harness.deps.archivePaseoWorktree).not.toHaveBeenCalled(); + }); + + test("does nothing when the worktree is ahead of origin", async () => { + const harness = createHarness({ + getSnapshot: async () => createSnapshot({ git: { aheadOfOrigin: 1 } }), + }); + + await runArchiveIfSafe(harness); + + expect(harness.deps.isPaseoOwnedWorktreeCwd).not.toHaveBeenCalled(); + expect(harness.deps.archivePaseoWorktree).not.toHaveBeenCalled(); + }); + + test("does nothing when the cwd is not a Paseo-owned worktree", async () => { + const harness = createHarness({ + isPaseoOwnedWorktreeCwd: async () => ({ allowed: false, worktreePath: CWD }), + }); + + await runArchiveIfSafe(harness); + + expect(harness.deps.isPaseoOwnedWorktreeCwd).toHaveBeenCalledWith(CWD, { + paseoHome: PASEO_HOME, + }); + expect(harness.deps.archivePaseoWorktree).not.toHaveBeenCalled(); + }); + + test("logs and does not throw when archiving fails", async () => { + const harness = createHarness({ + archivePaseoWorktree: async () => { + throw new Error("archive failed"); + }, + }); + + await runArchiveIfSafe(harness); + + expect(harness.log.warn).toHaveBeenCalledWith( + { err: expect.any(Error), cwd: CWD }, + "Auto-archive after merge failed", + ); + expect(harness.inFlight.has(CWD)).toBe(false); + }); + + test("archives a clean Paseo-owned worktree after merge", async () => { + const harness = createHarness(); + + await runArchiveIfSafe(harness); + + expect(harness.deps.archivePaseoWorktree).toHaveBeenCalledTimes(1); + expect(harness.deps.archivePaseoWorktree).toHaveBeenCalledWith( + expect.objectContaining({ + paseoHome: PASEO_HOME, + workspaceGitService: harness.options.workspaceGitService, + }), + { + targetPath: CWD, + repoRoot: "/tmp/repo", + worktreesRoot: WORKTREES_ROOT, + requestId: "auto-archive-on-merge", + }, + ); + expect(harness.log.info).toHaveBeenCalledWith( + { cwd: CWD }, + "Auto-archived worktree after PR merge", + ); + expect(harness.inFlight.has(CWD)).toBe(false); + }); +}); diff --git a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts new file mode 100644 index 000000000..0de19c0b0 --- /dev/null +++ b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts @@ -0,0 +1,131 @@ +import type { Logger } from "pino"; + +import type { AgentManager } from "../agent/agent-manager.js"; +import type { AgentStorage } from "../agent/agent-storage.js"; +import type { DaemonConfigStore } from "../daemon-config-store.js"; +import type { SessionOutboundMessage } from "../messages.js"; +import { archivePaseoWorktree, killTerminalsUnderPath } from "../paseo-worktree-archive-service.js"; +import { isSameOrDescendantPath } from "../path-utils.js"; +import type { + WorkspaceGitRuntimeSnapshot, + WorkspaceGitServiceImpl, +} from "../workspace-git-service.js"; +import type { GitHubService } from "../../services/github-service.js"; +import type { TerminalManager } from "../../terminal/terminal-manager.js"; +import { isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js"; + +export interface AutoArchiveArchiveOptions { + paseoHome: string; + daemonConfigStore: DaemonConfigStore; + workspaceGitService: WorkspaceGitServiceImpl; + github: GitHubService; + agentManager: AgentManager; + agentStorage: AgentStorage; + terminalManager: TerminalManager; + archiveWorkspaceRecord: (workspaceId: string) => Promise; + markWorkspaceArchiving: (workspaceIds: Iterable, archivingAt: string) => void; + clearWorkspaceArchiving: (workspaceIds: Iterable) => void; + emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable) => Promise; + emitSessionMessage: (message: SessionOutboundMessage) => void; +} + +export interface ArchiveIfSafeDependencies { + archivePaseoWorktree: typeof archivePaseoWorktree; + isPaseoOwnedWorktreeCwd: typeof isPaseoOwnedWorktreeCwd; + killTerminalsUnderPath: typeof killTerminalsUnderPath; + isPathWithinRoot: typeof isSameOrDescendantPath; +} + +const defaultDependencies: ArchiveIfSafeDependencies = { + archivePaseoWorktree, + isPaseoOwnedWorktreeCwd, + killTerminalsUnderPath, + isPathWithinRoot: isSameOrDescendantPath, +}; + +export async function archiveIfSafe(input: { + cwd: string; + pullRequest: WorkspaceGitRuntimeSnapshot["github"]["pullRequest"]; + inFlight: Set; + options: AutoArchiveArchiveOptions; + log: Logger; + deps?: ArchiveIfSafeDependencies; +}): Promise { + const { cwd, pullRequest, inFlight, options, log } = input; + const deps = input.deps ?? defaultDependencies; + + if (!pullRequest?.isMerged) { + return; + } + if (options.daemonConfigStore.get().autoArchiveAfterMerge !== true) { + return; + } + if (inFlight.has(cwd)) { + return; + } + + inFlight.add(cwd); + try { + let snapshot: Awaited> | null; + try { + snapshot = await options.workspaceGitService.getSnapshot(cwd, { + reason: "auto-archive-on-merge", + }); + } catch (error) { + log.warn({ err: error, cwd }, "Failed to read snapshot for auto-archive; skipping"); + return; + } + if (!snapshot) { + return; + } + + if (snapshot.git.isDirty === true || (snapshot.git.aheadOfOrigin ?? 0) > 0) { + return; + } + + const ownership = await deps.isPaseoOwnedWorktreeCwd(cwd, { paseoHome: options.paseoHome }); + if (!ownership.allowed) { + return; + } + + try { + await deps.archivePaseoWorktree( + { + paseoHome: options.paseoHome, + github: options.github, + workspaceGitService: options.workspaceGitService, + agentManager: options.agentManager, + agentStorage: options.agentStorage, + archiveWorkspaceRecord: options.archiveWorkspaceRecord, + emit: options.emitSessionMessage, + emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds, + markWorkspaceArchiving: options.markWorkspaceArchiving, + clearWorkspaceArchiving: options.clearWorkspaceArchiving, + isPathWithinRoot: deps.isPathWithinRoot, + killTerminalsUnderPath: (rootPath) => + deps.killTerminalsUnderPath( + { + terminalManager: options.terminalManager, + isPathWithinRoot: deps.isPathWithinRoot, + killTrackedTerminal: () => {}, + sessionLogger: log, + }, + rootPath, + ), + sessionLogger: log, + }, + { + targetPath: cwd, + repoRoot: ownership.repoRoot ?? null, + worktreesRoot: ownership.worktreeRoot, + requestId: "auto-archive-on-merge", + }, + ); + log.info({ cwd }, "Auto-archived worktree after PR merge"); + } catch (error) { + log.warn({ err: error, cwd }, "Auto-archive after merge failed"); + } + } finally { + inFlight.delete(cwd); + } +} diff --git a/packages/server/src/server/auto-archive-on-merge/index.ts b/packages/server/src/server/auto-archive-on-merge/index.ts new file mode 100644 index 000000000..2912a80b7 --- /dev/null +++ b/packages/server/src/server/auto-archive-on-merge/index.ts @@ -0,0 +1,25 @@ +import type { Logger } from "pino"; + +import { archiveIfSafe, type AutoArchiveArchiveOptions } from "./archive-if-safe.js"; +import type { WorkspaceGitSubscription } from "../workspace-git-service.js"; + +export interface AutoArchiveOnMergeOptions extends AutoArchiveArchiveOptions { + logger: Logger; +} + +export function setupAutoArchiveOnMerge( + options: AutoArchiveOnMergeOptions, +): WorkspaceGitSubscription { + const log = options.logger.child({ module: "auto-archive-on-merge" }); + const inFlight = new Set(); + + return options.workspaceGitService.onSnapshotUpdated((snapshot) => { + void archiveIfSafe({ + cwd: snapshot.cwd, + pullRequest: snapshot.github.pullRequest, + inFlight, + options, + log, + }); + }); +} diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 22baa93b1..20863a3f8 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -113,6 +113,7 @@ import { ScheduleService } from "./schedule/service.js"; import { DaemonConfigStore } from "./daemon-config-store.js"; import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js"; +import { setupAutoArchiveOnMerge } from "./auto-archive-on-merge/index.js"; import { wrapSessionMessage, type SessionOutboundMessage } from "./messages.js"; import type { TerminalManager } from "../terminal/terminal-manager.js"; import { createConfiguredTerminalManager } from "../terminal/terminal-manager-factory.js"; @@ -227,6 +228,7 @@ export interface PaseoDaemonConfig { hostnames?: HostnamesConfig; mcpEnabled?: boolean; mcpInjectIntoAgents?: boolean; + autoArchiveAfterMerge?: boolean; staticDir: string; mcpDebug: boolean; isDev?: boolean; @@ -285,6 +287,7 @@ export async function createPaseoDaemon( }, ]), ), + autoArchiveAfterMerge: config.autoArchiveAfterMerge ?? false, }, logger, ); @@ -560,52 +563,67 @@ export async function createPaseoDaemon( "Voice mode configured for agent-scoped resume flow (no dedicated voice assistant provider)", ); logger.info({ elapsed: elapsed() }, "Preparing voice and MCP runtime"); + + const archiveWorkspaceRecordExternal = async (workspaceId: string) => { + const sessions = wsServer?.listActiveSessions() ?? []; + if (sessions.length > 0) { + await Promise.all( + sessions.map((session) => session.archiveWorkspaceRecordForExternalMutation(workspaceId)), + ); + return; + } + + await archivePersistedWorkspaceRecord({ + workspaceId, + workspaceRegistry, + projectRegistry, + }); + }; + const markWorkspaceArchivingExternal = (workspaceIds: Iterable, archivingAt: string) => { + const workspaceIdList = Array.from(workspaceIds); + for (const session of wsServer?.listActiveSessions() ?? []) { + session.markWorkspaceArchivingForExternalMutation(workspaceIdList, archivingAt); + } + }; + const clearWorkspaceArchivingExternal = (workspaceIds: Iterable) => { + const workspaceIdList = Array.from(workspaceIds); + for (const session of wsServer?.listActiveSessions() ?? []) { + session.clearWorkspaceArchivingForExternalMutation(workspaceIdList); + } + }; + const emitWorkspaceUpdatesExternal = async (workspaceIds: Iterable) => { + const workspaceIdList = Array.from(workspaceIds); + await Promise.all( + (wsServer?.listActiveSessions() ?? []).map((session) => + session.emitWorkspaceUpdatesForExternalWorkspaceIds(workspaceIdList), + ), + ); + }; + const emitExternalSessionMessage = (message: SessionOutboundMessage) => { + wsServer?.broadcast(wrapSessionMessage(message)); + }; + + setupAutoArchiveOnMerge({ + paseoHome: config.paseoHome, + daemonConfigStore, + workspaceGitService, + github, + agentManager, + agentStorage, + terminalManager, + logger, + archiveWorkspaceRecord: archiveWorkspaceRecordExternal, + markWorkspaceArchiving: markWorkspaceArchivingExternal, + clearWorkspaceArchiving: clearWorkspaceArchivingExternal, + emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal, + emitSessionMessage: emitExternalSessionMessage, + }); + const mcpEnabled = config.mcpEnabled ?? true; let agentMcpBaseUrl: string | null = null; if (mcpEnabled) { const agentMcpRoute = "/mcp/agents"; const agentMcpTransports: AgentMcpTransportMap = new Map(); - const archiveWorkspaceRecordForMcp = async (workspaceId: string) => { - const sessions = wsServer?.listActiveSessions() ?? []; - if (sessions.length > 0) { - await Promise.all( - sessions.map((session) => session.archiveWorkspaceRecordForExternalMutation(workspaceId)), - ); - return; - } - - await archivePersistedWorkspaceRecord({ - workspaceId, - workspaceRegistry, - projectRegistry, - }); - }; - const markWorkspaceArchivingForMcpArchive = ( - workspaceIds: Iterable, - archivingAt: string, - ) => { - const workspaceIdList = Array.from(workspaceIds); - for (const session of wsServer?.listActiveSessions() ?? []) { - session.markWorkspaceArchivingForExternalMutation(workspaceIdList, archivingAt); - } - }; - const clearWorkspaceArchivingForMcpArchive = (workspaceIds: Iterable) => { - const workspaceIdList = Array.from(workspaceIds); - for (const session of wsServer?.listActiveSessions() ?? []) { - session.clearWorkspaceArchivingForExternalMutation(workspaceIdList); - } - }; - const emitWorkspaceUpdatesForMcpArchive = async (workspaceIds: Iterable) => { - const workspaceIdList = Array.from(workspaceIds); - await Promise.all( - (wsServer?.listActiveSessions() ?? []).map((session) => - session.emitWorkspaceUpdatesForExternalWorkspaceIds(workspaceIdList), - ), - ); - }; - const emitMcpArchiveSessionMessage = (message: SessionOutboundMessage) => { - wsServer?.broadcast(wrapSessionMessage(message)); - }; const createAgentMcpTransport = async (callerAgentId?: string) => { const agentMcpServer = await createAgentMcpServer({ @@ -617,11 +635,11 @@ export async function createPaseoDaemon( providerRegistry, github, workspaceGitService, - archiveWorkspaceRecord: archiveWorkspaceRecordForMcp, - emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesForMcpArchive, - markWorkspaceArchiving: markWorkspaceArchivingForMcpArchive, - clearWorkspaceArchiving: clearWorkspaceArchivingForMcpArchive, - emitSessionMessage: emitMcpArchiveSessionMessage, + archiveWorkspaceRecord: archiveWorkspaceRecordExternal, + emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal, + markWorkspaceArchiving: markWorkspaceArchivingExternal, + clearWorkspaceArchiving: clearWorkspaceArchivingExternal, + emitSessionMessage: emitExternalSessionMessage, createPaseoWorktree: async (input, serviceOptions) => { return createPaseoWorktreeWorkflow( { @@ -655,10 +673,10 @@ export async function createPaseoDaemon( void emitOptions; }, cacheWorkspaceSetupSnapshot: () => {}, - emit: emitMcpArchiveSessionMessage, + emit: emitExternalSessionMessage, sessionLogger: logger, terminalManager, - archiveWorkspaceRecord: archiveWorkspaceRecordForMcp, + archiveWorkspaceRecord: archiveWorkspaceRecordExternal, scriptRouteStore, scriptRuntimeStore, getDaemonTcpPort: () => diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index af81a52bd..9c620a9c9 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -246,6 +246,7 @@ function resolveStaticLoadConfigSettings( mcpEnabled: cli?.mcpEnabled ?? persisted.daemon?.mcp?.enabled ?? true, mcpInjectIntoAgents: cli?.mcpInjectIntoAgents ?? persisted.daemon?.mcp?.injectIntoAgents ?? false, + autoArchiveAfterMerge: persisted.daemon?.autoArchiveAfterMerge ?? false, hostnames: mergeHostnames([ persisted.daemon?.hostnames, parseHostnamesEnv(env.PASEO_HOSTNAMES ?? env.PASEO_ALLOWED_HOSTS), @@ -266,7 +267,7 @@ export function loadConfig( const persisted = loadPersistedConfig(paseoHome); const listen = resolveListenAddress(env, options?.cli, persisted); - const { mcpEnabled, mcpInjectIntoAgents, hostnames, appBaseUrl } = + const { mcpEnabled, mcpInjectIntoAgents, autoArchiveAfterMerge, hostnames, appBaseUrl } = resolveStaticLoadConfigSettings(env, options?.cli, persisted); const relay = resolveRelayConfig({ @@ -294,6 +295,7 @@ export function loadConfig( hostnames, mcpEnabled, mcpInjectIntoAgents, + autoArchiveAfterMerge, mcpDebug: env.MCP_DEBUG === "1", isDev: resolvePaseoNodeEnv(env) === "development", agentStoragePath: path.join(paseoHome, "agents"), diff --git a/packages/server/src/server/daemon-config-store.ts b/packages/server/src/server/daemon-config-store.ts index fba3401df..a12b8571c 100644 --- a/packages/server/src/server/daemon-config-store.ts +++ b/packages/server/src/server/daemon-config-store.ts @@ -185,6 +185,7 @@ function mergeMutableConfigIntoPersistedConfig(params: { ...persisted.daemon?.mcp, injectIntoAgents: mutable.mcp.injectIntoAgents, }, + autoArchiveAfterMerge: mutable.autoArchiveAfterMerge, }, agents: providerOverrides && Object.keys(providerOverrides).length > 0 diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index af8b6e132..825cb780c 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -250,6 +250,7 @@ export const PersistedConfigSchema = z }) .passthrough() .optional(), + autoArchiveAfterMerge: z.boolean().optional(), cors: z .object({ allowedOrigins: z.array(z.string()).optional(), diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index cdd65e37c..f81438e9f 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -112,6 +112,9 @@ function createFallbackWorkspaceGitService(): WorkspaceGitService { registerWorkspace: () => ({ unsubscribe: () => {}, }), + onSnapshotUpdated: () => ({ + unsubscribe: () => {}, + }), peekSnapshot: () => null, getCheckout: async (cwd: string) => ({ cwd, diff --git a/packages/server/src/server/workspace-git-service.test.ts b/packages/server/src/server/workspace-git-service.test.ts index 23973e26f..c7b3f5cd2 100644 --- a/packages/server/src/server/workspace-git-service.test.ts +++ b/packages/server/src/server/workspace-git-service.test.ts @@ -248,6 +248,30 @@ describe("WorkspaceGitServiceImpl", () => { service.dispose(); }); + test("onSnapshotUpdated emits only for observed workspace snapshots and can unsubscribe", async () => { + const service = createService(); + const snapshotListener = vi.fn(); + const snapshotSubscription = service.onSnapshotUpdated(snapshotListener); + + await service.getSnapshot(REPO_CWD, { force: true, reason: "unobserved" }); + + expect(snapshotListener).not.toHaveBeenCalled(); + + const workspaceSubscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); + await service.getSnapshot(REPO_CWD, { force: true, reason: "observed" }); + + expect(snapshotListener).toHaveBeenCalledTimes(1); + expect(snapshotListener).toHaveBeenCalledWith(createSnapshot(REPO_CWD)); + + snapshotSubscription.unsubscribe(); + await service.getSnapshot(REPO_CWD, { force: true, reason: "after-unsubscribe" }); + + expect(snapshotListener).toHaveBeenCalledTimes(1); + + workspaceSubscription.unsubscribe(); + service.dispose(); + }); + test("getSnapshot populates github pull request state in the runtime snapshot", async () => { const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult({ diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index 1caae0dab..9651b6543 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -105,6 +105,7 @@ export interface WorkspaceGitService { listener: WorkspaceGitListener, ): WorkspaceGitSubscription; + onSnapshotUpdated(listener: WorkspaceGitSnapshotUpdatedListener): WorkspaceGitSubscription; peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null; getCheckout(cwd: string): Promise; getSnapshot( @@ -153,6 +154,7 @@ export interface WorkspaceGitService { } export type WorkspaceGitListener = (snapshot: WorkspaceGitRuntimeSnapshot) => void; +export type WorkspaceGitSnapshotUpdatedListener = (snapshot: WorkspaceGitRuntimeSnapshot) => void; export interface WorkspaceGitSubscription { unsubscribe: () => void; @@ -329,6 +331,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private readonly logger: pino.Logger; private readonly paseoHome: string; private readonly deps: WorkspaceGitServiceDependencies; + private readonly snapshotUpdatedListeners = new Set(); private readonly workspaceTargets = new Map(); private readonly repoTargets = new Map(); private readonly workingTreeWatchTargets = new Map(); @@ -362,7 +365,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { string, WorkspaceGitAuxiliaryReadCacheEntry >(); - constructor(options: WorkspaceGitServiceOptions) { this.logger = options.logger.child({ module: "workspace-git-service" }); this.paseoHome = options.paseoHome; @@ -391,6 +393,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { }; } + onSnapshotUpdated(listener: WorkspaceGitSnapshotUpdatedListener): WorkspaceGitSubscription { + this.snapshotUpdatedListeners.add(listener); + return { + unsubscribe: () => { + this.snapshotUpdatedListeners.delete(listener); + }, + }; + } + async getSnapshot( cwd: string, options?: WorkspaceGitSnapshotOptions, @@ -660,6 +671,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } this.workingTreeWatchTargets.clear(); this.workingTreeWatchSetups.clear(); + this.snapshotUpdatedListeners.clear(); } private ensureWorkspaceTarget(cwd: string): WorkspaceGitTarget { @@ -1537,12 +1549,22 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return; } target.latestFingerprint = fingerprint; - if (!options?.notify) { + 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); + } catch (error) { + this.logger.warn( + { err: error, cwd: snapshot.cwd }, + "Workspace git snapshot listener threw", + ); + } + } } private async runRepoFetch(target: RepoGitTarget): Promise { diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 9cf5a5507..965ad8cf2 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -110,6 +110,7 @@ export const MutableDaemonConfigSchema = z }) .passthrough(), providers: z.record(z.string(), MutableDaemonProviderConfigSchema).default({}), + autoArchiveAfterMerge: z.boolean().default(false), }) .passthrough(); @@ -119,6 +120,7 @@ export const MutableDaemonConfigPatchSchema = z providers: z .record(z.string(), MutableDaemonProviderConfigSchema.partial().passthrough()) .optional(), + autoArchiveAfterMerge: z.boolean().optional(), }) .partial() .passthrough();