Fix legacy workspace compatibility
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user