fix(workspace): hide ephemeral quick runtimes
This commit is contained in:
@@ -347,6 +347,7 @@ export function createAgentRegistry({
|
|||||||
let stopping = false;
|
let stopping = false;
|
||||||
|
|
||||||
function publishWorkspace(type, runtime, data = {}) {
|
function publishWorkspace(type, runtime, data = {}) {
|
||||||
|
if (runtime?.ephemeral) return undefined;
|
||||||
const event = {
|
const event = {
|
||||||
version: "v1",
|
version: "v1",
|
||||||
bridgeInstanceId,
|
bridgeInstanceId,
|
||||||
@@ -1322,6 +1323,7 @@ export function createAgentRegistry({
|
|||||||
[...managedWorktrees].map((worktreePath) => [worktreePath, []]),
|
[...managedWorktrees].map((worktreePath) => [worktreePath, []]),
|
||||||
);
|
);
|
||||||
for (const runtime of runtimesByRuntimeId.values()) {
|
for (const runtime of runtimesByRuntimeId.values()) {
|
||||||
|
if (runtime.ephemeral) continue;
|
||||||
let group = grouped.get(runtime.worktreePath);
|
let group = grouped.get(runtime.worktreePath);
|
||||||
if (!group) {
|
if (!group) {
|
||||||
group = [];
|
group = [];
|
||||||
@@ -1562,7 +1564,9 @@ export function createAgentRegistry({
|
|||||||
},
|
},
|
||||||
getWorkspace: workspace,
|
getWorkspace: workspace,
|
||||||
getWorkspaceSummary() {
|
getWorkspaceSummary() {
|
||||||
const runtimes = [...runtimesByRuntimeId.values()];
|
const runtimes = [...runtimesByRuntimeId.values()].filter(
|
||||||
|
(runtime) => !runtime.ephemeral,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
bridgeInstanceId,
|
bridgeInstanceId,
|
||||||
latestSeq: workspaceSequence,
|
latestSeq: workspaceSequence,
|
||||||
|
|||||||
@@ -47,6 +47,83 @@ function adapters() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("keeps quick runtimes out of workspace state until promotion", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "pi-quick-isolation-"));
|
||||||
|
const home = join(root, "home");
|
||||||
|
const feature = join(root, "feature");
|
||||||
|
await Promise.all([mkdir(home), mkdir(feature)]);
|
||||||
|
const fixture = adapters();
|
||||||
|
const registry = createAgentRegistry({
|
||||||
|
homeWorktree: home,
|
||||||
|
sessionRoot: join(root, "sessions"),
|
||||||
|
startAdapter: fixture.startAdapter,
|
||||||
|
});
|
||||||
|
await registry.start();
|
||||||
|
assert.equal(
|
||||||
|
registry.workspaceEventsAfter(0).events.at(-1).type,
|
||||||
|
"runtime_opened",
|
||||||
|
);
|
||||||
|
const cursor = registry.getWorkspace().latestSeq;
|
||||||
|
const received = [];
|
||||||
|
const subscription = registry.subscribeWorkspace(cursor, (event) =>
|
||||||
|
received.push(event),
|
||||||
|
);
|
||||||
|
const quick = await Promise.all(
|
||||||
|
Array.from({ length: 5 }, () => registry.createQuickRuntime(feature)),
|
||||||
|
);
|
||||||
|
fixture.calls.at(-1).options.onEvent({
|
||||||
|
type: "transcript",
|
||||||
|
data: { event: { type: "message_end" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
registry
|
||||||
|
.getWorkspace()
|
||||||
|
.directories.map((directory) => directory.worktreePath),
|
||||||
|
[home],
|
||||||
|
);
|
||||||
|
assert.equal(registry.getWorkspace().directories[0].openCount, 1);
|
||||||
|
assert.deepEqual(registry.getWorkspaceSummary(), {
|
||||||
|
bridgeInstanceId: registry.bridgeInstanceId,
|
||||||
|
latestSeq: cursor,
|
||||||
|
openCount: 1,
|
||||||
|
workingCount: 0,
|
||||||
|
attentionCount: 0,
|
||||||
|
recoveringCount: 0,
|
||||||
|
errorCount: 0,
|
||||||
|
directoryCount: 1,
|
||||||
|
resourceWarning: false,
|
||||||
|
});
|
||||||
|
const quickSnapshot = await registry.getSessionRuntimeSnapshot(
|
||||||
|
quick[0].runtimeId,
|
||||||
|
);
|
||||||
|
assert.equal(quickSnapshot.runtime.runtimeId, quick[0].runtimeId);
|
||||||
|
await registry.closeQuickRuntime(quick.at(-1).runtimeId);
|
||||||
|
assert.deepEqual(received, []);
|
||||||
|
assert.deepEqual(registry.workspaceEventsAfter(cursor).events, []);
|
||||||
|
|
||||||
|
const promoted = await registry.promoteQuickRuntime(quick[0].runtimeId);
|
||||||
|
const featureDirectory = registry
|
||||||
|
.getWorkspace()
|
||||||
|
.directories.find((directory) => directory.worktreePath === feature);
|
||||||
|
assert.deepEqual(
|
||||||
|
featureDirectory?.runtimes.map((runtime) => runtime.runtimeId),
|
||||||
|
[quick[0].runtimeId],
|
||||||
|
);
|
||||||
|
assert.equal(promoted.runtimeId, quick[0].runtimeId);
|
||||||
|
assert.equal(registry.getWorkspaceSummary().openCount, 2);
|
||||||
|
assert.deepEqual(
|
||||||
|
received.map((event) => event.type),
|
||||||
|
["runtime_promoted"],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
registry.workspaceEventsAfter(cursor).events.map((event) => event.type),
|
||||||
|
["runtime_promoted"],
|
||||||
|
);
|
||||||
|
subscription.unsubscribe();
|
||||||
|
await registry.stop();
|
||||||
|
});
|
||||||
|
|
||||||
test("promotes an active quick runtime in place and makes stale quick cleanup harmless", async () => {
|
test("promotes an active quick runtime in place and makes stale quick cleanup harmless", async () => {
|
||||||
const root = await mkdtemp(join(tmpdir(), "pi-quick-promote-"));
|
const root = await mkdtemp(join(tmpdir(), "pi-quick-promote-"));
|
||||||
const home = join(root, "home");
|
const home = join(root, "home");
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { RuntimeSnapshot, Workspace, WorkspaceSummary } from "./types";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
invoke: vi.fn(),
|
||||||
|
listeners: {} as Record<string, (message: { payload: any }) => void>,
|
||||||
|
listen: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke }));
|
||||||
|
vi.mock("@tauri-apps/api/event", () => ({ listen: mocks.listen }));
|
||||||
|
vi.mock("@tauri-apps/api/window", () => ({
|
||||||
|
getCurrentWindow: () => ({}),
|
||||||
|
}));
|
||||||
|
vi.mock("./persistence", () => ({
|
||||||
|
legacyPreferences: () => ({}),
|
||||||
|
loadPresentation: async () => ({
|
||||||
|
version: 1,
|
||||||
|
directoryOrder: [],
|
||||||
|
runtimeOrderByDirectory: {},
|
||||||
|
drafts: {},
|
||||||
|
scroll: {},
|
||||||
|
lastSeen: {},
|
||||||
|
}),
|
||||||
|
presentationState: vi.fn(),
|
||||||
|
removeLegacyPreferences: vi.fn(),
|
||||||
|
savePresentation: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { useWorkspace } from "./useWorkspace";
|
||||||
|
|
||||||
|
const normalRuntime = {
|
||||||
|
runtimeId: "normal",
|
||||||
|
worktreePath: "/repo",
|
||||||
|
state: "idle",
|
||||||
|
label: "Normal",
|
||||||
|
attention: false,
|
||||||
|
queueCount: 0,
|
||||||
|
lastActivity: "now",
|
||||||
|
openedAt: "now",
|
||||||
|
};
|
||||||
|
const quickRuntime = { ...normalRuntime, runtimeId: "quick", label: "Quick" };
|
||||||
|
|
||||||
|
function workspace(runtimes: (typeof normalRuntime)[]): Workspace {
|
||||||
|
return {
|
||||||
|
bridgeInstanceId: "bridge",
|
||||||
|
latestSeq: 1,
|
||||||
|
directories: [
|
||||||
|
{
|
||||||
|
worktreePath: "/repo",
|
||||||
|
isHome: true,
|
||||||
|
openCount: runtimes.length,
|
||||||
|
workingCount: 0,
|
||||||
|
attentionCount: 0,
|
||||||
|
recoveringCount: 0,
|
||||||
|
errorCount: 0,
|
||||||
|
runtimes,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary: WorkspaceSummary = {
|
||||||
|
bridgeInstanceId: "bridge",
|
||||||
|
latestSeq: 1,
|
||||||
|
openCount: 1,
|
||||||
|
workingCount: 0,
|
||||||
|
attentionCount: 0,
|
||||||
|
recoveringCount: 0,
|
||||||
|
errorCount: 0,
|
||||||
|
directoryCount: 1,
|
||||||
|
resourceWarning: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function snapshot(runtime: typeof normalRuntime): RuntimeSnapshot {
|
||||||
|
return {
|
||||||
|
bridgeInstanceId: "bridge",
|
||||||
|
latestSeq: 1,
|
||||||
|
runtime,
|
||||||
|
extensions: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useWorkspace Quick runtime isolation", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
for (const key of Object.keys(mocks.listeners)) delete mocks.listeners[key];
|
||||||
|
mocks.listen.mockImplementation((event, listener) => {
|
||||||
|
mocks.listeners[event] = listener;
|
||||||
|
return Promise.resolve(() => delete mocks.listeners[event]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores unknown Quick events and exposes promoted runtime once", async () => {
|
||||||
|
let promoted = false;
|
||||||
|
mocks.invoke.mockImplementation(
|
||||||
|
(command: string, payload?: { runtimeId?: string }) => {
|
||||||
|
if (command === "get_workspace")
|
||||||
|
return Promise.resolve(
|
||||||
|
workspace(promoted ? [normalRuntime, quickRuntime] : [normalRuntime]),
|
||||||
|
);
|
||||||
|
if (command === "get_workspace_summary") return Promise.resolve(summary);
|
||||||
|
if (command === "get_session_runtime_snapshot")
|
||||||
|
return Promise.resolve(
|
||||||
|
snapshot(payload?.runtimeId === "quick" ? quickRuntime : normalRuntime),
|
||||||
|
);
|
||||||
|
if (command === "get_pending_promoted_runtime")
|
||||||
|
return Promise.resolve(null);
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const { result } = renderHook(() => useWorkspace());
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(result.current.state.runtimesById.normal).toBeDefined(),
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mocks.listeners["workspace-bridge"]).toBeDefined(),
|
||||||
|
);
|
||||||
|
const workspaceCalls = () =>
|
||||||
|
mocks.invoke.mock.calls.filter(([command]) => command === "get_workspace");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
mocks.listeners["workspace-bridge"]({
|
||||||
|
payload: {
|
||||||
|
kind: "event",
|
||||||
|
event: {
|
||||||
|
bridgeInstanceId: "bridge",
|
||||||
|
seq: 2,
|
||||||
|
type: "runtime_event",
|
||||||
|
data: {
|
||||||
|
runtimeId: "quick",
|
||||||
|
runtime: quickRuntime,
|
||||||
|
eventType: "transcript",
|
||||||
|
eventData: { event: { type: "message_end" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(result.current.state.runtimesById.quick).toBeUndefined();
|
||||||
|
expect(workspaceCalls()).toHaveLength(1);
|
||||||
|
|
||||||
|
promoted = true;
|
||||||
|
await act(async () => {
|
||||||
|
mocks.listeners["workspace-promoted-runtime"]({
|
||||||
|
payload: { runtimeId: "quick" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(result.current.state.runtimesById.quick).toBeDefined(),
|
||||||
|
);
|
||||||
|
expect(result.current.state.runtimeOrderByDirectory["/repo"]).toEqual([
|
||||||
|
"normal",
|
||||||
|
"quick",
|
||||||
|
]);
|
||||||
|
expect(result.current.state.selectedRuntimeId).toBe("quick");
|
||||||
|
expect(workspaceCalls()).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -221,10 +221,22 @@ export function useWorkspace() {
|
|||||||
void listen<HostEvent>("workspace-bridge", (message) => {
|
void listen<HostEvent>("workspace-bridge", (message) => {
|
||||||
if (disposed.current) return;
|
if (disposed.current) return;
|
||||||
const host = message.payload;
|
const host = message.payload;
|
||||||
|
const runtimeId =
|
||||||
|
host.kind === "event" ? host.event.data?.runtimeId : undefined;
|
||||||
|
if (
|
||||||
|
runtimeId &&
|
||||||
|
!stateRef.current.runtimesById[runtimeId] &&
|
||||||
|
[
|
||||||
|
"runtime_event",
|
||||||
|
"runtime_diagnostic",
|
||||||
|
"runtime_recovery",
|
||||||
|
"runtime_closing",
|
||||||
|
].includes(host.kind === "event" ? (host.event.type ?? "") : "")
|
||||||
|
)
|
||||||
|
return;
|
||||||
dispatch({ type: "hostEvent", host });
|
dispatch({ type: "hostEvent", host });
|
||||||
if (host.kind === "resetRequired") void refresh(undefined, true);
|
if (host.kind === "resetRequired") void refresh(undefined, true);
|
||||||
else if (host.kind === "event") {
|
else if (host.kind === "event") {
|
||||||
const runtimeId = host.event.data?.runtimeId;
|
|
||||||
const eventType = host.event.type;
|
const eventType = host.event.type;
|
||||||
const piType = String(host.event.data?.eventData?.event?.type ?? "");
|
const piType = String(host.event.data?.eventData?.event?.type ?? "");
|
||||||
if (
|
if (
|
||||||
|
|||||||
Reference in New Issue
Block a user