mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Fix legacy workspace compatibility
This commit is contained in:
@@ -35,7 +35,7 @@ function restoreArchivedWorkspace(serverId: string, agentId: string, workspaceId
|
||||
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
|
||||
// refresh_agent, so a gone directory would spin then flash a misleading
|
||||
// "couldn't restore". Surface an explicit "update your host" state instead.
|
||||
|
||||
@@ -2278,7 +2278,7 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
workspaceMultiplicity: z.boolean().optional(),
|
||||
// COMPAT(projectRemove): added in v0.1.97, drop the gate when floor >= v0.1.97.
|
||||
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(),
|
||||
})
|
||||
.optional(),
|
||||
|
||||
@@ -49,7 +49,11 @@ describe("backfillWorkspaceIdForLegacyAgents", () => {
|
||||
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({
|
||||
id,
|
||||
provider: "codex",
|
||||
@@ -65,7 +69,7 @@ describe("backfillWorkspaceIdForLegacyAgents", () => {
|
||||
config: null,
|
||||
runtimeInfo: { provider: "codex", sessionId: 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");
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const userHome = homedir();
|
||||
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.
|
||||
// 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.
|
||||
// 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(
|
||||
cwd: string,
|
||||
workspaces: Iterable<PersistedWorkspaceRecord>,
|
||||
options?: { includeArchived?: boolean },
|
||||
): string | null {
|
||||
const normalizedCwd = resolve(cwd);
|
||||
const userHome = resolve(homedir());
|
||||
const activeWorkspaces = Array.from(workspaces).filter((workspace) => !workspace.archivedAt);
|
||||
const exactMatches = activeWorkspaces.filter(
|
||||
const candidateWorkspaces = Array.from(workspaces).filter(
|
||||
(workspace) => options?.includeArchived === true || !workspace.archivedAt,
|
||||
);
|
||||
const exactMatches = candidateWorkspaces.filter(
|
||||
(workspace) => resolve(workspace.cwd) === normalizedCwd,
|
||||
);
|
||||
if (exactMatches.length > 0) {
|
||||
return oldestWorkspace(exactMatches).workspaceId;
|
||||
}
|
||||
|
||||
const prefixMatches = activeWorkspaces.filter((workspace) => {
|
||||
const prefixMatches = candidateWorkspaces.filter((workspace) => {
|
||||
const workspaceCwd = resolve(workspace.cwd);
|
||||
if (workspaceCwd === userHome) {
|
||||
return false;
|
||||
@@ -72,7 +77,9 @@ export async function backfillWorkspaceIdForLegacyAgents(options: {
|
||||
continue;
|
||||
}
|
||||
|
||||
const workspaceId = resolveLegacyWorkspaceOwner(record.cwd, workspaceRecords);
|
||||
const workspaceId = resolveLegacyWorkspaceOwner(record.cwd, workspaceRecords, {
|
||||
includeArchived: record.archivedAt != null,
|
||||
});
|
||||
if (!workspaceId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -988,12 +988,7 @@ export class Session {
|
||||
hasBinaryChannel: () => this.onBinaryMessage !== null,
|
||||
isPathWithinRoot: (rootPath, candidatePath) => this.isPathWithinRoot(rootPath, candidatePath),
|
||||
sessionLogger: this.sessionLogger,
|
||||
listTerminalWorkspaceRoots: async () => {
|
||||
const workspaces = await this.workspaceRegistry.list();
|
||||
return workspaces
|
||||
.filter((workspace) => !workspace.archivedAt)
|
||||
.map((workspace) => workspace.cwd);
|
||||
},
|
||||
listTerminalWorkspaceRefs: () => this.listActiveWorkspaceRefs(),
|
||||
clientSupportsWrapReflow: () =>
|
||||
this.clientCapabilities.has(CLIENT_CAPS.terminalReflowableSnapshot),
|
||||
getClientBufferedAmount: () => this.getTransportBufferedAmount(),
|
||||
|
||||
@@ -1159,7 +1159,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
workspaceMultiplicity: true,
|
||||
// COMPAT(projectRemove): added in v0.1.97, drop the gate when floor >= v0.1.97.
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
cwd: input.cwd,
|
||||
workspaceId: "ws-test",
|
||||
workspaceId: input.workspaceId ?? "ws-test",
|
||||
send: vi.fn(),
|
||||
subscribe: () => 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> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ export interface TerminalSessionControllerOptions {
|
||||
hasBinaryChannel: () => boolean;
|
||||
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
|
||||
sessionLogger: pino.Logger;
|
||||
listTerminalWorkspaceRefs?: () => Promise<readonly TerminalWorkspaceRef[]>;
|
||||
listTerminalWorkspaceRoots?: () => Promise<readonly string[]>;
|
||||
// Whether the connected client can reflow restored snapshots. When true the
|
||||
// daemon attaches per-row soft-wrap flags to snapshots; otherwise it omits them
|
||||
@@ -83,6 +84,11 @@ export interface TerminalSessionControllerOptions {
|
||||
getClientBufferedAmount?: () => number | null;
|
||||
}
|
||||
|
||||
interface TerminalWorkspaceRef {
|
||||
workspaceId: string;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export interface TerminalSessionControllerMetrics {
|
||||
directorySubscriptionCount: number;
|
||||
streamSubscriptionCount: number;
|
||||
@@ -120,6 +126,7 @@ export class TerminalSessionController {
|
||||
private readonly hasBinaryChannel: () => boolean;
|
||||
private readonly isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
|
||||
private readonly sessionLogger: pino.Logger;
|
||||
private readonly listTerminalWorkspaceRefs: () => Promise<readonly TerminalWorkspaceRef[]>;
|
||||
private readonly listTerminalWorkspaceRoots: () => Promise<readonly string[]>;
|
||||
private readonly clientSupportsWrapReflow: () => boolean;
|
||||
private readonly getClientBufferedAmount: () => number | null;
|
||||
@@ -145,7 +152,10 @@ export class TerminalSessionController {
|
||||
this.hasBinaryChannel = options.hasBinaryChannel;
|
||||
this.isPathWithinRoot = options.isPathWithinRoot;
|
||||
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.getClientBufferedAmount = options.getClientBufferedAmount ?? (() => 0);
|
||||
}
|
||||
@@ -520,7 +530,8 @@ export class TerminalSessionController {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!msg.workspaceId) {
|
||||
const workspaceId = msg.workspaceId ?? (await this.resolveLegacyTerminalWorkspaceId(msg.cwd));
|
||||
if (!workspaceId) {
|
||||
this.emit({
|
||||
type: "create_terminal_response",
|
||||
payload: {
|
||||
@@ -534,7 +545,7 @@ export class TerminalSessionController {
|
||||
|
||||
const session = await this.terminalManager.createTerminal({
|
||||
cwd: msg.cwd,
|
||||
workspaceId: msg.workspaceId,
|
||||
workspaceId,
|
||||
name: msg.name,
|
||||
command: msg.command,
|
||||
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> {
|
||||
const respond = (success: boolean, error: string | null): void => {
|
||||
this.emit({
|
||||
|
||||
Reference in New Issue
Block a user