Fix legacy workspace compatibility

This commit is contained in:
Mohamed Boudra
2026-06-19 04:14:12 +07:00
parent ee3a7a2d28
commit 112f1ace65
8 changed files with 186 additions and 21 deletions
@@ -35,7 +35,7 @@ function restoreArchivedWorkspace(serverId: string, agentId: string, workspaceId
return; return;
} }
// COMPAT(worktreeRestore): added in v0.1.98, drop the gate when floor >= v0.1.98 // COMPAT(worktreeRestore): added in v0.1.97, drop the gate when floor >= v0.1.97
// Single capability read for restore. An old daemon recreates nothing on // Single capability read for restore. An old daemon recreates nothing on
// refresh_agent, so a gone directory would spin then flash a misleading // refresh_agent, so a gone directory would spin then flash a misleading
// "couldn't restore". Surface an explicit "update your host" state instead. // "couldn't restore". Surface an explicit "update your host" state instead.
+1 -1
View File
@@ -2278,7 +2278,7 @@ export const ServerInfoStatusPayloadSchema = z
workspaceMultiplicity: z.boolean().optional(), workspaceMultiplicity: z.boolean().optional(),
// COMPAT(projectRemove): added in v0.1.97, drop the gate when floor >= v0.1.97. // COMPAT(projectRemove): added in v0.1.97, drop the gate when floor >= v0.1.97.
projectRemove: z.boolean().optional(), projectRemove: z.boolean().optional(),
// COMPAT(worktreeRestore): added in v0.1.98, drop the gate when floor >= v0.1.98 // COMPAT(worktreeRestore): added in v0.1.97, drop the gate when floor >= v0.1.97
worktreeRestore: z.boolean().optional(), worktreeRestore: z.boolean().optional(),
}) })
.optional(), .optional(),
@@ -49,7 +49,11 @@ describe("backfillWorkspaceIdForLegacyAgents", () => {
rmSync(home, { recursive: true, force: true }); rmSync(home, { recursive: true, force: true });
}); });
async function seedLegacyAgent(cwd: string, id: string): Promise<void> { async function seedLegacyAgent(
cwd: string,
id: string,
overrides?: { archivedAt?: string | null },
): Promise<void> {
await agentStorage.upsert({ await agentStorage.upsert({
id, id,
provider: "codex", provider: "codex",
@@ -65,7 +69,7 @@ describe("backfillWorkspaceIdForLegacyAgents", () => {
config: null, config: null,
runtimeInfo: { provider: "codex", sessionId: null }, runtimeInfo: { provider: "codex", sessionId: null },
persistence: null, persistence: null,
archivedAt: null, archivedAt: overrides?.archivedAt ?? null,
}); });
} }
@@ -133,6 +137,44 @@ describe("backfillWorkspaceIdForLegacyAgents", () => {
expect((await agentStorage.get("stamped-agent"))?.workspaceId).toBe("ws-explicit"); expect((await agentStorage.get("stamped-agent"))?.workspaceId).toBe("ws-explicit");
}); });
test("stamps archived legacy agents from archived workspace owners", async () => {
await workspaceRegistry.upsert(
workspaceRecord("/tmp/repo", "ws-archived", {
archivedAt: "2026-03-02T00:00:00.000Z",
}),
);
await seedLegacyAgent("/tmp/repo", "legacy-agent", {
archivedAt: "2026-03-02T12:00:00.000Z",
});
const migrated = await backfillWorkspaceIdForLegacyAgents({
agentStorage,
workspaceRegistry,
logger: createTestLogger(),
});
expect(migrated).toBe(1);
expect((await agentStorage.get("legacy-agent"))?.workspaceId).toBe("ws-archived");
});
test("does not stamp active legacy agents from archived workspace owners", async () => {
await workspaceRegistry.upsert(
workspaceRecord("/tmp/repo", "ws-archived", {
archivedAt: "2026-03-02T00:00:00.000Z",
}),
);
await seedLegacyAgent("/tmp/repo", "legacy-agent");
const migrated = await backfillWorkspaceIdForLegacyAgents({
agentStorage,
workspaceRegistry,
logger: createTestLogger(),
});
expect(migrated).toBe(0);
expect((await agentStorage.get("legacy-agent"))?.workspaceId).toBeUndefined();
});
test("does not let the home directory own descendants", async () => { test("does not let the home directory own descendants", async () => {
const userHome = homedir(); const userHome = homedir();
await workspaceRegistry.upsert(workspaceRecord(userHome, "ws-home")); await workspaceRegistry.upsert(workspaceRecord(userHome, "ws-home"));
@@ -18,22 +18,27 @@ import type { PersistedWorkspaceRecord, WorkspaceRegistry } from "../workspace-r
// Picks the workspace that owned `cwd` for a legacy, unstamped agent record. // Picks the workspace that owned `cwd` for a legacy, unstamped agent record.
// Prefers an exact-cwd workspace (oldest wins) and otherwise attributes to the // Prefers an exact-cwd workspace (oldest wins) and otherwise attributes to the
// deepest enclosing workspace directory, never letting the home directory own // deepest enclosing workspace directory, never letting the home directory own
// descendants. Used only by the one-time backfill below. // descendants. Live records only consider live workspaces; archived records can
// resolve to archived workspaces so History/restore retains legacy ownership.
// Used only by the one-time backfill below.
function resolveLegacyWorkspaceOwner( function resolveLegacyWorkspaceOwner(
cwd: string, cwd: string,
workspaces: Iterable<PersistedWorkspaceRecord>, workspaces: Iterable<PersistedWorkspaceRecord>,
options?: { includeArchived?: boolean },
): string | null { ): string | null {
const normalizedCwd = resolve(cwd); const normalizedCwd = resolve(cwd);
const userHome = resolve(homedir()); const userHome = resolve(homedir());
const activeWorkspaces = Array.from(workspaces).filter((workspace) => !workspace.archivedAt); const candidateWorkspaces = Array.from(workspaces).filter(
const exactMatches = activeWorkspaces.filter( (workspace) => options?.includeArchived === true || !workspace.archivedAt,
);
const exactMatches = candidateWorkspaces.filter(
(workspace) => resolve(workspace.cwd) === normalizedCwd, (workspace) => resolve(workspace.cwd) === normalizedCwd,
); );
if (exactMatches.length > 0) { if (exactMatches.length > 0) {
return oldestWorkspace(exactMatches).workspaceId; return oldestWorkspace(exactMatches).workspaceId;
} }
const prefixMatches = activeWorkspaces.filter((workspace) => { const prefixMatches = candidateWorkspaces.filter((workspace) => {
const workspaceCwd = resolve(workspace.cwd); const workspaceCwd = resolve(workspace.cwd);
if (workspaceCwd === userHome) { if (workspaceCwd === userHome) {
return false; return false;
@@ -72,7 +77,9 @@ export async function backfillWorkspaceIdForLegacyAgents(options: {
continue; continue;
} }
const workspaceId = resolveLegacyWorkspaceOwner(record.cwd, workspaceRecords); const workspaceId = resolveLegacyWorkspaceOwner(record.cwd, workspaceRecords, {
includeArchived: record.archivedAt != null,
});
if (!workspaceId) { if (!workspaceId) {
continue; continue;
} }
+1 -6
View File
@@ -988,12 +988,7 @@ export class Session {
hasBinaryChannel: () => this.onBinaryMessage !== null, hasBinaryChannel: () => this.onBinaryMessage !== null,
isPathWithinRoot: (rootPath, candidatePath) => this.isPathWithinRoot(rootPath, candidatePath), isPathWithinRoot: (rootPath, candidatePath) => this.isPathWithinRoot(rootPath, candidatePath),
sessionLogger: this.sessionLogger, sessionLogger: this.sessionLogger,
listTerminalWorkspaceRoots: async () => { listTerminalWorkspaceRefs: () => this.listActiveWorkspaceRefs(),
const workspaces = await this.workspaceRegistry.list();
return workspaces
.filter((workspace) => !workspace.archivedAt)
.map((workspace) => workspace.cwd);
},
clientSupportsWrapReflow: () => clientSupportsWrapReflow: () =>
this.clientCapabilities.has(CLIENT_CAPS.terminalReflowableSnapshot), this.clientCapabilities.has(CLIENT_CAPS.terminalReflowableSnapshot),
getClientBufferedAmount: () => this.getTransportBufferedAmount(), getClientBufferedAmount: () => this.getTransportBufferedAmount(),
@@ -1159,7 +1159,7 @@ export class VoiceAssistantWebSocketServer {
workspaceMultiplicity: true, workspaceMultiplicity: true,
// COMPAT(projectRemove): added in v0.1.97, drop the gate when floor >= v0.1.97. // COMPAT(projectRemove): added in v0.1.97, drop the gate when floor >= v0.1.97.
projectRemove: true, projectRemove: true,
// COMPAT(worktreeRestore): added in v0.1.98, drop the gate when floor >= v0.1.98 // COMPAT(worktreeRestore): added in v0.1.97, drop the gate when floor >= v0.1.97
worktreeRestore: true, worktreeRestore: true,
}, },
}; };
@@ -150,12 +150,17 @@ describe("terminal-session-controller restore", () => {
}); });
}); });
function listSession(input: { id: string; name: string; cwd: string }): TerminalSession { function listSession(input: {
id: string;
name: string;
cwd: string;
workspaceId?: string;
}): TerminalSession {
return { return {
id: input.id, id: input.id,
name: input.name, name: input.name,
cwd: input.cwd, cwd: input.cwd,
workspaceId: "ws-test", workspaceId: input.workspaceId ?? "ws-test",
send: vi.fn(), send: vi.fn(),
subscribe: () => vi.fn(), subscribe: () => vi.fn(),
onExit: () => vi.fn(), onExit: () => vi.fn(),
@@ -176,6 +181,86 @@ function listSession(input: { id: string; name: string; cwd: string }): Terminal
}; };
} }
describe("terminal-session-controller legacy terminal creation", () => {
test("resolves a missing workspaceId from the active workspace root", async () => {
const rootCwd = "/work/repo";
const appCwd = "/work/repo/packages/app";
const terminalCwd = "/work/repo/packages/app/src";
const outboundMessages: SessionOutboundMessage[] = [];
const createTerminal = vi.fn(
async (options: Parameters<TerminalManager["createTerminal"]>[0]) =>
listSession({
id: "term-1",
name: options.name ?? "Terminal 1",
cwd: options.cwd,
workspaceId: options.workspaceId,
}),
);
const terminalManager: TerminalManager = {
getTerminals: vi.fn(),
createTerminal,
registerCwdEnv: vi.fn(),
validateTerminalActivityToken: vi.fn(() => "unknown"),
getTerminal: vi.fn(),
getTerminalState: vi.fn(),
setTerminalTitle: vi.fn(),
setTerminalActivity: vi.fn(),
clearTerminalAttention: vi.fn(),
killTerminal: vi.fn(),
killTerminalAndWait: vi.fn(),
captureTerminal: vi.fn(),
listDirectories: vi.fn(() => []),
killAll: vi.fn(),
subscribeTerminalsChanged: vi.fn(() => vi.fn()),
subscribeTerminalActivity: vi.fn(() => vi.fn()),
subscribeTerminalWorkspaceContributionChanged: vi.fn(() => vi.fn()),
};
const controller = new TerminalSessionController({
terminalManager,
emit: (message) => outboundMessages.push(message),
emitBinary: vi.fn(),
hasBinaryChannel: () => true,
isPathWithinRoot: isSameOrDescendantPath,
sessionLogger: createLogger(),
listTerminalWorkspaceRefs: async () => [
{ workspaceId: "ws-root", cwd: rootCwd },
{ workspaceId: "ws-app", cwd: appCwd },
],
});
await controller.dispatch({
type: "create_terminal_request",
cwd: terminalCwd,
name: "App Shell",
requestId: "req-1",
});
expect(createTerminal).toHaveBeenCalledWith(
expect.objectContaining({
cwd: terminalCwd,
workspaceId: "ws-app",
name: "App Shell",
}),
);
expect(outboundMessages).toEqual([
{
type: "create_terminal_response",
payload: {
terminal: {
id: "term-1",
name: "App Shell",
cwd: terminalCwd,
workspaceId: "ws-app",
activity: null,
},
error: null,
requestId: "req-1",
},
},
]);
});
});
async function flushMicrotasks(): Promise<void> { async function flushMicrotasks(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
} }
@@ -68,6 +68,7 @@ export interface TerminalSessionControllerOptions {
hasBinaryChannel: () => boolean; hasBinaryChannel: () => boolean;
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean; isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
sessionLogger: pino.Logger; sessionLogger: pino.Logger;
listTerminalWorkspaceRefs?: () => Promise<readonly TerminalWorkspaceRef[]>;
listTerminalWorkspaceRoots?: () => Promise<readonly string[]>; listTerminalWorkspaceRoots?: () => Promise<readonly string[]>;
// Whether the connected client can reflow restored snapshots. When true the // Whether the connected client can reflow restored snapshots. When true the
// daemon attaches per-row soft-wrap flags to snapshots; otherwise it omits them // daemon attaches per-row soft-wrap flags to snapshots; otherwise it omits them
@@ -83,6 +84,11 @@ export interface TerminalSessionControllerOptions {
getClientBufferedAmount?: () => number | null; getClientBufferedAmount?: () => number | null;
} }
interface TerminalWorkspaceRef {
workspaceId: string;
cwd: string;
}
export interface TerminalSessionControllerMetrics { export interface TerminalSessionControllerMetrics {
directorySubscriptionCount: number; directorySubscriptionCount: number;
streamSubscriptionCount: number; streamSubscriptionCount: number;
@@ -120,6 +126,7 @@ export class TerminalSessionController {
private readonly hasBinaryChannel: () => boolean; private readonly hasBinaryChannel: () => boolean;
private readonly isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean; private readonly isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
private readonly sessionLogger: pino.Logger; private readonly sessionLogger: pino.Logger;
private readonly listTerminalWorkspaceRefs: () => Promise<readonly TerminalWorkspaceRef[]>;
private readonly listTerminalWorkspaceRoots: () => Promise<readonly string[]>; private readonly listTerminalWorkspaceRoots: () => Promise<readonly string[]>;
private readonly clientSupportsWrapReflow: () => boolean; private readonly clientSupportsWrapReflow: () => boolean;
private readonly getClientBufferedAmount: () => number | null; private readonly getClientBufferedAmount: () => number | null;
@@ -145,7 +152,10 @@ export class TerminalSessionController {
this.hasBinaryChannel = options.hasBinaryChannel; this.hasBinaryChannel = options.hasBinaryChannel;
this.isPathWithinRoot = options.isPathWithinRoot; this.isPathWithinRoot = options.isPathWithinRoot;
this.sessionLogger = options.sessionLogger; this.sessionLogger = options.sessionLogger;
this.listTerminalWorkspaceRoots = options.listTerminalWorkspaceRoots ?? (async () => []); this.listTerminalWorkspaceRefs = options.listTerminalWorkspaceRefs ?? (async () => []);
this.listTerminalWorkspaceRoots =
options.listTerminalWorkspaceRoots ??
(async () => (await this.listTerminalWorkspaceRefs()).map((workspace) => workspace.cwd));
this.clientSupportsWrapReflow = options.clientSupportsWrapReflow ?? (() => false); this.clientSupportsWrapReflow = options.clientSupportsWrapReflow ?? (() => false);
this.getClientBufferedAmount = options.getClientBufferedAmount ?? (() => 0); this.getClientBufferedAmount = options.getClientBufferedAmount ?? (() => 0);
} }
@@ -520,7 +530,8 @@ export class TerminalSessionController {
return; return;
} }
if (!msg.workspaceId) { const workspaceId = msg.workspaceId ?? (await this.resolveLegacyTerminalWorkspaceId(msg.cwd));
if (!workspaceId) {
this.emit({ this.emit({
type: "create_terminal_response", type: "create_terminal_response",
payload: { payload: {
@@ -534,7 +545,7 @@ export class TerminalSessionController {
const session = await this.terminalManager.createTerminal({ const session = await this.terminalManager.createTerminal({
cwd: msg.cwd, cwd: msg.cwd,
workspaceId: msg.workspaceId, workspaceId,
name: msg.name, name: msg.name,
command: msg.command, command: msg.command,
args: msg.args, args: msg.args,
@@ -568,6 +579,31 @@ export class TerminalSessionController {
} }
} }
private async resolveLegacyTerminalWorkspaceId(cwd: string): Promise<string | null> {
const workspaceRefs = await this.listTerminalWorkspaceRefs();
if (workspaceRefs.length === 0) {
return null;
}
const exactMatch = workspaceRefs.find((workspace) => this.isSamePath(workspace.cwd, cwd));
if (exactMatch) {
return exactMatch.workspaceId;
}
const ownerRoot = this.resolveTerminalOwnerRoot(
cwd,
workspaceRefs.map((workspace) => workspace.cwd),
);
if (!ownerRoot) {
return null;
}
return (
workspaceRefs.find((workspace) => this.isSamePath(workspace.cwd, ownerRoot))?.workspaceId ??
null
);
}
private async handleRenameTerminalRequest(msg: RenameTerminalRequest): Promise<void> { private async handleRenameTerminalRequest(msg: RenameTerminalRequest): Promise<void> {
const respond = (success: boolean, error: string | null): void => { const respond = (success: boolean, error: string | null): void => {
this.emit({ this.emit({