mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 08:24:46 +00:00
feat(plugin): observe terminals across connected hosts
Share host-wide terminal observations across plugin surfaces and gate them on terminalCollection. Keep directory observations compatible, and carry creation time in worker v6 state so v5 terminals can drain with an unknown timestamp.
This commit is contained in:
@@ -9,6 +9,7 @@ test("sidebar views replace the content, preserve chrome, and recover to the def
|
||||
await views.expectDefault();
|
||||
await views.chooseWorkspaces();
|
||||
await views.exerciseExamples();
|
||||
await views.exerciseTerminals();
|
||||
await views.restoreDefault();
|
||||
await views.recoverFromFailure();
|
||||
await views.chooseWorkspaces();
|
||||
|
||||
@@ -17,6 +17,8 @@ type NewWorkspaceDaemonClient = Pick<
|
||||
| "connect"
|
||||
| "createPaseoWorktree"
|
||||
| "createWorkspace"
|
||||
| "createTerminal"
|
||||
| "killTerminal"
|
||||
| "fetchAgents"
|
||||
| "fetchWorkspaces"
|
||||
| "getPaseoWorktreeList"
|
||||
|
||||
@@ -134,6 +134,38 @@ function sidebarViews(
|
||||
)
|
||||
.toEqual(["Inbox"]);
|
||||
},
|
||||
async exerciseTerminals() {
|
||||
await choose("Example terminals");
|
||||
const slot = visibleTestId(page, "plugin-sidebar-view");
|
||||
await slot
|
||||
.getByRole("button", { name: `Terminals on ${getServerId()}`, exact: true })
|
||||
.click();
|
||||
await expect(slot.getByText("No terminals", { exact: true })).toBeVisible();
|
||||
const workspace = (await client.fetchWorkspaces()).entries.find(
|
||||
(item) => item.id === workspaceId,
|
||||
)!;
|
||||
const created = await client.createTerminal(
|
||||
workspace.workspaceDirectory,
|
||||
"Sidebar terminal",
|
||||
undefined,
|
||||
{ workspaceId },
|
||||
);
|
||||
if (!created.terminal) throw new Error(created.error ?? "Terminal creation failed");
|
||||
try {
|
||||
await expect(
|
||||
slot.getByTestId(`sidebar-terminal-open-${created.terminal.id}`),
|
||||
).toBeVisible();
|
||||
await slot.getByTestId(`sidebar-terminal-open-${created.terminal.id}`).click();
|
||||
await expect(page.getByTestId("terminal-surface").first()).toBeVisible();
|
||||
await slot.getByTestId(`sidebar-terminal-kill-${created.terminal.id}`).click();
|
||||
await expect
|
||||
.poll(async () => (await client.listTerminals()).terminals.map((item) => item.id))
|
||||
.not.toContain(created.terminal!.id);
|
||||
await expect(slot.getByText("No terminals", { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await client.killTerminal(created.terminal.id);
|
||||
}
|
||||
},
|
||||
async restoreDefault() {
|
||||
await choose("Default view");
|
||||
await expectDefault();
|
||||
|
||||
@@ -55,8 +55,8 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
|
||||
subscriptionId: string;
|
||||
}>;
|
||||
unsubscribeCheckoutDiffCalls: string[];
|
||||
subscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }>;
|
||||
unsubscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }>;
|
||||
subscribeTerminalCalls: Array<{ cwd?: string; workspaceId?: string }>;
|
||||
unsubscribeTerminalCalls: Array<{ cwd?: string; workspaceId?: string }>;
|
||||
} {
|
||||
const handlers: Record<RouterMessageType, RouterHandler[]> = {
|
||||
providers_snapshot_update: [],
|
||||
@@ -71,8 +71,8 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
|
||||
subscriptionId: string;
|
||||
}> = [];
|
||||
const unsubscribeCheckoutDiffCalls: string[] = [];
|
||||
const subscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }> = [];
|
||||
const unsubscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }> = [];
|
||||
const subscribeTerminalCalls: Array<{ cwd?: string; workspaceId?: string }> = [];
|
||||
const unsubscribeTerminalCalls: Array<{ cwd?: string; workspaceId?: string }> = [];
|
||||
|
||||
function on<K extends RouterMessageType>(
|
||||
type: K,
|
||||
|
||||
@@ -15,11 +15,11 @@ interface InstallationApi {
|
||||
}
|
||||
// Registry presentation clones share the installation lifetime and its client pool.
|
||||
const apis = new WeakMap<AbortSignal, InstallationApi>();
|
||||
const state = createPluginClientStateSource();
|
||||
|
||||
export function getPluginClientApi(plugin: InstalledPlugin): InstallationApi {
|
||||
const cached = apis.get(plugin.lifetime.signal);
|
||||
if (cached) return cached;
|
||||
const state = createPluginClientStateSource();
|
||||
const hosts = createPluginHosts(getHostRuntimeStore(), plugin.lifetime.signal);
|
||||
const paseo = createPluginReferenceApi({
|
||||
paseo: plugin.paseo,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { fr } from "@/i18n/resources/fr";
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { PluginClientStateProvider } from "@getpaseo/plugin/client/host";
|
||||
import { createEntityRefs, PluginClientStateProvider } from "@getpaseo/plugin/client/host";
|
||||
import {
|
||||
useAgents,
|
||||
useWorkspaces,
|
||||
@@ -377,3 +377,44 @@ describe("sidebar data hooks through the app store and plugin provider", () => {
|
||||
expect(new Set(source.getSnapshot().workspaces.map((item) => item.ref.key)).size).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
it("observes terminals only while a terminal reader is subscribed", () => {
|
||||
let active = 0;
|
||||
const source = createPluginClientStateSource(undefined, {
|
||||
hosts: { getSnapshot: () => [], subscribe: () => () => {} },
|
||||
terminals: {
|
||||
getSnapshot: () => [],
|
||||
subscribe: () => {
|
||||
active++;
|
||||
return () => {
|
||||
active--;
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
const releaseWorkspace = source.subscribe(() => {});
|
||||
expect(active).toBe(0);
|
||||
const releaseTerminal = source.subscribe(() => {}, { terminals: true });
|
||||
const releaseSecond = source.subscribe(() => {}, { terminals: true });
|
||||
expect(active).toBe(1);
|
||||
releaseTerminal();
|
||||
expect(active).toBe(1);
|
||||
releaseSecond();
|
||||
expect(active).toBe(0);
|
||||
releaseWorkspace();
|
||||
});
|
||||
|
||||
it("terminal row reads propagate only the referenced host's observation error", () => {
|
||||
const error = new Error("Update the host");
|
||||
const source = createPluginClientStateSource(undefined, {
|
||||
hosts: { getSnapshot: () => [], subscribe: () => () => {} },
|
||||
terminals: {
|
||||
getSnapshot: () => [],
|
||||
subscribe: () => () => {},
|
||||
getErrors: () => new Map([["unsupported", error]]),
|
||||
},
|
||||
});
|
||||
const refs = createEntityRefs();
|
||||
expect(() => source.getTerminal(refs.terminal("unsupported", "retained"))).toThrow(error);
|
||||
expect(source.getTerminal(refs.terminal("supported", "missing"))).toBeNull();
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { useSessionStore, type SessionState } from "@/stores/session-store";
|
||||
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
|
||||
import { connectedPluginHosts } from "./connected-hosts";
|
||||
import { pluginTerminals } from "./terminals";
|
||||
import { entityRefs } from "./refs";
|
||||
import {
|
||||
createPluginAgentSnapshot,
|
||||
@@ -20,13 +21,10 @@ export interface PluginConnectedHostsSource {
|
||||
export interface PluginTerminalsSource {
|
||||
subscribe(listener: () => void): () => void;
|
||||
getSnapshot(): readonly PluginTerminalSnapshot[];
|
||||
getErrors?(): ReadonlyMap<string, Error>;
|
||||
}
|
||||
|
||||
const emptyTerminals: readonly PluginTerminalSnapshot[] = Object.freeze([]);
|
||||
const noTerminals: PluginTerminalsSource = {
|
||||
subscribe: () => () => {},
|
||||
getSnapshot: () => emptyTerminals,
|
||||
};
|
||||
const listeners = new Set<() => void>();
|
||||
let releaseStores: (() => void) | null = null;
|
||||
|
||||
@@ -139,12 +137,15 @@ export function createPluginClientStateSource(
|
||||
serverId?: string,
|
||||
options: { hosts?: PluginConnectedHostsSource; terminals?: PluginTerminalsSource } = {},
|
||||
): PluginClientStateSource {
|
||||
const terminals = options.terminals ?? noTerminals;
|
||||
const terminals = options.terminals ?? pluginTerminals;
|
||||
const hosts = options.hosts ?? connectedPluginHosts;
|
||||
const subscribers = new Set<() => void>();
|
||||
const terminalSubscribers = new Set<() => void>();
|
||||
let releaseTerminals: (() => void) | null = null;
|
||||
let release: (() => void) | null = null;
|
||||
let previousProjections: readonly HostProjection[] = [];
|
||||
let previousTerminals = emptyTerminals;
|
||||
let previousTerminalErrors: ReadonlyMap<string, Error> | undefined;
|
||||
let snapshot: PluginClientSnapshot = {
|
||||
workspaces: [],
|
||||
agents: [],
|
||||
@@ -159,41 +160,58 @@ export function createPluginClientStateSource(
|
||||
if (projection) projections.push(projection);
|
||||
}
|
||||
const terminalSnapshots = terminals.getSnapshot();
|
||||
if (sameSequence(previousProjections, projections) && previousTerminals === terminalSnapshots)
|
||||
const terminalErrors = terminals.getErrors?.();
|
||||
if (
|
||||
sameSequence(previousProjections, projections) &&
|
||||
previousTerminals === terminalSnapshots &&
|
||||
previousTerminalErrors === terminalErrors
|
||||
)
|
||||
return snapshot;
|
||||
previousProjections = projections;
|
||||
previousTerminals = terminalSnapshots;
|
||||
previousTerminalErrors = terminalErrors;
|
||||
snapshot = Object.freeze({
|
||||
workspaces: Object.freeze(projections.flatMap((host) => host.snapshot.workspaces)),
|
||||
agents: Object.freeze(projections.flatMap((host) => host.snapshot.agents)),
|
||||
projects: Object.freeze(projections.flatMap((host) => host.snapshot.projects)),
|
||||
terminals: terminalSnapshots,
|
||||
terminalErrors,
|
||||
});
|
||||
return snapshot;
|
||||
}
|
||||
return {
|
||||
subscribe(listener) {
|
||||
subscribe(listener, subscriptionOptions) {
|
||||
subscribers.add(listener);
|
||||
if (subscriptionOptions?.terminals) {
|
||||
terminalSubscribers.add(listener);
|
||||
releaseTerminals ??= terminals.subscribe(() => {
|
||||
for (const subscriber of terminalSubscribers) subscriber();
|
||||
});
|
||||
}
|
||||
if (!release) {
|
||||
const notify = () => {
|
||||
for (const subscriber of subscribers) subscriber();
|
||||
};
|
||||
const releaseStore = subscribeStores(notify);
|
||||
const releaseHosts = hosts.subscribe(notify);
|
||||
const releaseTerminals = terminals.subscribe(notify);
|
||||
release = () => {
|
||||
releaseStore();
|
||||
releaseHosts();
|
||||
releaseTerminals();
|
||||
};
|
||||
}
|
||||
return () => {
|
||||
subscribers.delete(listener);
|
||||
terminalSubscribers.delete(listener);
|
||||
if (terminalSubscribers.size === 0 && releaseTerminals) {
|
||||
releaseTerminals();
|
||||
releaseTerminals = null;
|
||||
}
|
||||
if (subscribers.size === 0 && release) {
|
||||
release();
|
||||
release = null;
|
||||
previousProjections = [];
|
||||
previousTerminals = emptyTerminals;
|
||||
previousTerminalErrors = undefined;
|
||||
snapshot = { workspaces: [], agents: [], projects: [], terminals: emptyTerminals };
|
||||
}
|
||||
};
|
||||
@@ -221,6 +239,10 @@ export function createPluginClientStateSource(
|
||||
return detail && workspaceId ? createPluginAgentSnapshot(detail, workspaceId) : null;
|
||||
},
|
||||
getProject: (ref) => projectHost(ref.hostId)?.projectById.get(ref.id) ?? null,
|
||||
getTerminal: (ref) => terminals.getSnapshot().find((terminal) => terminal.ref === ref) ?? null,
|
||||
getTerminal(ref) {
|
||||
const error = terminals.getErrors?.().get(ref.hostId);
|
||||
if (error) throw error;
|
||||
return terminals.getSnapshot().find((terminal) => terminal.ref === ref) ?? null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { PluginTerminalSnapshot } from "@getpaseo/plugin";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { TerminalsChanged } from "@getpaseo/protocol/messages";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { entityRefs } from "./refs";
|
||||
import type { PluginTerminalsSource } from "./source";
|
||||
|
||||
interface TerminalHost {
|
||||
serverId: string;
|
||||
client: DaemonClient;
|
||||
epoch: number;
|
||||
}
|
||||
export interface TerminalHostsSource {
|
||||
subscribe(listener: () => void): () => void;
|
||||
getSnapshot(): readonly TerminalHost[];
|
||||
}
|
||||
interface Observation {
|
||||
client: DaemonClient;
|
||||
epoch: number;
|
||||
items: readonly PluginTerminalSnapshot[];
|
||||
release(): void;
|
||||
}
|
||||
|
||||
/** One owned observation per connected host, shared by every plugin surface. */
|
||||
export function createPluginTerminalsSource(hosts: TerminalHostsSource): PluginTerminalsSource {
|
||||
const observations = new Map<string, Observation>();
|
||||
const listeners = new Set<() => void>();
|
||||
let snapshot: readonly PluginTerminalSnapshot[] = Object.freeze([]);
|
||||
let errors: ReadonlyMap<string, Error> = new Map();
|
||||
let releaseHosts: (() => void) | null = null;
|
||||
function publish() {
|
||||
const next = [...observations.values()].flatMap((item) => item.items);
|
||||
if (next.length !== snapshot.length || next.some((item, index) => item !== snapshot[index]))
|
||||
snapshot = Object.freeze(next);
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
function receive(hostId: string, observation: Observation, payload: TerminalsChanged["payload"]) {
|
||||
if (observations.get(hostId) !== observation) return;
|
||||
const previous = new Map(observation.items.map((item) => [item.ref.id, item]));
|
||||
observation.items = Object.freeze(
|
||||
payload.terminals.flatMap((terminal) => {
|
||||
// Host-wide observation includes unowned legacy terminals. Sidebar navigation needs a workspace.
|
||||
if (!terminal.workspaceId) return [];
|
||||
const ref = entityRefs.terminal(hostId, terminal.id);
|
||||
const workspace = entityRefs.workspace(hostId, terminal.workspaceId);
|
||||
const title = terminal.title ?? terminal.name;
|
||||
const createdAt = terminal.createdAt ?? null;
|
||||
const isScript = terminal.isScript === true;
|
||||
const cached = previous.get(terminal.id);
|
||||
if (
|
||||
cached &&
|
||||
cached.workspace === workspace &&
|
||||
cached.title === title &&
|
||||
cached.createdAt === createdAt &&
|
||||
cached.isScript === isScript
|
||||
)
|
||||
return [cached];
|
||||
return [Object.freeze({ ref, hostId, workspace, title, createdAt, isScript })];
|
||||
}),
|
||||
);
|
||||
errors = new Map([...errors].filter(([id]) => id !== hostId));
|
||||
publish();
|
||||
}
|
||||
function reconcile() {
|
||||
const active = new Map(hosts.getSnapshot().map((host) => [host.serverId, host]));
|
||||
for (const [id, observation] of observations) {
|
||||
if (
|
||||
active.get(id)?.client !== observation.client ||
|
||||
active.get(id)?.epoch !== observation.epoch
|
||||
) {
|
||||
observations.delete(id);
|
||||
observation.release();
|
||||
}
|
||||
}
|
||||
errors = new Map([...errors].filter(([id]) => active.has(id)));
|
||||
for (const [hostId, { client, epoch }] of active) {
|
||||
if (observations.has(hostId)) continue;
|
||||
const observation: Observation = { client, epoch, items: [], release() {} };
|
||||
observations.set(hostId, observation);
|
||||
try {
|
||||
// The SDK owns the capability gate; a failed host is reported only to terminal readers.
|
||||
const subscription = client.observeTerminals({});
|
||||
const unsubscribe = subscription.subscribe({
|
||||
snapshot: (payload) => receive(hostId, observation, payload),
|
||||
update: (message) => {
|
||||
if (message.type === "terminals_changed") receive(hostId, observation, message.payload);
|
||||
},
|
||||
error: (error) => {
|
||||
if (observations.get(hostId) !== observation) return;
|
||||
errors = new Map(errors).set(
|
||||
hostId,
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
);
|
||||
publish();
|
||||
},
|
||||
});
|
||||
void subscription.ready.catch(() => {}); // Observer owns error delivery.
|
||||
observation.release = () => {
|
||||
unsubscribe();
|
||||
void subscription.release().catch(() => {});
|
||||
};
|
||||
} catch (error) {
|
||||
errors = new Map(errors).set(
|
||||
hostId,
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
);
|
||||
}
|
||||
}
|
||||
publish();
|
||||
}
|
||||
return {
|
||||
getSnapshot: () => snapshot,
|
||||
getErrors: () => errors,
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
if (!releaseHosts) {
|
||||
releaseHosts = hosts.subscribe(reconcile);
|
||||
reconcile();
|
||||
}
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (!listeners.size && releaseHosts) {
|
||||
releaseHosts();
|
||||
releaseHosts = null;
|
||||
for (const observation of observations.values()) observation.release();
|
||||
observations.clear();
|
||||
snapshot = Object.freeze([]);
|
||||
errors = new Map();
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const pluginTerminals = createPluginTerminalsSource({
|
||||
getSnapshot() {
|
||||
const runtime = getHostRuntimeStore();
|
||||
return runtime.getHosts().flatMap((host) => {
|
||||
const state = runtime.getSnapshot(host.serverId);
|
||||
return state?.connectionStatus === "online" && state.client
|
||||
? [{ serverId: host.serverId, client: state.client, epoch: state.connectionEpoch }]
|
||||
: [];
|
||||
});
|
||||
},
|
||||
subscribe(listener) {
|
||||
const runtime = getHostRuntimeStore();
|
||||
const releaseRuntime = runtime.subscribeAll(listener);
|
||||
const releaseHosts = runtime.subscribeHostList(listener);
|
||||
return () => {
|
||||
releaseRuntime();
|
||||
releaseHosts();
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -6506,10 +6506,16 @@ export class DaemonClient {
|
||||
// ============================================================================
|
||||
|
||||
observeTerminals(input: {
|
||||
cwd: string;
|
||||
cwd?: string;
|
||||
workspaceId?: string;
|
||||
signal?: AbortSignal;
|
||||
}): OwnedSubscription<CorrelatedResponsePayload<"terminals_changed">> {
|
||||
// COMPAT(terminalCollection): added in v0.16, remove after 2027-06.
|
||||
if (
|
||||
input.cwd === undefined &&
|
||||
this.getLastServerInfoMessage()?.features?.terminalCollection !== true
|
||||
)
|
||||
throw new Error("Update the host to list terminals across workspaces.");
|
||||
const { signal, ...query } = input;
|
||||
return this.observe(
|
||||
"terminals_changed",
|
||||
|
||||
@@ -11,7 +11,7 @@ import { createCollectionQuery, type Query, type Group } from "./query.js";
|
||||
import { shallowEqual } from "./shallow.js";
|
||||
|
||||
export interface PluginClientStateSource {
|
||||
subscribe(listener: () => void): () => void;
|
||||
subscribe(listener: () => void, options?: { terminals?: boolean }): () => void;
|
||||
getWorkspace(id: string | WorkspaceRef): PluginWorkspaceSnapshot | null;
|
||||
getAgent(id: string | AgentRef): PluginAgentSnapshot | null;
|
||||
getProject(ref: ProjectRef): PluginProjectSnapshot | null;
|
||||
@@ -24,6 +24,7 @@ export interface PluginClientSnapshot {
|
||||
readonly agents: readonly PluginAgentSnapshot[];
|
||||
readonly projects: readonly PluginProjectSnapshot[];
|
||||
readonly terminals: readonly PluginTerminalSnapshot[];
|
||||
readonly terminalErrors?: ReadonlyMap<string, Error>;
|
||||
}
|
||||
|
||||
const PluginClientStateContext = createContext<PluginClientStateSource | null>(null);
|
||||
@@ -44,8 +45,24 @@ export function usePaseoSelector<Entity, Selection>(
|
||||
|
||||
type Equality<T> = { equals(left: T, right: T): boolean }["equals"];
|
||||
|
||||
function useStateSource(terminals: boolean) {
|
||||
const source = usePluginClientStateSource();
|
||||
if (!source) throw new Error("Plugin state hooks must run inside a plugin host");
|
||||
return useMemo(
|
||||
() =>
|
||||
terminals
|
||||
? {
|
||||
...source,
|
||||
subscribe: (listener: () => void) => source.subscribe(listener, { terminals: true }),
|
||||
}
|
||||
: source,
|
||||
[source, terminals],
|
||||
);
|
||||
}
|
||||
|
||||
function createEntityHook<Ref, Entity>(
|
||||
read: (source: PluginClientStateSource, ref: Ref) => Entity | null,
|
||||
terminals = false,
|
||||
) {
|
||||
function useEntity(
|
||||
ref: Ref,
|
||||
@@ -62,8 +79,7 @@ function createEntityHook<Ref, Entity>(
|
||||
selector?: (entity: Entity) => Selection,
|
||||
equals?: Equality<Entity | Selection | null>,
|
||||
): Entity | Selection | null {
|
||||
const source = usePluginClientStateSource();
|
||||
if (!source) throw new Error("Plugin state hooks must run inside a plugin host");
|
||||
const source = useStateSource(terminals);
|
||||
function select(entity: Entity | null): Entity | Selection | null {
|
||||
return entity !== null && selector ? selector(entity) : entity;
|
||||
}
|
||||
@@ -80,10 +96,14 @@ export const useWorkspace = createEntityHook((source, ref: WorkspaceRef | string
|
||||
// COMPAT(plugin-entity-id): string ids accepted since 0.16; remove after 2027-03.
|
||||
export const useAgent = createEntityHook((source, ref: AgentRef | string) => source.getAgent(ref));
|
||||
export const useProject = createEntityHook((source, ref: ProjectRef) => source.getProject(ref));
|
||||
export const useTerminal = createEntityHook((source, ref: TerminalRef) => source.getTerminal(ref));
|
||||
export const useTerminal = createEntityHook(
|
||||
(source, ref: TerminalRef) => source.getTerminal(ref),
|
||||
true,
|
||||
);
|
||||
|
||||
function createCollectionHook<Snapshot extends { readonly ref: unknown; readonly hostId: string }>(
|
||||
read: (source: PluginClientStateSource) => readonly Snapshot[],
|
||||
read: (source: PluginClientStateSource, query: Query<Snapshot>) => readonly Snapshot[],
|
||||
terminals = false,
|
||||
) {
|
||||
function useCollection(
|
||||
query: Query<Snapshot> & { groupBy: NonNullable<Query<Snapshot>["groupBy"]> },
|
||||
@@ -97,12 +117,11 @@ function createCollectionHook<Snapshot extends { readonly ref: unknown; readonly
|
||||
function useCollection(
|
||||
query: Query<Snapshot> = {},
|
||||
): readonly Snapshot["ref"][] | readonly Group<Snapshot["ref"]>[] {
|
||||
const source = usePluginClientStateSource();
|
||||
if (!source) throw new Error("Plugin state hooks must run inside a plugin host");
|
||||
const source = useStateSource(terminals);
|
||||
const project = useMemo(() => createCollectionQuery<Snapshot>(), []);
|
||||
return usePaseoSelector(
|
||||
source,
|
||||
() => read(source),
|
||||
() => read(source, query),
|
||||
(snapshots) => project(snapshots, query),
|
||||
Object.is,
|
||||
);
|
||||
@@ -113,7 +132,13 @@ function createCollectionHook<Snapshot extends { readonly ref: unknown; readonly
|
||||
export const useWorkspaces = createCollectionHook((source) => source.getSnapshot().workspaces);
|
||||
export const useAgents = createCollectionHook((source) => source.getSnapshot().agents);
|
||||
export const useProjects = createCollectionHook((source) => source.getSnapshot().projects);
|
||||
export const useTerminals = createCollectionHook((source) => source.getSnapshot().terminals);
|
||||
export const useTerminals = createCollectionHook<PluginTerminalSnapshot>((source, query) => {
|
||||
const snapshot = source.getSnapshot();
|
||||
for (const [hostId, error] of snapshot.terminalErrors ?? []) {
|
||||
if (!query.hosts || query.hosts.includes(hostId)) throw error;
|
||||
}
|
||||
return snapshot.terminals;
|
||||
}, true);
|
||||
|
||||
export function PluginClientStateProvider({
|
||||
children,
|
||||
|
||||
@@ -133,5 +133,6 @@ export interface PluginTerminalSnapshot {
|
||||
readonly workspace: WorkspaceRef;
|
||||
readonly title: string;
|
||||
readonly isScript: boolean;
|
||||
readonly createdAt: string;
|
||||
/** Null for terminals owned by workers started before timestamps were tracked. */
|
||||
readonly createdAt: string | null;
|
||||
}
|
||||
|
||||
@@ -571,3 +571,43 @@ test.each(["all", "base", "uncommitted"] as const)(
|
||||
expect(parsed.attachments).toEqual([attachment]);
|
||||
},
|
||||
);
|
||||
|
||||
describe("terminal collection protocol compatibility", () => {
|
||||
test("accepts released directory observations and their snapshots", () => {
|
||||
expect(
|
||||
SessionInboundMessageSchema.parse({
|
||||
type: "subscribe_terminals_request",
|
||||
cwd: "/repo",
|
||||
requestId: "old",
|
||||
}),
|
||||
).toMatchObject({ cwd: "/repo" });
|
||||
expect(
|
||||
SessionOutboundMessageSchema.parse({
|
||||
type: "terminals_changed",
|
||||
payload: { cwd: "/repo", terminals: [{ id: "terminal", name: "Shell" }] },
|
||||
}),
|
||||
).toMatchObject({ payload: { cwd: "/repo" } });
|
||||
});
|
||||
test("accepts capability-gated host observations and additive metadata", () => {
|
||||
expect(
|
||||
SessionInboundMessageSchema.parse({ type: "subscribe_terminals_request", requestId: "host" }),
|
||||
).toMatchObject({ requestId: "host" });
|
||||
expect(
|
||||
SessionOutboundMessageSchema.parse({
|
||||
type: "terminals_changed",
|
||||
payload: {
|
||||
terminals: [
|
||||
{
|
||||
id: "terminal",
|
||||
name: "Shell",
|
||||
cwd: "/repo",
|
||||
workspaceId: "workspace",
|
||||
createdAt: null,
|
||||
isScript: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
).toMatchObject({ payload: { terminals: [{ isScript: true, createdAt: null }] } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3059,13 +3059,13 @@ export const ListTerminalsRequestSchema = z.object({
|
||||
export const SubscribeTerminalsRequestSchema = z.object({
|
||||
type: z.literal("subscribe_terminals_request"),
|
||||
requestId: z.string().optional(),
|
||||
cwd: z.string(),
|
||||
cwd: z.string().optional(),
|
||||
workspaceId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const UnsubscribeTerminalsRequestSchema = z.object({
|
||||
type: z.literal("unsubscribe_terminals_request"),
|
||||
cwd: z.string(),
|
||||
cwd: z.string().optional(),
|
||||
workspaceId: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -3732,6 +3732,7 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
directorySync: z.boolean().optional(),
|
||||
// COMPAT(workspaceLabels): added in v0.5.0, remove after 2027-08-14.
|
||||
workspaceLabels: z.boolean().optional(),
|
||||
terminalCollection: z.boolean().optional(),
|
||||
// COMPAT(workspaceBaseBranch): added after v0.11.2, remove gate after 2027-10-14.
|
||||
workspaceBaseBranch: z.boolean().optional(),
|
||||
// COMPAT(workspaceSetupRun): added in v0.8.0, remove gate after 2027-09-02.
|
||||
@@ -6591,6 +6592,8 @@ export const ListCommandsResponseSchema = z.object({
|
||||
// ============================================================================
|
||||
|
||||
const TerminalInfoSchema = z.object({
|
||||
createdAt: z.string().nullable().optional(),
|
||||
isScript: z.boolean().optional(),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
cwd: z.string(),
|
||||
@@ -6654,8 +6657,8 @@ export const TerminalsChangedSchema = z.object({
|
||||
subscriptionId: z.string().optional(),
|
||||
requestId: z.string().optional(),
|
||||
workspaceId: z.string().optional(),
|
||||
cwd: z.string(),
|
||||
terminals: z.array(TerminalInfoSchema.omit({ cwd: true })),
|
||||
cwd: z.string().optional(),
|
||||
terminals: z.array(TerminalInfoSchema.partial({ cwd: true })),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
// and tear down its own live subscription without disturbing the other. An
|
||||
// absent workspaceId (old clients) keys to the cwd on its own. The `::`
|
||||
// separator cannot collide with a workspace id, which is always `wks_<hex>`.
|
||||
export function terminalSubscriptionKey(cwd: string, workspaceId: string | undefined): string {
|
||||
export function terminalSubscriptionKey(
|
||||
cwd: string | undefined,
|
||||
workspaceId: string | undefined,
|
||||
): string {
|
||||
if (cwd === undefined)
|
||||
return workspaceId === undefined ? "terminal-host" : `${workspaceId}::terminal-host`;
|
||||
return workspaceId === undefined ? cwd : `${workspaceId}::${cwd}`;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { resolveDaemonVersion } from "../daemon-version.js";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, expect, test } from "vitest";
|
||||
@@ -269,3 +269,132 @@ test.runIf(process.platform !== "win32")(
|
||||
expect((await sdk.terminals.list({ workspaceId })).entries).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
test("host-wide terminal observation tracks every workspace, metadata and release", async () => {
|
||||
const first = await createWorkspace("Observe first");
|
||||
const second = await createWorkspace("Observe second");
|
||||
expect(client.getLastServerInfoMessage()?.features?.terminalCollection).toBe(true);
|
||||
const observation = client.observeTerminals({});
|
||||
let terminals = (await observation.ready).terminals;
|
||||
const update = observation.subscribe({
|
||||
snapshot: (value) => {
|
||||
terminals = value.terminals;
|
||||
},
|
||||
update: (message) => {
|
||||
if (message.type === "terminals_changed") terminals = message.payload.terminals;
|
||||
},
|
||||
});
|
||||
try {
|
||||
expect(terminals).toEqual([]);
|
||||
const scoped = client.observeTerminals({ workspaceId: first });
|
||||
const scopedSnapshot = await scoped.ready;
|
||||
let owned = scopedSnapshot.terminals;
|
||||
const releaseScoped = scoped.subscribe({
|
||||
snapshot: (value) => {
|
||||
owned = value.terminals;
|
||||
},
|
||||
update: (message) => {
|
||||
if (message.type === "terminals_changed") owned = message.payload.terminals;
|
||||
},
|
||||
});
|
||||
// Use a controlled process: interactive shells publish their own OSC title on startup.
|
||||
const command = {
|
||||
command: process.execPath,
|
||||
args: ["-e", "console.log('READY'); process.stdin.resume()"],
|
||||
};
|
||||
const a = await sdk.terminals.create({
|
||||
workspaceId: first,
|
||||
name: "First terminal",
|
||||
...command,
|
||||
});
|
||||
const b = await sdk.terminals.create({
|
||||
workspaceId: second,
|
||||
name: "Second terminal",
|
||||
...command,
|
||||
});
|
||||
await expect
|
||||
.poll(async () => (await a.capture({ stripAnsi: true })).lines.join("\n"))
|
||||
.toContain("READY");
|
||||
await expect
|
||||
.poll(async () => (await b.capture({ stripAnsi: true })).lines.join("\n"))
|
||||
.toContain("READY");
|
||||
await expect.poll(() => terminals.map((item) => item.id).sort()).toEqual([a.id, b.id].sort());
|
||||
expect(terminals.every((item) => item.cwd === cwd && item.createdAt && !item.isScript)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(terminals.map((item) => item.workspaceId).sort()).toEqual([first, second].sort());
|
||||
await expect.poll(() => owned.map((item) => item.id)).toEqual([a.id]);
|
||||
releaseScoped();
|
||||
await scoped.release();
|
||||
await client.renameTerminal({ terminalId: a.id, title: "Renamed terminal" });
|
||||
await expect
|
||||
.poll(() => terminals.find((item) => item.id === a.id)?.title)
|
||||
.toBe("Renamed terminal");
|
||||
await b.kill();
|
||||
await expect.poll(() => terminals.map((item) => item.id)).toEqual([a.id]);
|
||||
await a.kill();
|
||||
await expect.poll(() => terminals).toEqual([]);
|
||||
} finally {
|
||||
update();
|
||||
await observation.release();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("terminal collection reports workspace script ownership after runtime registration", async () => {
|
||||
await writeFile(
|
||||
path.join(cwd, "paseo.json"),
|
||||
JSON.stringify({
|
||||
scripts: {
|
||||
sleeper: {
|
||||
type: "script",
|
||||
command: `node -e "const fs = require('node:fs'); fs.writeFileSync('sleeper.pid', String(process.pid)); setInterval(() => { if (fs.existsSync('finish-sleeper')) process.exit(0); }, 50)"`,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const workspaceId = await createWorkspace("Script observation");
|
||||
const observation = client.observeTerminals({});
|
||||
let terminals = (await observation.ready).terminals;
|
||||
const release = observation.subscribe({
|
||||
snapshot: (value) => {
|
||||
terminals = value.terminals;
|
||||
},
|
||||
update: (message) => {
|
||||
if (message.type === "terminals_changed") terminals = message.payload.terminals;
|
||||
},
|
||||
});
|
||||
try {
|
||||
expect((await client.startWorkspaceScript(workspaceId, "sleeper")).error).toBeNull();
|
||||
await expect
|
||||
.poll(() =>
|
||||
terminals.some((terminal) => terminal.workspaceId === workspaceId && terminal.isScript),
|
||||
)
|
||||
.toBe(true);
|
||||
const terminalId = terminals.find((terminal) => terminal.workspaceId === workspaceId)!.id;
|
||||
const pidFile = path.join(cwd, "sleeper.pid");
|
||||
await expect
|
||||
.poll(async () => Number(await readFile(pidFile, "utf8")), { timeout: 10_000 })
|
||||
.toBeGreaterThan(0);
|
||||
const pid = Number(await readFile(pidFile, "utf8"));
|
||||
await writeFile(path.join(cwd, "finish-sleeper"), "done");
|
||||
await expect.poll(() => isFixtureProcessRunning(pid), { timeout: 10_000 }).toBe(false);
|
||||
// The program has exited; now close its interactive shell as well.
|
||||
await sdk.terminals.ref(terminalId).kill();
|
||||
await expect
|
||||
.poll(() => terminals.filter((terminal) => terminal.workspaceId === workspaceId))
|
||||
.toEqual([]);
|
||||
} finally {
|
||||
release();
|
||||
await observation.release();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
function isFixtureProcessRunning(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ESRCH") return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1172,6 +1172,12 @@ export class Session {
|
||||
isPathWithinRoot: (rootPath, candidatePath) => this.isPathWithinRoot(rootPath, candidatePath),
|
||||
sessionLogger: this.sessionLogger,
|
||||
listTerminalWorkspaceRefs: () => this.listActiveWorkspaceRefs(),
|
||||
isScriptTerminal: (terminal) =>
|
||||
this.scriptRuntimeStore
|
||||
?.listForWorkspace(terminal.workspaceId)
|
||||
.some((script) => script.terminalId === terminal.id) ?? false,
|
||||
subscribeTerminalMetadata: (listener) =>
|
||||
this.scriptRuntimeStore?.subscribe(listener) ?? (() => {}),
|
||||
getClientBufferedAmount: (source) => this.getTransportBufferedAmount(source),
|
||||
});
|
||||
this.agentDirectory = new AgentDirectory({
|
||||
|
||||
@@ -2033,6 +2033,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
directorySync: true,
|
||||
// COMPAT(workspaceLabels): added in v0.5.0, remove after 2027-08-14.
|
||||
...(this.workspaceLabelService ? { workspaceLabels: true } : {}),
|
||||
terminalCollection: true,
|
||||
// COMPAT(workspaceSetupRun): added in v0.7.3, remove gate after 2027-09-02.
|
||||
workspaceSetupRun: true,
|
||||
workspaceSetupObservation: true,
|
||||
|
||||
@@ -97,3 +97,16 @@ describe("WorkspaceScriptRuntimeStore", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("notifies terminal metadata consumers when script ownership changes", () => {
|
||||
const store = new WorkspaceScriptRuntimeStore();
|
||||
const observed: string[][] = [];
|
||||
const release = store.subscribe(() =>
|
||||
observed.push(store.listForWorkspace("workspace-101").map((entry) => entry.terminalId)),
|
||||
);
|
||||
store.set(createEntry());
|
||||
store.remove({ workspaceId: "workspace-101", scriptName: "web" });
|
||||
release();
|
||||
store.set(createEntry());
|
||||
expect(observed).toEqual([["terminal-1"], []]);
|
||||
});
|
||||
|
||||
@@ -13,6 +13,17 @@ interface RuntimeEntryKey {
|
||||
}
|
||||
|
||||
export class WorkspaceScriptRuntimeStore {
|
||||
private readonly listeners = new Set<() => void>();
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
private changed(): void {
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
|
||||
private readonly entries = new Map<string, ScriptRuntimeEntry>();
|
||||
private readonly scriptsByWorkspace = new Map<string, Set<string>>();
|
||||
|
||||
@@ -31,6 +42,7 @@ export class WorkspaceScriptRuntimeStore {
|
||||
|
||||
this.entries.set(entryKey, { ...entry });
|
||||
this.addScriptToWorkspaceIndex(workspaceKey, entry.scriptName);
|
||||
this.changed();
|
||||
}
|
||||
|
||||
remove(key: RuntimeEntryKey): void {
|
||||
@@ -42,6 +54,7 @@ export class WorkspaceScriptRuntimeStore {
|
||||
|
||||
this.entries.delete(entryKey);
|
||||
this.removeScriptFromWorkspaceIndex(existing.workspaceId, existing.scriptName);
|
||||
this.changed();
|
||||
}
|
||||
|
||||
listForWorkspace(workspaceId: string): ScriptRuntimeEntry[] {
|
||||
@@ -70,6 +83,7 @@ export class WorkspaceScriptRuntimeStore {
|
||||
this.entries.delete(this.toEntryKey(entry));
|
||||
}
|
||||
this.scriptsByWorkspace.delete(this.toWorkspaceKey(workspaceId));
|
||||
this.changed();
|
||||
}
|
||||
|
||||
isRunning(key: RuntimeEntryKey): boolean {
|
||||
|
||||
@@ -387,8 +387,25 @@ describe("terminal-session-controller with terminals held by a newer Paseo", ()
|
||||
};
|
||||
}
|
||||
|
||||
test("reports incompatible workers to a legacy subscription without a request id", async () => {
|
||||
const outboundMessages: SessionOutboundMessage[] = [];
|
||||
const controller = createController({
|
||||
terminalManager: incompatibleManager(),
|
||||
emit: (message) => outboundMessages.push(message),
|
||||
emitBinary: () => {},
|
||||
hasBinaryChannel: () => true,
|
||||
isPathWithinRoot: isSameOrDescendantPath,
|
||||
sessionLogger: createLogger(),
|
||||
});
|
||||
await controller.dispatch({ type: "subscribe_terminals_request" });
|
||||
expect(outboundMessages).toEqual([
|
||||
{ type: "status", payload: { status: "error", message: incompatible.message } },
|
||||
]);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ type: "list_terminals_request" as const, requestId: "req-list" },
|
||||
{ type: "subscribe_terminals_request" as const, requestId: "req-observe" },
|
||||
{ type: "list_terminals_request" as const, requestId: "req-list-cwd", cwd: "/work/repo" },
|
||||
{
|
||||
type: "create_terminal_request" as const,
|
||||
@@ -500,8 +517,22 @@ describe("terminal-session-controller subdirectory aggregation", () => {
|
||||
payload: {
|
||||
cwd: rootCwd,
|
||||
terminals: [
|
||||
{ id: "root-term", name: "Terminal 1", workspaceId: "ws-test", activity: null },
|
||||
{ id: "subdir-term", name: "Mobile", workspaceId: "ws-test", activity: null },
|
||||
{
|
||||
id: "root-term",
|
||||
name: "Terminal 1",
|
||||
workspaceId: "ws-test",
|
||||
activity: null,
|
||||
createdAt: null,
|
||||
isScript: false,
|
||||
},
|
||||
{
|
||||
id: "subdir-term",
|
||||
name: "Mobile",
|
||||
workspaceId: "ws-test",
|
||||
activity: null,
|
||||
createdAt: null,
|
||||
isScript: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -571,6 +602,8 @@ describe("terminal-session-controller subdirectory aggregation", () => {
|
||||
cwd: rootCwd,
|
||||
workspaceId: "ws-test",
|
||||
activity: null,
|
||||
createdAt: null,
|
||||
isScript: false,
|
||||
},
|
||||
],
|
||||
requestId: "req-root",
|
||||
@@ -587,6 +620,8 @@ describe("terminal-session-controller subdirectory aggregation", () => {
|
||||
cwd: worktreeCwd,
|
||||
workspaceId: "ws-test",
|
||||
activity: null,
|
||||
createdAt: null,
|
||||
isScript: false,
|
||||
},
|
||||
],
|
||||
requestId: "req-worktree",
|
||||
@@ -672,7 +707,16 @@ describe("terminal-session-controller workspace-scoped subscriptions", () => {
|
||||
type: "terminals_changed",
|
||||
payload: {
|
||||
cwd,
|
||||
terminals: [{ id: "a", name: "A", workspaceId: "ws-a", activity: null }],
|
||||
terminals: [
|
||||
{
|
||||
id: "a",
|
||||
name: "A",
|
||||
workspaceId: "ws-a",
|
||||
activity: null,
|
||||
createdAt: null,
|
||||
isScript: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -38,13 +38,12 @@ import type { TerminalSession } from "./terminal.js";
|
||||
import type { TerminalManager, TerminalsChangedEvent } from "./terminal-manager.js";
|
||||
import { applyTerminalSize } from "./terminal-size-ownership.js";
|
||||
import { TerminalWorkerIncompatibleError } from "./terminal-worker-pool.js";
|
||||
import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity";
|
||||
import { terminalSubscriptionKey } from "@getpaseo/protocol/terminal-subscription-key";
|
||||
|
||||
const MAX_TERMINAL_STREAM_SLOTS = 256;
|
||||
|
||||
interface TerminalDirectorySubscription {
|
||||
cwd: string;
|
||||
cwd: string | undefined;
|
||||
workspaceId: string | undefined;
|
||||
owner: OwnedSubscription;
|
||||
refresh: Promise<void> | null;
|
||||
@@ -87,6 +86,8 @@ export interface TerminalSessionControllerOptions {
|
||||
supportsChunkedReplay?: (source: object) => boolean;
|
||||
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
|
||||
sessionLogger: pino.Logger;
|
||||
isScriptTerminal?: (terminal: TerminalSession) => boolean;
|
||||
subscribeTerminalMetadata?: (listener: () => void) => () => void;
|
||||
listTerminalWorkspaceRefs?: () => Promise<readonly TerminalWorkspaceRef[]>;
|
||||
listTerminalWorkspaceRoots?: () => Promise<readonly string[]>;
|
||||
// Drives the snapshot catch-up fallback: a keeping-up client reports ~0 and
|
||||
@@ -163,9 +164,17 @@ export class TerminalSessionController {
|
||||
|
||||
start(): void {
|
||||
if (!this.terminalManager || this.unsubscribeTerminalsChanged) return;
|
||||
this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged((event) => {
|
||||
const releaseManager = this.terminalManager.subscribeTerminalsChanged((event) => {
|
||||
void this.handleTerminalsChanged(event);
|
||||
});
|
||||
const releaseMetadata = this.options.subscribeTerminalMetadata?.(() => {
|
||||
for (const subscription of this.subscribedDirectories.values())
|
||||
void this.emitTerminalsSnapshotForSubscription(subscription);
|
||||
});
|
||||
this.unsubscribeTerminalsChanged = () => {
|
||||
releaseManager();
|
||||
releaseMetadata?.();
|
||||
};
|
||||
}
|
||||
|
||||
getMetrics(): TerminalSessionControllerMetrics {
|
||||
@@ -191,6 +200,7 @@ export class TerminalSessionController {
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (subscription.cwd === undefined) return true;
|
||||
return this.terminalBelongsToRoot(subscription.cwd, input.cwd, workspaceRoots);
|
||||
});
|
||||
}
|
||||
@@ -302,15 +312,7 @@ export class TerminalSessionController {
|
||||
}
|
||||
}
|
||||
|
||||
private toTerminalInfo(
|
||||
terminal: Pick<TerminalSession, "id" | "name" | "workspaceId" | "getTitle" | "getActivity">,
|
||||
): {
|
||||
id: string;
|
||||
name: string;
|
||||
workspaceId: string;
|
||||
title?: string;
|
||||
activity: TerminalActivity | null;
|
||||
} {
|
||||
private toTerminalInfo(terminal: TerminalSession) {
|
||||
const title = terminal.getTitle();
|
||||
const activity = terminal.getActivity();
|
||||
return {
|
||||
@@ -319,6 +321,8 @@ export class TerminalSessionController {
|
||||
workspaceId: terminal.workspaceId,
|
||||
...(title ? { title } : {}),
|
||||
activity,
|
||||
createdAt: terminal.createdAt ?? null,
|
||||
isScript: this.options.isScriptTerminal?.(terminal) ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -329,7 +333,8 @@ export class TerminalSessionController {
|
||||
// aggregated list — so the client's cache replacement doesn't drop the
|
||||
// terminals that live directly at the root.
|
||||
const matchingSubscriptions = Array.from(this.subscribedDirectories.values()).filter(
|
||||
(subscription) => this.isPathWithinRoot(subscription.cwd, event.cwd),
|
||||
(subscription) =>
|
||||
subscription.cwd === undefined || this.isPathWithinRoot(subscription.cwd, event.cwd),
|
||||
);
|
||||
for (const subscription of matchingSubscriptions) {
|
||||
await this.emitTerminalsSnapshotForSubscription(subscription);
|
||||
@@ -368,6 +373,10 @@ export class TerminalSessionController {
|
||||
await this.emitTerminalsSnapshotForSubscription(subscription, msg.requestId);
|
||||
} catch (error) {
|
||||
await owner.release();
|
||||
if (error instanceof TerminalWorkerIncompatibleError) {
|
||||
this.emitIncompatible(msg, error);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -383,17 +392,26 @@ export class TerminalSessionController {
|
||||
const refresh = (async () => {
|
||||
do {
|
||||
subscription.pending = false;
|
||||
const terminals = await this.getTerminalsForWorkspaceRoot(
|
||||
subscription.cwd,
|
||||
subscription.workspaceId,
|
||||
);
|
||||
const terminals =
|
||||
subscription.cwd === undefined
|
||||
? (await this.getAllTerminalSessions()).filter(
|
||||
(terminal) =>
|
||||
subscription.workspaceId === undefined ||
|
||||
terminal.workspaceId === subscription.workspaceId,
|
||||
)
|
||||
: await this.getTerminalsForWorkspaceRoot(subscription.cwd, subscription.workspaceId);
|
||||
if (subscription.owner.signal.aborted) return;
|
||||
subscription.owner.emit({
|
||||
type: "terminals_changed",
|
||||
payload: {
|
||||
cwd: subscription.cwd,
|
||||
workspaceId: subscription.workspaceId,
|
||||
terminals: terminals.map((terminal) => this.toTerminalInfo(terminal)),
|
||||
terminals: terminals.map((terminal) =>
|
||||
Object.assign(
|
||||
this.toTerminalInfo(terminal),
|
||||
subscription.cwd === undefined ? { cwd: terminal.cwd } : {},
|
||||
),
|
||||
),
|
||||
...(requestId ? { requestId } : {}),
|
||||
},
|
||||
});
|
||||
@@ -464,9 +482,13 @@ export class TerminalSessionController {
|
||||
// An empty list or a generic failure would read as "no terminals"; the client needs to know
|
||||
// they exist and why it cannot reach them.
|
||||
private emitIncompatible(
|
||||
msg: { type: string; requestId: string },
|
||||
msg: { type: string; requestId?: string },
|
||||
error: TerminalWorkerIncompatibleError,
|
||||
): void {
|
||||
if (msg.requestId === undefined) {
|
||||
this.emit({ type: "status", payload: { status: "error", message: error.message } });
|
||||
return;
|
||||
}
|
||||
this.emit({
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
@@ -482,6 +504,8 @@ export class TerminalSessionController {
|
||||
if (!this.terminalManager) {
|
||||
return [];
|
||||
}
|
||||
const unavailable = this.terminalManager.getUnavailableError?.();
|
||||
if (unavailable) throw unavailable;
|
||||
const directories = this.terminalManager.listDirectories();
|
||||
const manager = this.terminalManager;
|
||||
const terminalsByDirectory = await Promise.all(
|
||||
|
||||
@@ -263,7 +263,12 @@ function runWorker(argv: string[]): void {
|
||||
}
|
||||
|
||||
function terminalMetadata(session: LocalTerminalSession) {
|
||||
return { ...session.getSize(), hasOutput: session.hasOutput(), title: session.getTitle() };
|
||||
return {
|
||||
...session.getSize(),
|
||||
createdAt: session.createdAt,
|
||||
hasOutput: session.hasOutput(),
|
||||
title: session.getTitle(),
|
||||
};
|
||||
}
|
||||
|
||||
function toTerminalInfo(session: LocalTerminalSession): WorkerTerminalInfo {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { classifyTerminalWorkerProtocol } from "./terminal-worker-protocol.js";
|
||||
import {
|
||||
classifyTerminalWorkerProtocol,
|
||||
TERMINAL_WORKER_PROTOCOL_VERSION,
|
||||
} from "./terminal-worker-protocol.js";
|
||||
|
||||
describe("classifyTerminalWorkerProtocol", () => {
|
||||
it("rejects the unreleased cell protocol without a draining adapter", () => {
|
||||
@@ -23,3 +26,8 @@ describe("classifyTerminalWorkerProtocol", () => {
|
||||
expect(classifyTerminalWorkerProtocol(reported, 6)).toBe(fit);
|
||||
});
|
||||
});
|
||||
|
||||
it("drains v5 terminals while v6 supplies creation timestamps", () => {
|
||||
expect(TERMINAL_WORKER_PROTOCOL_VERSION).toBe(6);
|
||||
expect(classifyTerminalWorkerProtocol(5)).toBe("previous");
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { TerminalExitInfo, ServerMessage, ClientMessage } from "./terminal.js";
|
||||
import type { TerminalReplayEvent, TerminalReplayOptions } from "./terminal-replay.js";
|
||||
export interface WorkerTerminalState {
|
||||
/** COMPAT(terminal-created-at): v6; v5 draining workers omit this, remove after 2027-06. */
|
||||
createdAt?: string;
|
||||
rows: number;
|
||||
cols: number;
|
||||
hasOutput: boolean;
|
||||
@@ -43,7 +45,7 @@ export interface WorkerCreateTerminalOptions {
|
||||
// `shutdown`. A daemon drives a worker of its own version, drains one a version older (no new
|
||||
// terminals; it exits after its last terminal), shuts down anything older, and leaves a newer
|
||||
// worker running untouched. See docs/terminal-worker.md.
|
||||
export const TERMINAL_WORKER_PROTOCOL_VERSION = 5;
|
||||
export const TERMINAL_WORKER_PROTOCOL_VERSION = 6;
|
||||
|
||||
// Test-only: the protocol version a worker reports, so tests can stand up older and newer
|
||||
// workers from one build.
|
||||
|
||||
@@ -88,6 +88,8 @@ export interface TerminalActivityTransition {
|
||||
}
|
||||
|
||||
export interface TerminalSession {
|
||||
/** Absent on terminal workers started before creation timestamps were tracked. */
|
||||
createdAt?: string;
|
||||
id: string;
|
||||
name: string;
|
||||
cwd: string;
|
||||
@@ -1378,6 +1380,7 @@ export async function createTerminal(
|
||||
|
||||
return {
|
||||
id,
|
||||
createdAt: new Date().toISOString(),
|
||||
pid: ptyProcess.pid,
|
||||
name,
|
||||
cwd,
|
||||
|
||||
@@ -1646,19 +1646,19 @@ it.each([1, 4])(
|
||||
expect(manager.getTerminal(old.terminalId)).toBeUndefined();
|
||||
const workers = await workerRecords(paseoHome);
|
||||
expect(workers).toHaveLength(1);
|
||||
expect(workers[0]!.protocolVersion).toBe(5);
|
||||
expect(workers[0]!.protocolVersion).toBe(6);
|
||||
},
|
||||
);
|
||||
|
||||
it("leaves a worker from a newer Paseo running, reports it incompatible, and closes it on a kill stop", async () => {
|
||||
const paseoHome = createTemporaryHome();
|
||||
const newer = await leaveWorkerWithTerminal(paseoHome, 6);
|
||||
const newer = await leaveWorkerWithTerminal(paseoHome, 7);
|
||||
|
||||
manager = await startRealWorkerManager({ paseoHome });
|
||||
|
||||
const unavailable = manager.getUnavailableError?.();
|
||||
expect(unavailable).toBeInstanceOf(TerminalWorkerIncompatibleError);
|
||||
expect(unavailable?.message).toContain("worker protocol 6");
|
||||
expect(unavailable?.message).toContain("worker protocol 7");
|
||||
await expect(manager.getTerminals(newer.cwd)).rejects.toBeInstanceOf(
|
||||
TerminalWorkerIncompatibleError,
|
||||
);
|
||||
|
||||
@@ -268,6 +268,9 @@ export function createWorkerTerminalManager(
|
||||
};
|
||||
|
||||
const session: TerminalSession = {
|
||||
get createdAt() {
|
||||
return record.state.createdAt;
|
||||
},
|
||||
get id() {
|
||||
return record.info.id;
|
||||
},
|
||||
@@ -373,6 +376,10 @@ export function createWorkerTerminalManager(
|
||||
for (const listener of Array.from(record.titleChangeListeners)) {
|
||||
listener(manualTitle);
|
||||
}
|
||||
emitTerminalsChanged({
|
||||
cwd: record.info.cwd,
|
||||
terminals: listTerminalItemsForCwd(record.info.cwd),
|
||||
});
|
||||
},
|
||||
getExitInfo(): TerminalExitInfo | null {
|
||||
return record.exitInfo;
|
||||
|
||||
Reference in New Issue
Block a user