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.
This commit is contained in:
Mohamed Boudra
2026-05-14 10:08:48 +00:00
committed by GitHub
parent 2e8bced707
commit 2dd77aae04
12 changed files with 571 additions and 52 deletions
@@ -202,7 +202,7 @@ const disabledCodexEntry: ProviderSnapshotEntry = {
}; };
function makeConfig(providers: MutableDaemonConfig["providers"] = {}): MutableDaemonConfig { function makeConfig(providers: MutableDaemonConfig["providers"] = {}): MutableDaemonConfig {
return { mcp: { injectIntoAgents: false }, providers }; return { mcp: { injectIntoAgents: false }, providers, autoArchiveAfterMerge: false };
} }
function descendants(el: HTMLElement): HTMLElement[] { function descendants(el: HTMLElement): HTMLElement[] {
@@ -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<WorkspaceGitRuntimeSnapshot["github"]["pullRequest"]>>,
): NonNullable<WorkspaceGitRuntimeSnapshot["github"]["pullRequest"]> {
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<WorkspaceGitRuntimeSnapshot["git"]>;
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<WorkspaceGitRuntimeSnapshot | null>;
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<string>();
return {
deps,
getConfig,
getSnapshot,
inFlight,
log,
options,
};
}
async function runArchiveIfSafe(
harness: ReturnType<typeof createHarness>,
overrides?: {
cwd?: string;
pullRequest?: WorkspaceGitRuntimeSnapshot["github"]["pullRequest"];
},
): Promise<void> {
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);
});
});
@@ -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<void>;
markWorkspaceArchiving: (workspaceIds: Iterable<string>, archivingAt: string) => void;
clearWorkspaceArchiving: (workspaceIds: Iterable<string>) => void;
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable<string>) => Promise<void>;
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<string>;
options: AutoArchiveArchiveOptions;
log: Logger;
deps?: ArchiveIfSafeDependencies;
}): Promise<void> {
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<ReturnType<typeof options.workspaceGitService.getSnapshot>> | 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);
}
}
@@ -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<string>();
return options.workspaceGitService.onSnapshotUpdated((snapshot) => {
void archiveIfSafe({
cwd: snapshot.cwd,
pullRequest: snapshot.github.pullRequest,
inFlight,
options,
log,
});
});
}
+66 -48
View File
@@ -113,6 +113,7 @@ import { ScheduleService } from "./schedule/service.js";
import { DaemonConfigStore } from "./daemon-config-store.js"; import { DaemonConfigStore } from "./daemon-config-store.js";
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
import { archivePersistedWorkspaceRecord } from "./workspace-archive-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 { wrapSessionMessage, type SessionOutboundMessage } from "./messages.js";
import type { TerminalManager } from "../terminal/terminal-manager.js"; import type { TerminalManager } from "../terminal/terminal-manager.js";
import { createConfiguredTerminalManager } from "../terminal/terminal-manager-factory.js"; import { createConfiguredTerminalManager } from "../terminal/terminal-manager-factory.js";
@@ -227,6 +228,7 @@ export interface PaseoDaemonConfig {
hostnames?: HostnamesConfig; hostnames?: HostnamesConfig;
mcpEnabled?: boolean; mcpEnabled?: boolean;
mcpInjectIntoAgents?: boolean; mcpInjectIntoAgents?: boolean;
autoArchiveAfterMerge?: boolean;
staticDir: string; staticDir: string;
mcpDebug: boolean; mcpDebug: boolean;
isDev?: boolean; isDev?: boolean;
@@ -285,6 +287,7 @@ export async function createPaseoDaemon(
}, },
]), ]),
), ),
autoArchiveAfterMerge: config.autoArchiveAfterMerge ?? false,
}, },
logger, logger,
); );
@@ -560,52 +563,67 @@ export async function createPaseoDaemon(
"Voice mode configured for agent-scoped resume flow (no dedicated voice assistant provider)", "Voice mode configured for agent-scoped resume flow (no dedicated voice assistant provider)",
); );
logger.info({ elapsed: elapsed() }, "Preparing voice and MCP runtime"); 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<string>, archivingAt: string) => {
const workspaceIdList = Array.from(workspaceIds);
for (const session of wsServer?.listActiveSessions() ?? []) {
session.markWorkspaceArchivingForExternalMutation(workspaceIdList, archivingAt);
}
};
const clearWorkspaceArchivingExternal = (workspaceIds: Iterable<string>) => {
const workspaceIdList = Array.from(workspaceIds);
for (const session of wsServer?.listActiveSessions() ?? []) {
session.clearWorkspaceArchivingForExternalMutation(workspaceIdList);
}
};
const emitWorkspaceUpdatesExternal = async (workspaceIds: Iterable<string>) => {
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; const mcpEnabled = config.mcpEnabled ?? true;
let agentMcpBaseUrl: string | null = null; let agentMcpBaseUrl: string | null = null;
if (mcpEnabled) { if (mcpEnabled) {
const agentMcpRoute = "/mcp/agents"; const agentMcpRoute = "/mcp/agents";
const agentMcpTransports: AgentMcpTransportMap = new Map(); 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<string>,
archivingAt: string,
) => {
const workspaceIdList = Array.from(workspaceIds);
for (const session of wsServer?.listActiveSessions() ?? []) {
session.markWorkspaceArchivingForExternalMutation(workspaceIdList, archivingAt);
}
};
const clearWorkspaceArchivingForMcpArchive = (workspaceIds: Iterable<string>) => {
const workspaceIdList = Array.from(workspaceIds);
for (const session of wsServer?.listActiveSessions() ?? []) {
session.clearWorkspaceArchivingForExternalMutation(workspaceIdList);
}
};
const emitWorkspaceUpdatesForMcpArchive = async (workspaceIds: Iterable<string>) => {
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 createAgentMcpTransport = async (callerAgentId?: string) => {
const agentMcpServer = await createAgentMcpServer({ const agentMcpServer = await createAgentMcpServer({
@@ -617,11 +635,11 @@ export async function createPaseoDaemon(
providerRegistry, providerRegistry,
github, github,
workspaceGitService, workspaceGitService,
archiveWorkspaceRecord: archiveWorkspaceRecordForMcp, archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesForMcpArchive, emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal,
markWorkspaceArchiving: markWorkspaceArchivingForMcpArchive, markWorkspaceArchiving: markWorkspaceArchivingExternal,
clearWorkspaceArchiving: clearWorkspaceArchivingForMcpArchive, clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
emitSessionMessage: emitMcpArchiveSessionMessage, emitSessionMessage: emitExternalSessionMessage,
createPaseoWorktree: async (input, serviceOptions) => { createPaseoWorktree: async (input, serviceOptions) => {
return createPaseoWorktreeWorkflow( return createPaseoWorktreeWorkflow(
{ {
@@ -655,10 +673,10 @@ export async function createPaseoDaemon(
void emitOptions; void emitOptions;
}, },
cacheWorkspaceSetupSnapshot: () => {}, cacheWorkspaceSetupSnapshot: () => {},
emit: emitMcpArchiveSessionMessage, emit: emitExternalSessionMessage,
sessionLogger: logger, sessionLogger: logger,
terminalManager, terminalManager,
archiveWorkspaceRecord: archiveWorkspaceRecordForMcp, archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
scriptRouteStore, scriptRouteStore,
scriptRuntimeStore, scriptRuntimeStore,
getDaemonTcpPort: () => getDaemonTcpPort: () =>
+3 -1
View File
@@ -246,6 +246,7 @@ function resolveStaticLoadConfigSettings(
mcpEnabled: cli?.mcpEnabled ?? persisted.daemon?.mcp?.enabled ?? true, mcpEnabled: cli?.mcpEnabled ?? persisted.daemon?.mcp?.enabled ?? true,
mcpInjectIntoAgents: mcpInjectIntoAgents:
cli?.mcpInjectIntoAgents ?? persisted.daemon?.mcp?.injectIntoAgents ?? false, cli?.mcpInjectIntoAgents ?? persisted.daemon?.mcp?.injectIntoAgents ?? false,
autoArchiveAfterMerge: persisted.daemon?.autoArchiveAfterMerge ?? false,
hostnames: mergeHostnames([ hostnames: mergeHostnames([
persisted.daemon?.hostnames, persisted.daemon?.hostnames,
parseHostnamesEnv(env.PASEO_HOSTNAMES ?? env.PASEO_ALLOWED_HOSTS), parseHostnamesEnv(env.PASEO_HOSTNAMES ?? env.PASEO_ALLOWED_HOSTS),
@@ -266,7 +267,7 @@ export function loadConfig(
const persisted = loadPersistedConfig(paseoHome); const persisted = loadPersistedConfig(paseoHome);
const listen = resolveListenAddress(env, options?.cli, persisted); const listen = resolveListenAddress(env, options?.cli, persisted);
const { mcpEnabled, mcpInjectIntoAgents, hostnames, appBaseUrl } = const { mcpEnabled, mcpInjectIntoAgents, autoArchiveAfterMerge, hostnames, appBaseUrl } =
resolveStaticLoadConfigSettings(env, options?.cli, persisted); resolveStaticLoadConfigSettings(env, options?.cli, persisted);
const relay = resolveRelayConfig({ const relay = resolveRelayConfig({
@@ -294,6 +295,7 @@ export function loadConfig(
hostnames, hostnames,
mcpEnabled, mcpEnabled,
mcpInjectIntoAgents, mcpInjectIntoAgents,
autoArchiveAfterMerge,
mcpDebug: env.MCP_DEBUG === "1", mcpDebug: env.MCP_DEBUG === "1",
isDev: resolvePaseoNodeEnv(env) === "development", isDev: resolvePaseoNodeEnv(env) === "development",
agentStoragePath: path.join(paseoHome, "agents"), agentStoragePath: path.join(paseoHome, "agents"),
@@ -185,6 +185,7 @@ function mergeMutableConfigIntoPersistedConfig(params: {
...persisted.daemon?.mcp, ...persisted.daemon?.mcp,
injectIntoAgents: mutable.mcp.injectIntoAgents, injectIntoAgents: mutable.mcp.injectIntoAgents,
}, },
autoArchiveAfterMerge: mutable.autoArchiveAfterMerge,
}, },
agents: agents:
providerOverrides && Object.keys(providerOverrides).length > 0 providerOverrides && Object.keys(providerOverrides).length > 0
@@ -250,6 +250,7 @@ export const PersistedConfigSchema = z
}) })
.passthrough() .passthrough()
.optional(), .optional(),
autoArchiveAfterMerge: z.boolean().optional(),
cors: z cors: z
.object({ .object({
allowedOrigins: z.array(z.string()).optional(), allowedOrigins: z.array(z.string()).optional(),
@@ -112,6 +112,9 @@ function createFallbackWorkspaceGitService(): WorkspaceGitService {
registerWorkspace: () => ({ registerWorkspace: () => ({
unsubscribe: () => {}, unsubscribe: () => {},
}), }),
onSnapshotUpdated: () => ({
unsubscribe: () => {},
}),
peekSnapshot: () => null, peekSnapshot: () => null,
getCheckout: async (cwd: string) => ({ getCheckout: async (cwd: string) => ({
cwd, cwd,
@@ -248,6 +248,30 @@ describe("WorkspaceGitServiceImpl", () => {
service.dispose(); 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 () => { test("getSnapshot populates github pull request state in the runtime snapshot", async () => {
const getPullRequestStatus = vi.fn(async () => const getPullRequestStatus = vi.fn(async () =>
createPullRequestStatusResult({ createPullRequestStatusResult({
@@ -105,6 +105,7 @@ export interface WorkspaceGitService {
listener: WorkspaceGitListener, listener: WorkspaceGitListener,
): WorkspaceGitSubscription; ): WorkspaceGitSubscription;
onSnapshotUpdated(listener: WorkspaceGitSnapshotUpdatedListener): WorkspaceGitSubscription;
peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null; peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null;
getCheckout(cwd: string): Promise<ProjectCheckoutLitePayload>; getCheckout(cwd: string): Promise<ProjectCheckoutLitePayload>;
getSnapshot( getSnapshot(
@@ -153,6 +154,7 @@ export interface WorkspaceGitService {
} }
export type WorkspaceGitListener = (snapshot: WorkspaceGitRuntimeSnapshot) => void; export type WorkspaceGitListener = (snapshot: WorkspaceGitRuntimeSnapshot) => void;
export type WorkspaceGitSnapshotUpdatedListener = (snapshot: WorkspaceGitRuntimeSnapshot) => void;
export interface WorkspaceGitSubscription { export interface WorkspaceGitSubscription {
unsubscribe: () => void; unsubscribe: () => void;
@@ -329,6 +331,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
private readonly logger: pino.Logger; private readonly logger: pino.Logger;
private readonly paseoHome: string; private readonly paseoHome: string;
private readonly deps: WorkspaceGitServiceDependencies; private readonly deps: WorkspaceGitServiceDependencies;
private readonly snapshotUpdatedListeners = new Set<WorkspaceGitSnapshotUpdatedListener>();
private readonly workspaceTargets = new Map<string, WorkspaceGitTarget>(); private readonly workspaceTargets = new Map<string, WorkspaceGitTarget>();
private readonly repoTargets = new Map<string, RepoGitTarget>(); private readonly repoTargets = new Map<string, RepoGitTarget>();
private readonly workingTreeWatchTargets = new Map<string, WorkingTreeWatchTarget>(); private readonly workingTreeWatchTargets = new Map<string, WorkingTreeWatchTarget>();
@@ -362,7 +365,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
string, string,
WorkspaceGitAuxiliaryReadCacheEntry<CheckoutDiffResult> WorkspaceGitAuxiliaryReadCacheEntry<CheckoutDiffResult>
>(); >();
constructor(options: WorkspaceGitServiceOptions) { constructor(options: WorkspaceGitServiceOptions) {
this.logger = options.logger.child({ module: "workspace-git-service" }); this.logger = options.logger.child({ module: "workspace-git-service" });
this.paseoHome = options.paseoHome; 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( async getSnapshot(
cwd: string, cwd: string,
options?: WorkspaceGitSnapshotOptions, options?: WorkspaceGitSnapshotOptions,
@@ -660,6 +671,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
} }
this.workingTreeWatchTargets.clear(); this.workingTreeWatchTargets.clear();
this.workingTreeWatchSetups.clear(); this.workingTreeWatchSetups.clear();
this.snapshotUpdatedListeners.clear();
} }
private ensureWorkspaceTarget(cwd: string): WorkspaceGitTarget { private ensureWorkspaceTarget(cwd: string): WorkspaceGitTarget {
@@ -1537,12 +1549,22 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
return; return;
} }
target.latestFingerprint = fingerprint; target.latestFingerprint = fingerprint;
if (!options?.notify) { if (!options?.notify || target.listeners.size === 0) {
return; return;
} }
for (const listener of target.listeners) { for (const listener of target.listeners) {
listener(snapshot); 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<void> { private async runRepoFetch(target: RepoGitTarget): Promise<void> {
+2
View File
@@ -110,6 +110,7 @@ export const MutableDaemonConfigSchema = z
}) })
.passthrough(), .passthrough(),
providers: z.record(z.string(), MutableDaemonProviderConfigSchema).default({}), providers: z.record(z.string(), MutableDaemonProviderConfigSchema).default({}),
autoArchiveAfterMerge: z.boolean().default(false),
}) })
.passthrough(); .passthrough();
@@ -119,6 +120,7 @@ export const MutableDaemonConfigPatchSchema = z
providers: z providers: z
.record(z.string(), MutableDaemonProviderConfigSchema.partial().passthrough()) .record(z.string(), MutableDaemonProviderConfigSchema.partial().passthrough())
.optional(), .optional(),
autoArchiveAfterMerge: z.boolean().optional(),
}) })
.partial() .partial()
.passthrough(); .passthrough();