Key workspace status and ownership by ID, not directory
Multiple workspaces can share one checkout directory. Agent and terminal status, sidebar tabs, and ownership now attribute strictly by workspace ID, so a running or blocked agent in one workspace no longer shows up on its same-directory siblings. A one-time startup migration backfills workspace IDs onto legacy agents, and every agent and terminal is created with one thereafter. Directory lookups remain only for genuinely folder-derived git facts (branch, diff) and the archive-by-path adapter.
This commit is contained in:
@@ -1583,6 +1583,7 @@ test("importProviderSession imports the selected session without listing and pub
|
||||
provider: "codex",
|
||||
providerHandleId: "thread-selected",
|
||||
cwd: workdir,
|
||||
workspaceId: "ws-imported",
|
||||
});
|
||||
|
||||
expect(client.listCalls).toBe(0);
|
||||
|
||||
@@ -733,35 +733,37 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
async listProviderAvailability(): Promise<ProviderAvailability[]> {
|
||||
const checks = Array.from(this.clients.keys()).map(async (provider) => {
|
||||
const client = this.clients.get(provider);
|
||||
if (!client) {
|
||||
return {
|
||||
provider,
|
||||
available: false,
|
||||
error: `No client registered for provider '${provider}'`,
|
||||
} satisfies ProviderAvailability;
|
||||
}
|
||||
return Promise.all(
|
||||
Array.from(this.clients.keys()).map((provider) => this.getProviderAvailability(provider)),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const available = await client.isAvailable();
|
||||
return {
|
||||
provider,
|
||||
available,
|
||||
error: null,
|
||||
} satisfies ProviderAvailability;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logger.warn({ err: error, provider }, "Failed to check provider availability");
|
||||
return {
|
||||
provider,
|
||||
available: false,
|
||||
error: message,
|
||||
} satisfies ProviderAvailability;
|
||||
}
|
||||
});
|
||||
async getProviderAvailability(provider: AgentProvider): Promise<ProviderAvailability> {
|
||||
const client = this.clients.get(provider);
|
||||
if (!client) {
|
||||
return {
|
||||
provider,
|
||||
available: false,
|
||||
error: `No client registered for provider '${provider}'`,
|
||||
};
|
||||
}
|
||||
|
||||
return Promise.all(checks);
|
||||
try {
|
||||
const available = await client.isAvailable();
|
||||
return {
|
||||
provider,
|
||||
available,
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logger.warn({ err: error, provider }, "Failed to check provider availability");
|
||||
return {
|
||||
provider,
|
||||
available: false,
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async listDraftCommands(config: AgentSessionConfig): Promise<AgentSlashCommand[]> {
|
||||
@@ -931,6 +933,7 @@ export class AgentManager {
|
||||
provider: AgentProvider;
|
||||
providerHandleId: string;
|
||||
cwd: string;
|
||||
workspaceId: string;
|
||||
labels?: Record<string, string>;
|
||||
}): Promise<ManagedAgent> {
|
||||
const resolvedAgentId = validateAgentId(this.idFactory(), "importProviderSession");
|
||||
@@ -962,6 +965,7 @@ export class AgentManager {
|
||||
|
||||
return this.registerSession(imported.session, importedConfig, resolvedAgentId, {
|
||||
labels: input.labels,
|
||||
workspaceId: input.workspaceId,
|
||||
timelineRows,
|
||||
timelineNextSeq: timelineRows.length + 1,
|
||||
persistence: imported.persistence,
|
||||
|
||||
@@ -135,10 +135,22 @@ describe("generateStructuredAgentResponseWithFallback", () => {
|
||||
|
||||
function createManager(
|
||||
availability: Array<{ provider: string; available: boolean; error: string | null }>,
|
||||
) {
|
||||
): AgentManager & { checkedProviders: string[] } {
|
||||
const checkedProviders: string[] = [];
|
||||
const availabilityByProvider = new Map(availability.map((entry) => [entry.provider, entry]));
|
||||
return {
|
||||
listProviderAvailability: async () => availability,
|
||||
} as unknown as AgentManager;
|
||||
checkedProviders,
|
||||
getProviderAvailability: async (provider: string) => {
|
||||
checkedProviders.push(provider);
|
||||
return (
|
||||
availabilityByProvider.get(provider) ?? {
|
||||
provider,
|
||||
available: false,
|
||||
error: `No client registered for provider '${provider}'`,
|
||||
}
|
||||
);
|
||||
},
|
||||
} as unknown as AgentManager & { checkedProviders: string[] };
|
||||
}
|
||||
|
||||
it("uses the first available provider in the waterfall", async () => {
|
||||
@@ -171,6 +183,7 @@ describe("generateStructuredAgentResponseWithFallback", () => {
|
||||
|
||||
expect(result).toEqual({ summary: "ok" });
|
||||
expect(calls).toEqual([{ provider: "claude", model: "haiku", persistSession: false }]);
|
||||
expect(manager.checkedProviders).toEqual(["claude"]);
|
||||
});
|
||||
|
||||
it("skips unavailable providers and uses the next available one", async () => {
|
||||
@@ -201,6 +214,7 @@ describe("generateStructuredAgentResponseWithFallback", () => {
|
||||
|
||||
expect(result).toEqual({ summary: "ok" });
|
||||
expect(calls).toEqual([{ provider: "codex", model: "gpt-5.4-mini" }]);
|
||||
expect(manager.checkedProviders).toEqual(["claude", "codex"]);
|
||||
});
|
||||
|
||||
it("falls back when an available provider fails", async () => {
|
||||
@@ -237,6 +251,7 @@ describe("generateStructuredAgentResponseWithFallback", () => {
|
||||
{ provider: "claude", model: "haiku" },
|
||||
{ provider: "codex", model: "gpt-5.4-mini" },
|
||||
]);
|
||||
expect(manager.checkedProviders).toEqual(["claude", "codex"]);
|
||||
});
|
||||
|
||||
it("throws a fallback error when all providers are unavailable or fail", async () => {
|
||||
|
||||
@@ -403,13 +403,11 @@ export async function generateStructuredAgentResponseWithFallback<T>(
|
||||
const runStructured =
|
||||
runner ??
|
||||
((input: StructuredAgentGenerationOptions<T>) => generateStructuredAgentResponse<T>(input));
|
||||
const availability = await manager.listProviderAvailability();
|
||||
const availabilityByProvider = new Map(availability.map((entry) => [entry.provider, entry]));
|
||||
const attempts: StructuredGenerationAttempt[] = [];
|
||||
|
||||
for (const candidate of providers) {
|
||||
const availabilityEntry = availabilityByProvider.get(candidate.provider);
|
||||
if (availabilityEntry && !availabilityEntry.available) {
|
||||
const availabilityEntry = await manager.getProviderAvailability(candidate.provider);
|
||||
if (!availabilityEntry.available) {
|
||||
const reason = availabilityEntry.error ?? "unavailable";
|
||||
attempts.push({
|
||||
provider: candidate.provider,
|
||||
|
||||
@@ -29,7 +29,7 @@ interface CreateAgentLifecycleDispatchDependencies {
|
||||
workspaceGitService: WorkspaceGitService;
|
||||
createPaseoWorktreeWorkflow: CreatePaseoWorktreeWorkflowFn;
|
||||
archiveAgentForClose: (agentId: string) => Promise<unknown>;
|
||||
resolveWorkspaceIdForCwd: (cwd: string) => Promise<string | null>;
|
||||
findWorkspaceIdForCwd: (cwd: string) => Promise<string | null>;
|
||||
listActiveWorkspaces: () => Promise<ActiveWorkspaceRef[]>;
|
||||
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
|
||||
emit: (message: SessionOutboundMessage) => void;
|
||||
@@ -211,7 +211,7 @@ export class CreateAgentLifecycleDispatch {
|
||||
workspaceGitService: this.dependencies.workspaceGitService,
|
||||
agentManager: this.dependencies.agentManager,
|
||||
agentStorage: this.dependencies.agentStorage,
|
||||
resolveWorkspaceIdForCwd: this.dependencies.resolveWorkspaceIdForCwd,
|
||||
findWorkspaceIdForCwd: this.dependencies.findWorkspaceIdForCwd,
|
||||
listActiveWorkspaces: this.dependencies.listActiveWorkspaces,
|
||||
archiveWorkspaceRecord: this.dependencies.archiveWorkspaceRecord,
|
||||
emitWorkspaceUpdatesForWorkspaceIds: this.dependencies.emitWorkspaceUpdatesForWorkspaceIds,
|
||||
|
||||
@@ -54,6 +54,10 @@ interface CreateAgentCommandDependencies {
|
||||
providerSnapshotManager: ProviderSnapshotManager;
|
||||
daemonConfig?: StructuredGenerationDaemonConfig | null;
|
||||
createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn;
|
||||
// Mints a fresh workspace for a cwd and returns its id. Used when an agent is
|
||||
// created with no parent and no worktree: it owns a brand-new workspace rather
|
||||
// than being attributed to an existing same-cwd workspace by path.
|
||||
ensureWorkspaceForCreate?: (cwd: string) => Promise<string>;
|
||||
}
|
||||
|
||||
export interface CreateAgentFromSessionInput {
|
||||
@@ -258,8 +262,11 @@ async function resolveMcpCreateAgent(
|
||||
// workspace. When a new worktree is created the child lives in that fresh
|
||||
// workspace, so it is stamped with the new worktree's workspaceId instead
|
||||
// (mirrors the session path) — keeping the agent discoverable by
|
||||
// workspaceId-scoped archive.
|
||||
const workspaceId = setupContinuation ? createdWorkspaceId : parentAgent?.workspaceId;
|
||||
// workspaceId-scoped archive. With neither a parent nor a worktree, the agent
|
||||
// mints its own workspace; ownership is never resolved from cwd.
|
||||
const workspaceId = setupContinuation
|
||||
? createdWorkspaceId
|
||||
: (parentAgent?.workspaceId ?? (await ensureWorkspaceForMcpCreate(dependencies, resolvedCwd)));
|
||||
|
||||
const { modeId: resolvedMode, featureValues: resolvedFeatures } =
|
||||
await dependencies.providerSnapshotManager.resolveCreateConfig({
|
||||
@@ -305,6 +312,16 @@ async function resolveMcpCreateAgent(
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureWorkspaceForMcpCreate(
|
||||
dependencies: CreateAgentCommandDependencies,
|
||||
cwd: string,
|
||||
): Promise<string | undefined> {
|
||||
if (!dependencies.ensureWorkspaceForCreate) {
|
||||
return undefined;
|
||||
}
|
||||
return dependencies.ensureWorkspaceForCreate(cwd);
|
||||
}
|
||||
|
||||
async function sendInitialPrompt(
|
||||
dependencies: CreateAgentCommandDependencies,
|
||||
resolved: ResolvedCreateAgent,
|
||||
|
||||
@@ -388,6 +388,7 @@ test("importProviderSession imports a selected provider session without listing"
|
||||
providerHandleId: "provider-thread-imported",
|
||||
cwd,
|
||||
},
|
||||
workspaceId: "ws-imported",
|
||||
agentManager,
|
||||
agentStorage,
|
||||
logger: { warn: vi.fn(), error: vi.fn() } as never,
|
||||
@@ -398,6 +399,7 @@ test("importProviderSession imports a selected provider session without listing"
|
||||
provider: "custom-codex",
|
||||
providerHandleId: "provider-thread-imported",
|
||||
cwd,
|
||||
workspaceId: "ws-imported",
|
||||
labels: undefined,
|
||||
});
|
||||
expect(scheduleAgentMetadataGeneration).toHaveBeenCalledWith(
|
||||
@@ -437,6 +439,7 @@ test("importProviderSession passes labels through the manager import operation",
|
||||
cwd,
|
||||
labels: { source: "import" },
|
||||
},
|
||||
workspaceId: "ws-imported",
|
||||
agentManager,
|
||||
agentStorage,
|
||||
logger: { warn: vi.fn(), error: vi.fn() } as never,
|
||||
@@ -446,6 +449,7 @@ test("importProviderSession passes labels through the manager import operation",
|
||||
provider: "codex",
|
||||
providerHandleId: "thread-imported",
|
||||
cwd,
|
||||
workspaceId: "ws-imported",
|
||||
labels: { source: "import" },
|
||||
});
|
||||
});
|
||||
@@ -460,6 +464,7 @@ test("importProviderSession requires cwd from the selected provider row", async
|
||||
provider: "opencode",
|
||||
providerHandleId: "thread-imported",
|
||||
},
|
||||
workspaceId: "ws-imported",
|
||||
agentManager,
|
||||
agentStorage: { list: vi.fn() } as unknown as AgentStorage,
|
||||
logger: { warn: vi.fn(), error: vi.fn() } as never,
|
||||
|
||||
@@ -62,6 +62,7 @@ export interface ListImportableProviderSessionsResult {
|
||||
|
||||
export interface ImportProviderSessionInput {
|
||||
request: NormalizedImportAgentRequest;
|
||||
workspaceId: string;
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
|
||||
@@ -163,6 +164,7 @@ export async function importProviderSession(
|
||||
provider,
|
||||
providerHandleId,
|
||||
cwd,
|
||||
workspaceId: input.workspaceId,
|
||||
labels,
|
||||
});
|
||||
await unarchiveAgentState(input.agentStorage, input.agentManager, snapshot.id);
|
||||
|
||||
@@ -1448,7 +1448,7 @@ describe("create_agent MCP tool", () => {
|
||||
WorkspaceGitService,
|
||||
"getSnapshot" | "listWorktrees" | "resolveRepoRoot"
|
||||
>,
|
||||
resolveWorkspaceIdForCwd: vi.fn(async () => "ws-archive-tool-worktree"),
|
||||
findWorkspaceIdForCwd: vi.fn(async () => "ws-archive-tool-worktree"),
|
||||
listActiveWorkspaces: vi.fn(async () => []),
|
||||
archiveWorkspaceRecord,
|
||||
emitWorkspaceUpdatesForWorkspaceIds,
|
||||
@@ -1532,7 +1532,7 @@ describe("create_agent MCP tool", () => {
|
||||
WorkspaceGitService,
|
||||
"getSnapshot" | "listWorktrees" | "resolveRepoRoot"
|
||||
>,
|
||||
resolveWorkspaceIdForCwd: vi.fn(async () => "ws-archive-mcp"),
|
||||
findWorkspaceIdForCwd: vi.fn(async () => "ws-archive-mcp"),
|
||||
listActiveWorkspaces: vi.fn(async () => []),
|
||||
archiveWorkspaceRecord: vi.fn(async () => undefined),
|
||||
emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async () => undefined),
|
||||
|
||||
@@ -93,13 +93,16 @@ export interface AgentMcpServerOptions {
|
||||
WorkspaceGitService,
|
||||
"getSnapshot" | "listWorktrees" | "resolveRepoRoot"
|
||||
>;
|
||||
resolveWorkspaceIdForCwd?: ArchivePaseoWorktreeDependencies["resolveWorkspaceIdForCwd"];
|
||||
findWorkspaceIdForCwd?: ArchivePaseoWorktreeDependencies["findWorkspaceIdForCwd"];
|
||||
listActiveWorkspaces?: ArchivePaseoWorktreeDependencies["listActiveWorkspaces"];
|
||||
archiveWorkspaceRecord?: ArchivePaseoWorktreeDependencies["archiveWorkspaceRecord"];
|
||||
emitWorkspaceUpdatesForWorkspaceIds?: ArchivePaseoWorktreeDependencies["emitWorkspaceUpdatesForWorkspaceIds"];
|
||||
markWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["markWorkspaceArchiving"];
|
||||
clearWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["clearWorkspaceArchiving"];
|
||||
createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn;
|
||||
// Mints a fresh workspace for a cwd and returns its id, used when an agent is
|
||||
// created with no parent and no worktree.
|
||||
ensureWorkspaceForCreate?: (cwd: string) => Promise<string>;
|
||||
paseoHome?: string;
|
||||
worktreesRoot?: string;
|
||||
/**
|
||||
@@ -572,6 +575,25 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
return expandUserPath(trimmedCwd);
|
||||
};
|
||||
|
||||
async function resolveTerminalWorkspaceId(resolvedCwd: string): Promise<string> {
|
||||
// An MCP-spawned terminal belongs to the caller agent's workspace. Only if
|
||||
// the caller has no workspace do we mint one for the cwd.
|
||||
const callerAgent = callerAgentId ? agentManager.getAgent(callerAgentId) : null;
|
||||
if (callerAgent?.workspaceId) {
|
||||
return callerAgent.workspaceId;
|
||||
}
|
||||
|
||||
if (!options.ensureWorkspaceForCreate) {
|
||||
throw new Error(
|
||||
callerAgentId
|
||||
? `Caller agent ${callerAgentId} has no workspace and workspace minting is not configured`
|
||||
: "workspaceId is required outside an agent-scoped session",
|
||||
);
|
||||
}
|
||||
|
||||
return options.ensureWorkspaceForCreate(resolvedCwd);
|
||||
}
|
||||
|
||||
const buildCallerAgentScheduleConfigExtras = (
|
||||
callerAgent: NonNullable<ReturnType<typeof resolveCallerAgent>>,
|
||||
): Record<string, unknown> => {
|
||||
@@ -926,6 +948,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
terminalManager,
|
||||
providerSnapshotManager,
|
||||
createPaseoWorktree: options.createPaseoWorktree,
|
||||
...(options.ensureWorkspaceForCreate
|
||||
? { ensureWorkspaceForCreate: options.ensureWorkspaceForCreate }
|
||||
: {}),
|
||||
},
|
||||
{
|
||||
kind: "mcp",
|
||||
@@ -1520,8 +1545,12 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
throw new Error("Terminal manager is not configured");
|
||||
}
|
||||
|
||||
const resolvedCwd = resolveScopedCwd(cwd, { required: true });
|
||||
const workspaceId = await resolveTerminalWorkspaceId(resolvedCwd);
|
||||
|
||||
const terminal = await terminalManager.createTerminal({
|
||||
cwd: resolveScopedCwd(cwd, { required: true }),
|
||||
cwd: resolvedCwd,
|
||||
workspaceId,
|
||||
...(name?.trim() ? { name: name.trim() } : {}),
|
||||
});
|
||||
|
||||
@@ -2411,7 +2440,7 @@ function archiveWorktreeDependencies(
|
||||
if (!options.archiveWorkspaceRecord) {
|
||||
throw new Error("Workspace registry archiver is required to archive worktrees");
|
||||
}
|
||||
if (!options.resolveWorkspaceIdForCwd) {
|
||||
if (!options.findWorkspaceIdForCwd) {
|
||||
throw new Error("Workspace resolver is required to archive worktrees");
|
||||
}
|
||||
if (!options.listActiveWorkspaces) {
|
||||
@@ -2433,7 +2462,7 @@ function archiveWorktreeDependencies(
|
||||
workspaceGitService: options.workspaceGitService,
|
||||
agentManager: context.agentManager,
|
||||
agentStorage: context.agentStorage,
|
||||
resolveWorkspaceIdForCwd: options.resolveWorkspaceIdForCwd,
|
||||
findWorkspaceIdForCwd: options.findWorkspaceIdForCwd,
|
||||
listActiveWorkspaces: options.listActiveWorkspaces,
|
||||
archiveWorkspaceRecord: options.archiveWorkspaceRecord,
|
||||
emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds,
|
||||
|
||||
@@ -45,6 +45,63 @@ describe("MockLoadTestAgentClient", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("returns schema-shaped JSON for structured branch-name generation", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = new MockLoadTestAgentClient();
|
||||
const session = await client.createSession({
|
||||
provider: "mock",
|
||||
cwd: process.cwd(),
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
|
||||
const resultPromise = session.run(
|
||||
[
|
||||
"Generate a git branch name for a coding agent based on the user prompt and attachments.",
|
||||
"Title: a short human-readable sentence-case label for the task (no slug rules, max 80 characters).",
|
||||
"Branch: concise lowercase slug using letters, numbers, hyphens, and slashes only.",
|
||||
"Return JSON only with fields 'title' and 'branch'.",
|
||||
"",
|
||||
"User context:",
|
||||
"Fix login bug",
|
||||
].join("\n"),
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
sessionId: session.id,
|
||||
finalText: JSON.stringify({ title: "Fix login bug", branch: "fix-login-bug" }),
|
||||
canceled: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns schema-shaped JSON for structured agent-title generation", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = new MockLoadTestAgentClient();
|
||||
const session = await client.createSession({
|
||||
provider: "mock",
|
||||
cwd: process.cwd(),
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
|
||||
const resultPromise = session.run(
|
||||
[
|
||||
"Generate metadata for a coding agent based on the user prompt.",
|
||||
"Title: short descriptive label (<= 80 chars).",
|
||||
"Return JSON only with a single field 'title'.",
|
||||
"",
|
||||
"User prompt:",
|
||||
"Fix login bug",
|
||||
].join("\n"),
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
sessionId: session.id,
|
||||
finalText: JSON.stringify({ title: "Fix login bug" }),
|
||||
canceled: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("emits sub-word tokens, reasoning, and sequential tool calls during a foreground turn", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = new MockLoadTestAgentClient();
|
||||
|
||||
@@ -270,6 +270,78 @@ function parseAgentStreamStressPrompt(prompt: AgentPromptInput): AgentStreamStre
|
||||
};
|
||||
}
|
||||
|
||||
function parseStructuredBranchNamePrompt(
|
||||
prompt: AgentPromptInput,
|
||||
): { title: string; branch: string } | null {
|
||||
const text = promptToText(prompt);
|
||||
const hasBranchNamePrompt =
|
||||
text.includes("Generate a git branch name for a coding agent") &&
|
||||
(text.includes("Return JSON only with fields 'title' and 'branch'.") ||
|
||||
text.includes('"title"') ||
|
||||
text.includes('"branch"'));
|
||||
if (
|
||||
!hasBranchNamePrompt &&
|
||||
!(
|
||||
text.includes("You must respond with JSON only that matches this JSON Schema") &&
|
||||
text.includes('"title"') &&
|
||||
text.includes('"branch"')
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const seed = text.split("User context:\n").at(-1)?.trim() ?? "";
|
||||
const firstLine =
|
||||
seed
|
||||
.split("\n")
|
||||
.find((line) => line.trim().length > 0)
|
||||
?.trim() ?? "Mock task";
|
||||
const title = firstLine
|
||||
.replace(/^["'`]+|["'`]+$/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.slice(0, 80)
|
||||
.trim();
|
||||
const branch =
|
||||
title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 100) || "mock-task";
|
||||
|
||||
return { title: title || "Mock task", branch };
|
||||
}
|
||||
|
||||
function parseStructuredAgentTitlePrompt(prompt: AgentPromptInput): { title: string } | null {
|
||||
const text = promptToText(prompt);
|
||||
const hasAgentTitlePrompt =
|
||||
text.includes("Generate metadata for a coding agent based on the user prompt.") &&
|
||||
(text.includes("Return JSON only with a single field 'title'.") || text.includes('"title"'));
|
||||
if (
|
||||
!hasAgentTitlePrompt &&
|
||||
!(
|
||||
text.includes("You must respond with JSON only that matches this JSON Schema") &&
|
||||
text.includes('"title"') &&
|
||||
text.includes("User prompt:")
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const seed = text.split("User prompt:\n").at(-1)?.trim() ?? "";
|
||||
const firstLine =
|
||||
seed
|
||||
.split("\n")
|
||||
.find((line) => line.trim().length > 0)
|
||||
?.trim() ?? "Mock task";
|
||||
const title = firstLine
|
||||
.replace(/^["'`]+|["'`]+$/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.slice(0, 80)
|
||||
.trim();
|
||||
|
||||
return { title: title || "Mock task" };
|
||||
}
|
||||
|
||||
function buildRepeatedPayload(bytes: number, prefix: string): string {
|
||||
const line = `${prefix} ${"x".repeat(96)}\n`;
|
||||
let output = "";
|
||||
@@ -605,7 +677,13 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
const largePayload = parseLargeAgentStreamPayloadPrompt(prompt);
|
||||
const stress = parseAgentStreamStressPrompt(prompt);
|
||||
const questionPrompt = parseMockQuestionPrompt(prompt);
|
||||
if (shouldEmitPlanApprovalPrompt(prompt)) {
|
||||
const structuredBranchName = parseStructuredBranchNamePrompt(prompt);
|
||||
const structuredAgentTitle = parseStructuredAgentTitlePrompt(prompt);
|
||||
if (structuredBranchName) {
|
||||
this.scheduleStructuredJsonTurn(turn, structuredBranchName);
|
||||
} else if (structuredAgentTitle) {
|
||||
this.scheduleStructuredJsonTurn(turn, structuredAgentTitle);
|
||||
} else if (shouldEmitPlanApprovalPrompt(prompt)) {
|
||||
this.schedulePlanApprovalTurn(turn);
|
||||
} else if (questionPrompt) {
|
||||
this.scheduleQuestionPromptTurn(turn, questionPrompt);
|
||||
@@ -791,6 +869,49 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
turn.timer.unref?.();
|
||||
}
|
||||
|
||||
private scheduleStructuredJsonTurn(turn: ActiveTurn, result: Record<string, string>): void {
|
||||
turn.timer = setTimeout(() => {
|
||||
this.emitStructuredJsonTurn(turn, result);
|
||||
}, 0);
|
||||
turn.timer.unref?.();
|
||||
}
|
||||
|
||||
private emitStructuredJsonTurn(turn: ActiveTurn, result: Record<string, string>): void {
|
||||
if (this.activeTurn !== turn) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearTurnTimer(turn);
|
||||
this.emit({
|
||||
type: "turn_started",
|
||||
provider: this.provider,
|
||||
turnId: turn.turnId,
|
||||
});
|
||||
|
||||
const finalText = JSON.stringify(result);
|
||||
this.emitTimeline(turn.turnId, {
|
||||
type: "assistant_message",
|
||||
text: finalText,
|
||||
});
|
||||
this.activeTurn = null;
|
||||
this.emit({
|
||||
type: "turn_completed",
|
||||
provider: this.provider,
|
||||
turnId: turn.turnId,
|
||||
});
|
||||
turn.resolve({
|
||||
sessionId: this.id,
|
||||
finalText,
|
||||
timeline: [
|
||||
{
|
||||
type: "assistant_message",
|
||||
text: finalText,
|
||||
},
|
||||
],
|
||||
canceled: false,
|
||||
});
|
||||
}
|
||||
|
||||
private emitPlanApprovalTurn(turn: ActiveTurn): void {
|
||||
if (this.activeTurn !== turn) {
|
||||
return;
|
||||
|
||||
@@ -1,13 +1,42 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { resolveStructuredGenerationProviders } from "./structured-generation-providers.js";
|
||||
import type { ProviderSnapshotEntry } from "./agent-sdk-types.js";
|
||||
|
||||
const READY = "ready" as const;
|
||||
const ERROR = "error" as const;
|
||||
|
||||
class ProviderSnapshots {
|
||||
readonly calls: Array<{ cwd?: string; wait?: boolean }> = [];
|
||||
|
||||
constructor(private readonly entries: ProviderSnapshotEntry[]) {}
|
||||
|
||||
async listProviders(input: { cwd?: string; wait?: boolean } = {}) {
|
||||
this.calls.push({ cwd: input.cwd, wait: input.wait });
|
||||
return this.entries;
|
||||
}
|
||||
}
|
||||
|
||||
describe("resolveStructuredGenerationProviders", () => {
|
||||
test("prefers configured providers, resolves dynamic defaults, and dedupes duplicates", async () => {
|
||||
const listProviders = vi.fn(async () => [
|
||||
test("uses explicit configured provider models without refreshing provider snapshots", async () => {
|
||||
const snapshots = new ProviderSnapshots([]);
|
||||
|
||||
const providers = await resolveStructuredGenerationProviders({
|
||||
cwd: "/tmp/repo",
|
||||
providerSnapshotManager: snapshots,
|
||||
daemonConfig: {
|
||||
metadataGeneration: {
|
||||
providers: [{ provider: "mock", model: "ten-second-stream" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(providers).toEqual([{ provider: "mock", model: "ten-second-stream" }]);
|
||||
expect(snapshots.calls).toEqual([]);
|
||||
});
|
||||
|
||||
test("falls back to dynamic defaults and current selection when no provider is configured", async () => {
|
||||
const snapshots = new ProviderSnapshots([
|
||||
{
|
||||
provider: "work-claude",
|
||||
status: READY,
|
||||
@@ -47,15 +76,7 @@ describe("resolveStructuredGenerationProviders", () => {
|
||||
|
||||
const providers = await resolveStructuredGenerationProviders({
|
||||
cwd: "/tmp/repo",
|
||||
providerSnapshotManager: { listProviders },
|
||||
daemonConfig: {
|
||||
metadataGeneration: {
|
||||
providers: [
|
||||
{ provider: "stale-codex", model: "missing-model", thinkingOptionId: "low" },
|
||||
{ provider: "work-claude" },
|
||||
],
|
||||
},
|
||||
},
|
||||
providerSnapshotManager: snapshots,
|
||||
currentSelection: {
|
||||
provider: "focused-provider",
|
||||
model: "focused-model",
|
||||
@@ -64,36 +85,35 @@ describe("resolveStructuredGenerationProviders", () => {
|
||||
});
|
||||
|
||||
expect(providers).toEqual([
|
||||
{ provider: "stale-codex", model: "missing-model", thinkingOptionId: "low" },
|
||||
{ provider: "work-claude", model: "claude-haiku-2026" },
|
||||
{ provider: "work-codex", model: "gpt-5.4-mini-2026", thinkingOptionId: "low" },
|
||||
{ provider: "router", model: "minimax-m2.5-free" },
|
||||
{ provider: "router", model: "nemotron-3-super-free" },
|
||||
{ provider: "focused-provider", model: "focused-model", thinkingOptionId: "high" },
|
||||
]);
|
||||
expect(listProviders).toHaveBeenCalledWith({ cwd: "/tmp/repo", wait: true });
|
||||
expect(snapshots.calls).toEqual([{ cwd: "/tmp/repo", wait: true }]);
|
||||
});
|
||||
|
||||
test("falls back to the current selection when defaults do not match", async () => {
|
||||
const providers = await resolveStructuredGenerationProviders({
|
||||
cwd: "/tmp/repo",
|
||||
providerSnapshotManager: {
|
||||
listProviders: vi.fn(async () => [
|
||||
const snapshots = new ProviderSnapshots([
|
||||
{
|
||||
provider: "current-provider",
|
||||
status: READY,
|
||||
enabled: true,
|
||||
models: [
|
||||
{
|
||||
provider: "current-provider",
|
||||
status: READY,
|
||||
enabled: true,
|
||||
models: [
|
||||
{
|
||||
provider: "current-provider",
|
||||
id: "selected-model",
|
||||
label: "Selected Model",
|
||||
isDefault: true,
|
||||
},
|
||||
],
|
||||
id: "selected-model",
|
||||
label: "Selected Model",
|
||||
isDefault: true,
|
||||
},
|
||||
]),
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const providers = await resolveStructuredGenerationProviders({
|
||||
cwd: "/tmp/repo",
|
||||
providerSnapshotManager: snapshots,
|
||||
currentSelection: {
|
||||
provider: "current-provider",
|
||||
model: "selected-model",
|
||||
@@ -107,26 +127,26 @@ describe("resolveStructuredGenerationProviders", () => {
|
||||
});
|
||||
|
||||
test("resolves a provider-only current selection to that provider's default model", async () => {
|
||||
const providers = await resolveStructuredGenerationProviders({
|
||||
cwd: "/tmp/repo",
|
||||
providerSnapshotManager: {
|
||||
listProviders: vi.fn(async () => [
|
||||
const snapshots = new ProviderSnapshots([
|
||||
{
|
||||
provider: "focused-provider",
|
||||
status: READY,
|
||||
enabled: true,
|
||||
models: [
|
||||
{
|
||||
provider: "focused-provider",
|
||||
status: READY,
|
||||
enabled: true,
|
||||
models: [
|
||||
{
|
||||
provider: "focused-provider",
|
||||
id: "focused-default",
|
||||
label: "Focused Default",
|
||||
isDefault: true,
|
||||
defaultThinkingOptionId: "balanced",
|
||||
},
|
||||
],
|
||||
id: "focused-default",
|
||||
label: "Focused Default",
|
||||
isDefault: true,
|
||||
defaultThinkingOptionId: "balanced",
|
||||
},
|
||||
]),
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const providers = await resolveStructuredGenerationProviders({
|
||||
cwd: "/tmp/repo",
|
||||
providerSnapshotManager: snapshots,
|
||||
currentSelection: { provider: "focused-provider" },
|
||||
});
|
||||
|
||||
@@ -135,30 +155,30 @@ describe("resolveStructuredGenerationProviders", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("normalizes nested OpenCode provider entries to the top-level provider and full model id", async () => {
|
||||
const providers = await resolveStructuredGenerationProviders({
|
||||
cwd: "/tmp/repo",
|
||||
providerSnapshotManager: {
|
||||
listProviders: vi.fn(async () => [
|
||||
test("uses explicit configured provider models as-is instead of waiting to normalize aliases", async () => {
|
||||
const snapshots = new ProviderSnapshots([
|
||||
{
|
||||
provider: "opencode",
|
||||
status: READY,
|
||||
enabled: true,
|
||||
models: [
|
||||
{
|
||||
provider: "opencode",
|
||||
status: READY,
|
||||
enabled: true,
|
||||
models: [
|
||||
{
|
||||
provider: "opencode",
|
||||
id: "plexus/small-fast",
|
||||
label: "Small Fast",
|
||||
isDefault: true,
|
||||
metadata: {
|
||||
providerId: "plexus",
|
||||
modelId: "small-fast",
|
||||
},
|
||||
},
|
||||
],
|
||||
id: "plexus/small-fast",
|
||||
label: "Small Fast",
|
||||
isDefault: true,
|
||||
metadata: {
|
||||
providerId: "plexus",
|
||||
modelId: "small-fast",
|
||||
},
|
||||
},
|
||||
]),
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const providers = await resolveStructuredGenerationProviders({
|
||||
cwd: "/tmp/repo",
|
||||
providerSnapshotManager: snapshots,
|
||||
daemonConfig: {
|
||||
metadataGeneration: {
|
||||
providers: [{ provider: "plexus", model: "small-fast" }],
|
||||
@@ -166,22 +186,23 @@ describe("resolveStructuredGenerationProviders", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(providers).toEqual([{ provider: "opencode", model: "plexus/small-fast" }]);
|
||||
expect(providers).toEqual([{ provider: "plexus", model: "small-fast" }]);
|
||||
expect(snapshots.calls).toEqual([]);
|
||||
});
|
||||
|
||||
test("keeps explicit candidates when provider snapshots are in error state", async () => {
|
||||
const snapshots = new ProviderSnapshots([
|
||||
{
|
||||
provider: "current-provider",
|
||||
status: ERROR,
|
||||
enabled: true,
|
||||
error: "timed out",
|
||||
},
|
||||
]);
|
||||
|
||||
const providers = await resolveStructuredGenerationProviders({
|
||||
cwd: "/tmp/repo",
|
||||
providerSnapshotManager: {
|
||||
listProviders: vi.fn(async () => [
|
||||
{
|
||||
provider: "current-provider",
|
||||
status: ERROR,
|
||||
enabled: true,
|
||||
error: "timed out",
|
||||
},
|
||||
]),
|
||||
},
|
||||
providerSnapshotManager: snapshots,
|
||||
daemonConfig: {
|
||||
metadataGeneration: {
|
||||
providers: [{ provider: "current-provider", model: "configured-model" }],
|
||||
@@ -194,9 +215,7 @@ describe("resolveStructuredGenerationProviders", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(providers).toEqual([
|
||||
{ provider: "current-provider", model: "configured-model" },
|
||||
{ provider: "current-provider", model: "selected-model", thinkingOptionId: "medium" },
|
||||
]);
|
||||
expect(providers).toEqual([{ provider: "current-provider", model: "configured-model" }]);
|
||||
expect(snapshots.calls).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,6 +43,23 @@ export interface ResolveStructuredGenerationProvidersOptions {
|
||||
export async function resolveStructuredGenerationProviders(
|
||||
options: ResolveStructuredGenerationProvidersOptions,
|
||||
): Promise<StructuredGenerationProvider[]> {
|
||||
const configuredProviders = readConfiguredProviders(options.daemonConfig);
|
||||
if (configuredProviders.length > 0) {
|
||||
const explicitProviders = resolveExplicitConfiguredProviders(configuredProviders);
|
||||
if (explicitProviders.length === configuredProviders.length) {
|
||||
return dedupeProviders(explicitProviders);
|
||||
}
|
||||
|
||||
const providerEntries = await options.providerSnapshotManager.listProviders({
|
||||
cwd: options.cwd,
|
||||
wait: false,
|
||||
});
|
||||
const providers = resolveConfiguredProviders(configuredProviders, providerEntries);
|
||||
if (providers.length > 0) {
|
||||
return dedupeProviders(providers);
|
||||
}
|
||||
}
|
||||
|
||||
const providerEntries = await options.providerSnapshotManager.listProviders({
|
||||
cwd: options.cwd,
|
||||
wait: true,
|
||||
@@ -52,7 +69,7 @@ export async function resolveStructuredGenerationProviders(
|
||||
const entriesByProvider = new Map(enabledEntries.map((entry) => [entry.provider, entry]));
|
||||
const providers: StructuredGenerationProvider[] = [];
|
||||
|
||||
for (const configured of readConfiguredProviders(options.daemonConfig)) {
|
||||
for (const configured of configuredProviders) {
|
||||
const resolvedConfigured = resolveConfiguredCandidate(
|
||||
configured,
|
||||
modelEntries,
|
||||
@@ -83,6 +100,42 @@ export async function resolveStructuredGenerationProviders(
|
||||
return dedupeProviders(providers);
|
||||
}
|
||||
|
||||
function resolveConfiguredProviders(
|
||||
configuredProviders: readonly { provider: string; model?: string; thinkingOptionId?: string }[],
|
||||
providerEntries: readonly ProviderSnapshotEntry[],
|
||||
): StructuredGenerationProvider[] {
|
||||
const enabledEntries = providerEntries.filter((entry) => entry.enabled);
|
||||
const modelEntries = enabledEntries.filter((entry) => (entry.models?.length ?? 0) > 0);
|
||||
const entriesByProvider = new Map(enabledEntries.map((entry) => [entry.provider, entry]));
|
||||
const providers: StructuredGenerationProvider[] = [];
|
||||
for (const configured of configuredProviders) {
|
||||
const resolved = resolveConfiguredCandidate(configured, modelEntries, entriesByProvider);
|
||||
if (resolved) {
|
||||
providers.push(resolved);
|
||||
}
|
||||
}
|
||||
return providers;
|
||||
}
|
||||
|
||||
function resolveExplicitConfiguredProviders(
|
||||
configuredProviders: readonly { provider: string; model?: string; thinkingOptionId?: string }[],
|
||||
): StructuredGenerationProvider[] {
|
||||
const providers: StructuredGenerationProvider[] = [];
|
||||
for (const configured of configuredProviders) {
|
||||
const provider = configured.provider.trim();
|
||||
const model = configured.model?.trim();
|
||||
if (!provider || !model) {
|
||||
continue;
|
||||
}
|
||||
providers.push({
|
||||
provider,
|
||||
model,
|
||||
...(configured.thinkingOptionId ? { thinkingOptionId: configured.thinkingOptionId } : {}),
|
||||
});
|
||||
}
|
||||
return providers;
|
||||
}
|
||||
|
||||
function resolveCurrentSelection(
|
||||
selection: ResolveStructuredGenerationProvidersOptions["currentSelection"],
|
||||
readyEntries: readonly ProviderSnapshotEntry[],
|
||||
|
||||
@@ -93,7 +93,7 @@ function createHarness(overrides?: {
|
||||
agentManager: {} as AutoArchiveArchiveOptions["agentManager"],
|
||||
agentStorage: {} as AutoArchiveArchiveOptions["agentStorage"],
|
||||
terminalManager: {} as AutoArchiveArchiveOptions["terminalManager"],
|
||||
resolveWorkspaceIdForCwd: vi.fn(async () => "ws-auto-archive"),
|
||||
findWorkspaceIdForCwd: vi.fn(async () => "ws-auto-archive"),
|
||||
listActiveWorkspaces: vi.fn(async () => []),
|
||||
archiveWorkspaceRecord: vi.fn(),
|
||||
markWorkspaceArchiving: vi.fn(),
|
||||
|
||||
@@ -25,7 +25,7 @@ export interface AutoArchiveArchiveOptions {
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
terminalManager: TerminalManager;
|
||||
resolveWorkspaceIdForCwd: (cwd: string) => Promise<string | null>;
|
||||
findWorkspaceIdForCwd: (cwd: string) => Promise<string | null>;
|
||||
listActiveWorkspaces: () => Promise<ActiveWorkspaceRef[]>;
|
||||
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
|
||||
markWorkspaceArchiving: (workspaceIds: Iterable<string>, archivingAt: string) => void;
|
||||
@@ -105,7 +105,7 @@ export async function archiveIfSafe(input: {
|
||||
workspaceGitService: options.workspaceGitService,
|
||||
agentManager: options.agentManager,
|
||||
agentStorage: options.agentStorage,
|
||||
resolveWorkspaceIdForCwd: options.resolveWorkspaceIdForCwd,
|
||||
findWorkspaceIdForCwd: options.findWorkspaceIdForCwd,
|
||||
listActiveWorkspaces: options.listActiveWorkspaces,
|
||||
archiveWorkspaceRecord: options.archiveWorkspaceRecord,
|
||||
emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds,
|
||||
|
||||
@@ -89,7 +89,10 @@ function formatListenTarget(listenTarget: ListenTarget | null): string | null {
|
||||
|
||||
import { VoiceAssistantWebSocketServer } from "./websocket-server.js";
|
||||
import { createGitHubService } from "../services/github-service.js";
|
||||
import { createPaseoWorktree as createRegisteredPaseoWorktree } from "./paseo-worktree-service.js";
|
||||
import {
|
||||
createPaseoWorktree as createRegisteredPaseoWorktree,
|
||||
createLocalCheckoutWorkspace,
|
||||
} from "./paseo-worktree-service.js";
|
||||
import { createPaseoWorktreeWorkflow } from "./worktree-session.js";
|
||||
import { DownloadTokenStore } from "./file-download/token-store.js";
|
||||
import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/config.js";
|
||||
@@ -110,7 +113,7 @@ import { LoopService } from "./loop-service.js";
|
||||
import { ScheduleService } from "./schedule/service.js";
|
||||
import { DaemonConfigStore } from "./daemon-config-store.js";
|
||||
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
|
||||
import { resolveRegisteredWorkspaceIdForCwd } from "./workspace-directory.js";
|
||||
import { resolveWorkspaceIdForPath } from "./workspace-ownership.js";
|
||||
import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js";
|
||||
import { setupAutoArchiveOnMerge } from "./auto-archive-on-merge/index.js";
|
||||
import type { ActiveWorkspaceRef } from "./paseo-worktree-archive-service.js";
|
||||
@@ -748,8 +751,17 @@ export async function createPaseoDaemon(
|
||||
workspaceRegistry,
|
||||
});
|
||||
};
|
||||
const resolveWorkspaceIdForCwdExternal = async (cwd: string): Promise<string | null> => {
|
||||
return resolveRegisteredWorkspaceIdForCwd(cwd, await workspaceRegistry.list());
|
||||
// external path→workspace adapter, not ownership: archive-by-path requests that
|
||||
// arrive with a worktree path and no workspaceId (old clients / CLI).
|
||||
const findWorkspaceIdForCwdExternal = async (cwd: string): Promise<string | null> => {
|
||||
return resolveWorkspaceIdForPath(cwd, await workspaceRegistry.list());
|
||||
};
|
||||
const ensureWorkspaceForCreateExternal = async (cwd: string): Promise<string> => {
|
||||
const workspace = await createLocalCheckoutWorkspace(
|
||||
{ cwd },
|
||||
{ projectRegistry, workspaceRegistry, workspaceGitService },
|
||||
);
|
||||
return workspace.workspaceId;
|
||||
};
|
||||
const listActiveWorkspacesExternal = async (): Promise<ActiveWorkspaceRef[]> => {
|
||||
const workspaces = await workspaceRegistry.list();
|
||||
@@ -795,7 +807,7 @@ export async function createPaseoDaemon(
|
||||
agentStorage,
|
||||
terminalManager,
|
||||
logger,
|
||||
resolveWorkspaceIdForCwd: resolveWorkspaceIdForCwdExternal,
|
||||
findWorkspaceIdForCwd: findWorkspaceIdForCwdExternal,
|
||||
listActiveWorkspaces: listActiveWorkspacesExternal,
|
||||
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
|
||||
markWorkspaceArchiving: markWorkspaceArchivingExternal,
|
||||
@@ -819,12 +831,13 @@ export async function createPaseoDaemon(
|
||||
providerSnapshotManager,
|
||||
github,
|
||||
workspaceGitService,
|
||||
resolveWorkspaceIdForCwd: resolveWorkspaceIdForCwdExternal,
|
||||
findWorkspaceIdForCwd: findWorkspaceIdForCwdExternal,
|
||||
listActiveWorkspaces: listActiveWorkspacesExternal,
|
||||
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
|
||||
emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal,
|
||||
markWorkspaceArchiving: markWorkspaceArchivingExternal,
|
||||
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
|
||||
ensureWorkspaceForCreate: ensureWorkspaceForCreateExternal,
|
||||
createPaseoWorktree: async (input, serviceOptions) => {
|
||||
return createPaseoWorktreeWorkflow(
|
||||
{
|
||||
@@ -850,13 +863,8 @@ export async function createPaseoDaemon(
|
||||
.map((session) => session.warmWorkspaceGitDataForWorkspace(workspace)) ?? [],
|
||||
);
|
||||
},
|
||||
emitWorkspaceUpdateForCwd: async (cwd, emitOptions) => {
|
||||
await Promise.all(
|
||||
wsServer
|
||||
?.listActiveSessions()
|
||||
.map((session) => session.emitWorkspaceUpdatesForExternalCwds([cwd])) ?? [],
|
||||
);
|
||||
void emitOptions;
|
||||
emitWorkspaceUpdateForWorkspaceId: async (workspaceId) => {
|
||||
await emitWorkspaceUpdatesExternal([workspaceId]);
|
||||
},
|
||||
cacheWorkspaceSetupSnapshot: () => {},
|
||||
emit: emitExternalSessionMessage,
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { homedir } from "node:os";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { AgentStorage } from "../agent/agent-storage.js";
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import {
|
||||
FileBackedWorkspaceRegistry,
|
||||
createPersistedWorkspaceRecord,
|
||||
} from "../workspace-registry.js";
|
||||
import { backfillWorkspaceIdForLegacyAgents } from "./backfill-workspace-id.migration.js";
|
||||
|
||||
function workspaceRecord(
|
||||
cwd: string,
|
||||
workspaceId: string,
|
||||
overrides?: { createdAt?: string; archivedAt?: string },
|
||||
) {
|
||||
return createPersistedWorkspaceRecord({
|
||||
workspaceId,
|
||||
projectId: workspaceId,
|
||||
cwd,
|
||||
kind: "directory",
|
||||
displayName: path.basename(cwd) || cwd,
|
||||
createdAt: overrides?.createdAt ?? "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: overrides?.createdAt ?? "2026-03-01T00:00:00.000Z",
|
||||
archivedAt: overrides?.archivedAt ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
describe("backfillWorkspaceIdForLegacyAgents", () => {
|
||||
let home: string;
|
||||
let agentStorage: AgentStorage;
|
||||
let workspaceRegistry: FileBackedWorkspaceRegistry;
|
||||
|
||||
beforeEach(async () => {
|
||||
home = mkdtempSync(path.join(tmpdir(), "paseo-backfill-"));
|
||||
agentStorage = new AgentStorage(path.join(home, "agents"), createTestLogger());
|
||||
await agentStorage.initialize();
|
||||
workspaceRegistry = new FileBackedWorkspaceRegistry(
|
||||
path.join(home, "workspaces.json"),
|
||||
createTestLogger(),
|
||||
);
|
||||
await workspaceRegistry.initialize();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function seedLegacyAgent(cwd: string, id: string): Promise<void> {
|
||||
await agentStorage.upsert({
|
||||
id,
|
||||
provider: "codex",
|
||||
cwd,
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
lastActivityAt: "2026-03-01T12:00:00.000Z",
|
||||
lastUserMessageAt: null,
|
||||
title: null,
|
||||
labels: {},
|
||||
lastStatus: "closed",
|
||||
lastModeId: null,
|
||||
config: null,
|
||||
runtimeInfo: { provider: "codex", sessionId: null },
|
||||
persistence: null,
|
||||
archivedAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
test("stamps the oldest exact-cwd workspace onto an unstamped legacy agent", async () => {
|
||||
await workspaceRegistry.upsert(
|
||||
workspaceRecord("/tmp/repo", "ws-newer", { createdAt: "2026-03-02T00:00:00.000Z" }),
|
||||
);
|
||||
await workspaceRegistry.upsert(
|
||||
workspaceRecord("/tmp/repo", "ws-older", { createdAt: "2026-03-01T00:00:00.000Z" }),
|
||||
);
|
||||
await seedLegacyAgent("/tmp/repo", "legacy-agent");
|
||||
|
||||
const migrated = await backfillWorkspaceIdForLegacyAgents({
|
||||
agentStorage,
|
||||
workspaceRegistry,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
expect(migrated).toBe(1);
|
||||
expect((await agentStorage.get("legacy-agent"))?.workspaceId).toBe("ws-older");
|
||||
});
|
||||
|
||||
test("attributes to the deepest enclosing workspace when there is no exact match", async () => {
|
||||
await workspaceRegistry.upsert(workspaceRecord("/tmp/repo", "ws-root"));
|
||||
await workspaceRegistry.upsert(workspaceRecord("/tmp/repo/packages/app", "ws-app"));
|
||||
await seedLegacyAgent("/tmp/repo/packages/app/src", "legacy-agent");
|
||||
|
||||
await backfillWorkspaceIdForLegacyAgents({
|
||||
agentStorage,
|
||||
workspaceRegistry,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
expect((await agentStorage.get("legacy-agent"))?.workspaceId).toBe("ws-app");
|
||||
});
|
||||
|
||||
test("leaves already-stamped records untouched", async () => {
|
||||
await workspaceRegistry.upsert(workspaceRecord("/tmp/repo", "ws-cwd"));
|
||||
await agentStorage.upsert({
|
||||
id: "stamped-agent",
|
||||
provider: "codex",
|
||||
cwd: "/tmp/repo",
|
||||
workspaceId: "ws-explicit",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
lastActivityAt: "2026-03-01T12:00:00.000Z",
|
||||
lastUserMessageAt: null,
|
||||
title: null,
|
||||
labels: {},
|
||||
lastStatus: "closed",
|
||||
lastModeId: null,
|
||||
config: null,
|
||||
runtimeInfo: { provider: "codex", sessionId: null },
|
||||
persistence: null,
|
||||
archivedAt: null,
|
||||
});
|
||||
|
||||
const migrated = await backfillWorkspaceIdForLegacyAgents({
|
||||
agentStorage,
|
||||
workspaceRegistry,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
expect(migrated).toBe(0);
|
||||
expect((await agentStorage.get("stamped-agent"))?.workspaceId).toBe("ws-explicit");
|
||||
});
|
||||
|
||||
test("does not let the home directory own descendants", async () => {
|
||||
const userHome = homedir();
|
||||
await workspaceRegistry.upsert(workspaceRecord(userHome, "ws-home"));
|
||||
await seedLegacyAgent(path.join(userHome, "repo"), "legacy-agent");
|
||||
|
||||
const migrated = await backfillWorkspaceIdForLegacyAgents({
|
||||
agentStorage,
|
||||
workspaceRegistry,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
expect(migrated).toBe(0);
|
||||
expect((await agentStorage.get("legacy-agent"))?.workspaceId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
// COMPAT(workspaceIdBackfill): one-time legacy backfill, delete after 2026-12-16
|
||||
// once floor >= the release that always stamps workspaceId at create time.
|
||||
//
|
||||
// This is the ONLY place in the codebase that maps a cwd to a workspaceId.
|
||||
// Every other code path treats a record's `workspaceId` field as authoritative
|
||||
// ownership. Legacy agent records persisted before workspaceId stamping have no
|
||||
// owner, so this migration stamps each one with the workspace that owned its
|
||||
// directory at the time it was written. It runs once at startup and writes the
|
||||
// id back to storage, after which all runtime code can assume the field exists.
|
||||
import { homedir } from "node:os";
|
||||
import { resolve, sep } from "node:path";
|
||||
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import type { AgentStorage } from "../agent/agent-storage.js";
|
||||
import type { PersistedWorkspaceRecord, WorkspaceRegistry } from "../workspace-registry.js";
|
||||
|
||||
// Picks the workspace that owned `cwd` for a legacy, unstamped agent record.
|
||||
// Prefers an exact-cwd workspace (oldest wins) and otherwise attributes to the
|
||||
// deepest enclosing workspace directory, never letting the home directory own
|
||||
// descendants. Used only by the one-time backfill below.
|
||||
function resolveLegacyWorkspaceOwner(
|
||||
cwd: string,
|
||||
workspaces: Iterable<PersistedWorkspaceRecord>,
|
||||
): string | null {
|
||||
const normalizedCwd = resolve(cwd);
|
||||
const userHome = resolve(homedir());
|
||||
const activeWorkspaces = Array.from(workspaces).filter((workspace) => !workspace.archivedAt);
|
||||
const exactMatches = activeWorkspaces.filter(
|
||||
(workspace) => resolve(workspace.cwd) === normalizedCwd,
|
||||
);
|
||||
if (exactMatches.length > 0) {
|
||||
return oldestWorkspace(exactMatches).workspaceId;
|
||||
}
|
||||
|
||||
const prefixMatches = activeWorkspaces.filter((workspace) => {
|
||||
const workspaceCwd = resolve(workspace.cwd);
|
||||
if (workspaceCwd === userHome) {
|
||||
return false;
|
||||
}
|
||||
return normalizedCwd === workspaceCwd || normalizedCwd.startsWith(`${workspaceCwd}${sep}`);
|
||||
});
|
||||
if (prefixMatches.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const deepestPrefixLength = Math.max(
|
||||
...prefixMatches.map((workspace) => resolve(workspace.cwd).length),
|
||||
);
|
||||
return oldestWorkspace(
|
||||
prefixMatches.filter((workspace) => resolve(workspace.cwd).length === deepestPrefixLength),
|
||||
).workspaceId;
|
||||
}
|
||||
|
||||
function oldestWorkspace(workspaces: PersistedWorkspaceRecord[]): PersistedWorkspaceRecord {
|
||||
return workspaces.reduce((oldest, candidate) =>
|
||||
candidate.createdAt < oldest.createdAt ? candidate : oldest,
|
||||
);
|
||||
}
|
||||
|
||||
export async function backfillWorkspaceIdForLegacyAgents(options: {
|
||||
agentStorage: AgentStorage;
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
logger: Logger;
|
||||
}): Promise<number> {
|
||||
const workspaceRecords = await options.workspaceRegistry.list();
|
||||
const records = await options.agentStorage.list();
|
||||
let migrated = 0;
|
||||
|
||||
for (const record of records) {
|
||||
if (record.workspaceId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const workspaceId = resolveLegacyWorkspaceOwner(record.cwd, workspaceRecords);
|
||||
if (!workspaceId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await options.agentStorage.upsert({ ...record, workspaceId });
|
||||
migrated += 1;
|
||||
}
|
||||
|
||||
if (migrated > 0) {
|
||||
options.logger.info({ migrated }, "Backfilled workspaceId for legacy agent records");
|
||||
}
|
||||
return migrated;
|
||||
}
|
||||
@@ -27,7 +27,10 @@ export interface ArchivePaseoWorktreeDependencies {
|
||||
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot">;
|
||||
agentManager: Pick<AgentManager, "listAgents" | "archiveAgent" | "archiveSnapshot">;
|
||||
agentStorage: Pick<AgentStorage, "list">;
|
||||
resolveWorkspaceIdForCwd: (cwd: string) => Promise<string | null>;
|
||||
// Resolves the worktree at a path to its workspaceId for archive-by-path. The
|
||||
// path uniquely identifies a worktree workspace; this is a directory lookup for
|
||||
// the archive target, not status/ownership.
|
||||
findWorkspaceIdForCwd: (cwd: string) => Promise<string | null>;
|
||||
// Active (non-archived) workspaces, used to decide whether the workspace being
|
||||
// archived is the last reference to its backing worktree directory, and to
|
||||
// break a same-cwd tie in favor of the worktree-kind record when archiving by
|
||||
@@ -150,7 +153,7 @@ export async function archivePaseoWorktree(
|
||||
async function resolveTargetWorkspaceId(
|
||||
dependencies: Pick<
|
||||
ArchivePaseoWorktreeDependencies,
|
||||
"resolveWorkspaceIdForCwd" | "listActiveWorkspaces"
|
||||
"findWorkspaceIdForCwd" | "listActiveWorkspaces"
|
||||
>,
|
||||
targetPath: string,
|
||||
): Promise<string | null> {
|
||||
@@ -162,7 +165,7 @@ async function resolveTargetWorkspaceId(
|
||||
if (worktreeMatch) {
|
||||
return worktreeMatch.workspaceId;
|
||||
}
|
||||
return dependencies.resolveWorkspaceIdForCwd(targetPath);
|
||||
return dependencies.findWorkspaceIdForCwd(targetPath);
|
||||
}
|
||||
|
||||
export type ArchiveWorkspaceContentsDependencies = Pick<
|
||||
|
||||
@@ -162,8 +162,8 @@ import {
|
||||
deriveProjectGroupingName,
|
||||
deriveWorkspaceDisplayName,
|
||||
generateWorkspaceId,
|
||||
resolveWorkspaceIdForRecord,
|
||||
} from "./workspace-registry-model.js";
|
||||
import { resolveWorkspaceIdForPath } from "./workspace-ownership.js";
|
||||
import {
|
||||
createPersistedProjectRecord,
|
||||
createPersistedWorkspaceRecord,
|
||||
@@ -241,6 +241,7 @@ import {
|
||||
} from "../services/github-service.js";
|
||||
import {
|
||||
summarizeFetchWorkspacesEntries,
|
||||
workspaceIdsOnCheckout,
|
||||
WorkspaceDirectory,
|
||||
type WorkspaceUpdatesFilter,
|
||||
} from "./workspace-directory.js";
|
||||
@@ -252,10 +253,6 @@ import {
|
||||
type CreatePaseoWorktreeInput,
|
||||
type CreatePaseoWorktreeResult,
|
||||
} from "./paseo-worktree-service.js";
|
||||
import {
|
||||
migrateLegacyAgentWorkspaceOwnership,
|
||||
resolveLegacyWorkspaceOwnerForCwd,
|
||||
} from "./workspace-registry-bootstrap.js";
|
||||
import {
|
||||
generateBranchNameFromFirstAgentContext,
|
||||
type GeneratedWorkspaceName,
|
||||
@@ -839,9 +836,6 @@ export class Session {
|
||||
private readonly github: GitHubService;
|
||||
private readonly renameCurrentBranch: typeof renameCurrentBranchDefault;
|
||||
private readonly generateWorkspaceName: typeof generateBranchNameFromFirstAgentContext;
|
||||
// Resolves when the most recently scheduled background workspace-naming write
|
||||
// completes. Tests await this (via the getter below) instead of sleeping.
|
||||
private pendingWorkspaceNaming: Promise<void> = Promise.resolve();
|
||||
private readonly workspaceGitService: WorkspaceGitService;
|
||||
private readonly daemonConfigStore: DaemonConfigStore;
|
||||
private readonly mcpBaseUrl: string | null;
|
||||
@@ -1006,7 +1000,7 @@ export class Session {
|
||||
createPaseoWorktreeWorkflow: (input, workflowOptions) =>
|
||||
this.createPaseoWorktreeWorkflow(input, workflowOptions),
|
||||
archiveAgentForClose: (agentId) => this.archiveAgentForClose(agentId),
|
||||
resolveWorkspaceIdForCwd: (cwd) => this.resolveWorkspaceIdForCwd(cwd),
|
||||
findWorkspaceIdForCwd: (cwd) => this.findWorkspaceIdForCwd(cwd),
|
||||
listActiveWorkspaces: () => this.listActiveWorkspaceRefs(),
|
||||
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
|
||||
emit: (message) => this.emit(message),
|
||||
@@ -1123,10 +1117,6 @@ export class Session {
|
||||
await this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds);
|
||||
}
|
||||
|
||||
async emitWorkspaceUpdatesForExternalCwds(cwds: Iterable<string>): Promise<void> {
|
||||
await Promise.all(Array.from(cwds, (cwd) => this.emitWorkspaceUpdateForCwd(cwd)));
|
||||
}
|
||||
|
||||
async warmWorkspaceGitDataForWorkspace(workspace: PersistedWorkspaceRecord): Promise<void> {
|
||||
await this.syncWorkspaceGitObserverForWorkspace(workspace);
|
||||
await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId);
|
||||
@@ -1664,7 +1654,7 @@ export class Session {
|
||||
): Promise<PersistedWorkspaceRecord | null> {
|
||||
const normalizedCwd = await this.resolveWorkspaceDirectory(cwd, options);
|
||||
const workspaces = await this.workspaceRegistry.list();
|
||||
const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(normalizedCwd, workspaces);
|
||||
const workspaceId = resolveWorkspaceIdForPath(normalizedCwd, workspaces);
|
||||
if (!workspaceId) {
|
||||
return null;
|
||||
}
|
||||
@@ -1778,7 +1768,11 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
await this.emitWorkspaceUpdateForCwd(payload.cwd);
|
||||
// A lifecycle change updates exactly the agent's owning workspace, never
|
||||
// every workspace sharing its cwd. Ownership is the agent's workspaceId.
|
||||
if (payload.workspaceId) {
|
||||
await this.emitWorkspaceUpdateForWorkspaceId(payload.workspaceId);
|
||||
}
|
||||
} catch (error) {
|
||||
this.sessionLogger.error({ err: error }, "Failed to emit agent update");
|
||||
}
|
||||
@@ -2403,9 +2397,9 @@ export class Session {
|
||||
private async handleDeleteAgentRequest(agentId: string, requestId: string): Promise<void> {
|
||||
this.sessionLogger.info({ agentId }, `Deleting agent ${agentId} from registry`);
|
||||
|
||||
const knownCwd =
|
||||
this.agentManager.getAgent(agentId)?.cwd ??
|
||||
(await this.agentStorage.get(agentId))?.cwd ??
|
||||
const knownWorkspaceId =
|
||||
this.agentManager.getAgent(agentId)?.workspaceId ??
|
||||
(await this.agentStorage.get(agentId))?.workspaceId ??
|
||||
null;
|
||||
|
||||
// File-backed storage still needs an early delete fence before closeAgent().
|
||||
@@ -2446,8 +2440,8 @@ export class Session {
|
||||
});
|
||||
}
|
||||
|
||||
if (knownCwd) {
|
||||
await this.emitWorkspaceUpdateForCwd(knownCwd);
|
||||
if (knownWorkspaceId) {
|
||||
await this.emitWorkspaceUpdateForWorkspaceId(knownWorkspaceId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2506,7 +2500,9 @@ export class Session {
|
||||
agentId,
|
||||
});
|
||||
}
|
||||
await this.emitWorkspaceUpdateForCwd(payload.cwd);
|
||||
if (payload.workspaceId) {
|
||||
await this.emitWorkspaceUpdateForWorkspaceId(payload.workspaceId);
|
||||
}
|
||||
}
|
||||
|
||||
return { agentId, archivedAt };
|
||||
@@ -3286,10 +3282,13 @@ export class Session {
|
||||
const createAgentConfig: AgentSessionConfig = createdWorktree
|
||||
? { ...config, cwd: createdWorktree.worktree.worktreePath }
|
||||
: config;
|
||||
// Ownership comes from an explicit id (worktree or request). An agent
|
||||
// created with no explicit workspace mints a fresh one — we never resolve
|
||||
// an existing workspace by cwd, because many workspaces may share a cwd.
|
||||
const workspaceId =
|
||||
createdWorktree?.workspace.workspaceId ??
|
||||
msg.workspaceId ??
|
||||
(await this.resolveLegacyWorkspaceOwnerForAgentCreate(createAgentConfig.cwd));
|
||||
(await this.createWorkspaceForDirectory(createAgentConfig.cwd)).workspaceId;
|
||||
|
||||
const { snapshot, liveSnapshot } = await createAgentCommand(
|
||||
{
|
||||
@@ -3324,11 +3323,6 @@ export class Session {
|
||||
);
|
||||
createdAgentId = snapshot.id;
|
||||
await this.forwardAgentUpdate(snapshot);
|
||||
this.createAgentLifecycleDispatch.registerAutoArchiveIfRequested({
|
||||
autoArchive,
|
||||
agentId: snapshot.id,
|
||||
createdWorktree,
|
||||
});
|
||||
if (!createdWorktree && trimmedPrompt) {
|
||||
await this.scheduleAutoNameLocalWorkspaceTitleForFirstAgent({
|
||||
workspaceId,
|
||||
@@ -3336,6 +3330,11 @@ export class Session {
|
||||
firstAgentContext,
|
||||
});
|
||||
}
|
||||
this.createAgentLifecycleDispatch.registerAutoArchiveIfRequested({
|
||||
autoArchive,
|
||||
agentId: snapshot.id,
|
||||
createdWorktree,
|
||||
});
|
||||
if (requestId) {
|
||||
const agentPayload = await this.buildAgentPayload(liveSnapshot);
|
||||
this.emit({
|
||||
@@ -3483,8 +3482,15 @@ export class Session {
|
||||
);
|
||||
|
||||
try {
|
||||
if (!normalized.cwd) {
|
||||
throw new Error("Import requires cwd from the selected provider session");
|
||||
}
|
||||
// An imported agent mints its own workspace; ownership is its workspaceId,
|
||||
// never an existing same-cwd workspace resolved by path.
|
||||
const workspace = await this.createWorkspaceForDirectory(normalized.cwd);
|
||||
const { snapshot, timelineSize } = await importProviderSession({
|
||||
request: normalized,
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentManager: this.agentManager,
|
||||
agentStorage: this.agentStorage,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
@@ -3493,7 +3499,7 @@ export class Session {
|
||||
paseoHome: this.paseoHome,
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
await this.registerWorkspaceForImportedAgent(snapshot.cwd);
|
||||
await this.registerWorkspaceForImportedAgent(workspace);
|
||||
const agentPayload = await this.buildAgentPayload(snapshot);
|
||||
this.emit({
|
||||
type: "status",
|
||||
@@ -3718,7 +3724,7 @@ export class Session {
|
||||
firstAgentContext: FirstAgentContext;
|
||||
}): Promise<void> {
|
||||
// Capture the generated title from the generator callback so we can write
|
||||
// displayName := title after the branch rename completes.
|
||||
// title := generated title after the branch rename completes.
|
||||
let generatedTitle: string | null = null;
|
||||
const result = await attemptFirstAgentBranchAutoName({
|
||||
cwd: input.workspace.cwd,
|
||||
@@ -3755,10 +3761,10 @@ export class Session {
|
||||
await this.emitWorkspaceUpdateForCwd(input.workspace.cwd);
|
||||
}
|
||||
|
||||
// applyGeneratedWorkspaceTitle writes displayName := title (and, for a worktree
|
||||
// rename, the new branch) for a workspace. It re-reads the current record from
|
||||
// the registry so concurrent upserts that happened after workspace creation are
|
||||
// not clobbered, and writes only its own fields (K4 fix).
|
||||
// applyGeneratedWorkspaceTitle fills the generated title only while the
|
||||
// workspace is still untitled. It re-reads the current record from the
|
||||
// registry so concurrent upserts that happened after workspace creation are
|
||||
// not clobbered, while still persisting branch metadata from the rename path.
|
||||
private async applyGeneratedWorkspaceTitle(
|
||||
workspaceId: string,
|
||||
input: { title: string; branch?: string | null },
|
||||
@@ -3769,7 +3775,7 @@ export class Session {
|
||||
}
|
||||
await this.workspaceRegistry.upsert({
|
||||
...current,
|
||||
displayName: input.title,
|
||||
title: current.title || input.title,
|
||||
...(input.branch ? { branch: input.branch } : {}),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
@@ -3812,18 +3818,15 @@ export class Session {
|
||||
// K4: applyGeneratedWorkspaceTitle re-reads from the registry before writing.
|
||||
// Directory workspaces have no branch — write only the title.
|
||||
await this.applyGeneratedWorkspaceTitle(input.workspaceId, { title });
|
||||
await this.emitWorkspaceUpdateForCwd(input.cwd);
|
||||
await this.emitWorkspaceUpdateForWorkspaceId(input.workspaceId);
|
||||
}
|
||||
|
||||
private async scheduleAutoNameLocalWorkspaceTitleForFirstAgent(input: {
|
||||
workspaceId?: string;
|
||||
workspaceId: string;
|
||||
cwd: string;
|
||||
firstAgentContext: FirstAgentContext;
|
||||
}): Promise<void> {
|
||||
const workspaceId = input.workspaceId ?? (await this.resolveWorkspaceIdForCwd(input.cwd));
|
||||
if (!workspaceId) {
|
||||
return;
|
||||
}
|
||||
const workspaceId = input.workspaceId;
|
||||
this.scheduleWorkspaceNaming(
|
||||
() =>
|
||||
this.maybeAutoNameDirectoryWorkspaceTitle({
|
||||
@@ -5348,29 +5351,10 @@ export class Session {
|
||||
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
||||
this.handleWorkspaceGitBranchSnapshot(cwd, result.currentBranch);
|
||||
|
||||
// K3: persist the new git branch for all workspaces at this cwd. We write
|
||||
// only branch+updatedAt — displayName holds the human title and title is the
|
||||
// user override; a branch rename must touch neither (name and branch are
|
||||
// decoupled fields by construction).
|
||||
// Branch is a git fact derived per-descriptor from each workspace's own
|
||||
// live git snapshot (id → cwd); the reconciliation pass re-persists the
|
||||
// `branch` field per workspace from its own cwd. No cwd → ids fan-out here.
|
||||
// TODO(K10): PR-binding on branch rename is deferred — see plan K10.
|
||||
if (result.currentBranch) {
|
||||
const allWorkspaces = await this.workspaceRegistry.list();
|
||||
const workspaceIds = this.workspaceDirectory.resolveRegisteredWorkspaceIdsForCwd(
|
||||
cwd,
|
||||
allWorkspaces,
|
||||
);
|
||||
await Promise.all(
|
||||
workspaceIds.map(async (workspaceId) => {
|
||||
const current = await this.workspaceRegistry.get(workspaceId);
|
||||
if (!current) return;
|
||||
await this.workspaceRegistry.upsert({
|
||||
...current,
|
||||
branch: result.currentBranch as string,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Push a workspace_update immediately so the sidebar/header reflect
|
||||
// the new branch name without waiting for the background git watcher.
|
||||
@@ -6074,7 +6058,7 @@ export class Session {
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
agentManager: this.agentManager,
|
||||
agentStorage: this.agentStorage,
|
||||
resolveWorkspaceIdForCwd: (cwd) => this.resolveWorkspaceIdForCwd(cwd),
|
||||
findWorkspaceIdForCwd: (cwd) => this.findWorkspaceIdForCwd(cwd),
|
||||
listActiveWorkspaces: () => this.listActiveWorkspaceRefs(),
|
||||
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
|
||||
emit: (message) => this.emit(message),
|
||||
@@ -6822,16 +6806,13 @@ export class Session {
|
||||
return this.workspaceDirectory.buildDescriptorMap(options);
|
||||
}
|
||||
|
||||
private resolveRegisteredWorkspaceIdForCwd(
|
||||
cwd: string,
|
||||
workspaces: PersistedWorkspaceRecord[],
|
||||
): string | null {
|
||||
return this.workspaceDirectory.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces);
|
||||
}
|
||||
|
||||
private async resolveWorkspaceIdForCwd(cwd: string): Promise<string | null> {
|
||||
// external path→workspace adapter, not ownership. Used by archive-by-path flows
|
||||
// where the request carries a worktree path (unique to one workspace) rather
|
||||
// than a workspaceId. This is a directory lookup for an archive target, not a
|
||||
// status/ownership attribution.
|
||||
private async findWorkspaceIdForCwd(cwd: string): Promise<string | null> {
|
||||
const workspaces = await this.workspaceRegistry.list();
|
||||
return this.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces);
|
||||
return resolveWorkspaceIdForPath(cwd, workspaces);
|
||||
}
|
||||
|
||||
private matchesWorkspaceFilter(input: {
|
||||
@@ -7324,32 +7305,21 @@ export class Session {
|
||||
private async emitWorkspaceUpdateForTerminalContribution(
|
||||
event: TerminalWorkspaceContributionChangedEvent,
|
||||
): Promise<void> {
|
||||
if (event.workspaceId) {
|
||||
const workspaces = await this.workspaceRegistry.list();
|
||||
const workspaceId = resolveWorkspaceIdForRecord(
|
||||
{ workspaceId: event.workspaceId, cwd: event.cwd },
|
||||
workspaces,
|
||||
);
|
||||
const owningWorkspace = workspaceId
|
||||
? workspaces.find(
|
||||
(workspace) => workspace.workspaceId === workspaceId && !workspace.archivedAt,
|
||||
)
|
||||
: null;
|
||||
if (!owningWorkspace) {
|
||||
return;
|
||||
}
|
||||
const workspaceIds = this.workspaceDirectory.resolveRegisteredWorkspaceIdsForCwd(
|
||||
owningWorkspace.cwd,
|
||||
workspaces,
|
||||
);
|
||||
await this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds, {
|
||||
skipReconcile: true,
|
||||
});
|
||||
// A terminal's activity contributes only to the workspace it carries. A
|
||||
// terminal with no workspaceId attributes to nothing — status is per-id.
|
||||
if (!event.workspaceId) {
|
||||
return;
|
||||
}
|
||||
await this.emitWorkspaceUpdateForCwd(event.cwd, { skipReconcile: true });
|
||||
await this.emitWorkspaceUpdatesForWorkspaceIds([event.workspaceId], {
|
||||
skipReconcile: true,
|
||||
});
|
||||
}
|
||||
|
||||
// A git fact (branch, diff, dirty, PR) changed at `cwd`. Every workspace whose
|
||||
// OWN cwd is this folder re-derives its git facts from that folder (id → cwd)
|
||||
// and emits its own per-id descriptor. This is a deliberate same-folder fan,
|
||||
// not a cwd → id ownership lookup: git never resolves which workspace owns a
|
||||
// path. See `workspaceIdsOnCheckout`.
|
||||
private async emitWorkspaceUpdateForCwd(
|
||||
cwd: string,
|
||||
options?: {
|
||||
@@ -7357,11 +7327,7 @@ export class Session {
|
||||
dedupeGitState?: boolean;
|
||||
},
|
||||
): Promise<void> {
|
||||
const workspaces = await this.workspaceRegistry.list();
|
||||
const workspaceIds = this.workspaceDirectory.resolveRegisteredWorkspaceIdsForCwd(
|
||||
cwd,
|
||||
workspaces,
|
||||
);
|
||||
const workspaceIds = workspaceIdsOnCheckout(await this.workspaceRegistry.list(), cwd);
|
||||
if (workspaceIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -7594,15 +7560,16 @@ export class Session {
|
||||
return { snapshotByWorkspaceId };
|
||||
}
|
||||
|
||||
private async registerWorkspaceForImportedAgent(cwd: string): Promise<void> {
|
||||
private async registerWorkspaceForImportedAgent(
|
||||
workspace: PersistedWorkspaceRecord,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const workspace = await this.findOrCreateWorkspaceForDirectory(cwd);
|
||||
await this.syncWorkspaceGitObserverForWorkspace(workspace);
|
||||
await this.describeWorkspaceRecord(workspace);
|
||||
await this.emitWorkspaceUpdateForCwd(workspace.cwd);
|
||||
await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId);
|
||||
} catch (error) {
|
||||
this.sessionLogger.warn(
|
||||
{ err: error, cwd },
|
||||
{ err: error, workspaceId: workspace.workspaceId, cwd: workspace.cwd },
|
||||
"Failed to register workspace for imported agent",
|
||||
);
|
||||
}
|
||||
@@ -7658,13 +7625,6 @@ export class Session {
|
||||
return;
|
||||
}
|
||||
|
||||
await migrateLegacyAgentWorkspaceOwnership({
|
||||
agentStorage: this.agentStorage,
|
||||
workspaceRegistry: this.workspaceRegistry,
|
||||
logger: this.sessionLogger,
|
||||
cwds: [cwd],
|
||||
});
|
||||
|
||||
const workspace = await createLocalCheckoutWorkspace(
|
||||
{ cwd, title: request.title ?? null },
|
||||
{
|
||||
@@ -7684,7 +7644,7 @@ export class Session {
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
await this.emitWorkspaceUpdateForCwd(workspace.cwd);
|
||||
await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId);
|
||||
void this.workspaceGitService
|
||||
.getSnapshot(workspace.cwd, { force: true, includeGitHub: true, reason: "open_project" })
|
||||
.catch((error) => {
|
||||
@@ -7693,7 +7653,7 @@ export class Session {
|
||||
"Background snapshot refresh failed after workspace.create",
|
||||
);
|
||||
});
|
||||
if (request.firstAgentContext?.prompt) {
|
||||
if (request.firstAgentContext) {
|
||||
const firstAgentContext = request.firstAgentContext;
|
||||
this.scheduleWorkspaceNaming(
|
||||
() =>
|
||||
@@ -7707,34 +7667,17 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveLegacyWorkspaceOwnerForAgentCreate(
|
||||
cwd: string,
|
||||
): Promise<string | undefined> {
|
||||
return resolveLegacyWorkspaceOwnerForCwd(cwd, await this.workspaceRegistry.list()) ?? undefined;
|
||||
}
|
||||
|
||||
// Schedules a background workspace-naming write off the request path and
|
||||
// records the resulting promise so tests can await completion deterministically
|
||||
// (no wall-clock sleeps). The setTimeout(0) keeps the LLM call off the hot path.
|
||||
// Schedules a background workspace-naming write off the request path. The
|
||||
// setTimeout(0) keeps the LLM call off the hot path.
|
||||
private scheduleWorkspaceNaming(
|
||||
run: () => Promise<void>,
|
||||
context: { cwd: string; message: string },
|
||||
): void {
|
||||
this.pendingWorkspaceNaming = new Promise<void>((resolveNaming) => {
|
||||
setTimeout(() => {
|
||||
void run()
|
||||
.catch((error) => {
|
||||
this.sessionLogger.warn({ err: error, cwd: context.cwd }, context.message);
|
||||
})
|
||||
.finally(resolveNaming);
|
||||
}, 0);
|
||||
});
|
||||
}
|
||||
|
||||
// Test-only handle: resolves when the most recent scheduled workspace-naming
|
||||
// write finishes, so tests await real completion instead of a wall-clock sleep.
|
||||
get pendingWorkspaceNamingForTests(): Promise<void> {
|
||||
return this.pendingWorkspaceNaming;
|
||||
setTimeout(() => {
|
||||
void run().catch((error) => {
|
||||
this.sessionLogger.warn({ err: error, cwd: context.cwd }, context.message);
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
|
||||
private async handleWorkspaceCreateWorktree(
|
||||
@@ -7855,7 +7798,7 @@ export class Session {
|
||||
const project = await this.projectRegistry.get(workspace.projectId);
|
||||
await this.syncWorkspaceGitObserverForWorkspace(workspace);
|
||||
const descriptor = await this.describeWorkspaceRecord(workspace);
|
||||
await this.emitWorkspaceUpdateForCwd(workspace.cwd);
|
||||
await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId);
|
||||
this.sessionLogger.info(
|
||||
{
|
||||
requestedCwd,
|
||||
@@ -8078,8 +8021,8 @@ export class Session {
|
||||
warmWorkspaceGitData: (workspace) => this.warmWorkspaceGitDataForWorkspace(workspace),
|
||||
autoNameWorkspaceBranchForFirstAgent: (autoNameInput) =>
|
||||
this.scheduleAutoNameWorkspaceBranchForFirstAgent(autoNameInput),
|
||||
emitWorkspaceUpdateForCwd: (cwd, emitOptions) =>
|
||||
this.emitWorkspaceUpdateForCwd(cwd, emitOptions),
|
||||
emitWorkspaceUpdateForWorkspaceId: (workspaceId) =>
|
||||
this.emitWorkspaceUpdateForWorkspaceId(workspaceId),
|
||||
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => {
|
||||
this.workspaceSetupSnapshots.set(workspaceId, snapshot);
|
||||
},
|
||||
@@ -8145,7 +8088,7 @@ export class Session {
|
||||
} finally {
|
||||
this.clearWorkspaceArchiving([existing.workspaceId]);
|
||||
}
|
||||
await this.emitWorkspaceUpdateForCwd(existing.cwd);
|
||||
await this.emitWorkspaceUpdateForWorkspaceId(existing.workspaceId);
|
||||
this.emit({
|
||||
type: "archive_workspace_response",
|
||||
payload: {
|
||||
@@ -8217,10 +8160,12 @@ export class Session {
|
||||
throw new Error(`Workspace not found: ${requestedWorkspaceId}`);
|
||||
}
|
||||
|
||||
const workspaceCwd = resolve(workspace.cwd);
|
||||
// Clearing attention is scoped to the workspace that OWNS the agent, by
|
||||
// workspaceId — never by comparing cwd strings. A sibling workspace
|
||||
// sharing the same directory keeps its own agents' attention.
|
||||
const clearableAgentIds = agents
|
||||
.filter((agent) => !agent.archivedAt)
|
||||
.filter((agent) => resolve(agent.cwd) === workspaceCwd)
|
||||
.filter((agent) => agent.workspaceId === workspace.workspaceId)
|
||||
.filter((agent) => agent.requiresAttention === true)
|
||||
.filter((agent) => (agent.pendingPermissions?.length ?? 0) === 0)
|
||||
.filter((agent) => agent.attentionReason !== "permission")
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Session } from "./session.js";
|
||||
@@ -119,10 +119,6 @@ interface SessionTestAccess {
|
||||
getAgentPayloadById(agentId: string): Promise<unknown>;
|
||||
buildProjectPlacementForCwd(cwd: string): Promise<unknown>;
|
||||
buildProjectPlacement(cwd: string): Promise<unknown>;
|
||||
resolveRegisteredWorkspaceIdForCwd(
|
||||
cwd: string,
|
||||
workspaces: ReturnType<typeof createPersistedWorkspaceRecord>[],
|
||||
): string;
|
||||
buildWorkspaceDescriptorMap(...args: unknown[]): Promise<Map<string, unknown>>;
|
||||
describeWorkspaceRecord(...args: unknown[]): Promise<unknown>;
|
||||
describeWorkspaceRecordWithGitData(...args: unknown[]): Promise<unknown>;
|
||||
@@ -134,7 +130,6 @@ interface SessionTestAccess {
|
||||
workspaceId: string,
|
||||
input: { title: string; branch?: string | null },
|
||||
): Promise<void>;
|
||||
pendingWorkspaceNamingForTests: Promise<void>;
|
||||
emit(message: unknown): void;
|
||||
onMessage(message: unknown): void;
|
||||
paseoHome: string;
|
||||
@@ -783,142 +778,6 @@ test("create_agent_request keeps requested child cwd when grouped under an exist
|
||||
}
|
||||
});
|
||||
|
||||
test("create_agent_request with an initial prompt renames the local workspace title", async () => {
|
||||
const workdir = mkdtempSync(path.join(tmpdir(), "paseo-create-agent-local-title-"));
|
||||
try {
|
||||
const repoDir = path.join(workdir, "repo");
|
||||
mkdirSync(repoDir, { recursive: true });
|
||||
|
||||
const logger = {
|
||||
child: () => logger,
|
||||
trace: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const agentStorage = new AgentStorage(path.join(workdir, "agents"), asSessionLogger(logger));
|
||||
const agentManager = new AgentManager({
|
||||
clients: { codex: new CreateAgentTestClient() },
|
||||
registry: agentStorage,
|
||||
logger: asSessionLogger(logger),
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000552",
|
||||
});
|
||||
const projectRegistry = new FileBackedProjectRegistry(
|
||||
path.join(workdir, "projects.json"),
|
||||
asSessionLogger(logger),
|
||||
);
|
||||
const workspaceRegistry = new FileBackedWorkspaceRegistry(
|
||||
path.join(workdir, "workspaces.json"),
|
||||
asSessionLogger(logger),
|
||||
);
|
||||
const workspaceGitService = createNoopWorkspaceGitService({
|
||||
getCheckout: async (cwd: string) => ({
|
||||
cwd,
|
||||
isGit: true,
|
||||
currentBranch: "main",
|
||||
remoteUrl: null,
|
||||
worktreeRoot: repoDir,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
}),
|
||||
});
|
||||
|
||||
await projectRegistry.upsert(
|
||||
createPersistedProjectRecord({
|
||||
projectId: "proj-local-title",
|
||||
rootPath: repoDir,
|
||||
kind: "git",
|
||||
displayName: "repo",
|
||||
createdAt: "2026-05-07T00:00:00.000Z",
|
||||
updatedAt: "2026-05-07T00:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
await workspaceRegistry.upsert(
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-local-title",
|
||||
projectId: "proj-local-title",
|
||||
cwd: repoDir,
|
||||
kind: "local_checkout",
|
||||
displayName: "main",
|
||||
branch: "main",
|
||||
createdAt: "2026-05-07T00:00:00.000Z",
|
||||
updatedAt: "2026-05-07T00:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = asTestSession(
|
||||
new Session({
|
||||
clientId: "test-client",
|
||||
appVersion: null,
|
||||
onMessage: (message) => emitted.push(message),
|
||||
logger: asSessionLogger(logger),
|
||||
downloadTokenStore: asDownloadTokenStore(),
|
||||
pushTokenStore: asPushTokenStore(),
|
||||
paseoHome: path.join(workdir, "paseo-home"),
|
||||
agentManager,
|
||||
agentStorage,
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
chatService: asChatService(),
|
||||
scheduleService: asScheduleService(),
|
||||
loopService: asLoopService(),
|
||||
checkoutDiffManager: asCheckoutDiffManager({
|
||||
subscribe: async () => ({
|
||||
initial: { cwd: repoDir, files: [], error: null },
|
||||
unsubscribe: () => {},
|
||||
}),
|
||||
scheduleRefreshForCwd: () => {},
|
||||
onWorkspaceStateMayHaveChanged: () => {},
|
||||
getMetrics: () => ({
|
||||
checkoutDiffTargetCount: 0,
|
||||
checkoutDiffSubscriptionCount: 0,
|
||||
checkoutDiffWatcherCount: 0,
|
||||
checkoutDiffFallbackRefreshTargetCount: 0,
|
||||
}),
|
||||
dispose: () => {},
|
||||
}),
|
||||
workspaceGitService,
|
||||
generateWorkspaceName: async () => ({
|
||||
title: "Investigate Agent Health",
|
||||
branch: null,
|
||||
}),
|
||||
daemonConfigStore: asDaemonConfigStore({
|
||||
get: () => ({ mcp: { injectIntoAgents: false }, providers: {} }),
|
||||
onChange: () => () => {},
|
||||
}),
|
||||
mcpBaseUrl: null,
|
||||
stt: null,
|
||||
tts: null,
|
||||
providerSnapshotManager: createProviderSnapshotManagerStub().manager,
|
||||
terminalManager: null,
|
||||
}),
|
||||
);
|
||||
|
||||
await session.handleMessage({
|
||||
type: "create_agent_request",
|
||||
requestId: "req-create-local-title",
|
||||
workspaceId: "ws-local-title",
|
||||
config: { provider: "codex", cwd: repoDir },
|
||||
initialPrompt: "Investigate the agent health dashboard",
|
||||
attachments: [],
|
||||
});
|
||||
await session.pendingWorkspaceNamingForTests;
|
||||
|
||||
expect(findByType(emitted, "status")?.payload).toMatchObject({
|
||||
status: "agent_created",
|
||||
agent: { workspaceId: "ws-local-title" },
|
||||
});
|
||||
await expect(workspaceRegistry.get("ws-local-title")).resolves.toMatchObject({
|
||||
displayName: "Investigate Agent Health",
|
||||
branch: "main",
|
||||
});
|
||||
} finally {
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("unsupported persisted agents are excluded from active lists but preserved in history payloads", async () => {
|
||||
const session = createSessionForWorkspaceTests({ appVersion: "0.1.45" });
|
||||
const storedRecord = {
|
||||
@@ -1350,6 +1209,7 @@ test("workspace clear attention clears stored-only agents and responds", async (
|
||||
makeAgent({
|
||||
id: storedRecord.id,
|
||||
cwd: storedRecord.cwd,
|
||||
workspaceId: workspace.workspaceId,
|
||||
status: "closed",
|
||||
updatedAt: storedRecord.updatedAt,
|
||||
requiresAttention: true,
|
||||
@@ -1458,16 +1318,18 @@ test("workspace clear attention can clear multiple workspaces in one request", a
|
||||
storedRecords.set(storedRecord.id, storedRecord);
|
||||
};
|
||||
session.listAgentPayloads = async () =>
|
||||
Array.from(storedRecords.values()).map((record) =>
|
||||
makeAgent({
|
||||
Array.from(storedRecords.values()).map((record) => {
|
||||
const owner = workspaces.find((workspace) => workspace.cwd === record.cwd);
|
||||
return makeAgent({
|
||||
id: record.id,
|
||||
cwd: record.cwd,
|
||||
...(owner ? { workspaceId: owner.workspaceId } : {}),
|
||||
status: "closed",
|
||||
updatedAt: record.updatedAt,
|
||||
requiresAttention: record.requiresAttention,
|
||||
attentionReason: record.attentionReason,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
type: "workspace.clear_attention.request",
|
||||
@@ -2714,18 +2576,21 @@ test("branch/detached policies and dominant status bucket are deterministic", as
|
||||
makeAgent({
|
||||
id: "a1",
|
||||
cwd: REPO_CWD,
|
||||
workspaceId: "ws-repo-status",
|
||||
status: "running",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
}),
|
||||
makeAgent({
|
||||
id: "a2",
|
||||
cwd: REPO_CWD,
|
||||
workspaceId: "ws-repo-status",
|
||||
status: "error",
|
||||
updatedAt: "2026-03-01T12:01:00.000Z",
|
||||
}),
|
||||
makeAgent({
|
||||
id: "a3",
|
||||
cwd: REPO_CWD,
|
||||
workspaceId: "ws-repo-status",
|
||||
status: "idle",
|
||||
updatedAt: "2026-03-01T12:02:00.000Z",
|
||||
pendingPermissions: 1,
|
||||
@@ -2741,7 +2606,7 @@ test("branch/detached policies and dominant status bucket are deterministic", as
|
||||
expect(result.entries[0]?.status).toBe("needs_input");
|
||||
});
|
||||
|
||||
test("subdirectory agents map to an existing parent workspace descriptor", async () => {
|
||||
test("subdirectory agents contribute to their owning workspace descriptor", async () => {
|
||||
const session = createSessionForWorkspaceTests();
|
||||
session.workspaceRegistry.list = async () => [
|
||||
createPersistedWorkspaceRecord({
|
||||
@@ -2754,10 +2619,13 @@ test("subdirectory agents map to an existing parent workspace descriptor", async
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
}),
|
||||
];
|
||||
// The agent runs in a subdirectory but carries its owning workspaceId; the
|
||||
// subdir cwd is cosmetic and never drives attribution.
|
||||
session.listAgentPayloads = async () => [
|
||||
makeAgent({
|
||||
id: "a1",
|
||||
cwd: "/tmp/repo/packages/app",
|
||||
workspaceId: "ws-repo-subdir",
|
||||
status: "running",
|
||||
updatedAt: "2026-03-01T12:03:00.000Z",
|
||||
}),
|
||||
@@ -2771,7 +2639,7 @@ test("subdirectory agents map to an existing parent workspace descriptor", async
|
||||
expect(result.entries).toHaveLength(1);
|
||||
expect(result.entries[0]).toMatchObject({
|
||||
id: "ws-repo-subdir",
|
||||
status: "done",
|
||||
status: "running",
|
||||
activityAt: null,
|
||||
});
|
||||
});
|
||||
@@ -4543,13 +4411,17 @@ test("buildWorkspaceDescriptorMap computes statusEnteredAt from runtime agent fi
|
||||
expect(descriptor.statusEnteredAt).toBeNull();
|
||||
}
|
||||
|
||||
// Agents own the workspace by id; cwd is incidental.
|
||||
const owned = (input: Parameters<typeof makeAgent>[0]) =>
|
||||
makeAgent({ ...input, workspaceId: "ws-status-entered" });
|
||||
|
||||
// 2. Single idle agent (derives to "done") — statusEnteredAt uses the
|
||||
// agent's updatedAt as a best-effort timestamp.
|
||||
{
|
||||
const { session, workspace } = setupSession();
|
||||
const updatedAt = "2026-05-12T09:30:00.000Z";
|
||||
session.listAgentPayloads = async () => [
|
||||
makeAgent({
|
||||
owned({
|
||||
id: "agent-done",
|
||||
cwd: workspace.cwd,
|
||||
status: "idle",
|
||||
@@ -4567,7 +4439,7 @@ test("buildWorkspaceDescriptorMap computes statusEnteredAt from runtime agent fi
|
||||
const { session, workspace } = setupSession();
|
||||
const updatedAt = "2026-05-12T09:45:00.000Z";
|
||||
session.listAgentPayloads = async () => [
|
||||
makeAgent({
|
||||
owned({
|
||||
id: "agent-initializing",
|
||||
cwd: workspace.cwd,
|
||||
status: "initializing",
|
||||
@@ -4588,19 +4460,19 @@ test("buildWorkspaceDescriptorMap computes statusEnteredAt from runtime agent fi
|
||||
const runningUpdatedAt = "2026-05-12T10:00:00.000Z";
|
||||
const needsInputUpdatedAt = "2026-05-12T10:15:00.000Z";
|
||||
session.listAgentPayloads = async () => [
|
||||
makeAgent({
|
||||
owned({
|
||||
id: "agent-done",
|
||||
cwd: workspace.cwd,
|
||||
status: "idle",
|
||||
updatedAt: doneUpdatedAt,
|
||||
}),
|
||||
makeAgent({
|
||||
owned({
|
||||
id: "agent-running",
|
||||
cwd: workspace.cwd,
|
||||
status: "running",
|
||||
updatedAt: runningUpdatedAt,
|
||||
}),
|
||||
makeAgent({
|
||||
owned({
|
||||
id: "agent-needs-input",
|
||||
cwd: workspace.cwd,
|
||||
status: "idle",
|
||||
@@ -4620,7 +4492,7 @@ test("buildWorkspaceDescriptorMap computes statusEnteredAt from runtime agent fi
|
||||
const earlyUpdatedAt = "2026-05-12T08:00:00.000Z";
|
||||
const lateUpdatedAt = "2026-05-12T08:30:00.000Z";
|
||||
session.listAgentPayloads = async () => [
|
||||
makeAgent({
|
||||
owned({
|
||||
id: "agent-done-early",
|
||||
cwd: workspace.cwd,
|
||||
status: "idle",
|
||||
@@ -4634,13 +4506,13 @@ test("buildWorkspaceDescriptorMap computes statusEnteredAt from runtime agent fi
|
||||
// Second call: same winning bucket, newer agent updatedAt must not move
|
||||
// the workspace bucket entry time forward.
|
||||
session.listAgentPayloads = async () => [
|
||||
makeAgent({
|
||||
owned({
|
||||
id: "agent-done-early",
|
||||
cwd: workspace.cwd,
|
||||
status: "idle",
|
||||
updatedAt: earlyUpdatedAt,
|
||||
}),
|
||||
makeAgent({
|
||||
owned({
|
||||
id: "agent-done-late",
|
||||
cwd: workspace.cwd,
|
||||
status: "idle",
|
||||
@@ -4661,13 +4533,13 @@ test("buildWorkspaceDescriptorMap computes statusEnteredAt from runtime agent fi
|
||||
const doneUpdatedAt = "2026-05-12T08:00:00.000Z";
|
||||
const needsInputUpdatedAt = "2026-05-12T07:00:00.000Z";
|
||||
session.listAgentPayloads = async () => [
|
||||
makeAgent({
|
||||
owned({
|
||||
id: "agent-done",
|
||||
cwd: workspace.cwd,
|
||||
status: "idle",
|
||||
updatedAt: doneUpdatedAt,
|
||||
}),
|
||||
makeAgent({
|
||||
owned({
|
||||
id: "agent-needs-input",
|
||||
cwd: workspace.cwd,
|
||||
status: "idle",
|
||||
@@ -4680,7 +4552,7 @@ test("buildWorkspaceDescriptorMap computes statusEnteredAt from runtime agent fi
|
||||
|
||||
// Drop the needs_input agent. The unmask time is "now", not doneUpdatedAt.
|
||||
session.listAgentPayloads = async () => [
|
||||
makeAgent({
|
||||
owned({
|
||||
id: "agent-done",
|
||||
cwd: workspace.cwd,
|
||||
status: "idle",
|
||||
@@ -4699,7 +4571,7 @@ test("buildWorkspaceDescriptorMap computes statusEnteredAt from runtime agent fi
|
||||
const attentionTs = "2026-05-12T11:00:00.000Z";
|
||||
const updatedAt = "2026-05-12T10:00:00.000Z";
|
||||
session.listAgentPayloads = async () => [
|
||||
makeAgent({
|
||||
owned({
|
||||
id: "agent-attention",
|
||||
cwd: workspace.cwd,
|
||||
status: "idle",
|
||||
@@ -4716,7 +4588,7 @@ test("buildWorkspaceDescriptorMap computes statusEnteredAt from runtime agent fi
|
||||
}
|
||||
});
|
||||
|
||||
test("same-cwd workspace descriptors share agent status by cwd", async () => {
|
||||
test("same-cwd workspace descriptors compute agent status per workspaceId", async () => {
|
||||
const session = createSessionForWorkspaceTests();
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: "proj-same-cwd-status",
|
||||
@@ -4747,6 +4619,7 @@ test("same-cwd workspace descriptors share agent status by cwd", async () => {
|
||||
session.projectRegistry.list = async () => [project];
|
||||
session.workspaceRegistry.list = async () => [workspaceA, workspaceB];
|
||||
|
||||
// A running agent owned by A leaves the sibling B done — status is per id.
|
||||
session.listAgentPayloads = async () => [
|
||||
makeAgent({
|
||||
id: "agent-running-a",
|
||||
@@ -4758,8 +4631,9 @@ test("same-cwd workspace descriptors share agent status by cwd", async () => {
|
||||
];
|
||||
const runningDescriptors = await session.buildWorkspaceDescriptorMap({ includeGitData: false });
|
||||
expect(runningDescriptors.get(workspaceA.workspaceId)?.status).toBe("running");
|
||||
expect(runningDescriptors.get(workspaceB.workspaceId)?.status).toBe("running");
|
||||
expect(runningDescriptors.get(workspaceB.workspaceId)?.status).toBe("done");
|
||||
|
||||
// An attention agent owned by B leaves the sibling A done.
|
||||
session.listAgentPayloads = async () => [
|
||||
makeAgent({
|
||||
id: "agent-attention-b",
|
||||
@@ -4773,7 +4647,7 @@ test("same-cwd workspace descriptors share agent status by cwd", async () => {
|
||||
}),
|
||||
];
|
||||
const attentionDescriptors = await session.buildWorkspaceDescriptorMap({ includeGitData: false });
|
||||
expect(attentionDescriptors.get(workspaceA.workspaceId)?.status).toBe("attention");
|
||||
expect(attentionDescriptors.get(workspaceA.workspaceId)?.status).toBe("done");
|
||||
expect(attentionDescriptors.get(workspaceB.workspaceId)?.status).toBe("attention");
|
||||
});
|
||||
|
||||
@@ -4805,6 +4679,7 @@ test("buildWorkspaceDescriptorMap keeps a done workspace recent after its agents
|
||||
makeAgent({
|
||||
id: "agent-done",
|
||||
cwd: workspace.cwd,
|
||||
workspaceId: workspace.workspaceId,
|
||||
status: "idle",
|
||||
updatedAt: doneEnteredAt,
|
||||
}),
|
||||
@@ -4819,6 +4694,7 @@ test("buildWorkspaceDescriptorMap keeps a done workspace recent after its agents
|
||||
...makeAgent({
|
||||
id: "agent-done",
|
||||
cwd: workspace.cwd,
|
||||
workspaceId: workspace.workspaceId,
|
||||
status: "idle",
|
||||
updatedAt: doneEnteredAt,
|
||||
}),
|
||||
@@ -5591,24 +5467,6 @@ test("workspace.title.set.request returns accepted=false when workspace is not f
|
||||
expect(response?.payload.error).toBeTruthy();
|
||||
});
|
||||
|
||||
test("resolveRegisteredWorkspaceIdForCwd does not match home directory as a prefix", () => {
|
||||
const session = createSessionForWorkspaceTests();
|
||||
const home = homedir();
|
||||
const childCwd = path.join(home, "projects/new-app");
|
||||
const homeWorkspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-home",
|
||||
projectId: "proj-home",
|
||||
cwd: home,
|
||||
kind: "directory",
|
||||
displayName: "home",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(session.resolveRegisteredWorkspaceIdForCwd(childCwd, [homeWorkspace])).toBeNull();
|
||||
expect(session.resolveRegisteredWorkspaceIdForCwd(home, [homeWorkspace])).toBe("ws-home");
|
||||
});
|
||||
|
||||
function createSessionWithTerminalManager(options: {
|
||||
workspaces: PersistedWorkspaceRecord[];
|
||||
projects: PersistedProjectRecord[];
|
||||
@@ -5770,7 +5628,7 @@ test("terminal activity contribution change updates the correct workspace", asyn
|
||||
});
|
||||
});
|
||||
|
||||
test("same-cwd terminal activity updates every workspace status bucket for that cwd", async () => {
|
||||
test("same-cwd terminal activity updates only the workspace that owns the terminal", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-session-same-cwd-"));
|
||||
const workspaceA = createPersistedWorkspaceRecord({
|
||||
@@ -5816,25 +5674,22 @@ test("same-cwd terminal activity updates every workspace status bucket for that
|
||||
message.payload.kind === "upsert" &&
|
||||
message.payload.workspace.id === workspaceB.workspaceId &&
|
||||
message.payload.workspace.status === "running",
|
||||
"same-cwd terminal activity updates the stamped workspace",
|
||||
);
|
||||
await waitForWorkspaceUpdate(
|
||||
emitted,
|
||||
(message) =>
|
||||
message.payload.kind === "upsert" &&
|
||||
message.payload.workspace.id === workspaceA.workspaceId &&
|
||||
message.payload.workspace.status === "running",
|
||||
"same-cwd terminal activity updates the sibling workspace status bucket",
|
||||
"same-cwd terminal activity updates the workspace that owns the terminal",
|
||||
);
|
||||
|
||||
// The sibling A does not own the terminal, so its status is never driven to
|
||||
// running by it. Status is per workspaceId, not per cwd.
|
||||
const updates = filterByType(emitted, "workspace_update");
|
||||
const upserts = updates.filter((update) => update.payload.kind === "upsert");
|
||||
const targetIds = new Set(upserts.map((update) => update.payload.workspace.id));
|
||||
expect(targetIds.has(workspaceB.workspaceId)).toBe(true);
|
||||
expect(targetIds.has(workspaceA.workspaceId)).toBe(true);
|
||||
const siblingRunning = updates.some(
|
||||
(update) =>
|
||||
update.payload.kind === "upsert" &&
|
||||
update.payload.workspace.id === workspaceA.workspaceId &&
|
||||
update.payload.workspace.status === "running",
|
||||
);
|
||||
expect(siblingRunning).toBe(false);
|
||||
});
|
||||
|
||||
test("nested worktree attribution targets the deepest active workspace", async () => {
|
||||
test("a worktree terminal updates only the workspace that owns it", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const rootCwd = mkdtempSync(path.join(tmpdir(), "paseo-session-nested-"));
|
||||
const worktreeCwd = path.join(rootCwd, "worktree");
|
||||
@@ -5872,7 +5727,13 @@ test("nested worktree attribution targets the deepest active workspace", async (
|
||||
onMessage: (message) => emitted.push(message),
|
||||
});
|
||||
|
||||
const terminal = await terminalManager.createTerminal({ cwd: terminalCwd });
|
||||
// The terminal is stamped with the worktree workspace at creation. Its cwd is
|
||||
// a subdirectory, but ownership is the workspaceId, so only the worktree
|
||||
// workspace (never the enclosing root) reflects the activity.
|
||||
const terminal = await terminalManager.createTerminal({
|
||||
cwd: terminalCwd,
|
||||
workspaceId: workspaceWorktree.workspaceId,
|
||||
});
|
||||
await terminalManager.setTerminalActivity(terminal.id, "working");
|
||||
await waitForWorkspaceUpdate(
|
||||
emitted,
|
||||
@@ -5880,23 +5741,17 @@ test("nested worktree attribution targets the deepest active workspace", async (
|
||||
message.payload.kind === "upsert" &&
|
||||
message.payload.workspace.id === workspaceWorktree.workspaceId &&
|
||||
message.payload.workspace.status === "running",
|
||||
"nested terminal activity targets the deepest workspace",
|
||||
"worktree terminal activity targets its owning workspace",
|
||||
);
|
||||
|
||||
const updates = filterByType(emitted, "workspace_update");
|
||||
const upserts = updates.filter((update) => update.payload.kind === "upsert");
|
||||
const targetIds = new Set(upserts.map((update) => update.payload.workspace.id));
|
||||
// The terminal has no workspaceId, so it falls back to cwd attribution.
|
||||
// The deepest active workspace covering `terminalCwd` should win.
|
||||
expect(targetIds.has(workspaceWorktree.workspaceId)).toBe(true);
|
||||
expect(targetIds.has(workspaceRoot.workspaceId)).toBe(false);
|
||||
expect(upserts[upserts.length - 1]?.payload).toMatchObject({
|
||||
kind: "upsert",
|
||||
workspace: {
|
||||
id: workspaceWorktree.workspaceId,
|
||||
status: "running",
|
||||
},
|
||||
});
|
||||
const rootRunning = updates.some(
|
||||
(update) =>
|
||||
update.payload.kind === "upsert" &&
|
||||
update.payload.workspace.id === workspaceRoot.workspaceId &&
|
||||
update.payload.workspace.status === "running",
|
||||
);
|
||||
expect(rootRunning).toBe(false);
|
||||
});
|
||||
|
||||
test("removing an idle terminal does not update workspace status", async () => {
|
||||
@@ -6004,10 +5859,43 @@ test("removing a contributing terminal clears workspace status", async () => {
|
||||
// observable branch proves the request fields reached createWorktreeCore. We do
|
||||
// not intercept the private workflow here.
|
||||
|
||||
test("failed local create_agent_request does not schedule workspace title generation", async () => {
|
||||
vi.useFakeTimers();
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
let generateCalls = 0;
|
||||
const session = createSessionForWorkspaceTests({
|
||||
onMessage: (message) => emitted.push(message),
|
||||
generateWorkspaceName: async () => {
|
||||
generateCalls += 1;
|
||||
return { title: "Should Not Be Written", branch: null };
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await session.handleMessage({
|
||||
type: "create_agent_request",
|
||||
requestId: "req-failed-local-title",
|
||||
workspaceId: "ws-repo-running",
|
||||
config: { provider: "codex", cwd: REPO_CWD },
|
||||
initialPrompt: "This create will fail before an agent exists",
|
||||
attachments: [],
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(findByType(emitted, "status")?.payload).toMatchObject({
|
||||
status: "agent_create_failed",
|
||||
requestId: "req-failed-local-title",
|
||||
});
|
||||
expect(generateCalls).toBe(0);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
// K4: applyGeneratedWorkspaceTitle re-reads from the registry before writing so a
|
||||
// concurrent upsert that happened between workspace creation and the async name
|
||||
// write is not clobbered.
|
||||
test("applyGeneratedWorkspaceTitle writes only displayName and does not clobber concurrent title writes", async () => {
|
||||
test("applyGeneratedWorkspaceTitle writes branch metadata and does not clobber concurrent title writes", async () => {
|
||||
const session = createSessionForWorkspaceTests();
|
||||
|
||||
// The record at create-time: no title override.
|
||||
@@ -6045,63 +5933,20 @@ test("applyGeneratedWorkspaceTitle writes only displayName and does not clobber
|
||||
});
|
||||
|
||||
const saved = stored.get("ws-worktree-1");
|
||||
// The generated title is written as the displayName.
|
||||
expect(saved?.displayName).toBe("Generated Task Title");
|
||||
// The branch-shaped display name stays branch-shaped.
|
||||
expect(saved?.displayName).toBe("task-branch");
|
||||
// The renamed branch is persisted into the dedicated branch field.
|
||||
expect(saved?.branch).toBe("task-branch-renamed");
|
||||
// The concurrent user-set title is NOT clobbered.
|
||||
expect(saved?.title).toBe("User-set title");
|
||||
});
|
||||
|
||||
// U8: Directory workspaces get a generated title when firstAgentContext has a prompt.
|
||||
// The generation is workspace-level (title only — no branch rename for directory workspaces).
|
||||
test("workspace.create.request directory source with firstAgentContext schedules title generation and writes displayName", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
// Inject the workspace-name generator so no LLM is called and we control the
|
||||
// returned title. Directory workspaces generate a title only (no branch).
|
||||
const session = createSessionForWorkspaceTests({
|
||||
onMessage: (message) => emitted.push(message),
|
||||
generateWorkspaceName: async () => ({ title: "Fix login bug", branch: null }),
|
||||
});
|
||||
|
||||
// Track workspaceRegistry upserts so we can read the final stored displayName.
|
||||
const stored = new Map<string, Record<string, unknown>>();
|
||||
session.workspaceRegistry.upsert = async (record: unknown) => {
|
||||
const r = record as Record<string, unknown>;
|
||||
stored.set(r.workspaceId as string, r);
|
||||
};
|
||||
session.workspaceRegistry.get = async (workspaceId: string) =>
|
||||
(stored.get(workspaceId) ?? null) as unknown;
|
||||
|
||||
// Silence workspace update side-effects.
|
||||
session.emitWorkspaceUpdateForCwd = async () => {};
|
||||
session.emitWorkspaceUpdatesForWorkspaceIds = async () => {};
|
||||
|
||||
await session.handleMessage({
|
||||
type: "workspace.create.request",
|
||||
requestId: "req-dir-title",
|
||||
source: { kind: "directory", path: REPO_CWD },
|
||||
firstAgentContext: { prompt: "Fix the login bug so users can sign in", attachments: [] },
|
||||
});
|
||||
|
||||
// Await the background title-generation write deterministically (no sleep):
|
||||
// scheduleWorkspaceNaming records the pending write the test awaits here.
|
||||
await session.pendingWorkspaceNamingForTests;
|
||||
|
||||
const response = emitted.find((m) => m.type === "workspace.create.response");
|
||||
expect(response?.payload).toMatchObject({ requestId: "req-dir-title", error: null });
|
||||
|
||||
// displayName must be the generated human title, NOT the directory basename.
|
||||
const saved = [...stored.values()].find((r) => r.kind === "directory") as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
expect(saved?.displayName).toBe("Fix login bug");
|
||||
});
|
||||
|
||||
// K3: handleCheckoutRenameBranchRequest must persist the new git branch into the
|
||||
// dedicated branch field and must NOT touch displayName (the human title) or the
|
||||
// user-set title. branch and name are decoupled fields by construction.
|
||||
test("checkout.rename_branch.request persists the new branch and leaves name/title untouched", async () => {
|
||||
// Phase 7: branch is a git fact derived per-descriptor from each workspace's own
|
||||
// live git snapshot, and reconciliation re-persists `branch` per workspace from
|
||||
// its own cwd. handleCheckoutRenameBranchRequest renames the git branch and
|
||||
// re-emits, but performs NO denormalized cwd → ids branch write of its own — it
|
||||
// never resolves which workspaces share the cwd to rewrite a cached branch.
|
||||
test("checkout.rename_branch.request renames the branch without a denormalized branch write", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const renameCalls: Array<{ cwd: string; newName: string }> = [];
|
||||
const session = createSessionForWorkspaceTests({
|
||||
@@ -6125,10 +5970,12 @@ test("checkout.rename_branch.request persists the new branch and leaves name/tit
|
||||
});
|
||||
|
||||
const workspaces = new Map([[workspace.workspaceId, workspace]]);
|
||||
const upsertedRecords: Array<typeof workspace> = [];
|
||||
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
||||
session.workspaceRegistry.get = async (id: string) => workspaces.get(id) ?? null;
|
||||
session.workspaceRegistry.upsert = async (record: unknown) => {
|
||||
const parsed = record as typeof workspace;
|
||||
upsertedRecords.push(parsed);
|
||||
workspaces.set(parsed.workspaceId, parsed);
|
||||
};
|
||||
|
||||
@@ -6148,11 +5995,10 @@ test("checkout.rename_branch.request persists the new branch and leaves name/tit
|
||||
requestId: "req-rename-k3",
|
||||
});
|
||||
|
||||
// K3: the new git branch must be persisted into the dedicated branch field.
|
||||
// Phase 7: the handler performs no denormalized branch write of its own; the
|
||||
// record is left for per-descriptor derivation and reconciliation to update.
|
||||
expect(upsertedRecords).toEqual([]);
|
||||
const persisted = workspaces.get(workspace.workspaceId);
|
||||
expect(persisted?.branch).toBe("feature/new-name");
|
||||
|
||||
// K3: rename must NOT touch displayName (the human name) or the user-set title.
|
||||
expect(persisted?.displayName).toBe("Refactor auth flow");
|
||||
expect(persisted?.title).toBe("Refactor auth flow");
|
||||
});
|
||||
|
||||
@@ -9,7 +9,6 @@ import type { DownloadTokenStore } from "./file-download/token-store.js";
|
||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import type pino from "pino";
|
||||
import type { ProjectRegistry, WorkspaceRegistry } from "./workspace-registry.js";
|
||||
import { resolveActiveWorkspaceRecordForCwd } from "./workspace-registry-model.js";
|
||||
import type { FileBackedChatService } from "./chat/chat-service.js";
|
||||
import type { LoopService } from "./loop-service.js";
|
||||
import type { ScheduleService } from "./schedule/service.js";
|
||||
@@ -570,6 +569,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
void this.broadcastTerminalAttention({
|
||||
terminalId: event.terminalId,
|
||||
cwd: event.cwd,
|
||||
...(event.workspaceId ? { workspaceId: event.workspaceId } : {}),
|
||||
terminalName: event.name,
|
||||
reason,
|
||||
}).catch((err) => {
|
||||
@@ -1827,14 +1827,10 @@ export class VoiceAssistantWebSocketServer {
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveWorkspaceIdForCwd(cwd: string): Promise<string | undefined> {
|
||||
const workspaces = await this.workspaceRegistry.list();
|
||||
return resolveActiveWorkspaceRecordForCwd(cwd, workspaces)?.workspaceId;
|
||||
}
|
||||
|
||||
private async broadcastTerminalAttention(params: {
|
||||
terminalId: string;
|
||||
cwd: string;
|
||||
workspaceId?: string;
|
||||
terminalName: string;
|
||||
reason: TerminalAttentionReason;
|
||||
}): Promise<void> {
|
||||
@@ -1852,7 +1848,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
|
||||
const allStates = clientEntries.map((e) => e.state);
|
||||
const nowMs = Date.now();
|
||||
const workspaceId = await this.resolveWorkspaceIdForCwd(params.cwd);
|
||||
const workspaceId = params.workspaceId;
|
||||
|
||||
const plan = computeNotificationPlan({
|
||||
allStates,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest";
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import { createTestLogger } from "../test-utils/test-logger.js";
|
||||
import type { AgentSnapshotPayload, WorkspaceDescriptorPayload } from "./messages.js";
|
||||
import { WorkspaceDirectory, resolveRegisteredWorkspaceIdsForCwd } from "./workspace-directory.js";
|
||||
import { WorkspaceDirectory } from "./workspace-directory.js";
|
||||
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||
import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity";
|
||||
|
||||
@@ -91,7 +91,13 @@ class WorkspaceStatus {
|
||||
});
|
||||
|
||||
hasRootAgent(input: AgentState): void {
|
||||
this.agents.push(createAgent({ ...input, cwd: this.workspace.cwd }));
|
||||
this.agents.push(
|
||||
createAgent({
|
||||
...input,
|
||||
cwd: this.workspace.cwd,
|
||||
workspaceId: this.workspace.workspaceId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
hasSiblingWorkspaceSameCwd(): void {
|
||||
@@ -99,8 +105,8 @@ class WorkspaceStatus {
|
||||
}
|
||||
|
||||
// A root agent owned by a specific workspace, even though both same-cwd
|
||||
// workspaces share the directory. Ownership follows workspaceId; aggregate
|
||||
// status intentionally fans out to every workspace for that cwd.
|
||||
// workspaces share the directory. Ownership follows workspaceId, and status is
|
||||
// computed per id: only the owning workspace reflects this agent's bucket.
|
||||
hasStampedRootAgent(input: AgentState & { workspaceId: string }): void {
|
||||
this.agents.push(
|
||||
createAgent({ ...input, cwd: this.workspace.cwd, workspaceId: input.workspaceId }),
|
||||
@@ -112,6 +118,7 @@ class WorkspaceStatus {
|
||||
createAgent({
|
||||
...input,
|
||||
cwd: this.workspace.cwd,
|
||||
workspaceId: this.workspace.workspaceId,
|
||||
labels: { [PARENT_AGENT_ID_LABEL]: "parent-agent" },
|
||||
}),
|
||||
);
|
||||
@@ -126,6 +133,7 @@ class WorkspaceStatus {
|
||||
createAgent({
|
||||
...input,
|
||||
cwd: this.worktreeWorkspace.cwd,
|
||||
workspaceId: this.worktreeWorkspace.workspaceId,
|
||||
labels: { [PARENT_AGENT_ID_LABEL]: "parent-agent" },
|
||||
}),
|
||||
);
|
||||
@@ -150,13 +158,14 @@ class WorkspaceStatus {
|
||||
hasWorkingTerminal(changedAt: number): void {
|
||||
this.terminals.push({
|
||||
cwd: this.workspace.cwd,
|
||||
workspaceId: this.workspace.workspaceId,
|
||||
activity: { state: "working", changedAt },
|
||||
});
|
||||
}
|
||||
|
||||
// A working terminal owned by a specific same-cwd workspace. Ownership follows
|
||||
// workspaceId; aggregate status intentionally fans out to every workspace for
|
||||
// that cwd.
|
||||
// workspaceId, and status is computed per id: only the owning workspace
|
||||
// reflects this terminal's activity.
|
||||
hasStampedWorkingTerminal(input: { workspaceId: string; changedAt: number }): void {
|
||||
this.terminals.push({
|
||||
cwd: this.workspace.cwd,
|
||||
@@ -165,9 +174,12 @@ class WorkspaceStatus {
|
||||
});
|
||||
}
|
||||
|
||||
// A terminal opened in a subdirectory still carries the owning workspace's id
|
||||
// (stamped at creation); the subdir cwd is cosmetic, ownership is the id.
|
||||
hasWorkingTerminalInSubdirectory(changedAt: number): void {
|
||||
this.terminals.push({
|
||||
cwd: `${this.workspace.cwd}/packages/app`,
|
||||
workspaceId: this.workspace.workspaceId,
|
||||
activity: { state: "working", changedAt },
|
||||
});
|
||||
}
|
||||
@@ -175,6 +187,7 @@ class WorkspaceStatus {
|
||||
hasIdleTerminal(changedAt: number): void {
|
||||
this.terminals.push({
|
||||
cwd: this.workspace.cwd,
|
||||
workspaceId: this.workspace.workspaceId,
|
||||
activity: { state: "idle", changedAt },
|
||||
});
|
||||
}
|
||||
@@ -182,6 +195,7 @@ class WorkspaceStatus {
|
||||
hasFinishedTerminal(changedAt: number): void {
|
||||
this.terminals.push({
|
||||
cwd: this.workspace.cwd,
|
||||
workspaceId: this.workspace.workspaceId,
|
||||
activity: { state: "idle", attentionReason: "finished", changedAt },
|
||||
});
|
||||
}
|
||||
@@ -189,6 +203,7 @@ class WorkspaceStatus {
|
||||
hasUnknownTerminal(): void {
|
||||
this.terminals.push({
|
||||
cwd: this.workspace.cwd,
|
||||
workspaceId: this.workspace.workspaceId,
|
||||
activity: null,
|
||||
});
|
||||
}
|
||||
@@ -280,7 +295,7 @@ describe("WorkspaceDirectory", () => {
|
||||
await expect(workspace.workspaceStatus()).resolves.toBe("running");
|
||||
});
|
||||
|
||||
test("same-cwd workspaces share agent status buckets", async () => {
|
||||
test("same-cwd workspaces attribute agent status only to the owner", async () => {
|
||||
const workspace = new WorkspaceStatus();
|
||||
|
||||
workspace.hasSiblingWorkspaceSameCwd();
|
||||
@@ -289,19 +304,16 @@ describe("WorkspaceDirectory", () => {
|
||||
status: "running",
|
||||
workspaceId: "workspace-1-sibling",
|
||||
});
|
||||
workspace.hasStampedRootAgent({
|
||||
id: "agent-b",
|
||||
status: "idle",
|
||||
workspaceId: "workspace-1",
|
||||
});
|
||||
|
||||
// The running agent belongs to the sibling; workspace-1 owns nothing active
|
||||
// and stays done. Status never fans out across same-cwd workspaces.
|
||||
await expect(workspace.workspaceStatuses()).resolves.toEqual({
|
||||
"workspace-1": "running",
|
||||
"workspace-1": "done",
|
||||
"workspace-1-sibling": "running",
|
||||
});
|
||||
});
|
||||
|
||||
test("same-cwd workspaces share agent attention buckets", async () => {
|
||||
test("same-cwd workspaces attribute agent attention only to the owner", async () => {
|
||||
const workspace = new WorkspaceStatus();
|
||||
|
||||
workspace.hasSiblingWorkspaceSameCwd();
|
||||
@@ -313,24 +325,34 @@ describe("WorkspaceDirectory", () => {
|
||||
});
|
||||
|
||||
await expect(workspace.workspaceStatuses()).resolves.toEqual({
|
||||
"workspace-1": "needs_input",
|
||||
"workspace-1": "done",
|
||||
"workspace-1-sibling": "needs_input",
|
||||
});
|
||||
});
|
||||
|
||||
test("same-cwd workspaces share legacy unstamped agent status buckets", async () => {
|
||||
test("each same-cwd workspace reflects only its own agent", async () => {
|
||||
const workspace = new WorkspaceStatus();
|
||||
|
||||
workspace.hasSiblingWorkspaceSameCwd();
|
||||
workspace.hasRootAgent({ id: "legacy-agent", status: "running" });
|
||||
workspace.hasStampedRootAgent({
|
||||
id: "agent-a",
|
||||
status: "running",
|
||||
workspaceId: "workspace-1-sibling",
|
||||
});
|
||||
workspace.hasStampedRootAgent({
|
||||
id: "agent-b",
|
||||
status: "idle",
|
||||
pendingPermissionCount: 1,
|
||||
workspaceId: "workspace-1",
|
||||
});
|
||||
|
||||
await expect(workspace.workspaceStatuses()).resolves.toEqual({
|
||||
"workspace-1": "running",
|
||||
"workspace-1": "needs_input",
|
||||
"workspace-1-sibling": "running",
|
||||
});
|
||||
});
|
||||
|
||||
test("same-cwd workspaces share terminal status buckets", async () => {
|
||||
test("terminal status attributes only to the owning workspace", async () => {
|
||||
const workspace = new WorkspaceStatus();
|
||||
const changedAt = new Date(NOW).getTime();
|
||||
|
||||
@@ -338,7 +360,7 @@ describe("WorkspaceDirectory", () => {
|
||||
workspace.hasStampedWorkingTerminal({ workspaceId: "workspace-1-sibling", changedAt });
|
||||
|
||||
await expect(workspace.workspaceStatuses()).resolves.toEqual({
|
||||
"workspace-1": "running",
|
||||
"workspace-1": "done",
|
||||
"workspace-1-sibling": "running",
|
||||
});
|
||||
});
|
||||
@@ -530,71 +552,3 @@ describe("WorkspaceDirectory empty projects", () => {
|
||||
expect(result.emptyProjects.map((p) => p.projectId)).toEqual(["empty"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveRegisteredWorkspaceIdsForCwd", () => {
|
||||
const sharedCwd = "/workspace/project";
|
||||
|
||||
const workspace1: PersistedWorkspaceRecord = {
|
||||
workspaceId: "ws-1",
|
||||
projectId: "proj-1",
|
||||
cwd: sharedCwd,
|
||||
kind: "local_checkout",
|
||||
displayName: "main",
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
archivedAt: null,
|
||||
};
|
||||
|
||||
const workspace2: PersistedWorkspaceRecord = {
|
||||
workspaceId: "ws-2",
|
||||
projectId: "proj-1",
|
||||
cwd: sharedCwd,
|
||||
kind: "local_checkout",
|
||||
displayName: "main-2",
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
archivedAt: null,
|
||||
};
|
||||
|
||||
const workspace3: PersistedWorkspaceRecord = {
|
||||
workspaceId: "ws-3",
|
||||
projectId: "proj-2",
|
||||
cwd: "/workspace/other",
|
||||
kind: "local_checkout",
|
||||
displayName: "other",
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
archivedAt: null,
|
||||
};
|
||||
|
||||
test("returns both ids when two workspaces share the same cwd", () => {
|
||||
const result = resolveRegisteredWorkspaceIdsForCwd(sharedCwd, [
|
||||
workspace1,
|
||||
workspace2,
|
||||
workspace3,
|
||||
]);
|
||||
expect(result.sort()).toEqual(["ws-1", "ws-2"]);
|
||||
});
|
||||
|
||||
test("returns a single id when only one workspace matches the cwd", () => {
|
||||
const result = resolveRegisteredWorkspaceIdsForCwd("/workspace/other", [
|
||||
workspace1,
|
||||
workspace2,
|
||||
workspace3,
|
||||
]);
|
||||
expect(result).toEqual(["ws-3"]);
|
||||
});
|
||||
|
||||
test("returns prefix-matched id when no exact cwd match exists", () => {
|
||||
const subdir = `${sharedCwd}/packages/app`;
|
||||
// workspace1 and workspace2 both have sharedCwd as a prefix; the plural
|
||||
// resolver must return both when multiple workspaces share the best prefix.
|
||||
const result = resolveRegisteredWorkspaceIdsForCwd(subdir, [workspace1, workspace2]);
|
||||
expect(result.sort()).toEqual(["ws-1", "ws-2"]);
|
||||
});
|
||||
|
||||
test("returns empty array when no workspace matches", () => {
|
||||
const result = resolveRegisteredWorkspaceIdsForCwd("/unrelated/path", [workspace1, workspace2]);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { homedir } from "node:os";
|
||||
import { resolve, sep } from "node:path";
|
||||
import { resolve } from "node:path";
|
||||
import type pino from "pino";
|
||||
import type {
|
||||
AgentSnapshotPayload,
|
||||
@@ -16,17 +15,11 @@ import { getParentAgentIdFromLabels, isDelegatedAgent } from "@getpaseo/protocol
|
||||
import { SortablePager } from "./pagination/sortable-pager.js";
|
||||
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||
import { resolveProjectDisplayName } from "./workspace-registry.js";
|
||||
import {
|
||||
resolveActiveWorkspaceRecordForCwd,
|
||||
resolveWorkspaceIdForRecord,
|
||||
} from "./workspace-registry-model.js";
|
||||
import {
|
||||
deriveTerminalActivityStatusBucket,
|
||||
type TerminalActivity,
|
||||
} from "@getpaseo/protocol/terminal-activity";
|
||||
|
||||
type WorkspaceIdResolver = (cwd: string) => string | undefined;
|
||||
|
||||
const FETCH_WORKSPACES_SORT_KEYS = [
|
||||
"status_priority",
|
||||
"activity_at",
|
||||
@@ -63,46 +56,6 @@ type WorkspaceProjectDescriptor = FetchWorkspacesResponsePayload["emptyProjects"
|
||||
|
||||
export type WorkspaceUpdatesFilter = FetchWorkspacesRequestFilter;
|
||||
|
||||
export function resolveRegisteredWorkspaceIdForCwd(
|
||||
cwd: string,
|
||||
workspaces: PersistedWorkspaceRecord[],
|
||||
): string | null {
|
||||
const ids = resolveRegisteredWorkspaceIdsForCwd(cwd, workspaces);
|
||||
return ids[0] ?? null;
|
||||
}
|
||||
|
||||
export function resolveRegisteredWorkspaceIdsForCwd(
|
||||
cwd: string,
|
||||
workspaces: PersistedWorkspaceRecord[],
|
||||
): string[] {
|
||||
const resolvedCwd = resolve(cwd);
|
||||
const exactMatches = workspaces.filter((workspace) => workspace.cwd === resolvedCwd);
|
||||
if (exactMatches.length > 0) {
|
||||
return exactMatches.map((workspace) => workspace.workspaceId);
|
||||
}
|
||||
|
||||
const userHome = homedir();
|
||||
let bestMatchLength = 0;
|
||||
const prefixMatches: PersistedWorkspaceRecord[] = [];
|
||||
for (const workspace of workspaces) {
|
||||
if (workspace.cwd === userHome) continue;
|
||||
if (workspace.archivedAt) continue;
|
||||
const prefix = workspace.cwd.endsWith(sep) ? workspace.cwd : `${workspace.cwd}${sep}`;
|
||||
if (!resolvedCwd.startsWith(prefix)) {
|
||||
continue;
|
||||
}
|
||||
if (workspace.cwd.length > bestMatchLength) {
|
||||
bestMatchLength = workspace.cwd.length;
|
||||
prefixMatches.length = 0;
|
||||
prefixMatches.push(workspace);
|
||||
} else if (workspace.cwd.length === bestMatchLength) {
|
||||
prefixMatches.push(workspace);
|
||||
}
|
||||
}
|
||||
|
||||
return prefixMatches.map((workspace) => workspace.workspaceId);
|
||||
}
|
||||
|
||||
export interface WorkspaceDirectoryDeps {
|
||||
logger: pino.Logger;
|
||||
projectRegistry: {
|
||||
@@ -159,6 +112,23 @@ export function summarizeFetchWorkspacesEntries(entries: Iterable<FetchWorkspace
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Git facts (branch, diff, dirty, PR) belong to a checkout on disk, not to a
|
||||
* workspace identity. Every workspace whose own cwd is that checkout re-derives
|
||||
* its git facts from the same folder. This returns the ids of those workspaces
|
||||
* so a git change can fan out to all of them. This is git-fact display, NOT
|
||||
* ownership: do not use it to decide which workspace owns an arbitrary path.
|
||||
*/
|
||||
export function workspaceIdsOnCheckout(
|
||||
workspaces: Iterable<PersistedWorkspaceRecord>,
|
||||
cwd: string,
|
||||
): string[] {
|
||||
const resolvedCwd = resolve(cwd);
|
||||
return Array.from(workspaces)
|
||||
.filter((workspace) => !workspace.archivedAt && resolve(workspace.cwd) === resolvedCwd)
|
||||
.map((workspace) => workspace.workspaceId);
|
||||
}
|
||||
|
||||
export class WorkspaceDirectory {
|
||||
private readonly archivingByWorkspaceId = new Map<string, string>();
|
||||
/**
|
||||
@@ -232,9 +202,6 @@ export class WorkspaceDirectory {
|
||||
const descriptorsByWorkspaceId = new Map<string, WorkspaceDescriptorPayload>();
|
||||
const workspaceIds = options.workspaceIds ? new Set(options.workspaceIds) : null;
|
||||
const activeWorkspaceIds = new Set(activeRecords.map((workspace) => workspace.workspaceId));
|
||||
const resolveActiveWorkspaceIdForCwd: WorkspaceIdResolver = (cwd) =>
|
||||
resolveActiveWorkspaceRecordForCwd(cwd, activeRecords)?.workspaceId;
|
||||
|
||||
const includedWorkspaces = activeRecords.filter(
|
||||
(workspace) => !workspaceIds || workspaceIds.has(workspace.workspaceId),
|
||||
);
|
||||
@@ -260,22 +227,17 @@ export class WorkspaceDirectory {
|
||||
);
|
||||
this.applyAgentBucketContributions({
|
||||
activeAgents,
|
||||
activeRecords,
|
||||
activeWorkspaceIds,
|
||||
descriptorsByWorkspaceId,
|
||||
});
|
||||
|
||||
// Terminal activity contributions: working terminal → running bucket.
|
||||
const terminalEntriesByWorkspaceId = this.applyTerminalContributions(
|
||||
terminalContributions,
|
||||
activeRecords,
|
||||
resolveActiveWorkspaceIdForCwd,
|
||||
descriptorsByWorkspaceId,
|
||||
);
|
||||
|
||||
const contributingAgentsByWorkspaceId = groupAgentsByWorkspaceId(
|
||||
activeAgents,
|
||||
activeRecords,
|
||||
activeWorkspaceIds,
|
||||
);
|
||||
|
||||
@@ -305,15 +267,14 @@ export class WorkspaceDirectory {
|
||||
}
|
||||
|
||||
// Aggregate each agent's state bucket into its owning workspace descriptor,
|
||||
// keeping the highest-priority bucket. Delegated agents contribute to their
|
||||
// delegation root's workspace; their own status is ignored unless running.
|
||||
// keeping the highest-priority bucket. A record's owner IS its `workspaceId`;
|
||||
// status never fans out to same-cwd siblings. Delegated agents contribute to
|
||||
// their delegation root's workspace; their own status is ignored unless running.
|
||||
private applyAgentBucketContributions(params: {
|
||||
activeAgents: AgentSnapshotPayload[];
|
||||
activeRecords: PersistedWorkspaceRecord[];
|
||||
activeWorkspaceIds: ReadonlySet<string>;
|
||||
descriptorsByWorkspaceId: Map<string, WorkspaceDescriptorPayload>;
|
||||
}): void {
|
||||
const { activeAgents, activeRecords, activeWorkspaceIds, descriptorsByWorkspaceId } = params;
|
||||
const { activeAgents, descriptorsByWorkspaceId } = params;
|
||||
const activeAgentsById = new Map(activeAgents.map((agent) => [agent.id, agent] as const));
|
||||
|
||||
for (const agent of activeAgents) {
|
||||
@@ -338,70 +299,56 @@ export class WorkspaceDirectory {
|
||||
});
|
||||
}
|
||||
|
||||
const workspaceIds = resolveWorkspaceIdsForStatusContribution(workspaceAgent, activeRecords);
|
||||
for (const workspaceId of workspaceIds) {
|
||||
if (!activeWorkspaceIds.has(workspaceId)) {
|
||||
continue;
|
||||
}
|
||||
const existing = descriptorsByWorkspaceId.get(workspaceId);
|
||||
if (!existing) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
getWorkspaceStateBucketPriority(bucket) < getWorkspaceStateBucketPriority(existing.status)
|
||||
) {
|
||||
existing.status = bucket;
|
||||
}
|
||||
const workspaceId = workspaceAgent.workspaceId;
|
||||
if (!workspaceId) {
|
||||
continue;
|
||||
}
|
||||
const existing = descriptorsByWorkspaceId.get(workspaceId);
|
||||
if (!existing) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
getWorkspaceStateBucketPriority(bucket) < getWorkspaceStateBucketPriority(existing.status)
|
||||
) {
|
||||
existing.status = bucket;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply working terminal contributions to descriptor statuses and build a map
|
||||
// of terminal timestamp entries per workspace for use in `resolveStatusEnteredAt`.
|
||||
// A terminal contributes only to the workspace it carries; same-cwd siblings
|
||||
// are untouched.
|
||||
private applyTerminalContributions(
|
||||
terminalContributions: Array<{
|
||||
cwd: string;
|
||||
workspaceId?: string;
|
||||
activity: TerminalActivity | null;
|
||||
}>,
|
||||
activeRecords: PersistedWorkspaceRecord[],
|
||||
resolveWorkspaceIdForCwd: WorkspaceIdResolver,
|
||||
descriptorsByWorkspaceId: Map<string, WorkspaceDescriptorPayload>,
|
||||
): Map<string, Array<{ bucket: WorkspaceStateBucket; changedAtIso: string }>> {
|
||||
const terminalEntriesByWorkspaceId = new Map<
|
||||
string,
|
||||
Array<{ bucket: WorkspaceStateBucket; changedAtIso: string }>
|
||||
>();
|
||||
for (const { cwd, workspaceId: contributedWorkspaceId, activity } of terminalContributions) {
|
||||
if (!activity) {
|
||||
for (const { workspaceId, activity } of terminalContributions) {
|
||||
if (!activity || !workspaceId) {
|
||||
continue;
|
||||
}
|
||||
const bucket = deriveTerminalActivityStatusBucket(activity);
|
||||
if (!bucket) continue;
|
||||
const resolvedWorkspaceId = contributedWorkspaceId
|
||||
? resolveWorkspaceIdForRecord({ workspaceId: contributedWorkspaceId, cwd }, activeRecords)
|
||||
: resolveWorkspaceIdForCwd(cwd);
|
||||
const workspaceIds = resolvedWorkspaceId
|
||||
? resolveWorkspaceIdsForStatusContribution(
|
||||
{ workspaceId: resolvedWorkspaceId, cwd },
|
||||
activeRecords,
|
||||
)
|
||||
: resolveWorkspaceIdsForStatusContribution({ cwd }, activeRecords);
|
||||
for (const workspaceId of workspaceIds) {
|
||||
const existing = descriptorsByWorkspaceId.get(workspaceId);
|
||||
if (!existing) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
getWorkspaceStateBucketPriority(bucket) < getWorkspaceStateBucketPriority(existing.status)
|
||||
) {
|
||||
existing.status = bucket;
|
||||
}
|
||||
const entries = terminalEntriesByWorkspaceId.get(workspaceId) ?? [];
|
||||
entries.push({ bucket, changedAtIso: new Date(activity.changedAt).toISOString() });
|
||||
terminalEntriesByWorkspaceId.set(workspaceId, entries);
|
||||
const existing = descriptorsByWorkspaceId.get(workspaceId);
|
||||
if (!existing) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
getWorkspaceStateBucketPriority(bucket) < getWorkspaceStateBucketPriority(existing.status)
|
||||
) {
|
||||
existing.status = bucket;
|
||||
}
|
||||
const entries = terminalEntriesByWorkspaceId.get(workspaceId) ?? [];
|
||||
entries.push({ bucket, changedAtIso: new Date(activity.changedAt).toISOString() });
|
||||
terminalEntriesByWorkspaceId.set(workspaceId, entries);
|
||||
}
|
||||
return terminalEntriesByWorkspaceId;
|
||||
}
|
||||
@@ -508,20 +455,6 @@ export class WorkspaceDirectory {
|
||||
return candidates.at(-1) ?? null;
|
||||
}
|
||||
|
||||
resolveRegisteredWorkspaceIdForCwd(
|
||||
cwd: string,
|
||||
workspaces: PersistedWorkspaceRecord[],
|
||||
): string | null {
|
||||
return resolveRegisteredWorkspaceIdForCwd(cwd, workspaces);
|
||||
}
|
||||
|
||||
resolveRegisteredWorkspaceIdsForCwd(
|
||||
cwd: string,
|
||||
workspaces: PersistedWorkspaceRecord[],
|
||||
): string[] {
|
||||
return resolveRegisteredWorkspaceIdsForCwd(cwd, workspaces);
|
||||
}
|
||||
|
||||
// Project parents that have no active workspaces. These persist as first-class
|
||||
// empty projects so the sidebar can render an empty project row with a
|
||||
// "+ New workspace" affordance.
|
||||
@@ -651,49 +584,21 @@ export class WorkspaceDirectory {
|
||||
|
||||
function groupAgentsByWorkspaceId(
|
||||
agents: AgentSnapshotPayload[],
|
||||
activeRecords: PersistedWorkspaceRecord[],
|
||||
activeWorkspaceIds: ReadonlySet<string>,
|
||||
): Map<string, AgentSnapshotPayload[]> {
|
||||
const byWorkspaceId = new Map<string, AgentSnapshotPayload[]>();
|
||||
for (const agent of agents) {
|
||||
const workspaceIds = resolveWorkspaceIdsForStatusContribution(agent, activeRecords);
|
||||
for (const workspaceId of workspaceIds) {
|
||||
if (!activeWorkspaceIds.has(workspaceId)) {
|
||||
continue;
|
||||
}
|
||||
const entries = byWorkspaceId.get(workspaceId) ?? [];
|
||||
entries.push(agent);
|
||||
byWorkspaceId.set(workspaceId, entries);
|
||||
const workspaceId = agent.workspaceId;
|
||||
if (!workspaceId || !activeWorkspaceIds.has(workspaceId)) {
|
||||
continue;
|
||||
}
|
||||
const entries = byWorkspaceId.get(workspaceId) ?? [];
|
||||
entries.push(agent);
|
||||
byWorkspaceId.set(workspaceId, entries);
|
||||
}
|
||||
return byWorkspaceId;
|
||||
}
|
||||
|
||||
function resolveWorkspaceIdsForStatusContribution(
|
||||
record: { workspaceId?: string; cwd: string },
|
||||
activeWorkspaces: PersistedWorkspaceRecord[],
|
||||
): string[] {
|
||||
const owningWorkspaceId = resolveWorkspaceIdForRecord(record, activeWorkspaces);
|
||||
if (!owningWorkspaceId) {
|
||||
const recordCwd = resolve(record.cwd);
|
||||
return activeWorkspaces
|
||||
.filter((workspace) => !workspace.archivedAt && resolve(workspace.cwd) === recordCwd)
|
||||
.map((workspace) => workspace.workspaceId);
|
||||
}
|
||||
|
||||
const owningWorkspace = activeWorkspaces.find(
|
||||
(workspace) => workspace.workspaceId === owningWorkspaceId && !workspace.archivedAt,
|
||||
);
|
||||
if (!owningWorkspace) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const owningCwd = resolve(owningWorkspace.cwd);
|
||||
return activeWorkspaces
|
||||
.filter((workspace) => !workspace.archivedAt && resolve(workspace.cwd) === owningCwd)
|
||||
.map((workspace) => workspace.workspaceId);
|
||||
}
|
||||
|
||||
function resolveDelegationRootAgent(
|
||||
agent: AgentSnapshotPayload,
|
||||
activeAgentsById: ReadonlyMap<string, AgentSnapshotPayload>,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { homedir } from "node:os";
|
||||
import { basename } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createPersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||
import { resolveWorkspaceIdForPath } from "./workspace-ownership.js";
|
||||
|
||||
function createWorkspaceRecord(
|
||||
cwd: string,
|
||||
workspaceId: string,
|
||||
overrides?: { createdAt?: string; archivedAt?: string },
|
||||
) {
|
||||
return createPersistedWorkspaceRecord({
|
||||
workspaceId,
|
||||
projectId: workspaceId,
|
||||
cwd,
|
||||
kind: "directory",
|
||||
displayName: basename(cwd) || cwd,
|
||||
createdAt: overrides?.createdAt ?? "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: overrides?.createdAt ?? "2026-03-01T00:00:00.000Z",
|
||||
archivedAt: overrides?.archivedAt ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// resolveWorkspaceIdForPath is the external path→workspace adapter for
|
||||
// archive-by-path and agent project-placement display. It is NOT ownership: a
|
||||
// record's owner is its workspaceId. The per-id status law is exercised in
|
||||
// workspace-directory.test.ts.
|
||||
|
||||
describe("resolveWorkspaceIdForPath", () => {
|
||||
test("returns a single id when multiple workspaces share the exact cwd", () => {
|
||||
const id = resolveWorkspaceIdForPath("/workspace/project", [
|
||||
createWorkspaceRecord("/workspace/project", "ws-1"),
|
||||
createWorkspaceRecord("/workspace/project", "ws-2"),
|
||||
createWorkspaceRecord("/workspace/other", "ws-3"),
|
||||
]);
|
||||
expect(["ws-1", "ws-2"]).toContain(id);
|
||||
});
|
||||
|
||||
test("resolves an exact archived workspace match for archive-by-path", () => {
|
||||
expect(
|
||||
resolveWorkspaceIdForPath("/workspace/project", [
|
||||
createWorkspaceRecord("/workspace/project", "ws-archived", {
|
||||
archivedAt: "2026-03-05T00:00:00.000Z",
|
||||
}),
|
||||
]),
|
||||
).toEqual("ws-archived");
|
||||
});
|
||||
|
||||
test("resolves the deepest enclosing workspace for a subdirectory path", () => {
|
||||
expect(
|
||||
resolveWorkspaceIdForPath("/workspace/project/packages/app", [
|
||||
createWorkspaceRecord("/workspace/project", "ws-1"),
|
||||
createWorkspaceRecord("/workspace", "ws-root"),
|
||||
]),
|
||||
).toEqual("ws-1");
|
||||
});
|
||||
|
||||
test("does not match the home directory as a prefix", () => {
|
||||
const home = homedir();
|
||||
|
||||
expect(
|
||||
resolveWorkspaceIdForPath(`${home}/child`, [createWorkspaceRecord(home, "ws-home")]),
|
||||
).toBeNull();
|
||||
expect(resolveWorkspaceIdForPath(home, [createWorkspaceRecord(home, "ws-home")])).toEqual(
|
||||
"ws-home",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { homedir } from "node:os";
|
||||
import { resolve, sep } from "node:path";
|
||||
|
||||
import type { PersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||
|
||||
// external path→workspace adapter, not ownership.
|
||||
//
|
||||
// Resolves a raw filesystem path to a single workspace id for the two flows that
|
||||
// receive a path rather than an id: archive-by-path (an old client / CLI passes a
|
||||
// worktree path) and agent project-placement display (an agent's cwd, which may
|
||||
// be a subdirectory of its workspace). This is NEVER used to attribute a record's
|
||||
// status to a workspace — status is per `workspaceId`, and git facts derive from a
|
||||
// workspace's OWN cwd (id → cwd).
|
||||
//
|
||||
// Resolution: an exact directory match wins; otherwise the deepest enclosing
|
||||
// workspace directory, never the home directory; null when nothing encloses it.
|
||||
export function resolveWorkspaceIdForPath(
|
||||
cwd: string,
|
||||
workspaces: Iterable<PersistedWorkspaceRecord>,
|
||||
): string | null {
|
||||
const workspaceRecords = Array.from(workspaces);
|
||||
const resolvedCwd = resolve(cwd);
|
||||
const exactMatch = workspaceRecords.find((workspace) => resolve(workspace.cwd) === resolvedCwd);
|
||||
if (exactMatch) {
|
||||
return exactMatch.workspaceId;
|
||||
}
|
||||
|
||||
const userHome = resolve(homedir());
|
||||
let bestMatchLength = 0;
|
||||
let bestMatch: PersistedWorkspaceRecord | null = null;
|
||||
for (const workspace of workspaceRecords) {
|
||||
if (workspace.archivedAt) continue;
|
||||
const workspaceCwd = resolve(workspace.cwd);
|
||||
if (workspaceCwd === userHome) continue;
|
||||
const prefix = workspaceCwd.endsWith(sep) ? workspaceCwd : `${workspaceCwd}${sep}`;
|
||||
if (!resolvedCwd.startsWith(prefix)) {
|
||||
continue;
|
||||
}
|
||||
if (workspaceCwd.length > bestMatchLength) {
|
||||
bestMatchLength = workspaceCwd.length;
|
||||
bestMatch = workspace;
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch?.workspaceId ?? null;
|
||||
}
|
||||
@@ -101,6 +101,7 @@ function createWorkspaceGitServiceStub(
|
||||
projectDisplayName: string;
|
||||
workspaceDisplayName: string;
|
||||
gitRemote?: string | null;
|
||||
currentBranch?: string | null;
|
||||
}
|
||||
>,
|
||||
) {
|
||||
@@ -580,7 +581,7 @@ describe("WorkspaceReconciliationService", () => {
|
||||
expect(projects.get("p1")!.customName).toBe("My Fork");
|
||||
});
|
||||
|
||||
test("updates workspace display name when branch changes", async () => {
|
||||
test("updates workspace branch metadata without clobbering the workspace name", async () => {
|
||||
const dir = createTempGitRepo("reconcile-branch-");
|
||||
tempDirs.push(dir);
|
||||
|
||||
@@ -606,7 +607,8 @@ describe("WorkspaceReconciliationService", () => {
|
||||
projectId: "p1",
|
||||
cwd: dir,
|
||||
kind: "local_checkout",
|
||||
displayName: "main",
|
||||
displayName: "Human workspace title",
|
||||
branch: "main",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
@@ -621,6 +623,7 @@ describe("WorkspaceReconciliationService", () => {
|
||||
projectKind: "git",
|
||||
projectDisplayName: path.basename(dir),
|
||||
workspaceDisplayName: "feature-branch",
|
||||
currentBranch: "feature-branch",
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -629,7 +632,12 @@ describe("WorkspaceReconciliationService", () => {
|
||||
|
||||
const wsUpdate = result.changesApplied.find((c) => c.kind === "workspace_updated");
|
||||
expect(wsUpdate).toBeDefined();
|
||||
expect(workspaces.get("w1")!.displayName).toBe("feature-branch");
|
||||
expect(wsUpdate).toMatchObject({
|
||||
kind: "workspace_updated",
|
||||
fields: { branch: "feature-branch" },
|
||||
});
|
||||
expect(workspaces.get("w1")!.displayName).toBe("Human workspace title");
|
||||
expect(workspaces.get("w1")!.branch).toBe("feature-branch");
|
||||
});
|
||||
|
||||
test("does not modify already-archived records", async () => {
|
||||
|
||||
@@ -50,7 +50,7 @@ export type ReconciliationChange =
|
||||
kind: "workspace_updated";
|
||||
workspaceId: string;
|
||||
directory: string;
|
||||
fields: Partial<Pick<PersistedWorkspaceRecord, "projectId" | "displayName" | "kind">>;
|
||||
fields: Partial<Pick<PersistedWorkspaceRecord, "projectId" | "branch" | "kind">>;
|
||||
};
|
||||
|
||||
export interface ReconciliationResult {
|
||||
@@ -343,11 +343,10 @@ export class WorkspaceReconciliationService {
|
||||
|
||||
const expectedKind = deriveWorkspaceKindFromMetadata(wsGit);
|
||||
|
||||
const workspaceUpdates: Partial<Pick<PersistedWorkspaceRecord, "displayName" | "kind">> =
|
||||
{};
|
||||
const workspaceUpdates: Partial<Pick<PersistedWorkspaceRecord, "branch" | "kind">> = {};
|
||||
|
||||
if (wsGit.projectKind === "git" && workspace.displayName !== wsGit.workspaceDisplayName) {
|
||||
workspaceUpdates.displayName = wsGit.workspaceDisplayName;
|
||||
if (wsGit.projectKind === "git" && workspace.branch !== wsGit.currentBranch) {
|
||||
workspaceUpdates.branch = wsGit.currentBranch;
|
||||
}
|
||||
|
||||
if (workspace.kind !== expectedKind) {
|
||||
|
||||
@@ -8,11 +8,11 @@ import {
|
||||
classifyDirectoryForProjectMembership,
|
||||
generateWorkspaceId,
|
||||
} from "./workspace-registry-model.js";
|
||||
import { backfillWorkspaceIdForLegacyAgents } from "./migrations/backfill-workspace-id.migration.js";
|
||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import {
|
||||
createPersistedProjectRecord,
|
||||
createPersistedWorkspaceRecord,
|
||||
type PersistedWorkspaceRecord,
|
||||
type ProjectRegistry,
|
||||
type WorkspaceRegistry,
|
||||
} from "./workspace-registry.js";
|
||||
@@ -45,71 +45,6 @@ function resolveAgentUpdatedAt(record: StoredAgentRecord): string {
|
||||
return record.lastActivityAt || record.updatedAt || record.createdAt || new Date(0).toISOString();
|
||||
}
|
||||
|
||||
export function resolveLegacyWorkspaceOwnerForCwd(
|
||||
cwd: string,
|
||||
workspaces: Iterable<PersistedWorkspaceRecord>,
|
||||
): string | null {
|
||||
const normalizedCwd = path.resolve(cwd);
|
||||
const activeWorkspaces = Array.from(workspaces).filter((workspace) => !workspace.archivedAt);
|
||||
const exactMatches = activeWorkspaces.filter(
|
||||
(workspace) => path.resolve(workspace.cwd) === normalizedCwd,
|
||||
);
|
||||
if (exactMatches.length > 0) {
|
||||
return oldestWorkspace(exactMatches).workspaceId;
|
||||
}
|
||||
|
||||
const prefixMatches = activeWorkspaces.filter((workspace) => {
|
||||
const workspaceCwd = path.resolve(workspace.cwd);
|
||||
return normalizedCwd === workspaceCwd || normalizedCwd.startsWith(`${workspaceCwd}${path.sep}`);
|
||||
});
|
||||
if (prefixMatches.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const longestPrefixLength = Math.max(
|
||||
...prefixMatches.map((workspace) => path.resolve(workspace.cwd).length),
|
||||
);
|
||||
return oldestWorkspace(
|
||||
prefixMatches.filter((workspace) => path.resolve(workspace.cwd).length === longestPrefixLength),
|
||||
).workspaceId;
|
||||
}
|
||||
|
||||
export async function migrateLegacyAgentWorkspaceOwnership(options: {
|
||||
agentStorage: AgentStorage;
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
logger: Logger;
|
||||
cwds?: Iterable<string>;
|
||||
}): Promise<number> {
|
||||
const workspaceRecords = await options.workspaceRegistry.list();
|
||||
const cwdFilter = options.cwds
|
||||
? new Set(Array.from(options.cwds, (cwd) => path.resolve(cwd)))
|
||||
: null;
|
||||
const records = await options.agentStorage.list();
|
||||
let migrated = 0;
|
||||
|
||||
for (const record of records) {
|
||||
if (record.workspaceId) {
|
||||
continue;
|
||||
}
|
||||
if (cwdFilter && !cwdFilter.has(path.resolve(record.cwd))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const workspaceId = resolveLegacyWorkspaceOwnerForCwd(record.cwd, workspaceRecords);
|
||||
if (!workspaceId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await options.agentStorage.upsert({ ...record, workspaceId });
|
||||
migrated += 1;
|
||||
}
|
||||
|
||||
if (migrated > 0) {
|
||||
options.logger.info({ migrated }, "Migrated legacy agent workspace ownership");
|
||||
}
|
||||
return migrated;
|
||||
}
|
||||
|
||||
export async function bootstrapWorkspaceRegistries(options: {
|
||||
paseoHome: string;
|
||||
agentStorage: AgentStorage;
|
||||
@@ -126,7 +61,7 @@ export async function bootstrapWorkspaceRegistries(options: {
|
||||
await Promise.all([options.projectRegistry.initialize(), options.workspaceRegistry.initialize()]);
|
||||
|
||||
if (projectsExists && workspacesExists) {
|
||||
await migrateLegacyAgentWorkspaceOwnership(options);
|
||||
await backfillWorkspaceIdForLegacyAgents(options);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -235,7 +170,7 @@ export async function bootstrapWorkspaceRegistries(options: {
|
||||
),
|
||||
);
|
||||
|
||||
await migrateLegacyAgentWorkspaceOwnership(options);
|
||||
await backfillWorkspaceIdForLegacyAgents(options);
|
||||
|
||||
options.logger.info(
|
||||
{
|
||||
@@ -247,9 +182,3 @@ export async function bootstrapWorkspaceRegistries(options: {
|
||||
"Workspace registries bootstrapped from existing agent storage",
|
||||
);
|
||||
}
|
||||
|
||||
function oldestWorkspace(workspaces: PersistedWorkspaceRecord[]): PersistedWorkspaceRecord {
|
||||
return workspaces.reduce((oldest, candidate) =>
|
||||
candidate.createdAt < oldest.createdAt ? candidate : oldest,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { basename, isAbsolute, resolve } from "node:path";
|
||||
|
||||
import {
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
deriveWorkspaceKind,
|
||||
detectStaleWorkspaces,
|
||||
generateWorkspaceId,
|
||||
resolveWorkspaceIdForRecord,
|
||||
} from "./workspace-registry-model.js";
|
||||
import { createPersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||
|
||||
@@ -62,18 +61,22 @@ describe("deriveProjectGroupingName", () => {
|
||||
|
||||
describe("detectStaleWorkspaces", () => {
|
||||
test("returns workspace ids whose directories no longer exist", async () => {
|
||||
const checkDirectoryExists = vi.fn(async (cwd: string) => cwd !== "/tmp/missing");
|
||||
const checkedDirectories: string[] = [];
|
||||
const existingDirectories = new Set(["/tmp/existing"]);
|
||||
|
||||
const staleWorkspaceIds = await detectStaleWorkspaces({
|
||||
activeWorkspaces: [
|
||||
createWorkspaceRecord("/tmp/existing", "ws-existing"),
|
||||
createWorkspaceRecord("/tmp/missing", "ws-missing"),
|
||||
],
|
||||
checkDirectoryExists,
|
||||
checkDirectoryExists: async (cwd) => {
|
||||
checkedDirectories.push(cwd);
|
||||
return existingDirectories.has(cwd);
|
||||
},
|
||||
});
|
||||
|
||||
expect(Array.from(staleWorkspaceIds)).toEqual(["ws-missing"]);
|
||||
expect(checkDirectoryExists.mock.calls).toEqual([["/tmp/existing"], ["/tmp/missing"]]);
|
||||
expect(checkedDirectories).toEqual(["/tmp/existing", "/tmp/missing"]);
|
||||
});
|
||||
|
||||
test("keeps workspaces whose directories exist even when all agents are archived", async () => {
|
||||
@@ -232,65 +235,3 @@ describe("git worktree grouping", () => {
|
||||
).toBe("worktree");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveWorkspaceIdForRecord", () => {
|
||||
test("resolves a stamped record to its workspaceId, ignoring cwd matches", () => {
|
||||
const workspaces = [
|
||||
createWorkspaceRecord("/tmp/repo", "ws-a"),
|
||||
createWorkspaceRecord("/tmp/repo", "ws-b"),
|
||||
];
|
||||
|
||||
const resolved = resolveWorkspaceIdForRecord(
|
||||
{ workspaceId: "ws-b", cwd: "/tmp/repo" },
|
||||
workspaces,
|
||||
);
|
||||
|
||||
expect(resolved).toBe("ws-b");
|
||||
});
|
||||
|
||||
test("falls back to the single cwd match for a legacy record without workspaceId", () => {
|
||||
const workspaces = [
|
||||
createWorkspaceRecord("/tmp/repo", "ws-only"),
|
||||
createWorkspaceRecord("/tmp/other", "ws-other"),
|
||||
];
|
||||
|
||||
const resolved = resolveWorkspaceIdForRecord({ cwd: "/tmp/repo" }, workspaces);
|
||||
|
||||
expect(resolved).toBe("ws-only");
|
||||
});
|
||||
|
||||
test("does not resolve an unstamped legacy record with multiple cwd matches", () => {
|
||||
const workspaces = [
|
||||
createWorkspaceRecord("/tmp/repo", "ws-newer", { createdAt: "2026-03-02T00:00:00.000Z" }),
|
||||
createWorkspaceRecord("/tmp/repo", "ws-older", { createdAt: "2026-03-01T00:00:00.000Z" }),
|
||||
];
|
||||
|
||||
const resolved = resolveWorkspaceIdForRecord({ cwd: "/tmp/repo" }, workspaces);
|
||||
|
||||
expect(resolved).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for a legacy record with no cwd match", () => {
|
||||
const workspaces = [createWorkspaceRecord("/tmp/other", "ws-other")];
|
||||
|
||||
const resolved = resolveWorkspaceIdForRecord({ cwd: "/tmp/repo" }, workspaces);
|
||||
|
||||
expect(resolved).toBeNull();
|
||||
});
|
||||
|
||||
test("does not move a stamped record to another workspace when its owner is archived", () => {
|
||||
const workspaces = [
|
||||
createWorkspaceRecord("/tmp/repo", "ws-archived", {
|
||||
archivedAt: "2026-03-05T00:00:00.000Z",
|
||||
}),
|
||||
createWorkspaceRecord("/tmp/repo", "ws-live"),
|
||||
];
|
||||
|
||||
const resolved = resolveWorkspaceIdForRecord(
|
||||
{ workspaceId: "ws-archived", cwd: "/tmp/repo" },
|
||||
workspaces,
|
||||
);
|
||||
|
||||
expect(resolved).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { homedir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import type {
|
||||
@@ -7,7 +6,6 @@ import type {
|
||||
ProjectPlacementPayload,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js";
|
||||
import { isSameOrDescendantPath } from "./path-utils.js";
|
||||
import type { PersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||
|
||||
export type PersistedProjectKind = "git" | "non_git";
|
||||
@@ -34,60 +32,6 @@ export function generateWorkspaceId(): string {
|
||||
return `wks_${randomBytes(8).toString("hex")}`;
|
||||
}
|
||||
|
||||
// COMPAT(workspaceOwnership): added in v0.1.97, drop after 2026-12-15 once floor >= v0.1.97.
|
||||
// Resolves the owning workspace for a record (agent/terminal) that may predate
|
||||
// workspaceId stamping. New records always carry workspaceId and hit the first
|
||||
// branch; legacy records fall back only when cwd has a single active owner.
|
||||
// Duplicate-cwd records must be stamped by migration before runtime projection;
|
||||
// otherwise cwd-only membership leaks into every same-directory workspace.
|
||||
export function resolveWorkspaceIdForRecord(
|
||||
record: { workspaceId?: string; cwd: string },
|
||||
activeWorkspaces: Iterable<PersistedWorkspaceRecord>,
|
||||
): string | null {
|
||||
const workspaces = Array.from(activeWorkspaces);
|
||||
if (record.workspaceId) {
|
||||
const exact = workspaces.find(
|
||||
(workspace) => !workspace.archivedAt && workspace.workspaceId === record.workspaceId,
|
||||
);
|
||||
if (exact) {
|
||||
return exact.workspaceId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedCwd = resolve(record.cwd);
|
||||
const cwdMatches = workspaces.filter(
|
||||
(workspace) => !workspace.archivedAt && resolve(workspace.cwd) === resolvedCwd,
|
||||
);
|
||||
if (cwdMatches.length === 1) {
|
||||
return cwdMatches[0].workspaceId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveActiveWorkspaceRecordForCwd(
|
||||
cwd: string,
|
||||
workspaces: Iterable<PersistedWorkspaceRecord>,
|
||||
): PersistedWorkspaceRecord | null {
|
||||
const resolvedCwd = resolve(cwd);
|
||||
const userHome = resolve(homedir());
|
||||
let bestMatch: { workspace: PersistedWorkspaceRecord; cwd: string } | null = null;
|
||||
|
||||
for (const workspace of workspaces) {
|
||||
if (workspace.archivedAt) continue;
|
||||
|
||||
const workspaceCwd = resolve(workspace.cwd);
|
||||
if (workspaceCwd === userHome && resolvedCwd !== workspaceCwd) continue;
|
||||
if (!isSameOrDescendantPath(workspaceCwd, resolvedCwd)) continue;
|
||||
if (!bestMatch || workspaceCwd.length > bestMatch.cwd.length) {
|
||||
bestMatch = { workspace, cwd: workspaceCwd };
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch?.workspace ?? null;
|
||||
}
|
||||
|
||||
// Path-derived grouping key for a workspace directory. This is NOT the opaque
|
||||
// workspace identity (see generateWorkspaceId); never persist or compare it as one.
|
||||
export function deriveWorkspaceDirectoryKey(
|
||||
|
||||
@@ -5,7 +5,10 @@ import path from "node:path";
|
||||
|
||||
import { DaemonClient } from "./test-utils/index.js";
|
||||
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
||||
import { createTestLogger } from "../test-utils/test-logger.js";
|
||||
import { AgentStorage } from "./agent/agent-storage.js";
|
||||
import { getAskModeConfig } from "./daemon-e2e/agent-configs.js";
|
||||
import { MockLoadTestAgentClient } from "./agent/providers/mock-load-test-agent.js";
|
||||
import {
|
||||
createPersistedProjectRecord,
|
||||
createPersistedWorkspaceRecord,
|
||||
@@ -13,10 +16,12 @@ import {
|
||||
|
||||
const WORKSPACE_A = "wks_same_cwd_a";
|
||||
const WORKSPACE_B = "wks_same_cwd_b";
|
||||
const LEGACY_OWNER_WORKSPACE = "wks_legacy_owner";
|
||||
const PERMISSION_WAIT_MS = 15_000;
|
||||
|
||||
// Seed two active workspaces that share one cwd, so we can prove ownership
|
||||
// stays workspaceId-scoped while aggregate status is cwd-scoped. Both
|
||||
// registry files must exist on disk before the daemon starts:
|
||||
// Seed two active workspaces that share one cwd, so we can prove agent
|
||||
// ownership and status stay workspaceId-scoped — a sibling that owns nothing
|
||||
// active stays done. Both registry files must exist on disk before the daemon starts:
|
||||
// bootstrapWorkspaceRegistries skips materialization when both files are
|
||||
// present, leaving these seeded records untouched.
|
||||
function seedSameCwdWorkspaces(): { paseoHomeRoot: string; cwd: string } {
|
||||
@@ -63,12 +68,291 @@ function seedSameCwdWorkspaces(): { paseoHomeRoot: string; cwd: string } {
|
||||
return { paseoHomeRoot, cwd };
|
||||
}
|
||||
|
||||
async function seedWorkspaceWithLegacyAgent(): Promise<{ paseoHomeRoot: string; cwd: string }> {
|
||||
const paseoHomeRoot = mkdtempSync(path.join(tmpdir(), "paseo-legacy-agent-home-"));
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-legacy-agent-dir-"));
|
||||
const paseoHome = path.join(paseoHomeRoot, ".paseo");
|
||||
const projectsDir = path.join(paseoHome, "projects");
|
||||
mkdirSync(projectsDir, { recursive: true });
|
||||
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: "prj_legacy_agent",
|
||||
rootPath: cwd,
|
||||
kind: "non_git",
|
||||
displayName: path.basename(cwd),
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
});
|
||||
const workspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: LEGACY_OWNER_WORKSPACE,
|
||||
projectId: project.projectId,
|
||||
cwd,
|
||||
kind: "directory",
|
||||
displayName: "original",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
writeFileSync(path.join(projectsDir, "projects.json"), JSON.stringify([project]));
|
||||
writeFileSync(path.join(projectsDir, "workspaces.json"), JSON.stringify([workspace]));
|
||||
|
||||
const agentStorage = new AgentStorage(path.join(paseoHome, "agents"), createTestLogger());
|
||||
await agentStorage.initialize();
|
||||
await agentStorage.upsert({
|
||||
id: "legacy-cwd-only-agent",
|
||||
provider: "codex",
|
||||
cwd,
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
lastActivityAt: "2026-03-01T12:00:00.000Z",
|
||||
lastUserMessageAt: null,
|
||||
title: "Legacy cwd-only agent",
|
||||
labels: {},
|
||||
lastStatus: "running",
|
||||
lastModeId: null,
|
||||
config: null,
|
||||
runtimeInfo: { provider: "codex", sessionId: null },
|
||||
persistence: null,
|
||||
archivedAt: null,
|
||||
});
|
||||
|
||||
return { paseoHomeRoot, cwd };
|
||||
}
|
||||
|
||||
async function statusByWorkspaceId(client: DaemonClient): Promise<Map<string, string>> {
|
||||
const workspaces = await client.fetchWorkspaces();
|
||||
return new Map(workspaces.entries.map((entry) => [entry.id, entry.status]));
|
||||
}
|
||||
|
||||
test("two workspaces sharing one cwd share agent status without leaking ownership", async () => {
|
||||
async function workspaceName(client: DaemonClient, workspaceId: string): Promise<string | null> {
|
||||
const workspaces = await client.fetchWorkspaces();
|
||||
return workspaces.entries.find((entry) => entry.id === workspaceId)?.name ?? null;
|
||||
}
|
||||
|
||||
async function legacyAgentWorkspaceId(client: DaemonClient): Promise<string | null | undefined> {
|
||||
const agents = await client.fetchAgents({ scope: "active" });
|
||||
return agents.entries.find((entry) => entry.agent.id === "legacy-cwd-only-agent")?.agent
|
||||
.workspaceId;
|
||||
}
|
||||
|
||||
async function agentIdsOwnedByWorkspace(
|
||||
client: DaemonClient,
|
||||
workspaceId: string,
|
||||
): Promise<string[]> {
|
||||
const agents = await client.fetchAgents({ scope: "active" });
|
||||
return agents.entries
|
||||
.filter((entry) => entry.agent.workspaceId === workspaceId)
|
||||
.map((entry) => entry.agent.id);
|
||||
}
|
||||
|
||||
async function waitForPermission(client: DaemonClient, agentId: string) {
|
||||
const parked = await client.waitForFinish(agentId, PERMISSION_WAIT_MS);
|
||||
expect(parked.status).toBe("permission");
|
||||
return parked;
|
||||
}
|
||||
|
||||
test("daemon bootstrap migrates cwd-only legacy agents before same-cwd workspaces are added", async () => {
|
||||
const { paseoHomeRoot, cwd } = await seedWorkspaceWithLegacyAgent();
|
||||
const daemon = await createTestPaseoDaemon({ paseoHomeRoot });
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
appVersion: "0.1.82",
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
expect(await legacyAgentWorkspaceId(client)).toBe(LEGACY_OWNER_WORKSPACE);
|
||||
expect(await statusByWorkspaceId(client)).toEqual(
|
||||
new Map([[LEGACY_OWNER_WORKSPACE, "running"]]),
|
||||
);
|
||||
|
||||
const created = await client.createWorkspace({
|
||||
source: { kind: "directory", path: cwd, projectId: "prj_legacy_agent" },
|
||||
title: "Fresh same-cwd workspace",
|
||||
});
|
||||
const createdWorkspaceId = created.workspace?.id;
|
||||
if (!createdWorkspaceId) {
|
||||
throw new Error(created.error ?? "Expected same-cwd workspace to be created");
|
||||
}
|
||||
|
||||
// The migrated legacy agent stays owned by LEGACY_OWNER. The freshly created
|
||||
// same-cwd workspace owns nothing, so its status is done — status is per id,
|
||||
// never shared across same-cwd workspaces.
|
||||
expect(await legacyAgentWorkspaceId(client)).toBe(LEGACY_OWNER_WORKSPACE);
|
||||
expect(await agentIdsOwnedByWorkspace(client, LEGACY_OWNER_WORKSPACE)).toEqual([
|
||||
"legacy-cwd-only-agent",
|
||||
]);
|
||||
expect(await agentIdsOwnedByWorkspace(client, createdWorkspaceId)).toEqual([]);
|
||||
expect(await statusByWorkspaceId(client)).toEqual(
|
||||
new Map([
|
||||
[LEGACY_OWNER_WORKSPACE, "running"],
|
||||
[createdWorkspaceId, "done"],
|
||||
]),
|
||||
);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await daemon.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace.create directory source with firstAgentContext generates a daemon-visible workspace title", async () => {
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-named-local-dir-"));
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: { mock: new MockLoadTestAgentClient() },
|
||||
});
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
appVersion: "0.1.82",
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
await client.patchDaemonConfig({
|
||||
metadataGeneration: { providers: [{ provider: "mock", model: "ten-second-stream" }] },
|
||||
});
|
||||
|
||||
const created = await client.createWorkspace({
|
||||
source: { kind: "directory", path: cwd },
|
||||
firstAgentContext: {
|
||||
prompt: "Fix login bug",
|
||||
attachments: [],
|
||||
},
|
||||
});
|
||||
const workspaceId = created.workspace?.id;
|
||||
if (!workspaceId) {
|
||||
throw new Error(created.error ?? "Expected workspace to be created");
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(() => workspaceName(client, workspaceId), { timeout: 10_000 })
|
||||
.toBe("Fix login bug");
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await daemon.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 20_000);
|
||||
|
||||
test("create_agent_request with initialPrompt generates a daemon-visible workspace title", async () => {
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-agent-submit-title-"));
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: { mock: new MockLoadTestAgentClient() },
|
||||
});
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
appVersion: "0.1.82",
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
await client.patchDaemonConfig({
|
||||
metadataGeneration: { providers: [{ provider: "mock", model: "ten-second-stream" }] },
|
||||
});
|
||||
|
||||
const created = await client.createWorkspace({
|
||||
source: { kind: "directory", path: cwd },
|
||||
});
|
||||
const workspaceId = created.workspace?.id;
|
||||
if (!workspaceId) {
|
||||
throw new Error(created.error ?? "Expected workspace to be created");
|
||||
}
|
||||
|
||||
const agent = await client.createAgent({
|
||||
provider: "mock",
|
||||
cwd,
|
||||
workspaceId,
|
||||
model: "ten-second-stream",
|
||||
initialPrompt: "Fix login bug",
|
||||
});
|
||||
expect(agent.workspaceId).toBe(workspaceId);
|
||||
|
||||
await expect
|
||||
.poll(() => workspaceName(client, workspaceId), { timeout: 10_000 })
|
||||
.toBe("Fix login bug");
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await daemon.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 20_000);
|
||||
|
||||
test("creating another same-cwd local workspace keeps running status on the owning workspace only", async () => {
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-running-same-cwd-create-"));
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: { mock: new MockLoadTestAgentClient() },
|
||||
});
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
appVersion: "0.1.82",
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
|
||||
const first = await client.createWorkspace({
|
||||
source: { kind: "directory", path: cwd },
|
||||
title: "First same-cwd workspace",
|
||||
});
|
||||
const firstWorkspaceId = first.workspace?.id;
|
||||
if (!firstWorkspaceId) {
|
||||
throw new Error(first.error ?? "Expected first workspace to be created");
|
||||
}
|
||||
|
||||
const second = await client.createWorkspace({
|
||||
source: { kind: "directory", path: cwd },
|
||||
title: "Second same-cwd workspace",
|
||||
});
|
||||
const secondWorkspaceId = second.workspace?.id;
|
||||
if (!secondWorkspaceId) {
|
||||
throw new Error(second.error ?? "Expected second workspace to be created");
|
||||
}
|
||||
|
||||
const agent = await client.createAgent({
|
||||
provider: "mock",
|
||||
cwd,
|
||||
workspaceId: firstWorkspaceId,
|
||||
model: "five-minute-stream",
|
||||
initialPrompt: "stay running",
|
||||
});
|
||||
expect(agent.workspaceId).toBe(firstWorkspaceId);
|
||||
|
||||
await client.waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "running", 15_000);
|
||||
|
||||
// Only the workspace that owns the agent is running. The same-cwd sibling
|
||||
// owns nothing active and stays done — status never fans out across a cwd.
|
||||
expect(await statusByWorkspaceId(client)).toEqual(
|
||||
new Map([
|
||||
[firstWorkspaceId, "running"],
|
||||
[secondWorkspaceId, "done"],
|
||||
]),
|
||||
);
|
||||
|
||||
const third = await client.createWorkspace({
|
||||
source: { kind: "directory", path: cwd },
|
||||
title: "Third same-cwd workspace",
|
||||
});
|
||||
const thirdWorkspaceId = third.workspace?.id;
|
||||
if (!thirdWorkspaceId) {
|
||||
throw new Error(third.error ?? "Expected third workspace to be created");
|
||||
}
|
||||
|
||||
expect((await client.fetchAgent(agent.id))?.agent.status).toBe("running");
|
||||
expect(await statusByWorkspaceId(client)).toEqual(
|
||||
new Map([
|
||||
[firstWorkspaceId, "running"],
|
||||
[secondWorkspaceId, "done"],
|
||||
[thirdWorkspaceId, "done"],
|
||||
]),
|
||||
);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await daemon.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
test("two workspaces sharing one cwd compute agent status per workspaceId", async () => {
|
||||
const { paseoHomeRoot, cwd } = seedSameCwdWorkspaces();
|
||||
const daemon = await createTestPaseoDaemon({ paseoHomeRoot });
|
||||
const client = new DaemonClient({
|
||||
@@ -88,9 +372,9 @@ test("two workspaces sharing one cwd share agent status without leaking ownershi
|
||||
);
|
||||
|
||||
// 1. Agent created in workspace A carries workspaceId A. Ask mode + a
|
||||
// write parks the agent on a pending permission, which contributes the
|
||||
// deterministic "needs_input" aggregate signal to all same-cwd
|
||||
// workspaces.
|
||||
// write parks the agent on a pending permission, which drives the
|
||||
// "needs_input" signal onto workspace A only — its same-cwd sibling B
|
||||
// owns nothing and stays done.
|
||||
const agentA = await client.createAgent({
|
||||
...getAskModeConfig("codex"),
|
||||
cwd,
|
||||
@@ -103,8 +387,7 @@ test("two workspaces sharing one cwd share agent status without leaking ownershi
|
||||
agentA.id,
|
||||
'Use your shell tool to run: `printf "ok" > a.txt`. Request permission and wait.',
|
||||
);
|
||||
const parkedA = await client.waitForFinish(agentA.id, 60000);
|
||||
expect(parkedA.status).toBe("permission");
|
||||
await waitForPermission(client, agentA.id);
|
||||
|
||||
const fetchedA = await client.fetchAgent(agentA.id);
|
||||
expect(fetchedA?.agent.workspaceId).toBe(WORKSPACE_A);
|
||||
@@ -112,27 +395,12 @@ test("two workspaces sharing one cwd share agent status without leaking ownershi
|
||||
expect(await statusByWorkspaceId(client)).toEqual(
|
||||
new Map([
|
||||
[WORKSPACE_A, "needs_input"],
|
||||
[WORKSPACE_B, "needs_input"],
|
||||
[WORKSPACE_B, "done"],
|
||||
]),
|
||||
);
|
||||
|
||||
// 2. Terminal created in A is delivered to A's directory subscription and to
|
||||
// A's list, but never to B's — exercises the workspaceId terminal filter.
|
||||
const terminalSnapshotsByWorkspace = new Map<string | undefined, Set<string>>();
|
||||
const unsubscribeTerminals = client.on("terminals_changed", (message) => {
|
||||
if (message.type !== "terminals_changed") {
|
||||
return;
|
||||
}
|
||||
for (const terminal of message.payload.terminals) {
|
||||
const seen = terminalSnapshotsByWorkspace.get(terminal.workspaceId) ?? new Set<string>();
|
||||
seen.add(terminal.id);
|
||||
terminalSnapshotsByWorkspace.set(terminal.workspaceId, seen);
|
||||
}
|
||||
});
|
||||
|
||||
client.subscribeTerminals({ cwd, workspaceId: WORKSPACE_A });
|
||||
client.subscribeTerminals({ cwd, workspaceId: WORKSPACE_B });
|
||||
|
||||
// 2. Terminal created in A is visible in A's list, but never in B's —
|
||||
// exercises the workspaceId terminal filter at the daemon boundary.
|
||||
const createdTerminal = await client.createTerminal(cwd, "A terminal", undefined, {
|
||||
workspaceId: WORKSPACE_A,
|
||||
});
|
||||
@@ -142,26 +410,15 @@ test("two workspaces sharing one cwd share agent status without leaking ownershi
|
||||
throw new Error("Expected a created terminal id");
|
||||
}
|
||||
|
||||
// A's directory subscription receives a snapshot attributing the terminal
|
||||
// to A. Poll the observed snapshot state instead of sleeping for a fixed
|
||||
// window, so the assertion never races the daemon's snapshot push.
|
||||
await expect
|
||||
.poll(() => terminalSnapshotsByWorkspace.get(WORKSPACE_A)?.has(terminalId) ?? false)
|
||||
.toBe(true);
|
||||
unsubscribeTerminals();
|
||||
|
||||
// No snapshot ever attributed the terminal to B (the only other same-cwd
|
||||
// workspace).
|
||||
expect(terminalSnapshotsByWorkspace.get(WORKSPACE_B)?.has(terminalId) ?? false).toBe(false);
|
||||
|
||||
const listForA = await client.listTerminals(cwd, undefined, { workspaceId: WORKSPACE_A });
|
||||
expect(listForA.terminals.some((terminal) => terminal.id === terminalId)).toBe(true);
|
||||
|
||||
const listForB = await client.listTerminals(cwd, undefined, { workspaceId: WORKSPACE_B });
|
||||
expect(listForB.terminals.some((terminal) => terminal.id === terminalId)).toBe(false);
|
||||
|
||||
// 3. Agent created in workspace B carries workspaceId B while the aggregate
|
||||
// needs_input status stays shared across both same-cwd workspace rows.
|
||||
// 3. Agent created in workspace B carries workspaceId B and parks too. Now
|
||||
// each workspace owns its own parked agent, so both read needs_input —
|
||||
// by per-id ownership, not by sharing a cwd.
|
||||
const agentB = await client.createAgent({
|
||||
...getAskModeConfig("codex"),
|
||||
cwd,
|
||||
@@ -174,8 +431,7 @@ test("two workspaces sharing one cwd share agent status without leaking ownershi
|
||||
agentB.id,
|
||||
'Use your shell tool to run: `printf "ok" > b.txt`. Request permission and wait.',
|
||||
);
|
||||
const parkedB = await client.waitForFinish(agentB.id, 60000);
|
||||
expect(parkedB.status).toBe("permission");
|
||||
await waitForPermission(client, agentB.id);
|
||||
|
||||
const fetchedB = await client.fetchAgent(agentB.id);
|
||||
expect(fetchedB?.agent.workspaceId).toBe(WORKSPACE_B);
|
||||
|
||||
@@ -804,11 +804,19 @@ async function acquireWorkspaceScriptTerminal(params: {
|
||||
existingRuntimeEntry: ReturnType<WorkspaceScriptRuntimeStore["get"]>;
|
||||
terminalManager: TerminalManager;
|
||||
repoRoot: string;
|
||||
workspaceId: string;
|
||||
scriptName: string;
|
||||
env: Record<string, string> | undefined;
|
||||
}): Promise<{ terminal: TerminalSession; reusableTerminal: TerminalSession | null }> {
|
||||
const { serviceScript, existingRuntimeEntry, terminalManager, repoRoot, scriptName, env } =
|
||||
params;
|
||||
const {
|
||||
serviceScript,
|
||||
existingRuntimeEntry,
|
||||
terminalManager,
|
||||
repoRoot,
|
||||
workspaceId,
|
||||
scriptName,
|
||||
env,
|
||||
} = params;
|
||||
let reusableTerminal: TerminalSession | null = null;
|
||||
if (!serviceScript && existingRuntimeEntry?.terminalId) {
|
||||
reusableTerminal = terminalManager.getTerminal(existingRuntimeEntry.terminalId) ?? null;
|
||||
@@ -817,6 +825,7 @@ async function acquireWorkspaceScriptTerminal(params: {
|
||||
reusableTerminal ??
|
||||
(await terminalManager.createTerminal({
|
||||
cwd: repoRoot,
|
||||
workspaceId,
|
||||
name: scriptName,
|
||||
title: scriptName,
|
||||
env,
|
||||
@@ -892,6 +901,7 @@ export async function spawnWorkspaceScript(
|
||||
existingRuntimeEntry,
|
||||
terminalManager,
|
||||
repoRoot,
|
||||
workspaceId,
|
||||
scriptName,
|
||||
env,
|
||||
});
|
||||
|
||||
@@ -104,7 +104,7 @@ function createWorkflowForRequestTest(options: {
|
||||
paseoHome: options.paseoHome,
|
||||
createPaseoWorktree,
|
||||
warmWorkspaceGitData: options.warmWorkspaceGitData ?? (async () => {}),
|
||||
emitWorkspaceUpdateForCwd: async () => {},
|
||||
emitWorkspaceUpdateForWorkspaceId: async () => {},
|
||||
cacheWorkspaceSetupSnapshot: () => {},
|
||||
emit: () => {},
|
||||
sessionLogger: createLogger(),
|
||||
@@ -428,7 +428,7 @@ describe("create-agent worktree setup boundary", () => {
|
||||
paseoHome,
|
||||
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }),
|
||||
warmWorkspaceGitData: async () => {},
|
||||
emitWorkspaceUpdateForCwd: async () => {},
|
||||
emitWorkspaceUpdateForWorkspaceId: async () => {},
|
||||
cacheWorkspaceSetupSnapshot: () => {},
|
||||
emit: (message) => workspaceSetupEvents.push(message),
|
||||
sessionLogger: createLogger(),
|
||||
@@ -497,7 +497,7 @@ function createAgentStorageStub(): Pick<AgentStorage, "list"> {
|
||||
|
||||
function createWorkspaceArchivingDeps() {
|
||||
return {
|
||||
resolveWorkspaceIdForCwd: vi.fn(async () => "ws-archive-test"),
|
||||
findWorkspaceIdForCwd: vi.fn(async () => "ws-archive-test"),
|
||||
listActiveWorkspaces: vi.fn(async () => []),
|
||||
emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async () => {}),
|
||||
markWorkspaceArchiving: vi.fn(),
|
||||
@@ -588,13 +588,13 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
const snapshots = new Map<string, unknown>();
|
||||
const logger = createLogger();
|
||||
const terminalManager = createTerminalManagerStub();
|
||||
const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
|
||||
const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {});
|
||||
const archiveWorkspaceRecord = vi.fn(async () => {});
|
||||
|
||||
await runWorktreeSetupInBackground(
|
||||
{
|
||||
paseoHome,
|
||||
emitWorkspaceUpdateForCwd,
|
||||
emitWorkspaceUpdateForWorkspaceId,
|
||||
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) =>
|
||||
snapshots.set(workspaceId, snapshot),
|
||||
emit: (message) => emitted.push(message),
|
||||
@@ -659,7 +659,7 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
|
||||
expect(terminalManager.terminals).toHaveLength(0);
|
||||
expect(archiveWorkspaceRecord).not.toHaveBeenCalled();
|
||||
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath);
|
||||
expect(emitWorkspaceUpdateForWorkspaceId).toHaveBeenCalledWith("42");
|
||||
});
|
||||
|
||||
test("archives the pending workspace and emits a failed snapshot when setup cannot start", async () => {
|
||||
@@ -686,14 +686,14 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const snapshots = new Map<string, unknown>();
|
||||
const logger = createLogger();
|
||||
const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
|
||||
const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {});
|
||||
const archiveWorkspaceRecord = vi.fn(async () => {});
|
||||
const workspaceId = "ws-broken-feature";
|
||||
|
||||
await runWorktreeSetupInBackground(
|
||||
{
|
||||
paseoHome,
|
||||
emitWorkspaceUpdateForCwd,
|
||||
emitWorkspaceUpdateForWorkspaceId,
|
||||
cacheWorkspaceSetupSnapshot: (snapshotWorkspaceId, snapshot) =>
|
||||
snapshots.set(snapshotWorkspaceId, snapshot),
|
||||
emit: (message) => emitted.push(message),
|
||||
@@ -732,7 +732,7 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
error: expect.stringMatching(/Failed to parse paseo\.json at .*paseo\.json/),
|
||||
});
|
||||
expect(archiveWorkspaceRecord).toHaveBeenCalledWith(workspaceId);
|
||||
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath);
|
||||
expect(emitWorkspaceUpdateForWorkspaceId).toHaveBeenCalledWith(workspaceId);
|
||||
});
|
||||
|
||||
// POSIX-only: setup command is hardcoded to sh, printf, and sleep.
|
||||
@@ -761,13 +761,13 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const snapshots = new Map<string, unknown>();
|
||||
const logger = createLogger();
|
||||
const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
|
||||
const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {});
|
||||
const archiveWorkspaceRecord = vi.fn(async () => {});
|
||||
|
||||
await runWorktreeSetupInBackground(
|
||||
{
|
||||
paseoHome,
|
||||
emitWorkspaceUpdateForCwd,
|
||||
emitWorkspaceUpdateForWorkspaceId,
|
||||
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) =>
|
||||
snapshots.set(workspaceId, snapshot),
|
||||
emit: (message) => emitted.push(message),
|
||||
@@ -885,13 +885,13 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
const snapshots = new Map<string, unknown>();
|
||||
const logger = createLogger();
|
||||
const terminalManager = createTerminalManagerStub();
|
||||
const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
|
||||
const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {});
|
||||
const archiveWorkspaceRecord = vi.fn(async () => {});
|
||||
|
||||
await runWorktreeSetupInBackground(
|
||||
{
|
||||
paseoHome,
|
||||
emitWorkspaceUpdateForCwd,
|
||||
emitWorkspaceUpdateForWorkspaceId,
|
||||
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) =>
|
||||
snapshots.set(workspaceId, snapshot),
|
||||
emit: (message) => emitted.push(message),
|
||||
@@ -947,7 +947,7 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
error: null,
|
||||
});
|
||||
expect(archiveWorkspaceRecord).not.toHaveBeenCalled();
|
||||
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(existingWorktree.worktreePath);
|
||||
expect(emitWorkspaceUpdateForWorkspaceId).toHaveBeenCalledWith("44");
|
||||
});
|
||||
|
||||
test("keeps setup completed without attempting script launch afterward", async () => {
|
||||
@@ -980,13 +980,13 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
throw new Error("terminal spawn failed");
|
||||
},
|
||||
});
|
||||
const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
|
||||
const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {});
|
||||
const archiveWorkspaceRecord = vi.fn(async () => {});
|
||||
|
||||
await runWorktreeSetupInBackground(
|
||||
{
|
||||
paseoHome,
|
||||
emitWorkspaceUpdateForCwd,
|
||||
emitWorkspaceUpdateForWorkspaceId,
|
||||
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) =>
|
||||
snapshots.set(workspaceId, snapshot),
|
||||
emit: (message) => emitted.push(message),
|
||||
@@ -1033,7 +1033,7 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
error: null,
|
||||
});
|
||||
expect(archiveWorkspaceRecord).not.toHaveBeenCalled();
|
||||
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath);
|
||||
expect(emitWorkspaceUpdateForWorkspaceId).toHaveBeenCalledWith("45");
|
||||
});
|
||||
|
||||
test("does not auto-start scripts in socket mode", async () => {
|
||||
@@ -1062,13 +1062,13 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
const snapshots = new Map<string, unknown>();
|
||||
const logger = createLogger();
|
||||
const terminalManager = createTerminalManagerStub();
|
||||
const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
|
||||
const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {});
|
||||
const archiveWorkspaceRecord = vi.fn(async () => {});
|
||||
|
||||
await runWorktreeSetupInBackground(
|
||||
{
|
||||
paseoHome,
|
||||
emitWorkspaceUpdateForCwd,
|
||||
emitWorkspaceUpdateForWorkspaceId,
|
||||
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) =>
|
||||
snapshots.set(workspaceId, snapshot),
|
||||
emit: (message) => emitted.push(message),
|
||||
@@ -1096,7 +1096,7 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
error: null,
|
||||
});
|
||||
expect(archiveWorkspaceRecord).not.toHaveBeenCalled();
|
||||
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath);
|
||||
expect(emitWorkspaceUpdateForWorkspaceId).toHaveBeenCalledWith("46");
|
||||
});
|
||||
|
||||
test("returns the cached workspace setup snapshot for status requests", async () => {
|
||||
@@ -1816,7 +1816,7 @@ describe("archivePaseoWorktree", () => {
|
||||
agentStorage: createAgentStorageStub(),
|
||||
archiveWorkspaceRecord: vi.fn(async () => {}),
|
||||
...createWorkspaceArchivingDeps(),
|
||||
resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
|
||||
findWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
|
||||
cwd === created.worktreePath ? workspaceId : null,
|
||||
),
|
||||
killTerminalsForWorkspace,
|
||||
@@ -1924,7 +1924,7 @@ describe("archivePaseoWorktree", () => {
|
||||
archivedWorkspaceRecords.push(id);
|
||||
},
|
||||
...createWorkspaceArchivingDeps(),
|
||||
resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
|
||||
findWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
|
||||
cwd === sharedCwd ? workspaceA : null,
|
||||
),
|
||||
killTerminalsForWorkspace: (workspaceId) =>
|
||||
@@ -2030,7 +2030,7 @@ describe("archivePaseoWorktree", () => {
|
||||
archiveSnapshot,
|
||||
},
|
||||
agentStorage: createAgentStorageStub(),
|
||||
resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
|
||||
findWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
|
||||
cwd === created.worktreePath ? workspaceId : null,
|
||||
),
|
||||
listActiveWorkspaces: vi.fn(async () => []),
|
||||
@@ -2135,7 +2135,7 @@ describe("archivePaseoWorktree", () => {
|
||||
agentStorage: createAgentStorageStub(),
|
||||
archiveWorkspaceRecord,
|
||||
...createWorkspaceArchivingDeps(),
|
||||
resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
|
||||
findWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
|
||||
cwd === created.worktreePath ? workspaceId : null,
|
||||
),
|
||||
killTerminalsForWorkspace: vi.fn(async () => {}),
|
||||
@@ -2184,7 +2184,7 @@ describe("archivePaseoWorktree", () => {
|
||||
agentStorage: createAgentStorageStub(),
|
||||
archiveWorkspaceRecord: vi.fn(async () => {}),
|
||||
...createWorkspaceArchivingDeps(),
|
||||
resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
|
||||
findWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
|
||||
cwd === created.worktreePath ? "ws-archive-refresh" : null,
|
||||
),
|
||||
killTerminalsForWorkspace: vi.fn(async () => {}),
|
||||
@@ -2233,7 +2233,7 @@ describe("archivePaseoWorktree", () => {
|
||||
agentStorage: createAgentStorageStub(),
|
||||
archiveWorkspaceRecord: vi.fn(async () => {}),
|
||||
...createWorkspaceArchivingDeps(),
|
||||
resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
|
||||
findWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
|
||||
cwd === created.worktreePath ? workspaceId : null,
|
||||
),
|
||||
// No other active workspace references the worktree dir.
|
||||
@@ -2285,7 +2285,7 @@ describe("archivePaseoWorktree", () => {
|
||||
agentStorage: createAgentStorageStub(),
|
||||
archiveWorkspaceRecord: vi.fn(async () => {}),
|
||||
...createWorkspaceArchivingDeps(),
|
||||
resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
|
||||
findWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
|
||||
cwd === sharedCwd ? workspaceA : null,
|
||||
),
|
||||
// Sibling workspace B still references the same worktree directory.
|
||||
@@ -2336,7 +2336,7 @@ describe("archivePaseoWorktree", () => {
|
||||
agentStorage: createAgentStorageStub(),
|
||||
archiveWorkspaceRecord: vi.fn(async () => {}),
|
||||
...createWorkspaceArchivingDeps(),
|
||||
resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
|
||||
findWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
|
||||
cwd === created.worktreePath ? workspaceId : null,
|
||||
),
|
||||
listActiveWorkspaces,
|
||||
|
||||
@@ -97,7 +97,7 @@ interface BuildAgentSessionConfigDependencies {
|
||||
interface CreatePaseoWorktreeInBackgroundDependencies {
|
||||
paseoHome?: string;
|
||||
worktreesRoot?: string;
|
||||
emitWorkspaceUpdateForCwd: (cwd: string, options?: { dedupeGitState?: boolean }) => Promise<void>;
|
||||
emitWorkspaceUpdateForWorkspaceId: (workspaceId: string) => Promise<void>;
|
||||
cacheWorkspaceSetupSnapshot: (workspaceId: string, snapshot: WorkspaceSetupSnapshot) => void;
|
||||
emit: EmitSessionMessage;
|
||||
sessionLogger: Logger;
|
||||
@@ -764,6 +764,6 @@ export async function runWorktreeSetupInBackground(
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
await dependencies.emitWorkspaceUpdateForCwd(worktree.worktreePath);
|
||||
await dependencies.emitWorkspaceUpdateForWorkspaceId(options.workspaceId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ it("returns empty list for new cwd", async () => {
|
||||
it("returns existing terminals on subsequent calls", async () => {
|
||||
manager = createTerminalManager();
|
||||
const cwd = realpathSync(tmpdir());
|
||||
const created = await manager.createTerminal({ cwd });
|
||||
const created = await manager.createTerminal({ cwd, workspaceId: "ws-test" });
|
||||
const first = await manager.getTerminals(cwd);
|
||||
const second = await manager.getTerminals(cwd);
|
||||
|
||||
@@ -89,8 +89,8 @@ it("creates separate terminals for different cwds", async () => {
|
||||
const firstCwd = mkdtempSync(join(tmpdir(), "terminal-manager-first-"));
|
||||
const secondCwd = mkdtempSync(join(tmpdir(), "terminal-manager-second-"));
|
||||
temporaryDirs.push(firstCwd, secondCwd);
|
||||
const tmpTerminals = [await manager.createTerminal({ cwd: firstCwd })];
|
||||
const homeTerminals = [await manager.createTerminal({ cwd: secondCwd })];
|
||||
const tmpTerminals = [await manager.createTerminal({ cwd: firstCwd, workspaceId: "ws-test" })];
|
||||
const homeTerminals = [await manager.createTerminal({ cwd: secondCwd, workspaceId: "ws-test" })];
|
||||
|
||||
expect(tmpTerminals.length).toBe(1);
|
||||
expect(homeTerminals.length).toBe(1);
|
||||
@@ -104,7 +104,11 @@ it("lists subdirectory terminals when querying the workspace root", async () =>
|
||||
mkdirSync(subdirCwd, { recursive: true });
|
||||
temporaryDirs.push(rootCwd);
|
||||
|
||||
const created = await manager.createTerminal({ cwd: subdirCwd, name: "Mobile" });
|
||||
const created = await manager.createTerminal({
|
||||
cwd: subdirCwd,
|
||||
name: "Mobile",
|
||||
workspaceId: "ws-test",
|
||||
});
|
||||
|
||||
const rootTerminals = await manager.getTerminals(rootCwd);
|
||||
expect(rootTerminals.map((terminal) => terminal.id)).toEqual([created.id]);
|
||||
@@ -113,7 +117,7 @@ it("lists subdirectory terminals when querying the workspace root", async () =>
|
||||
it("includes only stamped terminals in workspace-scoped queries", async () => {
|
||||
manager = createTerminalManager();
|
||||
const cwd = realpathSync(tmpdir());
|
||||
const legacy = await manager.createTerminal({ cwd });
|
||||
const legacy = await manager.createTerminal({ cwd, workspaceId: "ws-test" });
|
||||
const owned = await manager.createTerminal({ cwd, workspaceId: "ws-owned" });
|
||||
const sibling = await manager.createTerminal({ cwd, workspaceId: "ws-sibling" });
|
||||
|
||||
@@ -127,8 +131,8 @@ it("includes only stamped terminals in workspace-scoped queries", async () => {
|
||||
it("creates additional terminal with auto-incrementing name", async () => {
|
||||
manager = createTerminalManager();
|
||||
const cwd = realpathSync(tmpdir());
|
||||
await manager.createTerminal({ cwd });
|
||||
const second = await manager.createTerminal({ cwd });
|
||||
await manager.createTerminal({ cwd, workspaceId: "ws-test" });
|
||||
const second = await manager.createTerminal({ cwd, workspaceId: "ws-test" });
|
||||
|
||||
expect(second.name).toBe("Terminal 2");
|
||||
|
||||
@@ -138,7 +142,11 @@ it("creates additional terminal with auto-incrementing name", async () => {
|
||||
|
||||
it("uses custom name when provided", async () => {
|
||||
manager = createTerminalManager();
|
||||
const session = await manager.createTerminal({ cwd: realpathSync(tmpdir()), name: "Dev Server" });
|
||||
const session = await manager.createTerminal({
|
||||
cwd: realpathSync(tmpdir()),
|
||||
name: "Dev Server",
|
||||
workspaceId: "ws-test",
|
||||
});
|
||||
|
||||
expect(session.name).toBe("Dev Server");
|
||||
});
|
||||
@@ -146,7 +154,7 @@ it("uses custom name when provided", async () => {
|
||||
it("creates first terminal if none exist", async () => {
|
||||
manager = createTerminalManager();
|
||||
const cwd = realpathSync(tmpdir());
|
||||
const session = await manager.createTerminal({ cwd });
|
||||
const session = await manager.createTerminal({ cwd, workspaceId: "ws-test" });
|
||||
|
||||
expect(session.name).toBe("Terminal 1");
|
||||
|
||||
@@ -157,7 +165,9 @@ it("creates first terminal if none exist", async () => {
|
||||
|
||||
it("throws for relative paths", async () => {
|
||||
manager = createTerminalManager();
|
||||
await expect(manager.createTerminal({ cwd: "tmp" })).rejects.toThrow("cwd must be absolute path");
|
||||
await expect(manager.createTerminal({ cwd: "tmp", workspaceId: "ws-test" })).rejects.toThrow(
|
||||
"cwd must be absolute path",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not reject Windows absolute paths as relative", async () => {
|
||||
@@ -166,7 +176,7 @@ it("does not reject Windows absolute paths as relative", async () => {
|
||||
temporaryDirs.push(cwd);
|
||||
|
||||
try {
|
||||
await manager.createTerminal({ cwd });
|
||||
await manager.createTerminal({ cwd, workspaceId: "ws-test" });
|
||||
} catch (error) {
|
||||
expect((error as Error).message).not.toBe("cwd must be absolute path");
|
||||
}
|
||||
@@ -183,6 +193,7 @@ it("inherits registered env for the worktree root cwd", async () => {
|
||||
env: { PASEO_WORKTREE_PORT: "45678" },
|
||||
});
|
||||
await manager.createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd,
|
||||
command: process.execPath,
|
||||
args: [
|
||||
@@ -208,6 +219,7 @@ it("inherits registered env for subdirectories within the worktree", async () =>
|
||||
env: { PASEO_WORKTREE_PORT: "45679" },
|
||||
});
|
||||
await manager.createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: subdirCwd,
|
||||
command: process.execPath,
|
||||
args: [
|
||||
@@ -222,7 +234,10 @@ it("inherits registered env for subdirectories within the worktree", async () =>
|
||||
|
||||
it("returns terminal by id", async () => {
|
||||
manager = createTerminalManager();
|
||||
const session = await manager.createTerminal({ cwd: realpathSync(tmpdir()) });
|
||||
const session = await manager.createTerminal({
|
||||
cwd: realpathSync(tmpdir()),
|
||||
workspaceId: "ws-test",
|
||||
});
|
||||
const found = manager.getTerminal(session.id);
|
||||
|
||||
expect(found).toBe(session);
|
||||
@@ -237,7 +252,10 @@ it("returns undefined for unknown id", () => {
|
||||
|
||||
it("removes terminal from manager", async () => {
|
||||
manager = createTerminalManager();
|
||||
const session = await manager.createTerminal({ cwd: realpathSync(tmpdir()) });
|
||||
const session = await manager.createTerminal({
|
||||
cwd: realpathSync(tmpdir()),
|
||||
workspaceId: "ws-test",
|
||||
});
|
||||
const id = session.id;
|
||||
|
||||
manager.killTerminal(id);
|
||||
@@ -248,7 +266,7 @@ it("removes terminal from manager", async () => {
|
||||
it("removes cwd entry when last terminal is killed", async () => {
|
||||
manager = createTerminalManager();
|
||||
const cwd = realpathSync(tmpdir());
|
||||
const created = await manager.createTerminal({ cwd });
|
||||
const created = await manager.createTerminal({ cwd, workspaceId: "ws-test" });
|
||||
manager.killTerminal(created.id);
|
||||
|
||||
const remaining = await manager.getTerminals(cwd);
|
||||
@@ -259,8 +277,8 @@ it("removes cwd entry when last terminal is killed", async () => {
|
||||
it("keeps cwd entry when other terminals remain", async () => {
|
||||
manager = createTerminalManager();
|
||||
const cwd = realpathSync(tmpdir());
|
||||
await manager.createTerminal({ cwd });
|
||||
const second = await manager.createTerminal({ cwd });
|
||||
await manager.createTerminal({ cwd, workspaceId: "ws-test" });
|
||||
const second = await manager.createTerminal({ cwd, workspaceId: "ws-test" });
|
||||
|
||||
const terminals = await manager.getTerminals(cwd);
|
||||
manager.killTerminal(terminals[0].id);
|
||||
@@ -279,7 +297,7 @@ it("is no-op for unknown id", () => {
|
||||
it("auto-removes terminal when shell exits", async () => {
|
||||
manager = createTerminalManager();
|
||||
const cwd = realpathSync(tmpdir());
|
||||
const session = await manager.createTerminal({ cwd });
|
||||
const session = await manager.createTerminal({ cwd, workspaceId: "ws-test" });
|
||||
const exitedId = session.id;
|
||||
session.kill();
|
||||
|
||||
@@ -301,8 +319,8 @@ it("returns all cwds with active terminals", async () => {
|
||||
const firstCwd = mkdtempSync(join(tmpdir(), "terminal-manager-list-first-"));
|
||||
const secondCwd = mkdtempSync(join(tmpdir(), "terminal-manager-list-second-"));
|
||||
temporaryDirs.push(firstCwd, secondCwd);
|
||||
await manager.createTerminal({ cwd: firstCwd });
|
||||
await manager.createTerminal({ cwd: secondCwd });
|
||||
await manager.createTerminal({ cwd: firstCwd, workspaceId: "ws-test" });
|
||||
await manager.createTerminal({ cwd: secondCwd, workspaceId: "ws-test" });
|
||||
|
||||
const dirs = manager.listDirectories();
|
||||
expect(dirs).toContain(firstCwd);
|
||||
@@ -315,8 +333,8 @@ it("kills all terminals and clears state", async () => {
|
||||
const firstCwd = mkdtempSync(join(tmpdir(), "terminal-manager-kill-first-"));
|
||||
const secondCwd = mkdtempSync(join(tmpdir(), "terminal-manager-kill-second-"));
|
||||
temporaryDirs.push(firstCwd, secondCwd);
|
||||
const tmpSession = await manager.createTerminal({ cwd: firstCwd });
|
||||
const homeSession = await manager.createTerminal({ cwd: secondCwd });
|
||||
const tmpSession = await manager.createTerminal({ cwd: firstCwd, workspaceId: "ws-test" });
|
||||
const homeSession = await manager.createTerminal({ cwd: secondCwd, workspaceId: "ws-test" });
|
||||
const tmpId = tmpSession.id;
|
||||
const homeId = homeSession.id;
|
||||
|
||||
@@ -340,8 +358,8 @@ it("emits cwd snapshots when terminals are created", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
await manager.createTerminal({ cwd });
|
||||
await manager.createTerminal({ cwd, name: "Dev Server" });
|
||||
await manager.createTerminal({ cwd, workspaceId: "ws-test" });
|
||||
await manager.createTerminal({ cwd, name: "Dev Server", workspaceId: "ws-test" });
|
||||
|
||||
expect(snapshots).toContainEqual({
|
||||
cwd,
|
||||
@@ -381,6 +399,7 @@ it("emits updated terminal titles after debounced title changes", async () => {
|
||||
});
|
||||
|
||||
const session = await manager.createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: realpathSync(tmpdir()),
|
||||
command: process.execPath,
|
||||
args: ["-e", "process.stdout.write('\\x1b]0;Logs\\x07'); setTimeout(() => {}, 10000);"],
|
||||
@@ -402,7 +421,7 @@ it("emits empty snapshot when last terminal is removed", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
const session = await manager.createTerminal({ cwd });
|
||||
const session = await manager.createTerminal({ cwd, workspaceId: "ws-test" });
|
||||
manager.killTerminal(session.id);
|
||||
|
||||
expect(snapshots).toContainEqual({
|
||||
@@ -416,6 +435,7 @@ it("emits empty snapshot when last terminal is removed", async () => {
|
||||
it("setTerminalTitle returns false for unknown terminal ids without changing existing terminals", async () => {
|
||||
manager = createTerminalManager();
|
||||
const session = await manager.createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: realpathSync(tmpdir()),
|
||||
title: "Existing title",
|
||||
});
|
||||
@@ -439,7 +459,10 @@ it("setTerminalTitle returns false for unknown terminal ids without changing exi
|
||||
|
||||
it("setTerminalTitle returns true and updates the terminal title for existing terminals", async () => {
|
||||
manager = createTerminalManager();
|
||||
const session = await manager.createTerminal({ cwd: realpathSync(tmpdir()) });
|
||||
const session = await manager.createTerminal({
|
||||
cwd: realpathSync(tmpdir()),
|
||||
workspaceId: "ws-test",
|
||||
});
|
||||
|
||||
expect(manager.setTerminalTitle(session.id, "x")).toBe(true);
|
||||
expect(session.getTitle()).toBe("x");
|
||||
@@ -447,7 +470,10 @@ it("setTerminalTitle returns true and updates the terminal title for existing te
|
||||
|
||||
it("new terminal starts with unknown activity", async () => {
|
||||
manager = createTerminalManager();
|
||||
const session = await manager.createTerminal({ cwd: realpathSync(tmpdir()) });
|
||||
const session = await manager.createTerminal({
|
||||
cwd: realpathSync(tmpdir()),
|
||||
workspaceId: "ws-test",
|
||||
});
|
||||
|
||||
expect(session.getActivity()).toBeNull();
|
||||
});
|
||||
@@ -465,7 +491,7 @@ it("includes nullable activity in terminals-changed snapshot items", async () =>
|
||||
);
|
||||
});
|
||||
|
||||
const session = await manager.createTerminal({ cwd });
|
||||
const session = await manager.createTerminal({ cwd, workspaceId: "ws-test" });
|
||||
|
||||
expect(snapshots.length).toBeGreaterThan(0);
|
||||
const lastSnapshot = snapshots[snapshots.length - 1];
|
||||
@@ -491,7 +517,7 @@ it("setTerminalActivity returns true and emits terminals-changed", async () => {
|
||||
);
|
||||
});
|
||||
|
||||
const session = await manager.createTerminal({ cwd });
|
||||
const session = await manager.createTerminal({ cwd, workspaceId: "ws-test" });
|
||||
await expect(manager.setTerminalActivity(session.id, "working")).resolves.toBe(true);
|
||||
|
||||
const hasWorkingActivity = (entries: ActivityEntry[]) =>
|
||||
@@ -519,7 +545,7 @@ it("clearTerminalAttention returns attention terminals to idle", async () => {
|
||||
);
|
||||
});
|
||||
|
||||
const session = await manager.createTerminal({ cwd });
|
||||
const session = await manager.createTerminal({ cwd, workspaceId: "ws-test" });
|
||||
await manager.setTerminalActivity(session.id, "attention");
|
||||
|
||||
await expect(manager.clearTerminalAttention(session.id)).resolves.toBe(true);
|
||||
@@ -536,7 +562,10 @@ it("clearTerminalAttention returns attention terminals to idle", async () => {
|
||||
|
||||
it("clearTerminalAttention leaves non-attention terminals unchanged", async () => {
|
||||
manager = createTerminalManager();
|
||||
const session = await manager.createTerminal({ cwd: realpathSync(tmpdir()) });
|
||||
const session = await manager.createTerminal({
|
||||
cwd: realpathSync(tmpdir()),
|
||||
workspaceId: "ws-test",
|
||||
});
|
||||
await manager.setTerminalActivity(session.id, "working");
|
||||
|
||||
await expect(manager.clearTerminalAttention(session.id)).resolves.toBe(false);
|
||||
@@ -574,7 +603,7 @@ it("does not emit workspace contribution event for title-only changes", async ()
|
||||
events.push(event);
|
||||
});
|
||||
|
||||
const session = await manager.createTerminal({ cwd });
|
||||
const session = await manager.createTerminal({ cwd, workspaceId: "ws-test" });
|
||||
manager.setTerminalTitle(session.id, "New title");
|
||||
|
||||
expect(events).toEqual([]);
|
||||
@@ -614,7 +643,7 @@ it("does not emit workspace contribution event when an idle terminal is removed"
|
||||
events.push(event);
|
||||
});
|
||||
|
||||
const session = await manager.createTerminal({ cwd });
|
||||
const session = await manager.createTerminal({ cwd, workspaceId: "ws-test" });
|
||||
manager.killTerminal(session.id);
|
||||
|
||||
expect(events).toEqual([]);
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface TerminalListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
cwd: string;
|
||||
workspaceId?: string;
|
||||
workspaceId: string;
|
||||
title?: string;
|
||||
activity: TerminalActivity | null;
|
||||
}
|
||||
@@ -32,6 +32,7 @@ export interface TerminalActivityTransitionEvent {
|
||||
terminalId: string;
|
||||
name: string;
|
||||
cwd: string;
|
||||
workspaceId: string;
|
||||
activity: TerminalActivity | null;
|
||||
previous: TerminalActivity | null;
|
||||
}
|
||||
@@ -41,7 +42,7 @@ export type TerminalActivityListener = (event: TerminalActivityTransitionEvent)
|
||||
export interface TerminalWorkspaceContributionChangedEvent {
|
||||
terminalId: string;
|
||||
cwd: string;
|
||||
workspaceId?: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export type TerminalWorkspaceContributionChangedListener = (
|
||||
@@ -53,7 +54,7 @@ export interface TerminalManager {
|
||||
createTerminal(options: {
|
||||
id?: string;
|
||||
cwd: string;
|
||||
workspaceId?: string;
|
||||
workspaceId: string;
|
||||
name?: string;
|
||||
title?: string;
|
||||
env?: Record<string, string>;
|
||||
@@ -159,7 +160,7 @@ export function createTerminalManager(
|
||||
emitTerminalWorkspaceContributionChanged({
|
||||
terminalId: session.id,
|
||||
cwd: session.cwd,
|
||||
...(session.workspaceId ? { workspaceId: session.workspaceId } : {}),
|
||||
workspaceId: session.workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -200,7 +201,7 @@ export function createTerminalManager(
|
||||
emitTerminalWorkspaceContributionChanged({
|
||||
terminalId: session.id,
|
||||
cwd: session.cwd,
|
||||
...(session.workspaceId ? { workspaceId: session.workspaceId } : {}),
|
||||
workspaceId: session.workspaceId,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -215,7 +216,7 @@ export function createTerminalManager(
|
||||
id: input.session.id,
|
||||
name: input.session.name,
|
||||
cwd: input.session.cwd,
|
||||
...(input.session.workspaceId ? { workspaceId: input.session.workspaceId } : {}),
|
||||
workspaceId: input.session.workspaceId,
|
||||
title: input.session.getTitle(),
|
||||
activity: input.session.getActivity(),
|
||||
};
|
||||
@@ -254,6 +255,7 @@ export function createTerminalManager(
|
||||
terminalId: input.session.id,
|
||||
name: input.session.name,
|
||||
cwd: input.session.cwd,
|
||||
workspaceId: input.session.workspaceId,
|
||||
activity: input.transition.activity,
|
||||
previous: input.transition.previous,
|
||||
};
|
||||
@@ -307,7 +309,7 @@ export function createTerminalManager(
|
||||
async createTerminal(options: {
|
||||
id?: string;
|
||||
cwd: string;
|
||||
workspaceId?: string;
|
||||
workspaceId: string;
|
||||
name?: string;
|
||||
title?: string;
|
||||
env?: Record<string, string>;
|
||||
@@ -341,7 +343,7 @@ export function createTerminalManager(
|
||||
await createTerminal({
|
||||
id: terminalId,
|
||||
cwd: options.cwd,
|
||||
...(options.workspaceId ? { workspaceId: options.workspaceId } : {}),
|
||||
workspaceId: options.workspaceId,
|
||||
name: options.name ?? defaultName,
|
||||
...(options.title ? { title: options.title } : {}),
|
||||
...(options.command ? { command: options.command } : {}),
|
||||
|
||||
@@ -60,6 +60,7 @@ describe("terminal-session-controller restore", () => {
|
||||
id: "term-1",
|
||||
name: "Terminal",
|
||||
cwd: "/tmp",
|
||||
workspaceId: "ws-test",
|
||||
send: vi.fn(),
|
||||
subscribe: (listener) => {
|
||||
terminalListener = listener;
|
||||
@@ -154,6 +155,7 @@ function listSession(input: { id: string; name: string; cwd: string }): Terminal
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
cwd: input.cwd,
|
||||
workspaceId: "ws-test",
|
||||
send: vi.fn(),
|
||||
subscribe: () => vi.fn(),
|
||||
onExit: () => vi.fn(),
|
||||
@@ -193,6 +195,7 @@ describe("terminal-session-controller wrap-flag gating", () => {
|
||||
id: "term-1",
|
||||
name: "Terminal",
|
||||
cwd: "/tmp",
|
||||
workspaceId: "ws-test",
|
||||
send: vi.fn(),
|
||||
subscribe: (listener) => {
|
||||
queueMicrotask(() => listener({ type: "snapshotReady", revision: 1 }));
|
||||
@@ -329,7 +332,7 @@ describe("terminal-session-controller subdirectory aggregation", () => {
|
||||
|
||||
changedListener?.({
|
||||
cwd: subdirCwd,
|
||||
terminals: [{ id: "subdir-term", name: "Mobile", cwd: subdirCwd }],
|
||||
terminals: [{ id: "subdir-term", name: "Mobile", cwd: subdirCwd, workspaceId: "ws-test" }],
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
@@ -339,8 +342,8 @@ describe("terminal-session-controller subdirectory aggregation", () => {
|
||||
payload: {
|
||||
cwd: rootCwd,
|
||||
terminals: [
|
||||
{ id: "root-term", name: "Terminal 1", activity: null },
|
||||
{ id: "subdir-term", name: "Mobile", activity: null },
|
||||
{ id: "root-term", name: "Terminal 1", workspaceId: "ws-test", activity: null },
|
||||
{ id: "subdir-term", name: "Mobile", workspaceId: "ws-test", activity: null },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -403,7 +406,9 @@ describe("terminal-session-controller subdirectory aggregation", () => {
|
||||
type: "list_terminals_response",
|
||||
payload: {
|
||||
cwd: rootCwd,
|
||||
terminals: [{ id: "root-term", name: "Terminal 1", activity: null }],
|
||||
terminals: [
|
||||
{ id: "root-term", name: "Terminal 1", workspaceId: "ws-test", activity: null },
|
||||
],
|
||||
requestId: "req-root",
|
||||
},
|
||||
},
|
||||
@@ -411,7 +416,9 @@ describe("terminal-session-controller subdirectory aggregation", () => {
|
||||
type: "list_terminals_response",
|
||||
payload: {
|
||||
cwd: worktreeCwd,
|
||||
terminals: [{ id: "worktree-term", name: "Feature", activity: null }],
|
||||
terminals: [
|
||||
{ id: "worktree-term", name: "Feature", workspaceId: "ws-test", activity: null },
|
||||
],
|
||||
requestId: "req-worktree",
|
||||
},
|
||||
},
|
||||
@@ -500,6 +507,7 @@ describe("terminal-session-controller backpressure snapshot fallback", () => {
|
||||
id: "term-1",
|
||||
name: "Terminal",
|
||||
cwd: "/tmp",
|
||||
workspaceId: "ws-test",
|
||||
send: vi.fn(),
|
||||
subscribe: (listener) => {
|
||||
terminalListener = listener;
|
||||
|
||||
@@ -298,7 +298,7 @@ export class TerminalSessionController {
|
||||
terminals: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
workspaceId?: string;
|
||||
workspaceId: string;
|
||||
title?: string;
|
||||
activity: TerminalActivity | null;
|
||||
}>;
|
||||
@@ -317,7 +317,7 @@ export class TerminalSessionController {
|
||||
): {
|
||||
id: string;
|
||||
name: string;
|
||||
workspaceId?: string;
|
||||
workspaceId: string;
|
||||
title?: string;
|
||||
activity: TerminalActivity | null;
|
||||
} {
|
||||
@@ -326,7 +326,7 @@ export class TerminalSessionController {
|
||||
return {
|
||||
id: terminal.id,
|
||||
name: terminal.name,
|
||||
...(terminal.workspaceId ? { workspaceId: terminal.workspaceId } : {}),
|
||||
workspaceId: terminal.workspaceId,
|
||||
...(title ? { title } : {}),
|
||||
activity,
|
||||
};
|
||||
@@ -520,9 +520,21 @@ export class TerminalSessionController {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!msg.workspaceId) {
|
||||
this.emit({
|
||||
type: "create_terminal_response",
|
||||
payload: {
|
||||
terminal: null,
|
||||
error: "workspaceId is required",
|
||||
requestId: msg.requestId,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await this.terminalManager.createTerminal({
|
||||
cwd: msg.cwd,
|
||||
...(msg.workspaceId ? { workspaceId: msg.workspaceId } : {}),
|
||||
workspaceId: msg.workspaceId,
|
||||
name: msg.name,
|
||||
command: msg.command,
|
||||
args: msg.args,
|
||||
@@ -535,7 +547,7 @@ export class TerminalSessionController {
|
||||
id: session.id,
|
||||
name: session.name,
|
||||
cwd: session.cwd,
|
||||
...(session.workspaceId ? { workspaceId: session.workspaceId } : {}),
|
||||
workspaceId: session.workspaceId,
|
||||
...(session.getTitle() ? { title: session.getTitle() } : {}),
|
||||
activity: session.getActivity(),
|
||||
},
|
||||
|
||||
@@ -66,7 +66,7 @@ function toTerminalInfo(session: TerminalSession): WorkerTerminalInfo {
|
||||
id: session.id,
|
||||
name: session.name,
|
||||
cwd: session.cwd,
|
||||
...(session.workspaceId ? { workspaceId: session.workspaceId } : {}),
|
||||
workspaceId: session.workspaceId,
|
||||
...(session.getTitle() ? { title: session.getTitle() } : {}),
|
||||
activity: session.getActivity(),
|
||||
};
|
||||
@@ -201,7 +201,11 @@ async function handleCreateTerminalRequest(message: TerminalCreateRequest): Prom
|
||||
};
|
||||
inFlightTerminalCreateRequest = request;
|
||||
try {
|
||||
const session = await manager.createTerminal(message.options);
|
||||
const { workspaceId } = message.options;
|
||||
if (!workspaceId) {
|
||||
throw new Error("workspaceId is required");
|
||||
}
|
||||
const session = await manager.createTerminal({ ...message.options, workspaceId });
|
||||
if (request.errorReported) {
|
||||
session.kill();
|
||||
return;
|
||||
|
||||
@@ -302,6 +302,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
it("executes a simple echo command", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
@@ -328,6 +329,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
it("executes multiple commands sequentially", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
@@ -353,6 +355,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
it("captures output from pwd in specified cwd", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
@@ -382,6 +385,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: homeDir,
|
||||
command: "/bin/zsh",
|
||||
args: ["-c", 'printf \'%s\\n%s\\n\' "${ZDOTDIR-}" "${PASEO_TEST_REAL_ZDOTDIR-}"'],
|
||||
@@ -409,6 +413,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: packageRoot,
|
||||
command: process.execPath,
|
||||
args: [scriptPath, "run", "dev"],
|
||||
@@ -432,6 +437,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
it("emits OSC title updates to title listeners", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
@@ -457,6 +463,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
it("keeps preset titles instead of applying OSC title updates", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
@@ -481,6 +488,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: packageRoot,
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
@@ -514,6 +522,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: packageRoot,
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
@@ -538,6 +547,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
it("debounces rapid title changes and emits only the final title", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
@@ -581,6 +591,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: workingDir,
|
||||
shell: "/bin/zsh",
|
||||
env: {
|
||||
@@ -663,6 +674,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: workingDir,
|
||||
shell: "/bin/zsh",
|
||||
env: {
|
||||
@@ -692,6 +704,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
it("captures ANSI 16 color codes (mode 1)", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ ", TERM: "xterm-256color" },
|
||||
@@ -721,6 +734,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
it("captures 256 color codes (mode 2)", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ ", TERM: "xterm-256color" },
|
||||
@@ -746,6 +760,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
it("captures true color RGB (mode 3)", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ ", TERM: "xterm-256color" },
|
||||
@@ -774,6 +789,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
it("captures background colors", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ ", TERM: "xterm-256color" },
|
||||
@@ -800,6 +816,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
it("receives a snapshot on initial subscription", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: realpathSync(tmpdir()),
|
||||
}),
|
||||
);
|
||||
@@ -820,6 +837,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
it("receives output messages on updates without replay snapshots", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
@@ -849,6 +867,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
it("does not emit snapshot messages for resize-only updates", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
@@ -877,6 +896,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
it("emits output only after getState reflects the new data", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
@@ -902,6 +922,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
it("unsubscribe stops receiving messages", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
@@ -931,6 +952,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
@@ -950,6 +972,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
@@ -967,6 +990,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
@@ -986,6 +1010,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
@@ -1005,6 +1030,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
@@ -1024,6 +1050,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
@@ -1043,6 +1070,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
it("streams raw output messages without replay metadata", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
@@ -1070,6 +1098,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
it("sends the current snapshot to a new subscriber instead of replaying raw output", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
@@ -1112,6 +1141,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
it("returns current terminal state with grid", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
@@ -1135,6 +1165,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
it("captures cursor presentation modes emitted by terminal apps", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
@@ -1164,6 +1195,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
it("grid cells have char and color attributes", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
@@ -1184,6 +1216,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
it("preserves scrollback buffer", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
@@ -1222,6 +1255,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
it("terminates the shell process", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
@@ -1240,6 +1274,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
it("send after kill is a no-op", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
|
||||
@@ -251,6 +251,7 @@ describe("createTerminal", () => {
|
||||
it("creates a terminal session with an id, name, and cwd", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: realpathSync(tmpdir()),
|
||||
}),
|
||||
);
|
||||
@@ -268,6 +269,7 @@ describe("createTerminal", () => {
|
||||
: "/bin/sh";
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: realpathSync(tmpdir()),
|
||||
shell,
|
||||
env: { PS1: "$ " },
|
||||
@@ -281,6 +283,7 @@ describe("createTerminal", () => {
|
||||
it("uses default shell if not specified", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: realpathSync(tmpdir()),
|
||||
}),
|
||||
);
|
||||
@@ -294,6 +297,7 @@ describe("createTerminal", () => {
|
||||
: "/bin/sh";
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: realpathSync(tmpdir()),
|
||||
shell,
|
||||
env: { PS1: "$ " },
|
||||
@@ -311,6 +315,7 @@ describe("createTerminal", () => {
|
||||
: "/bin/sh";
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: realpathSync(tmpdir()),
|
||||
shell,
|
||||
env: { PS1: "$ " },
|
||||
@@ -327,6 +332,7 @@ describe("createTerminal", () => {
|
||||
it("reports per-row soft-wrap flags only when wrap flags are requested", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: realpathSync(tmpdir()),
|
||||
cols: 40,
|
||||
rows: 10,
|
||||
@@ -358,6 +364,7 @@ describe("createTerminal", () => {
|
||||
it("captures exit diagnostics from the terminal buffer", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: realpathSync(tmpdir()),
|
||||
command: process.execPath,
|
||||
args: [
|
||||
@@ -389,6 +396,7 @@ describe("createTerminal", () => {
|
||||
// summary races the async xterm parse and reads a stale buffer.
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: realpathSync(tmpdir()),
|
||||
command: process.execPath,
|
||||
args: [
|
||||
@@ -429,6 +437,7 @@ describe.skipIf(isPlatform("win32"))("send input", () => {
|
||||
it("executes a simple echo command", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
@@ -455,6 +464,7 @@ describe.skipIf(isPlatform("win32"))("send input", () => {
|
||||
it("captures output from pwd in specified cwd", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
@@ -484,6 +494,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => {
|
||||
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: homeDir,
|
||||
command: "/bin/zsh",
|
||||
args: ["-c", 'printf \'%s\\n%s\\n\' "${ZDOTDIR-}" "${PASEO_TEST_REAL_ZDOTDIR-}"'],
|
||||
@@ -511,6 +522,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => {
|
||||
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: packageRoot,
|
||||
command: process.execPath,
|
||||
args: [scriptPath, "run", "dev"],
|
||||
@@ -534,6 +546,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => {
|
||||
it("emits OSC title updates to title listeners", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
@@ -559,6 +572,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => {
|
||||
it("keeps preset titles instead of applying OSC title updates", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
@@ -583,6 +597,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => {
|
||||
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: packageRoot,
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
@@ -616,6 +631,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => {
|
||||
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: packageRoot,
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
@@ -640,6 +656,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => {
|
||||
it("debounces rapid title changes and emits only the final title", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
@@ -683,6 +700,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => {
|
||||
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: workingDir,
|
||||
shell: "/bin/zsh",
|
||||
env: {
|
||||
@@ -765,6 +783,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => {
|
||||
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: workingDir,
|
||||
shell: "/bin/zsh",
|
||||
env: {
|
||||
@@ -792,6 +811,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => {
|
||||
it("clears already scheduled OSC title debounce timers when setting a user title", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
@@ -825,6 +845,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => {
|
||||
it("ignores later OSC title updates after setting a user title", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
@@ -854,6 +875,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => {
|
||||
it("trims user-set titles and treats empty titles as no-ops", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
@@ -884,6 +906,7 @@ describe.skipIf(isPlatform("win32"))("colors", () => {
|
||||
it("captures ANSI 16 color codes (mode 1)", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ ", TERM: "xterm-256color" },
|
||||
@@ -913,6 +936,7 @@ describe.skipIf(isPlatform("win32"))("colors", () => {
|
||||
it("captures true color RGB (mode 3)", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ ", TERM: "xterm-256color" },
|
||||
@@ -941,6 +965,7 @@ describe.skipIf(isPlatform("win32"))("colors", () => {
|
||||
it("captures background colors", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ ", TERM: "xterm-256color" },
|
||||
@@ -967,6 +992,7 @@ describe("resize", () => {
|
||||
it("updates terminal dimensions on resize", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: realpathSync(tmpdir()),
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
@@ -985,6 +1011,7 @@ describe("resize", () => {
|
||||
it("grid reflects new dimensions after resize", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: realpathSync(tmpdir()),
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
@@ -1002,6 +1029,7 @@ describe("resize", () => {
|
||||
it("exposes the current size without extracting full state", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: realpathSync(tmpdir()),
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
@@ -1021,6 +1049,7 @@ describe("mouse events", () => {
|
||||
it("accepts mouse events without throwing", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: realpathSync(tmpdir()),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -77,7 +77,7 @@ export interface TerminalSession {
|
||||
id: string;
|
||||
name: string;
|
||||
cwd: string;
|
||||
workspaceId?: string;
|
||||
workspaceId: string;
|
||||
send(msg: ClientMessage): void;
|
||||
subscribe(listener: (msg: ServerMessage) => void, options?: TerminalSubscribeOptions): () => void;
|
||||
onExit(listener: (info: TerminalExitInfo) => void): () => void;
|
||||
@@ -118,7 +118,7 @@ function parseCommandFinishedOsc(data: string): TerminalCommandFinishedInfo | nu
|
||||
export interface CreateTerminalOptions {
|
||||
id?: string;
|
||||
cwd: string;
|
||||
workspaceId?: string;
|
||||
workspaceId: string;
|
||||
shell?: string;
|
||||
env?: Record<string, string>;
|
||||
activityEnv?: Record<string, string>;
|
||||
@@ -865,7 +865,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
env: {
|
||||
...env,
|
||||
...activityEnv,
|
||||
...(workspaceId ? { PASEO_WORKSPACE_ID: workspaceId } : {}),
|
||||
PASEO_WORKSPACE_ID: workspaceId,
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -1448,7 +1448,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
id,
|
||||
name,
|
||||
cwd,
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
workspaceId,
|
||||
send,
|
||||
subscribe,
|
||||
onExit,
|
||||
|
||||
@@ -166,6 +166,7 @@ it("creates a terminal through the worker and streams output", async () => {
|
||||
manager = createWorkerTerminalManager();
|
||||
const session = trackTerminal(
|
||||
await manager.createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd,
|
||||
...nodeTerminalCommand(`
|
||||
process.stdin.on("data", (chunk) => {
|
||||
@@ -212,6 +213,7 @@ it("delivers rapid small writes complete and in order through worker coalescing"
|
||||
// on platform-specific PTY input echo/canonical-mode behavior.
|
||||
const session = trackTerminal(
|
||||
await manager.createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd,
|
||||
env: { PASEO_TERMINAL_BURST_GATE: burstGatePath },
|
||||
...nodeTerminalCommand(`
|
||||
@@ -272,6 +274,7 @@ it("pulls fresh terminal state from the worker authority", async () => {
|
||||
manager = createWorkerTerminalManager();
|
||||
const session = trackTerminal(
|
||||
await manager.createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd,
|
||||
...nodeTerminalCommand(`
|
||||
process.stdout.write("worker-state-ready\\n");
|
||||
@@ -304,6 +307,7 @@ it.skipIf(isPlatform("win32"))(
|
||||
// tracker records and reflects in its replay preamble (\x1b[=1;1u).
|
||||
const session = trackTerminal(
|
||||
await manager.createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd,
|
||||
...nodeTerminalCommand(`
|
||||
process.stdout.write("\\u001b[>1u");
|
||||
@@ -327,6 +331,7 @@ it("refreshes cached terminal title after worker title changes", async () => {
|
||||
manager = createWorkerTerminalManager();
|
||||
const session = trackTerminal(
|
||||
await manager.createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd,
|
||||
...nodeTerminalCommand(`
|
||||
process.stdout.write("\\u001b]0;Build Output\\u0007");
|
||||
@@ -344,7 +349,7 @@ it("refreshes cached terminal size after worker resize", async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-resize-"));
|
||||
temporaryDirs.push(cwd);
|
||||
manager = createWorkerTerminalManager();
|
||||
const session = trackTerminal(await manager.createTerminal({ cwd }));
|
||||
const session = trackTerminal(await manager.createTerminal({ cwd, workspaceId: "ws-test" }));
|
||||
|
||||
session.send({ type: "resize", rows: 10, cols: 40 });
|
||||
|
||||
@@ -361,7 +366,7 @@ it("captures terminal output from the worker authority", async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-capture-"));
|
||||
temporaryDirs.push(cwd);
|
||||
manager = createWorkerTerminalManager();
|
||||
const session = trackTerminal(await manager.createTerminal({ cwd }));
|
||||
const session = trackTerminal(await manager.createTerminal({ cwd, workspaceId: "ws-test" }));
|
||||
|
||||
session.send({ type: "input", data: "echo hello world\r" });
|
||||
|
||||
@@ -388,6 +393,7 @@ it("does not surface fire-and-forget send timeouts as unhandled rejections", asy
|
||||
id: "terminal-1",
|
||||
name: "Terminal",
|
||||
cwd: "/tmp",
|
||||
workspaceId: "ws-test",
|
||||
activity: { state: "idle", changedAt: 0 },
|
||||
},
|
||||
state: createTerminalState(),
|
||||
@@ -423,6 +429,7 @@ it("keeps registered cwd env inheritance behind the worker manager interface", a
|
||||
});
|
||||
trackTerminal(
|
||||
await manager.createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd,
|
||||
...nodeTerminalCommand(`
|
||||
require("node:fs").writeFileSync(
|
||||
@@ -450,6 +457,7 @@ it("injects parent-minted terminal activity env through the worker", async () =>
|
||||
|
||||
const session = trackTerminal(
|
||||
await manager.createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd,
|
||||
...nodeTerminalCommand(`
|
||||
require("node:fs").writeFileSync(
|
||||
@@ -495,7 +503,7 @@ it("starts the default shell through the worker and accepts quoted commands", as
|
||||
const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-shell-"));
|
||||
temporaryDirs.push(cwd);
|
||||
const markerPath = join(cwd, "shell quoted marker.txt");
|
||||
const session = trackTerminal(await manager.createTerminal({ cwd }));
|
||||
const session = trackTerminal(await manager.createTerminal({ cwd, workspaceId: "ws-test" }));
|
||||
const command = [
|
||||
"node",
|
||||
"-e",
|
||||
@@ -517,6 +525,7 @@ it("lists subdirectory terminals when querying the workspace root", async () =>
|
||||
manager = createWorkerTerminalManager();
|
||||
const created = trackTerminal(
|
||||
await manager.createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: subdirCwd,
|
||||
...nodeTerminalCommand("setInterval(() => {}, 1000);"),
|
||||
}),
|
||||
@@ -540,6 +549,7 @@ it("lists terminals locally without waiting on the worker", async () => {
|
||||
id: "terminal-root",
|
||||
name: "Shell",
|
||||
cwd: "/workspace",
|
||||
workspaceId: "ws-test",
|
||||
activity: { state: "idle", changedAt: 0 },
|
||||
},
|
||||
state: createTerminalState(),
|
||||
@@ -550,6 +560,7 @@ it("lists terminals locally without waiting on the worker", async () => {
|
||||
id: "terminal-subdir",
|
||||
name: "Shell",
|
||||
cwd: "/workspace/apps/mobile",
|
||||
workspaceId: "ws-test",
|
||||
activity: { state: "idle", changedAt: 0 },
|
||||
},
|
||||
state: createTerminalState(),
|
||||
@@ -579,6 +590,7 @@ it("includes only stamped terminals in workspace-scoped local reads", async () =
|
||||
id: "terminal-legacy",
|
||||
name: "Legacy",
|
||||
cwd: "/workspace",
|
||||
workspaceId: "ws-test",
|
||||
activity: null,
|
||||
},
|
||||
state: createTerminalState(),
|
||||
@@ -640,6 +652,7 @@ it("surfaces worker activity changes via getActivity, onActivityChange, and term
|
||||
id: "terminal-a",
|
||||
name: "Shell",
|
||||
cwd: "/workspace",
|
||||
workspaceId: "ws-test",
|
||||
activity: { state: "idle", changedAt: 0 },
|
||||
},
|
||||
state: createTerminalState(),
|
||||
@@ -681,6 +694,7 @@ it("surfaces worker activity changes via getActivity, onActivityChange, and term
|
||||
terminalId: "terminal-a",
|
||||
name: "Shell",
|
||||
cwd: "/workspace",
|
||||
workspaceId: "ws-test",
|
||||
activity: workingActivity,
|
||||
previous: idleActivity,
|
||||
},
|
||||
@@ -700,6 +714,7 @@ it("sets terminal activity through a worker request", async () => {
|
||||
id: "terminal-a",
|
||||
name: "Shell",
|
||||
cwd: "/workspace",
|
||||
workspaceId: "ws-test",
|
||||
activity: null,
|
||||
},
|
||||
state: createTerminalState(),
|
||||
@@ -732,6 +747,7 @@ it("clears terminal attention through a worker request", async () => {
|
||||
id: "terminal-a",
|
||||
name: "Shell",
|
||||
cwd: "/workspace",
|
||||
workspaceId: "ws-test",
|
||||
activity: { state: "idle", attentionReason: "finished", changedAt: 1000 },
|
||||
},
|
||||
state: createTerminalState(),
|
||||
@@ -757,6 +773,7 @@ it("clears finished attention on a real terminal", async () => {
|
||||
manager = createWorkerTerminalManager();
|
||||
const session = trackTerminal(
|
||||
await manager.createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd,
|
||||
...nodeTerminalCommand("setInterval(() => {}, 1000);"),
|
||||
}),
|
||||
@@ -792,6 +809,7 @@ it("removes worker terminals after killAndWait", async () => {
|
||||
manager = createWorkerTerminalManager();
|
||||
const session = trackTerminal(
|
||||
await manager.createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd,
|
||||
...nodeTerminalCommand("setInterval(() => {}, 1000);"),
|
||||
}),
|
||||
@@ -822,6 +840,7 @@ it("produces one terminals-changed snapshot per title change", async () => {
|
||||
id: "terminal-a",
|
||||
name: "Shell",
|
||||
cwd: "/workspace",
|
||||
workspaceId: "ws-test",
|
||||
activity: null,
|
||||
},
|
||||
state: createTerminalState(),
|
||||
|
||||
@@ -36,6 +36,20 @@ import type {
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 10000;
|
||||
|
||||
type RequiredWorkerTerminalInfo = WorkerTerminalInfo & { workspaceId: string };
|
||||
|
||||
function requiredWorkspaceId(workspaceId: string | undefined): string {
|
||||
if (workspaceId === undefined) {
|
||||
throw new Error("workspaceId is required");
|
||||
}
|
||||
return workspaceId;
|
||||
}
|
||||
|
||||
function asRequiredWorkerTerminalInfo(info: WorkerTerminalInfo): RequiredWorkerTerminalInfo {
|
||||
requiredWorkspaceId(info.workspaceId);
|
||||
return info as RequiredWorkerTerminalInfo;
|
||||
}
|
||||
|
||||
type TerminalWorkerRequestInput = TerminalWorkerRequest extends infer Request
|
||||
? Request extends TerminalWorkerRequest
|
||||
? Omit<Request, "requestId">
|
||||
@@ -49,7 +63,7 @@ interface PendingRequest {
|
||||
}
|
||||
|
||||
interface WorkerTerminalRecord {
|
||||
info: WorkerTerminalInfo;
|
||||
info: RequiredWorkerTerminalInfo;
|
||||
state: TerminalState;
|
||||
activity: TerminalActivity | null;
|
||||
// Cached input-mode preamble from the worker (the authoritative tracker lives
|
||||
@@ -114,12 +128,12 @@ function isResponse(message: TerminalWorkerToParentMessage): message is Terminal
|
||||
return message.type === "response";
|
||||
}
|
||||
|
||||
function cloneTerminalInfo(info: WorkerTerminalInfo): WorkerTerminalInfo {
|
||||
function cloneTerminalInfo(info: RequiredWorkerTerminalInfo): RequiredWorkerTerminalInfo {
|
||||
return {
|
||||
id: info.id,
|
||||
name: info.name,
|
||||
cwd: info.cwd,
|
||||
...(info.workspaceId ? { workspaceId: info.workspaceId } : {}),
|
||||
workspaceId: info.workspaceId,
|
||||
...(info.title ? { title: info.title } : {}),
|
||||
activity: info.activity,
|
||||
};
|
||||
@@ -196,7 +210,7 @@ export function createWorkerTerminalManager(
|
||||
id: record.info.id,
|
||||
name: record.info.name,
|
||||
cwd: record.info.cwd,
|
||||
...(record.info.workspaceId ? { workspaceId: record.info.workspaceId } : {}),
|
||||
workspaceId: record.info.workspaceId,
|
||||
...(record.info.title ? { title: record.info.title } : {}),
|
||||
activity: record.activity,
|
||||
});
|
||||
@@ -205,7 +219,7 @@ export function createWorkerTerminalManager(
|
||||
}
|
||||
|
||||
function registerRecord(input: {
|
||||
info: WorkerTerminalInfo;
|
||||
info: RequiredWorkerTerminalInfo;
|
||||
state: TerminalState;
|
||||
}): TerminalSession {
|
||||
const existing = recordsById.get(input.info.id);
|
||||
@@ -438,7 +452,7 @@ export function createWorkerTerminalManager(
|
||||
emitTerminalWorkspaceContributionChanged({
|
||||
terminalId: removedRecord.info.id,
|
||||
cwd: removedRecord.info.cwd,
|
||||
...(removedRecord.info.workspaceId ? { workspaceId: removedRecord.info.workspaceId } : {}),
|
||||
workspaceId: removedRecord.info.workspaceId,
|
||||
});
|
||||
}
|
||||
emitTerminalsChanged({
|
||||
@@ -506,6 +520,7 @@ export function createWorkerTerminalManager(
|
||||
terminalId: record.info.id,
|
||||
name: record.info.name,
|
||||
cwd: record.info.cwd,
|
||||
workspaceId: record.info.workspaceId,
|
||||
activity: message.activity,
|
||||
previous: message.previous,
|
||||
});
|
||||
@@ -515,7 +530,7 @@ export function createWorkerTerminalManager(
|
||||
emitTerminalWorkspaceContributionChanged({
|
||||
terminalId: record.info.id,
|
||||
cwd: record.info.cwd,
|
||||
...(record.info.workspaceId ? { workspaceId: record.info.workspaceId } : {}),
|
||||
workspaceId: record.info.workspaceId,
|
||||
});
|
||||
}
|
||||
emitTerminalsChanged({
|
||||
@@ -527,7 +542,10 @@ export function createWorkerTerminalManager(
|
||||
function handleWorkerEvent(message: TerminalWorkerToParentMessage): void {
|
||||
switch (message.type) {
|
||||
case "terminalCreated": {
|
||||
registerRecord({ info: message.terminal, state: message.state });
|
||||
registerRecord({
|
||||
info: asRequiredWorkerTerminalInfo(message.terminal),
|
||||
state: message.state,
|
||||
});
|
||||
emitTerminalsChanged({
|
||||
cwd: message.terminal.cwd,
|
||||
terminals: listTerminalItemsForCwd(message.terminal.cwd),
|
||||
@@ -661,13 +679,15 @@ export function createWorkerTerminalManager(
|
||||
return sessions;
|
||||
},
|
||||
|
||||
async createTerminal(options: WorkerCreateTerminalOptions): Promise<TerminalSession> {
|
||||
async createTerminal(
|
||||
options: WorkerCreateTerminalOptions & { workspaceId: string },
|
||||
): Promise<TerminalSession> {
|
||||
const terminalId = options.id ?? randomUUID();
|
||||
const activityToken = createActivityToken();
|
||||
const terminalActivityUrl = managerOptions.getTerminalActivityUrl?.() ?? null;
|
||||
terminalActivityTokenById.set(terminalId, activityToken);
|
||||
let result: {
|
||||
terminal: WorkerTerminalInfo;
|
||||
terminal: RequiredWorkerTerminalInfo;
|
||||
state: TerminalState;
|
||||
};
|
||||
try {
|
||||
@@ -680,7 +700,7 @@ export function createWorkerTerminalManager(
|
||||
activityUrl: terminalActivityUrl,
|
||||
},
|
||||
})) as {
|
||||
terminal: WorkerTerminalInfo;
|
||||
terminal: RequiredWorkerTerminalInfo;
|
||||
state: TerminalState;
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user