mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 10:25:03 +00:00
Address terminals by workspace ID in the SDK and CLI (#4358)
* feat(terminals): expose workspace ownership through the SDK and CLI Directory-based terminal creation selected the oldest workspace sharing that path. Expose explicit workspace ownership in the shared public API and CLI, retain ownership and process directories in listings, and acknowledge terminal deletion after teardown. * fix(file-observer): preserve nested shallow reconciliation scopes A parent directory scan does not visit its descendants. Collapsing change scopes discarded nested scans and left coalesced native deletions unseen until a full audit. * test(terminals): extract output waits from end-to-end scenarios
This commit is contained in:
@@ -230,6 +230,7 @@ test("createPaseoApi borrows daemon capabilities without exposing connection own
|
||||
"config",
|
||||
"projects",
|
||||
"providers",
|
||||
"terminals",
|
||||
"workspaces",
|
||||
]);
|
||||
expect("connect" in paseo).toBe(false);
|
||||
|
||||
@@ -25,6 +25,22 @@ import type {
|
||||
WorkspaceCreateRequest,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { DaemonClient } from "./daemon-client.js";
|
||||
import {
|
||||
createTerminalActions,
|
||||
type PaseoTerminalActions,
|
||||
type PaseoWorkspaceTerminalActions,
|
||||
} from "./terminals/index.js";
|
||||
export type {
|
||||
PaseoTerminal,
|
||||
PaseoTerminalActions,
|
||||
PaseoTerminalHandle,
|
||||
PaseoTerminalCreateOptions,
|
||||
PaseoTerminalListOptions,
|
||||
PaseoTerminalListResult,
|
||||
PaseoTerminalCaptureOptions,
|
||||
PaseoTerminalCaptureResult,
|
||||
PaseoWorkspaceTerminalActions,
|
||||
} from "./terminals/index.js";
|
||||
import type { PluginTimelineItem } from "@getpaseo/protocol/agent-types";
|
||||
import type {
|
||||
FetchAgentsEntry,
|
||||
@@ -141,6 +157,7 @@ export interface PaseoWorkspaceHandle {
|
||||
readonly agents: {
|
||||
create(options: PaseoWorkspaceAgentCreateOptions): Promise<PaseoAgentHandle>;
|
||||
};
|
||||
readonly terminals: PaseoWorkspaceTerminalActions;
|
||||
current(): PaseoWorkspace | null;
|
||||
refresh(options?: { requestId?: string }): Promise<PaseoWorkspace | null>;
|
||||
setTitle(title: string | null, requestId?: string): Promise<{ title: string | null }>;
|
||||
@@ -408,6 +425,7 @@ export interface PaseoConfigActions {
|
||||
}
|
||||
|
||||
export interface PaseoApi {
|
||||
readonly terminals: PaseoTerminalActions;
|
||||
readonly workspaces: PaseoWorkspaceActions;
|
||||
readonly projects: PaseoProjectActions;
|
||||
readonly agents: PaseoAgentActions;
|
||||
@@ -463,9 +481,17 @@ export function createPaseoApi(daemonClient: DaemonClient): PaseoApi {
|
||||
});
|
||||
return createAgentHandle(agent);
|
||||
};
|
||||
const createWorkspaceHandle = createWorkspaceHandleFactory(daemonClient, createAgent);
|
||||
const terminals = createTerminalActions(daemonClient, async (workspaceId) => {
|
||||
const workspace = await createWorkspaceHandle(workspaceId).refresh();
|
||||
if (!workspace?.workspaceDirectory) {
|
||||
throw new Error(`Workspace ${workspaceId} is not active or has no available directory`);
|
||||
}
|
||||
return workspace.workspaceDirectory;
|
||||
});
|
||||
const createWorkspaceHandle = createWorkspaceHandleFactory(daemonClient, createAgent, terminals);
|
||||
|
||||
return {
|
||||
terminals,
|
||||
projects: {
|
||||
list: (options) => daemonClient.listProjects(options),
|
||||
},
|
||||
@@ -531,6 +557,7 @@ type CreateAgent = (
|
||||
function createWorkspaceHandleFactory(
|
||||
daemonClient: DaemonClient,
|
||||
createAgent: CreateAgent,
|
||||
terminals: PaseoTerminalActions,
|
||||
): WorkspaceHandleFactory {
|
||||
return (workspace) => {
|
||||
const id = typeof workspace === "string" ? workspace : workspace.id;
|
||||
@@ -582,6 +609,10 @@ function createWorkspaceHandleFactory(
|
||||
);
|
||||
},
|
||||
},
|
||||
terminals: {
|
||||
create: (options) => terminals.create({ ...options, workspaceId: id }),
|
||||
list: (options) => terminals.list({ ...options, workspaceId: id }),
|
||||
},
|
||||
current: () => current,
|
||||
refresh,
|
||||
setTitle: (title, requestId) => daemonClient.setWorkspaceTitle(id, title, requestId),
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { createTerminalActions } from "./index.js";
|
||||
|
||||
test("an older host rejects terminal operations before sending requests", async () => {
|
||||
const unexpectedRequest = () => {
|
||||
throw new Error("Unexpected daemon request");
|
||||
};
|
||||
const terminals = createTerminalActions(
|
||||
{
|
||||
ensureConnected: () => {},
|
||||
getLastServerInfoMessage: () => ({
|
||||
status: "server_info",
|
||||
serverId: "older-host",
|
||||
features: {},
|
||||
}),
|
||||
createTerminal: unexpectedRequest,
|
||||
listTerminals: unexpectedRequest,
|
||||
sendTerminalInput: unexpectedRequest,
|
||||
captureTerminal: unexpectedRequest,
|
||||
killTerminal: unexpectedRequest,
|
||||
},
|
||||
unexpectedRequest,
|
||||
);
|
||||
|
||||
await expect(terminals.create({ workspaceId: "wks_existing" })).rejects.toThrow(
|
||||
"Update the host",
|
||||
);
|
||||
await expect(terminals.list({ workspaceId: "wks_existing" })).rejects.toThrow("Update the host");
|
||||
const terminal = terminals.ref("terminal-existing");
|
||||
expect(terminal.current()).toBeNull();
|
||||
expect(() => terminal.write("text")).toThrow("Update the host");
|
||||
expect(() => terminal.sendKeys(["Enter"])).toThrow("Update the host");
|
||||
expect(() => terminal.capture()).toThrow("Update the host");
|
||||
await expect(terminal.kill()).rejects.toThrow("Update the host");
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import { z } from "zod";
|
||||
import type { DaemonClient } from "../daemon-client.js";
|
||||
|
||||
const TerminalSchema = z.object({
|
||||
id: z.string(),
|
||||
workspaceId: z.string(),
|
||||
cwd: z.string(),
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export type PaseoTerminal = z.infer<typeof TerminalSchema>;
|
||||
|
||||
export interface PaseoTerminalCreateOptions {
|
||||
workspaceId: string;
|
||||
/** Process working directory; defaults to the workspace directory. */
|
||||
cwd?: string;
|
||||
name?: string;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
size?: { rows: number; cols: number };
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
export interface PaseoTerminalListOptions {
|
||||
/** Ownership filter. When supplied, cwd does not restrict the results. */
|
||||
workspaceId?: string;
|
||||
/** Workspace root directory filter for unscoped listings. */
|
||||
cwd?: string;
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
export interface PaseoTerminalListResult {
|
||||
entries: PaseoTerminal[];
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
export interface PaseoTerminalCaptureOptions {
|
||||
start?: number;
|
||||
end?: number;
|
||||
stripAnsi?: boolean;
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
export type PaseoTerminalCaptureResult = Awaited<ReturnType<DaemonClient["captureTerminal"]>>;
|
||||
|
||||
export interface PaseoTerminalHandle {
|
||||
readonly id: string;
|
||||
current(): PaseoTerminal | null;
|
||||
refresh(options?: { requestId?: string }): Promise<PaseoTerminal | null>;
|
||||
/** Sends literal input and returns its UTF-16 length. Does not await command execution. */
|
||||
write(data: string): number;
|
||||
/** Expands CLI key tokens; other strings are literal. Returns the input's UTF-16 length. */
|
||||
sendKeys(keys: readonly string[]): number;
|
||||
capture(options?: PaseoTerminalCaptureOptions): Promise<PaseoTerminalCaptureResult>;
|
||||
kill(requestId?: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PaseoTerminalActions {
|
||||
create(options: PaseoTerminalCreateOptions): Promise<PaseoTerminalHandle>;
|
||||
list(options?: PaseoTerminalListOptions): Promise<PaseoTerminalListResult>;
|
||||
ref(terminal: string | PaseoTerminal): PaseoTerminalHandle;
|
||||
}
|
||||
|
||||
export interface PaseoWorkspaceTerminalActions {
|
||||
create(options?: Omit<PaseoTerminalCreateOptions, "workspaceId">): Promise<PaseoTerminalHandle>;
|
||||
list(options?: { requestId?: string }): Promise<PaseoTerminalListResult>;
|
||||
}
|
||||
|
||||
type TerminalClient = Pick<
|
||||
DaemonClient,
|
||||
| "ensureConnected"
|
||||
| "getLastServerInfoMessage"
|
||||
| "createTerminal"
|
||||
| "listTerminals"
|
||||
| "sendTerminalInput"
|
||||
| "captureTerminal"
|
||||
| "killTerminal"
|
||||
>;
|
||||
|
||||
/** @package */
|
||||
export function createTerminalActions(
|
||||
daemonClient: TerminalClient,
|
||||
resolveWorkspaceDirectory: (workspaceId: string) => Promise<string>,
|
||||
): PaseoTerminalActions {
|
||||
function client(): TerminalClient {
|
||||
daemonClient.ensureConnected();
|
||||
// COMPAT(workspaceTerminals): added in v0.7.3, remove gate after 2027-09-05.
|
||||
if (daemonClient.getLastServerInfoMessage()?.features?.workspaceTerminals !== true) {
|
||||
throw new Error("Update the host to use workspace terminals through the SDK.");
|
||||
}
|
||||
return daemonClient;
|
||||
}
|
||||
|
||||
const list = async (options: PaseoTerminalListOptions = {}): Promise<PaseoTerminalListResult> => {
|
||||
const result = await client().listTerminals(options.cwd, options.requestId, {
|
||||
workspaceId: options.workspaceId,
|
||||
});
|
||||
return {
|
||||
entries: result.terminals.map((terminal) => TerminalSchema.parse(terminal)),
|
||||
requestId: result.requestId,
|
||||
};
|
||||
};
|
||||
|
||||
const ref = (terminal: string | PaseoTerminal): PaseoTerminalHandle => {
|
||||
const id = typeof terminal === "string" ? terminal : terminal.id;
|
||||
let current = typeof terminal === "string" ? null : terminal;
|
||||
const write = (data: string): number => {
|
||||
client().sendTerminalInput(id, { type: "input", data });
|
||||
return data.length;
|
||||
};
|
||||
return {
|
||||
id,
|
||||
current: () => current,
|
||||
refresh: async (options) => {
|
||||
const result = await list(options);
|
||||
current = result.entries.find((entry) => entry.id === id) ?? null;
|
||||
return current;
|
||||
},
|
||||
write,
|
||||
sendKeys: (keys) => write(keys.map(resolveKeyToken).join("")),
|
||||
capture: (options = {}) => {
|
||||
const { requestId, ...captureOptions } = options;
|
||||
return client().captureTerminal(id, captureOptions, requestId);
|
||||
},
|
||||
kill: async (requestId) => {
|
||||
const result = await client().killTerminal(id, requestId);
|
||||
if (!result.success) throw new Error(`Failed to kill terminal ${id}`);
|
||||
current = null;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
create: async ({ workspaceId, cwd, name, requestId, ...options }) => {
|
||||
const driver = client();
|
||||
if (!workspaceId) throw new Error("workspaceId is required");
|
||||
const directory = cwd ?? (await resolveWorkspaceDirectory(workspaceId));
|
||||
const result = await driver.createTerminal(directory, name, requestId, {
|
||||
...options,
|
||||
workspaceId,
|
||||
});
|
||||
if (result.error || !result.terminal) {
|
||||
throw new Error(result.error ?? "The daemon did not create a terminal");
|
||||
}
|
||||
return ref(TerminalSchema.parse(result.terminal));
|
||||
},
|
||||
list,
|
||||
ref,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveKeyToken(key: string): string {
|
||||
switch (key) {
|
||||
case "Enter":
|
||||
return "\r";
|
||||
case "Tab":
|
||||
return "\t";
|
||||
case "Escape":
|
||||
return "\u001b";
|
||||
case "Space":
|
||||
return " ";
|
||||
case "BSpace":
|
||||
return "\u007f";
|
||||
case "C-c":
|
||||
return "\u0003";
|
||||
case "C-d":
|
||||
return "\u0004";
|
||||
case "C-z":
|
||||
return "\u001a";
|
||||
case "C-l":
|
||||
return "\u000c";
|
||||
case "C-a":
|
||||
return "\u0001";
|
||||
case "C-e":
|
||||
return "\u0005";
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user