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();