Pass workspace ownership when creating terminals

This commit is contained in:
Mohamed Boudra
2026-06-16 17:35:16 +07:00
parent 10664487fb
commit 3cf3a63423
8 changed files with 159 additions and 59 deletions
+1 -1
View File
@@ -53,7 +53,7 @@ export interface SeedDaemonClient {
cwd: string,
name?: string,
requestId?: string,
options?: { agentId?: string; command?: string; args?: string[] },
options?: { agentId?: string; command?: string; args?: string[]; workspaceId?: string },
): Promise<{
terminal: { id: string; name: string; cwd: string; activity?: TerminalActivity | null } | null;
error: string | null;
+5 -2
View File
@@ -67,8 +67,9 @@ export class TerminalE2EHarness {
? {
command: input.command,
args: input.args,
workspaceId: this.workspaceId,
}
: undefined;
: { workspaceId: this.workspaceId };
const result = await this.client.createTerminal(
this.tempRepo.path,
input.name,
@@ -90,7 +91,9 @@ export class TerminalE2EHarness {
const timeoutMs = input.timeoutMs ?? 10_000;
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const result = await this.client.listTerminals(this.tempRepo.path);
const result = await this.client.listTerminals(this.tempRepo.path, undefined, {
workspaceId: this.workspaceId,
});
const terminal = result.terminals.find((entry) => entry.id === input.terminalId);
const activity = terminal?.activity ?? null;
const attentionMatches =
+16 -2
View File
@@ -108,7 +108,14 @@ test.describe("Terminal title propagation", () => {
test.skip("terminal tab title updates from OSC title escape sequence", async ({ page }) => {
test.setTimeout(60_000);
const result = await workspace.client.createTerminal(workspace.repoPath, "title-test");
const result = await workspace.client.createTerminal(
workspace.repoPath,
"title-test",
undefined,
{
workspaceId: workspace.workspaceId,
},
);
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
const terminalId = result.terminal.id;
@@ -138,7 +145,14 @@ test.describe("Terminal title propagation", () => {
test.skip("title debouncing coalesces rapid changes", async ({ page }) => {
test.setTimeout(60_000);
const result = await workspace.client.createTerminal(workspace.repoPath, "debounce-test");
const result = await workspace.client.createTerminal(
workspace.repoPath,
"debounce-test",
undefined,
{
workspaceId: workspace.workspaceId,
},
);
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
const terminalId = result.terminal.id;
+12 -1
View File
@@ -20,7 +20,18 @@ export async function runCreateCommand(
const cwd = options.cwd ?? process.cwd();
try {
const payload = await client.createTerminal(cwd, options.name);
const opened = await client.openProject(cwd);
if (!opened.workspace) {
const error: CommandError = {
code: "WORKSPACE_OPEN_FAILED",
message: opened.error ?? "Failed to open workspace",
};
throw error;
}
const payload = await client.createTerminal(cwd, options.name, undefined, {
workspaceId: opened.workspace.id,
});
if (!payload.terminal) {
const error: CommandError = {
code: "TERMINAL_CREATE_FAILED",
@@ -519,7 +519,18 @@ test("bootstraps configured worktree terminals after setup succeeds", async () =
const setupPort = readFileSync(path.join(agent.cwd, "setup-port.txt"), "utf8").trim();
expect(setupPort.length).toBeGreaterThan(0);
const createdTerminal = await ctx.client.createTerminal(agent.cwd, "Manual Port Check");
const openedWorktree = await ctx.client.openProject(agent.cwd);
if (!openedWorktree.workspace) {
throw new Error(openedWorktree.error ?? `Failed to open worktree workspace for ${agent.cwd}`);
}
const createdTerminal = await ctx.client.createTerminal(
agent.cwd,
"Manual Port Check",
undefined,
{
workspaceId: openedWorktree.workspace.id,
},
);
expect(createdTerminal.error).toBeNull();
expect(createdTerminal.terminal).toBeTruthy();
const manualTerminalId = createdTerminal.terminal?.id;
@@ -24,6 +24,26 @@ function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-terminal-e2e-"));
}
interface CreateTerminalInWorkspaceInput {
cwd: string;
name?: string;
options?: { command?: string; args?: string[] };
}
async function createTerminalInWorkspace(
client: DaemonClient,
input: CreateTerminalInWorkspaceInput,
) {
const opened = await client.openProject(input.cwd);
if (!opened.workspace) {
throw new Error(opened.error ?? `Failed to open workspace for ${input.cwd}`);
}
return client.createTerminal(input.cwd, input.name, undefined, {
...input.options,
workspaceId: opened.workspace.id,
});
}
function createLogger() {
return {
debug: () => {},
@@ -602,7 +622,11 @@ async function measureRepeatCadenceForTerminal(input: {
options?: { command?: string; args?: string[] };
setupInput?: string;
}): Promise<TerminalRepeatCadenceMeasurement> {
const created = await ctx.client.createTerminal(input.cwd, input.name, undefined, input.options);
const created = await createTerminalInWorkspace(ctx.client, {
cwd: input.cwd,
name: input.name,
options: input.options,
});
const terminalId = created.terminal!.id;
const ws = await connectRawWebSocket(ctx.daemon.port);
@@ -742,7 +766,7 @@ test("lists terminals for a directory", async () => {
test("client connects and receives a snapshot of the current terminal state", async () => {
const cwd = tmpCwd();
const created = await ctx.client.createTerminal(cwd);
const created = await createTerminalInWorkspace(ctx.client, { cwd });
const terminalId = created.terminal!.id;
ctx.client.sendTerminalInput(terminalId, {
@@ -764,7 +788,7 @@ test("client connects and receives a snapshot of the current terminal state", as
test("live terminal restore skips the initial snapshot", async () => {
const cwd = tmpCwd();
const created = await ctx.client.createTerminal(cwd);
const created = await createTerminalInWorkspace(ctx.client, { cwd });
const terminalId = created.terminal!.id;
const ws = await connectRawWebSocket(ctx.daemon.port);
@@ -809,12 +833,15 @@ test("live terminal restore skips the initial snapshot", async () => {
test("visible terminal restore sends bounded ANSI history", async () => {
const cwd = tmpCwd();
const created = await ctx.client.createTerminal(cwd, undefined, undefined, {
command: "/bin/sh",
args: [
"-lc",
"i=1; while [ $i -le 1200 ]; do printf 'restore-line-%04d\\n' $i; i=$((i+1)); done; sleep 30",
],
const created = await createTerminalInWorkspace(ctx.client, {
cwd,
options: {
command: "/bin/sh",
args: [
"-lc",
"i=1; while [ $i -le 1200 ]; do printf 'restore-line-%04d\\n' $i; i=$((i+1)); done; sleep 30",
],
},
});
const terminalId = created.terminal!.id;
const ws = await connectRawWebSocket(ctx.daemon.port);
@@ -853,9 +880,12 @@ test("visible terminal restore sends bounded ANSI history", async () => {
test("propagates debounced terminal titles through list responses and snapshots", async () => {
const cwd = tmpCwd();
const created = await ctx.client.createTerminal(cwd, undefined, undefined, {
command: "/bin/sh",
args: ["-lc", "printf '\\033]0;Build Output\\007'; sleep 2"],
const created = await createTerminalInWorkspace(ctx.client, {
cwd,
options: {
command: "/bin/sh",
args: ["-lc", "printf '\\033]0;Build Output\\007'; sleep 2"],
},
});
const terminalId = created.terminal!.id;
@@ -886,7 +916,7 @@ test("propagates debounced terminal titles through list responses and snapshots"
test("subscribe response is sent before the initial snapshot frame", async () => {
const cwd = tmpCwd();
const created = await ctx.client.createTerminal(cwd);
const created = await createTerminalInWorkspace(ctx.client, { cwd });
const terminalId = created.terminal!.id;
const ws = await connectRawWebSocket(ctx.daemon.port);
@@ -982,7 +1012,7 @@ test("subscribe response is sent before the initial snapshot frame", async () =>
test("client sends input and receives output as raw bytes", async () => {
const cwd = tmpCwd();
const created = await ctx.client.createTerminal(cwd);
const created = await createTerminalInWorkspace(ctx.client, { cwd });
const terminalId = created.terminal!.id;
const outputPromise = waitForTerminalOutput(ctx.client, terminalId, (text) =>
@@ -1008,8 +1038,8 @@ test("default zsh terminal does not eagerly flush full state during repeat burst
test("one client can stream two terminals concurrently", async () => {
const cwd = tmpCwd();
const firstCreated = await ctx.client.createTerminal(cwd, "first");
const secondCreated = await ctx.client.createTerminal(cwd, "second");
const firstCreated = await createTerminalInWorkspace(ctx.client, { cwd, name: "first" });
const secondCreated = await createTerminalInWorkspace(ctx.client, { cwd, name: "second" });
const firstTerminalId = firstCreated.terminal!.id;
const secondTerminalId = secondCreated.terminal!.id;
@@ -1044,7 +1074,7 @@ test("one client can stream two terminals concurrently", async () => {
test("disconnect and reconnect both receive the current snapshot", async () => {
const cwd = tmpCwd();
const created = await ctx.client.createTerminal(cwd);
const created = await createTerminalInWorkspace(ctx.client, { cwd });
const terminalId = created.terminal!.id;
await ctx.client.subscribeTerminal(terminalId);
@@ -1068,8 +1098,10 @@ test("disconnect and reconnect both receive the current snapshot", async () => {
test("reconnected terminal streams replay active input modes after the snapshot", async () => {
const cwd = tmpCwd();
const created = await ctx.client.createTerminal(cwd, "input-mode-probe", undefined, {
...createInputModeProbeOptions(),
const created = await createTerminalInWorkspace(ctx.client, {
cwd,
name: "input-mode-probe",
options: createInputModeProbeOptions(),
});
const terminalId = created.terminal!.id;
const firstWs = await connectRawWebSocket(ctx.daemon.port);
@@ -1125,8 +1157,10 @@ test("reconnected terminal streams replay active input modes after the snapshot"
test("reconnected terminal streams do not replay input modes before they are enabled", async () => {
const cwd = tmpCwd();
const created = await ctx.client.createTerminal(cwd, "input-mode-inactive-probe", undefined, {
...createInputModeProbeOptions(),
const created = await createTerminalInWorkspace(ctx.client, {
cwd,
name: "input-mode-inactive-probe",
options: createInputModeProbeOptions(),
});
const terminalId = created.terminal!.id;
const ws = await connectRawWebSocket(ctx.daemon.port);
@@ -1146,7 +1180,7 @@ test("reconnected terminal streams do not replay input modes before they are ena
test("fast output to a slow websocket client falls back to a snapshot", async () => {
const cwd = tmpCwd();
const created = await ctx.client.createTerminal(cwd);
const created = await createTerminalInWorkspace(ctx.client, { cwd });
const terminalId = created.terminal!.id;
const ws = await connectRawWebSocket(ctx.daemon.port);
@@ -1179,7 +1213,7 @@ test("fast output to a slow websocket client falls back to a snapshot", async ()
test("multiple clients on the same terminal each receive output independently", async () => {
const cwd = tmpCwd();
const created = await ctx.client.createTerminal(cwd);
const created = await createTerminalInWorkspace(ctx.client, { cwd });
const terminalId = created.terminal!.id;
const secondClient = await connectClient(
ctx.daemon.port,
@@ -1212,7 +1246,7 @@ test("multiple clients on the same terminal each receive output independently",
test("resize updates server dimensions without sending a live snapshot", async () => {
const cwd = tmpCwd();
const created = await ctx.client.createTerminal(cwd);
const created = await createTerminalInWorkspace(ctx.client, { cwd });
const terminalId = created.terminal!.id;
await ctx.client.subscribeTerminal(terminalId);
ctx.client.sendTerminalInput(terminalId, {
@@ -1232,7 +1266,7 @@ test("resize updates server dimensions without sending a live snapshot", async (
test("resize does not stall streamed output for an attached client", async () => {
const cwd = tmpCwd();
const created = await ctx.client.createTerminal(cwd);
const created = await createTerminalInWorkspace(ctx.client, { cwd });
const terminalId = created.terminal!.id;
const secondClient = await connectClient(
ctx.daemon.port,
@@ -1271,7 +1305,7 @@ test("resize does not stall streamed output for an attached client", async () =>
test("terminal exits notify the client", async () => {
const cwd = tmpCwd();
const created = await ctx.client.createTerminal(cwd);
const created = await createTerminalInWorkspace(ctx.client, { cwd });
const terminalId = created.terminal!.id;
let sawExit = false;
@@ -1296,7 +1330,7 @@ test("terminal exits notify the client", async () => {
test("websocket terminate then new connection gets snapshot with all prior output", async () => {
const cwd = tmpCwd();
const created = await ctx.client.createTerminal(cwd);
const created = await createTerminalInWorkspace(ctx.client, { cwd });
const terminalId = created.terminal!.id;
const firstSocket = await connectRawWebSocket(ctx.daemon.port);
@@ -1359,7 +1393,7 @@ test("websocket terminate then new connection gets snapshot with all prior outpu
test("two clients can both send input and each sees its own output", async () => {
const cwd = tmpCwd();
const created = await ctx.client.createTerminal(cwd);
const created = await createTerminalInWorkspace(ctx.client, { cwd });
const terminalId = created.terminal!.id;
const secondClient = await connectClient(
ctx.daemon.port,
@@ -1397,7 +1431,10 @@ test("two clients can both send input and each sees its own output", async () =>
test("snapshot fidelity through websocket decode preserves dimensions and visible text", async () => {
const cwd = tmpCwd();
const created = await ctx.client.createTerminal(cwd, "Snapshot Fidelity");
const created = await createTerminalInWorkspace(ctx.client, {
cwd,
name: "Snapshot Fidelity",
});
const terminalId = created.terminal!.id;
ctx.client.sendTerminalInput(terminalId, {
@@ -1435,7 +1472,7 @@ test("snapshot fidelity through websocket decode preserves dimensions and visibl
test("terminal exit prevents resubscribe and sends no frames after exit", async () => {
const cwd = tmpCwd();
const created = await ctx.client.createTerminal(cwd);
const created = await createTerminalInWorkspace(ctx.client, { cwd });
const terminalId = created.terminal!.id;
const ws = await connectRawWebSocket(ctx.daemon.port);
@@ -1470,7 +1507,7 @@ test("terminal exit prevents resubscribe and sends no frames after exit", async
test("empty input frame does not crash the server", async () => {
const cwd = tmpCwd();
const created = await ctx.client.createTerminal(cwd);
const created = await createTerminalInWorkspace(ctx.client, { cwd });
const terminalId = created.terminal!.id;
const ws = await connectRawWebSocket(ctx.daemon.port);
@@ -1509,7 +1546,7 @@ test("empty input frame does not crash the server", async () => {
test("1MB output burst keeps frames decodable and terminal usable afterward", async () => {
const cwd = tmpCwd();
const created = await ctx.client.createTerminal(cwd);
const created = await createTerminalInWorkspace(ctx.client, { cwd });
const terminalId = created.terminal!.id;
const ws = await connectRawWebSocket(ctx.daemon.port);
@@ -1568,7 +1605,7 @@ describe("capture", () => {
test("captures visible terminal output as plain text", async () => {
const cwd = tmpCwd();
tempDirs.push(cwd);
const created = await ctx.client.createTerminal(cwd);
const created = await createTerminalInWorkspace(ctx.client, { cwd });
const terminalId = created.terminal!.id;
await ctx.client.subscribeTerminal(terminalId);
@@ -1592,7 +1629,7 @@ describe("capture", () => {
test("captures with start/end line range", async () => {
const cwd = tmpCwd();
tempDirs.push(cwd);
const created = await ctx.client.createTerminal(cwd);
const created = await createTerminalInWorkspace(ctx.client, { cwd });
const terminalId = created.terminal!.id;
await ctx.client.subscribeTerminal(terminalId);
@@ -1625,7 +1662,7 @@ describe("capture", () => {
test("supports negative line indices", async () => {
const cwd = tmpCwd();
tempDirs.push(cwd);
const created = await ctx.client.createTerminal(cwd);
const created = await createTerminalInWorkspace(ctx.client, { cwd });
const terminalId = created.terminal!.id;
await ctx.client.subscribeTerminal(terminalId);
@@ -1655,7 +1692,7 @@ describe("capture", () => {
test("strips ANSI by default", async () => {
const cwd = tmpCwd();
tempDirs.push(cwd);
const created = await ctx.client.createTerminal(cwd);
const created = await createTerminalInWorkspace(ctx.client, { cwd });
const terminalId = created.terminal!.id;
await ctx.client.subscribeTerminal(terminalId);
@@ -1686,8 +1723,14 @@ describe("list terminals across directories", () => {
const cwd2 = tmpCwd();
tempDirs.push(cwd1, cwd2);
const firstCreated = await ctx.client.createTerminal(cwd1, "first-terminal");
const secondCreated = await ctx.client.createTerminal(cwd2, "second-terminal");
const firstCreated = await createTerminalInWorkspace(ctx.client, {
cwd: cwd1,
name: "first-terminal",
});
const secondCreated = await createTerminalInWorkspace(ctx.client, {
cwd: cwd2,
name: "second-terminal",
});
const list = await ctx.client.listTerminals();
@@ -1711,8 +1754,11 @@ describe("list terminals across directories", () => {
const cwd2 = tmpCwd();
tempDirs.push(cwd1, cwd2);
const firstCreated = await ctx.client.createTerminal(cwd1, "cwd-one-terminal");
await ctx.client.createTerminal(cwd2, "cwd-two-terminal");
const firstCreated = await createTerminalInWorkspace(ctx.client, {
cwd: cwd1,
name: "cwd-one-terminal",
});
await createTerminalInWorkspace(ctx.client, { cwd: cwd2, name: "cwd-two-terminal" });
const list = await ctx.client.listTerminals(cwd1);
@@ -231,11 +231,13 @@ function transition(input: {
state: "working" | "idle" | "attention" | null;
changedAt: number;
id?: string;
workspaceId?: string;
}): TerminalActivityTransitionEvent {
return {
terminalId: input.id ?? "term-1",
name: "bash",
cwd: CWD,
workspaceId: input.workspaceId ?? "ws-1",
activity: input.state ? { state: input.state, changedAt: input.changedAt } : null,
previous: { state: input.previousState, changedAt: input.previousChangedAt },
};
@@ -266,7 +268,7 @@ describe("VoiceAssistantWebSocketServer terminal attention notifications", () =>
const payload = readTerminalAttentionMessage(ws);
expect(payload.terminalId).toBe("term-1");
expect(payload.cwd).toBe(CWD);
expect(payload.workspaceId).toBeUndefined();
expect(payload.workspaceId).toBe("ws-1");
expect(payload.reason).toBe("finished");
expect(payload.title).toBe("Terminal finished");
expect(payload.body).toBe("bash");
@@ -274,9 +276,9 @@ describe("VoiceAssistantWebSocketServer terminal attention notifications", () =>
expect(payload.shouldNotify).toBe(false);
});
it("resolves the workspaceId for the terminal cwd into the payload", async () => {
it("forwards the terminal event workspaceId into the payload", async () => {
const { manager, emit } = createTerminalManager();
const { server } = createServer(manager, createWorkspaceRegistry([workspaceRecord()]));
const { server } = createServer(manager);
const ws = connectClient(server);
emit(
@@ -285,17 +287,18 @@ describe("VoiceAssistantWebSocketServer terminal attention notifications", () =>
previousChangedAt: 1000,
state: "idle",
changedAt: 11001,
workspaceId: "ws-event",
}),
);
await flushAsync();
expect(readTerminalAttentionMessage(ws).workspaceId).toBe("ws-1");
expect(readTerminalAttentionMessage(ws).workspaceId).toBe("ws-event");
});
it("resolves the parent workspaceId for a subdirectory terminal cwd", async () => {
it("keeps the event workspaceId for a subdirectory terminal cwd", async () => {
const { manager, emit } = createTerminalManager();
const { server } = createServer(manager, createWorkspaceRegistry([workspaceRecord()]));
const { server } = createServer(manager);
const ws = connectClient(server);
emit({
@@ -313,7 +316,7 @@ describe("VoiceAssistantWebSocketServer terminal attention notifications", () =>
expect(readTerminalAttentionMessage(ws).workspaceId).toBe("ws-1");
});
it("omits workspaceId for archived or unregistered workspaces", async () => {
it("does not derive terminal attention ownership from the workspace registry", async () => {
const { manager, emit } = createTerminalManager();
const { server } = createServer(
manager,
@@ -335,7 +338,7 @@ describe("VoiceAssistantWebSocketServer terminal attention notifications", () =>
await flushAsync();
expect(readTerminalAttentionMessage(ws).workspaceId).toBeUndefined();
expect(readTerminalAttentionMessage(ws).workspaceId).toBe("ws-1");
});
it("does not broadcast on working -> working (no transition to idle)", async () => {
+16 -4
View File
@@ -51,6 +51,8 @@ interface DaemonClientLike {
createTerminal(
cwd: string,
name?: string,
requestId?: string,
options?: { workspaceId?: string },
): Promise<{ terminal: { id: string } | null; error: string | null }>;
subscribeTerminal(terminalId: string): Promise<{ error: string | null }>;
killTerminal(terminalId: string): Promise<unknown>;
@@ -62,6 +64,7 @@ interface DaemonClientLike {
createAgent(options: {
provider: string;
cwd: string;
workspaceId?: string;
model?: string;
title?: string;
}): Promise<{ id: string }>;
@@ -483,6 +486,7 @@ if (process.env.BENCH_INCLUDE_L3 === "1") {
async function startLoad(
client: DaemonClientLike,
cwd: string,
workspaceId: string,
spec: LevelSpec,
): Promise<LoadController> {
const agentIds: string[] = [];
@@ -490,6 +494,7 @@ async function startLoad(
const agent = await client.createAgent({
provider: "mock",
cwd,
workspaceId,
model: "five-minute-stream",
title: `${spec.id}-load-${i}`,
});
@@ -511,7 +516,9 @@ async function startLoad(
let noisyTerminalId: string | null = null;
if (spec.secondNoisyTerminal) {
const created = await client.createTerminal(cwd, `${spec.id}-noisy`);
const created = await client.createTerminal(cwd, `${spec.id}-noisy`, undefined, {
workspaceId,
});
if (created.terminal) {
noisyTerminalId = created.terminal.id;
await client.subscribeTerminal(noisyTerminalId);
@@ -563,11 +570,12 @@ interface LevelResult {
async function runLevel(
client: DaemonClientLike,
cwd: string,
workspaceId: string,
spec: LevelSpec,
measuredTerminalId: string,
): Promise<LevelResult> {
console.log(`\n=== ${spec.id} (${spec.description}) ===`);
const load = await startLoad(client, cwd, spec);
const load = await startLoad(client, cwd, workspaceId, spec);
try {
// Ping sampling runs concurrently with the echo + burst measurements so it
// observes the daemon under the same load the latency primitives see.
@@ -679,7 +687,11 @@ async function main(): Promise<void> {
throw new Error(`Failed to open project: ${opened.error}`);
}
const created = await client.createTerminal(workspaceDir, "measured");
const workspaceId = opened.workspace.id;
const created = await client.createTerminal(workspaceDir, "measured", undefined, {
workspaceId,
});
if (!created.terminal) {
throw new Error(`Failed to create terminal: ${created.error}`);
}
@@ -691,7 +703,7 @@ async function main(): Promise<void> {
const results: LevelResult[] = [];
for (const spec of LEVELS) {
const result = await runLevel(client, workspaceDir, spec, measuredTerminalId);
const result = await runLevel(client, workspaceDir, workspaceId, spec, measuredTerminalId);
results.push(result);
// Settle between levels so a previous level's load fully drains.
await sleep(2_000);