mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 17:04:42 +00:00
Let plugins provide custom sidebar views across hosts (#8906)
* feat(plugin): expose stable sidebar entity data across hosts Collections return interned references and preserve their arrays when membership and order stay unchanged. Entity selectors share one hook with identity defaults for full snapshots and shallow defaults for selections. The app caches host projections and shares underlying store and host subscriptions. Browser render counts exercise the real session store and plugin provider without memoized rows. Terminal hooks consume the collection source port; daemon-wide terminal discovery is the separate terminal phase. * feat(plugin): let users select a sidebar view An exclusive view replaces the sidebar content while the host retains navigation, callouts, and footer. The device remembers the selection, unavailable views fall back to the default, and throwing views provide recovery. Device-scoped plugin settings persist locally with schema validation and revision conflicts. Web E2E and Android tablet QA cover selection, preserved chrome, disabled-view fallback, and error recovery. * feat(plugin): add sidebar view examples and ref actions Keep sidebar layout and gestures inside the plugin while routing entity handles to their owning hosts. Examples cover empty projects, pinned workspaces, task sections, nested agents and device-local label folders. * 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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2122,7 +2122,7 @@ it.each(examples)("builds and loads the %s plugin example", async (example) => {
|
||||
const runtime = createTestRuntime(
|
||||
{ settingsDirectory },
|
||||
undefined,
|
||||
resolveDaemonVersion(import.meta.url),
|
||||
example === "sidebar-views" ? "0.16.0" : resolveDaemonVersion(import.meta.url),
|
||||
);
|
||||
try {
|
||||
await runtime.startPlugin(example, path.join(examplesDirectory, example));
|
||||
|
||||
@@ -175,3 +175,11 @@ test("installation namespaces and definition IDs remain separate", async () => {
|
||||
expect(() => store.register(definition)).toThrow("Duplicate settings");
|
||||
expect(() => store.register({ ...definition, id: "../escape" })).toThrow("Invalid settings ID");
|
||||
});
|
||||
|
||||
test("device-scoped settings are owned by the client", async () => {
|
||||
const { directory } = await setup();
|
||||
const store = new PluginSettingsStore(directory, () => {});
|
||||
expect(() => store.register(defineSettings({ ...definition, scope: "device" }))).toThrow(
|
||||
"Device settings belong to the client",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -45,6 +45,7 @@ export class PluginSettingsStore {
|
||||
|
||||
register<Schema extends ZodType>(definition: SettingsDefinition<Schema>) {
|
||||
defineSettings(definition);
|
||||
if (definition.scope !== "host") throw new Error("Device settings belong to the client");
|
||||
if (this.definitions.has(definition.id))
|
||||
throw new Error(`Duplicate settings: ${definition.id}`);
|
||||
this.definitions.set(definition.id, definition);
|
||||
|
||||
@@ -1187,6 +1187,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({
|
||||
|
||||
@@ -2038,6 +2038,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 {
|
||||
|
||||
@@ -392,8 +392,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,
|
||||
@@ -505,8 +522,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,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -576,6 +607,8 @@ describe("terminal-session-controller subdirectory aggregation", () => {
|
||||
cwd: rootCwd,
|
||||
workspaceId: "ws-test",
|
||||
activity: null,
|
||||
createdAt: null,
|
||||
isScript: false,
|
||||
},
|
||||
],
|
||||
requestId: "req-root",
|
||||
@@ -592,6 +625,8 @@ describe("terminal-session-controller subdirectory aggregation", () => {
|
||||
cwd: worktreeCwd,
|
||||
workspaceId: "ws-test",
|
||||
activity: null,
|
||||
createdAt: null,
|
||||
isScript: false,
|
||||
},
|
||||
],
|
||||
requestId: "req-worktree",
|
||||
@@ -677,7 +712,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,
|
||||
|
||||
@@ -1666,19 +1666,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,
|
||||
);
|
||||
|
||||
@@ -265,6 +265,9 @@ export function createWorkerTerminalManager(
|
||||
};
|
||||
|
||||
const session: TerminalSession = {
|
||||
get createdAt() {
|
||||
return record.state.createdAt;
|
||||
},
|
||||
get id() {
|
||||
return record.info.id;
|
||||
},
|
||||
@@ -370,6 +373,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