Archived
Merge remote-tracking branch 'origin/main' into feat/model-questions-ux
# Conflicts: # src/server/sessiond.ts # src/server/sessions/sessionRoutes.test.ts
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@jmfederico/pi-web": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add action-palette commands for selecting a session's model and thinking level, with support for assigning custom shortcuts in Settings.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@jmfederico/pi-web": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Pick up git worktrees created or removed outside PI WEB without any user action. The selected project's workspace list is re-read whenever the browser tab regains focus or becomes visible, on local and remote machines, keeping the current workspace, session, and scroll position untouched. Worktrees whose checkout directory no longer exists are hidden instead of being offered as selectable workspaces.
|
||||||
@@ -101,6 +101,7 @@
|
|||||||
<a href="#remote-machines">How do remote machines work?</a>
|
<a href="#remote-machines">How do remote machines work?</a>
|
||||||
<a href="#laptop-or-server">Laptop or server?</a>
|
<a href="#laptop-or-server">Laptop or server?</a>
|
||||||
<a href="#plugins">Can I use local plugins?</a>
|
<a href="#plugins">Can I use local plugins?</a>
|
||||||
|
<a href="#worktree-list-out-of-date">A worktree I created is missing</a>
|
||||||
<a href="#sessions-stop">Sessions stop unexpectedly</a>
|
<a href="#sessions-stop">Sessions stop unexpectedly</a>
|
||||||
<a href="#logs">Where are logs?</a>
|
<a href="#logs">Where are logs?</a>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -293,6 +294,25 @@
|
|||||||
<p><a href="plugins">Read the plugin guide →</a></p>
|
<p><a href="plugins">Read the plugin guide →</a></p>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
<article id="worktree-list-out-of-date" class="faq-item">
|
||||||
|
<h2>A worktree I created or deleted outside PI WEB is missing or still listed</h2>
|
||||||
|
<p>
|
||||||
|
PI WEB does not register worktrees. It lists the git worktrees of the selected project on demand, so
|
||||||
|
worktrees you create or delete with <code>git worktree</code>, a terminal, or another tool are picked up
|
||||||
|
without any adopt or import step. This works the same way on remote machines.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The workspace list is re-read when the PI WEB tab regains focus or becomes visible again, not
|
||||||
|
continuously. If a worktree appeared while you were already looking at PI WEB, switch to another window
|
||||||
|
or tab and back, and the list updates. Your selected workspace, session, and scroll position are kept.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Worktrees whose checkout directory no longer exists are hidden, so a directory you removed with
|
||||||
|
<code>rm -rf</code> instead of <code>git worktree remove</code> stops appearing as a selectable
|
||||||
|
workspace. Git still tracks it until you run <code>git worktree prune</code>.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
<article id="sessions-stop" class="faq-item">
|
<article id="sessions-stop" class="faq-item">
|
||||||
<h2>Sessions stop unexpectedly</h2>
|
<h2>Sessions stop unexpectedly</h2>
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -253,6 +253,22 @@ describe("session API compatibility", () => {
|
|||||||
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ sessions: [{ id: "s 1", cwd: "/repo" }] });
|
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ sessions: [{ id: "s 1", cwd: "/repo" }] });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("carries a create's correlation token in the start request body when one is supplied", async () => {
|
||||||
|
const fetchMock = stubSequenceFetch([
|
||||||
|
jsonResponse(sessionInfoResponse("s 1")),
|
||||||
|
jsonResponse(sessionInfoResponse("s 2")),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await sessionsApi.startSession("/repo", "remote a", "pending-session-3-k2x9");
|
||||||
|
await sessionsApi.startSession("/repo", "remote a");
|
||||||
|
|
||||||
|
expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/sessions");
|
||||||
|
expect(JSON.parse(requestBody(fetchCall(fetchMock, 0)[1]))).toEqual({ cwd: "/repo", startupToken: "pending-session-3-k2x9" });
|
||||||
|
// The token is optional, so a caller with no row to label sends none rather
|
||||||
|
// than an empty one.
|
||||||
|
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ cwd: "/repo" });
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps legacy session-id calls free of cwd context", async () => {
|
it("keeps legacy session-id calls free of cwd context", async () => {
|
||||||
const fetchMock = stubJsonFetch({ accepted: true });
|
const fetchMock = stubJsonFetch({ accepted: true });
|
||||||
|
|
||||||
@@ -572,6 +588,10 @@ function requestBody(init: RequestInit | undefined): string {
|
|||||||
return init.body;
|
return init.body;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sessionInfoResponse(id: string) {
|
||||||
|
return { id, path: `/tmp/${id}.jsonl`, cwd: "/repo", created: "now", modified: "now", messageCount: 0, firstMessage: "" };
|
||||||
|
}
|
||||||
|
|
||||||
function piWebConfigResponse(config: PiWebConfigValues) {
|
function piWebConfigResponse(config: PiWebConfigValues) {
|
||||||
return {
|
return {
|
||||||
path: "/tmp/pi-web/config.json",
|
path: "/tmp/pi-web/config.json",
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ export const sessionsApi = {
|
|||||||
notificationInbox: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "notifications", machineId), parseSessionNotificationInboxSnapshot),
|
notificationInbox: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "notifications", machineId), parseSessionNotificationInboxSnapshot),
|
||||||
dismissNotification: (session: SessionLookup, daemonInstanceId: string, notificationId: string, machineId = "local") => request(sessionPath(session, "notifications/dismiss", machineId), parseSessionNotificationInboxSnapshot, { method: "POST", body: sessionBody(session, { daemonInstanceId, notificationId }) }),
|
dismissNotification: (session: SessionLookup, daemonInstanceId: string, notificationId: string, machineId = "local") => request(sessionPath(session, "notifications/dismiss", machineId), parseSessionNotificationInboxSnapshot, { method: "POST", body: sessionBody(session, { daemonInstanceId, notificationId }) }),
|
||||||
dismissAllNotifications: (session: SessionLookup, daemonInstanceId: string, through: SessionNotificationDismissThrough, machineId = "local") => request(sessionPath(session, "notifications/dismiss-all", machineId), parseSessionNotificationInboxSnapshot, { method: "POST", body: sessionBody(session, { daemonInstanceId, throughOrder: through.order, throughOverflowWatermark: through.overflowWatermark }) }),
|
dismissAllNotifications: (session: SessionLookup, daemonInstanceId: string, through: SessionNotificationDismissThrough, machineId = "local") => request(sessionPath(session, "notifications/dismiss-all", machineId), parseSessionNotificationInboxSnapshot, { method: "POST", body: sessionBody(session, { daemonInstanceId, throughOrder: through.order, throughOverflowWatermark: through.overflowWatermark }) }),
|
||||||
startSession: (cwd: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions`, parseSessionInfo, { method: "POST", body: JSON.stringify({ cwd }) }),
|
startSession: (cwd: string, machineId = "local", startupToken?: string) => request(`${machinePrefix(machineId)}/sessions`, parseSessionInfo, { method: "POST", body: JSON.stringify(startupToken === undefined ? { cwd } : { cwd, startupToken }) }),
|
||||||
cleanupPreview: (input: SessionCleanupRequest, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/cleanup/preview`, parseSessionCleanupPreviewResponse, { method: "POST", body: JSON.stringify(input) }),
|
cleanupPreview: (input: SessionCleanupRequest, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/cleanup/preview`, parseSessionCleanupPreviewResponse, { method: "POST", body: JSON.stringify(input) }),
|
||||||
cleanup: (input: SessionCleanupRequest, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/cleanup`, parseSessionCleanupExecuteResponse, { method: "POST", body: JSON.stringify(input) }),
|
cleanup: (input: SessionCleanupRequest, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/cleanup`, parseSessionCleanupExecuteResponse, { method: "POST", body: JSON.stringify(input) }),
|
||||||
archiveMany: (sessions: readonly SessionLookup[], machineId = "local") => request(`${machinePrefix(machineId)}/sessions/bulk/archive`, parseSessionBulkArchiveResponse, { method: "POST", body: sessionBulkMutationBody(sessions) }),
|
archiveMany: (sessions: readonly SessionLookup[], machineId = "local") => request(`${machinePrefix(machineId)}/sessions/bulk/archive`, parseSessionBulkArchiveResponse, { method: "POST", body: sessionBulkMutationBody(sessions) }),
|
||||||
|
|||||||
@@ -289,34 +289,45 @@ describe("API parsers", () => {
|
|||||||
})).toThrow("positive safe integer");
|
})).toThrow("positive safe integer");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parses session startup progress with and without a wait detail", () => {
|
it("parses session startup progress with and without a correlation token", () => {
|
||||||
const activity = { sessionId: "session-1", phase: "active", label: "Creating session", detail: "Starting the Pi session", at: "2026-07-20T00:00:01.000Z" };
|
const activity = { sessionId: "session-1", phase: "active", label: "Creating session", detail: "Starting the Pi session", at: "2026-07-20T00:00:01.000Z" };
|
||||||
|
|
||||||
expect(parseSessionStartupProgressEvent({ type: "session.startup", cwd: "/repo", activity })).toEqual({
|
expect(parseSessionStartupProgressEvent({ type: "session.startup", startupToken: "pending-session-1-abc", activity })).toEqual({
|
||||||
type: "session.startup",
|
type: "session.startup",
|
||||||
cwd: "/repo",
|
startupToken: "pending-session-1-abc",
|
||||||
activity,
|
activity,
|
||||||
});
|
});
|
||||||
|
// An open carries no token: the activity's own session id is the only route.
|
||||||
const idle = { sessionId: "session-1", phase: "idle", label: "idle", at: "2026-07-20T00:00:02.000Z" };
|
const idle = { sessionId: "session-1", phase: "idle", label: "idle", at: "2026-07-20T00:00:02.000Z" };
|
||||||
expect(parseSessionStartupProgressEvent({ type: "session.startup", cwd: "/repo", activity: idle })).toEqual({
|
expect(parseSessionStartupProgressEvent({ type: "session.startup", activity: idle })).toEqual({
|
||||||
type: "session.startup",
|
type: "session.startup",
|
||||||
cwd: "/repo",
|
|
||||||
activity: idle,
|
activity: idle,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("carries the startup marker so an opening session is not mistaken for a working one", () => {
|
||||||
|
const activity = { sessionId: "session-1", phase: "active", label: "Opening session", detail: "Starting the Pi session", at: "2026-07-20T00:00:01.000Z", startup: true };
|
||||||
|
|
||||||
|
expect(parseSessionStartupProgressEvent({ type: "session.startup", activity })).toEqual({ type: "session.startup", activity });
|
||||||
|
// A malformed marker is dropped like any other malformed field rather than
|
||||||
|
// being coerced into "this is startup" or "this is work".
|
||||||
|
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", activity: { ...activity, startup: "yes" } })).toThrow("Expected optional boolean field: startup");
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects session startup progress that cannot be routed or rendered honestly", () => {
|
it("rejects session startup progress that cannot be routed or rendered honestly", () => {
|
||||||
const activity = { sessionId: "session-1", phase: "active", label: "Creating session", at: "2026-07-20T00:00:01.000Z" };
|
const activity = { sessionId: "session-1", phase: "active", label: "Creating session", at: "2026-07-20T00:00:01.000Z" };
|
||||||
|
|
||||||
expect(() => parseSessionStartupProgressEvent({ type: "activity.update", cwd: "/repo", activity })).toThrow("Invalid session startup event type");
|
expect(() => parseSessionStartupProgressEvent({ type: "activity.update", activity })).toThrow("Invalid session startup event type");
|
||||||
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", activity })).toThrow("Expected string field: cwd");
|
expect(() => parseSessionStartupProgressEvent({ type: "session.startup" })).toThrow("Expected object response");
|
||||||
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", cwd: "", activity })).toThrow("Expected non-empty string field: cwd");
|
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", startupToken: 7, activity })).toThrow("Expected optional string field: startupToken");
|
||||||
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", cwd: "/repo" })).toThrow("Expected object response");
|
// An empty token would match nothing but must still be rejected rather than
|
||||||
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", cwd: "/repo", activity: { ...activity, phase: "waiting" } })).toThrow("Expected session activity phase field: phase");
|
// silently carried, so a malformed frame never reaches the routing at all.
|
||||||
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", cwd: "/repo", activity: { ...activity, label: 7 } })).toThrow("Expected string field: label");
|
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", startupToken: "", activity })).toThrow("Expected non-empty string field: startupToken");
|
||||||
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", cwd: "/repo", activity: { ...activity, label: "" } })).toThrow("Expected non-empty string field: label");
|
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", activity: { ...activity, phase: "waiting" } })).toThrow("Expected session activity phase field: phase");
|
||||||
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", cwd: "/repo", activity: { ...activity, detail: 7 } })).toThrow("Expected optional string field: detail");
|
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", activity: { ...activity, label: 7 } })).toThrow("Expected string field: label");
|
||||||
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", cwd: "/repo", activity: { ...activity, sessionId: "" } })).toThrow("Expected non-empty string field: sessionId");
|
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", activity: { ...activity, label: "" } })).toThrow("Expected non-empty string field: label");
|
||||||
|
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", activity: { ...activity, detail: 7 } })).toThrow("Expected optional string field: detail");
|
||||||
|
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", activity: { ...activity, sessionId: "" } })).toThrow("Expected non-empty string field: sessionId");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parses session cleanup preview and execute responses", () => {
|
it("parses session cleanup preview and execute responses", () => {
|
||||||
|
|||||||
@@ -402,15 +402,19 @@ export function parseSessionUnreadEvent(value: unknown): SessionUnreadEvent {
|
|||||||
/**
|
/**
|
||||||
* Validate a startup progress frame. The browser substitutes its own wording
|
* Validate a startup progress frame. The browser substitutes its own wording
|
||||||
* from this event, so a malformed frame must be dropped rather than rendered:
|
* from this event, so a malformed frame must be dropped rather than rendered:
|
||||||
* `cwd` is the routing key, and an activity missing its phase or label could
|
* `startupToken` is the routing key when present, and an activity missing its
|
||||||
* otherwise blank out or freeze the text a user is reading while they wait.
|
* phase or label could otherwise blank out or freeze the text a user is reading
|
||||||
|
* while they wait. An absent token is valid — an open routes by session id — but
|
||||||
|
* a present empty one is not, since it could match no row honestly.
|
||||||
*/
|
*/
|
||||||
export function parseSessionStartupProgressEvent(value: unknown): SessionStartupProgressEvent {
|
export function parseSessionStartupProgressEvent(value: unknown): SessionStartupProgressEvent {
|
||||||
const record = requireRecord(value);
|
const record = requireRecord(value);
|
||||||
if (record["type"] !== "session.startup") throw new Error("Invalid session startup event type");
|
if (record["type"] !== "session.startup") throw new Error("Invalid session startup event type");
|
||||||
|
const startupToken = optionalString(record, "startupToken");
|
||||||
|
if (startupToken === "") throw new Error("Expected non-empty string field: startupToken");
|
||||||
return {
|
return {
|
||||||
type: "session.startup",
|
type: "session.startup",
|
||||||
cwd: requireNonEmptyString(record, "cwd"),
|
...optionalField("startupToken", startupToken),
|
||||||
activity: parseSessionActivity(record["activity"]),
|
activity: parseSessionActivity(record["activity"]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -423,9 +427,22 @@ function parseSessionActivity(value: unknown): SessionActivity {
|
|||||||
label: requireNonEmptyString(record, "label"),
|
label: requireNonEmptyString(record, "label"),
|
||||||
...optionalField("detail", optionalString(record, "detail")),
|
...optionalField("detail", optionalString(record, "detail")),
|
||||||
at: requireNonEmptyString(record, "at"),
|
at: requireNonEmptyString(record, "at"),
|
||||||
|
...optionalField("startup", optionalActivityStartupMarker(record)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The startup marker says the activity is a session opening rather than work in
|
||||||
|
* progress, which decides whether "Stop Active Work" is offered and whether a
|
||||||
|
* reload is blocked. A malformed marker is rejected rather than guessed at.
|
||||||
|
*/
|
||||||
|
function optionalActivityStartupMarker(record: Record<string, unknown>): boolean | undefined {
|
||||||
|
const value = record["startup"];
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (typeof value !== "boolean") throw new Error("Expected optional boolean field: startup");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
function requireSessionActivityPhase(record: Record<string, unknown>, key: string): SessionActivity["phase"] {
|
function requireSessionActivityPhase(record: Record<string, unknown>, key: string): SessionActivity["phase"] {
|
||||||
const value = requireString(record, key);
|
const value = requireString(record, key);
|
||||||
if (value !== "active" && value !== "idle" && value !== "error") throw new Error(`Expected session activity phase field: ${key}`);
|
if (value !== "active" && value !== "idle" && value !== "error") throw new Error(`Expected session activity phase field: ${key}`);
|
||||||
|
|||||||
@@ -435,6 +435,7 @@ export class PiWebApp extends LitElement {
|
|||||||
this.sessions.refreshSelectedSession(),
|
this.sessions.refreshSelectedSession(),
|
||||||
this.refreshMachineActivities(),
|
this.refreshMachineActivities(),
|
||||||
this.refreshWorkspaceDeletionRuns(),
|
this.refreshWorkspaceDeletionRuns(),
|
||||||
|
this.workspaces.refreshSelectedProjectTopology(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,6 +493,7 @@ export class PiWebApp extends LitElement {
|
|||||||
this.loadClientConfig(),
|
this.loadClientConfig(),
|
||||||
this.refreshWorkspaceDeletionRuns(),
|
this.refreshWorkspaceDeletionRuns(),
|
||||||
this.refreshCurrentWorkspaceSurface(),
|
this.refreshCurrentWorkspaceSurface(),
|
||||||
|
this.workspaces.refreshSelectedProjectTopology(),
|
||||||
]);
|
]);
|
||||||
this.schedulePiWebStatusRefresh();
|
this.schedulePiWebStatusRefresh();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1789,6 +1791,8 @@ export class PiWebApp extends LitElement {
|
|||||||
configureAuth: () => this.auth.openLogin(),
|
configureAuth: () => this.auth.openLogin(),
|
||||||
logoutAuth: () => this.auth.openLogout(),
|
logoutAuth: () => this.auth.openLogout(),
|
||||||
openThemePicker: () => { this.openThemeDialog(); },
|
openThemePicker: () => { this.openThemeDialog(); },
|
||||||
|
openModelPicker: () => this.openModelDialog(),
|
||||||
|
openThinkingLevelPicker: () => this.openThinkingDialog(),
|
||||||
selectMainView: (view) => { this.selectMainView(view); },
|
selectMainView: (view) => { this.selectMainView(view); },
|
||||||
selectWorkspaceTool: (tool) => { this.openWorkspaceTool(tool); },
|
selectWorkspaceTool: (tool) => { this.openWorkspaceTool(tool); },
|
||||||
openTerminal: (options) => { this.openTerminal(options); },
|
openTerminal: (options) => { this.openTerminal(options); },
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { WorkspaceController } from "../controllers/workspaceController";
|
||||||
|
import { PiWebApp } from "./PiWebApp";
|
||||||
|
|
||||||
|
type RefreshCallback = () => void | Promise<void>;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PiWebApp workspace topology refresh wiring", () => {
|
||||||
|
it("re-lists the selected project's workspaces on the browser-resume refresh", async () => {
|
||||||
|
const app = createApp();
|
||||||
|
stubBackgroundRefreshes(app);
|
||||||
|
const refreshTopology = spyOnTopologyRefresh(app);
|
||||||
|
|
||||||
|
await browserResumeRefresh(app)();
|
||||||
|
|
||||||
|
expect(refreshTopology).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-lists the selected project's workspaces on the plugin-facing app-data refresh", async () => {
|
||||||
|
const app = createApp();
|
||||||
|
stubBackgroundRefreshes(app);
|
||||||
|
const refreshTopology = spyOnTopologyRefresh(app);
|
||||||
|
|
||||||
|
await refreshAppData(app);
|
||||||
|
|
||||||
|
expect(refreshTopology).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still re-lists workspaces when a sibling refresh in the same resume batch fails", async () => {
|
||||||
|
const app = createApp();
|
||||||
|
stubBackgroundRefreshes(app);
|
||||||
|
failBackgroundRefresh(app, "refreshMachineActivities", new Error("machine activity unavailable"));
|
||||||
|
const refreshTopology = spyOnTopologyRefresh(app);
|
||||||
|
|
||||||
|
await expect(browserResumeRefresh(app)()).rejects.toThrow("machine activity unavailable");
|
||||||
|
expect(refreshTopology).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function createApp(): PiWebApp {
|
||||||
|
const storage = {
|
||||||
|
getItem: () => null,
|
||||||
|
setItem: () => undefined,
|
||||||
|
removeItem: () => undefined,
|
||||||
|
};
|
||||||
|
vi.stubGlobal("window", { location: { search: "" }, localStorage: storage });
|
||||||
|
return new PiWebApp();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the sibling refreshes that already have their own coverage so this test
|
||||||
|
* observes only whether the resume/app-data paths include workspace topology.
|
||||||
|
*/
|
||||||
|
function stubBackgroundRefreshes(app: PiWebApp): void {
|
||||||
|
const result = () => Promise.resolve();
|
||||||
|
for (const name of [
|
||||||
|
"renegotiateUnreadMachines",
|
||||||
|
"refreshMachineActivities",
|
||||||
|
"refreshWorkspaceDeletionRuns",
|
||||||
|
"loadClientConfig",
|
||||||
|
"refreshCurrentWorkspaceSurface",
|
||||||
|
"schedulePiWebStatusRefresh",
|
||||||
|
]) {
|
||||||
|
if (!Reflect.set(app, name, result)) throw new Error(`Could not replace PiWebApp.${name}`);
|
||||||
|
}
|
||||||
|
const sessions: unknown = Reflect.get(app, "sessions");
|
||||||
|
if (typeof sessions !== "object" || sessions === null || !Reflect.set(sessions, "refreshSelectedSession", result)) {
|
||||||
|
throw new Error("Could not replace the selected-session refresh");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function failBackgroundRefresh(app: PiWebApp, name: string, error: Error): void {
|
||||||
|
if (!Reflect.set(app, name, () => Promise.reject(error))) throw new Error(`Could not fail PiWebApp.${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function spyOnTopologyRefresh(app: PiWebApp) {
|
||||||
|
const controller: unknown = Reflect.get(app, "workspaces");
|
||||||
|
if (!(controller instanceof WorkspaceController)) throw new Error("PiWebApp WorkspaceController was unavailable");
|
||||||
|
return vi.spyOn(controller, "refreshSelectedProjectTopology").mockResolvedValue(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The exact callback `BrowserResumeController` invokes after a focus/visibility signal. */
|
||||||
|
function browserResumeRefresh(app: PiWebApp): RefreshCallback {
|
||||||
|
const resume: unknown = Reflect.get(app, "browserResume");
|
||||||
|
if (typeof resume !== "object" || resume === null) throw new Error("PiWebApp BrowserResumeController was unavailable");
|
||||||
|
const callbacks: unknown = Reflect.get(resume, "callbacks");
|
||||||
|
if (typeof callbacks !== "object" || callbacks === null) throw new Error("Browser resume callbacks were unavailable");
|
||||||
|
const refresh: unknown = Reflect.get(callbacks, "refreshAfterResume");
|
||||||
|
if (!isRefreshCallback(refresh)) throw new Error("The browser resume refresh callback was unavailable");
|
||||||
|
return refresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAppData(app: PiWebApp): Promise<void> {
|
||||||
|
const refresh: unknown = Reflect.get(app, "refreshAppData");
|
||||||
|
if (!isRefreshCallback(refresh)) throw new Error("PiWebApp.refreshAppData is not callable");
|
||||||
|
await refresh.call(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRefreshCallback(value: unknown): value is RefreshCallback {
|
||||||
|
return typeof value === "function";
|
||||||
|
}
|
||||||
@@ -16,6 +16,14 @@ describe("sessionRowActivityKind", () => {
|
|||||||
expect(sessionRowActivityKind(session("s"), { ...idle, isStreaming: true }, undefined, false)).toBe("session");
|
expect(sessionRowActivityKind(session("s"), { ...idle, isStreaming: true }, undefined, false)).toBe("session");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows no active-work indicator for a session that is only starting up", () => {
|
||||||
|
const startup = { sessionId: "s", phase: "active" as const, label: "Opening session", detail: "Starting the Pi session", at: "now", startup: true };
|
||||||
|
|
||||||
|
expect(sessionRowActivityKind(session("s"), idle, startup, false)).toBeUndefined();
|
||||||
|
// Ordinary activity is work and keeps its indicator.
|
||||||
|
expect(sessionRowActivityKind(session("s"), idle, { sessionId: "s", phase: "active", label: "running tool", at: "now" }, false)).toBe("session");
|
||||||
|
});
|
||||||
|
|
||||||
it("reports unread only while the session is idle", () => {
|
it("reports unread only while the session is idle", () => {
|
||||||
expect(sessionRowActivityKind(session("s"), idle, undefined, false, true)).toBe("unread");
|
expect(sessionRowActivityKind(session("s"), idle, undefined, false, true)).toBe("unread");
|
||||||
expect(sessionRowActivityKind(session("s"), { ...idle, isStreaming: true }, undefined, false, true)).toBe("session");
|
expect(sessionRowActivityKind(session("s"), { ...idle, isStreaming: true }, undefined, false, true)).toBe("session");
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { isSessionActive } from "../../../shared/activity";
|
||||||
import { initialAppState } from "../appState";
|
import { initialAppState } from "../appState";
|
||||||
import { SessionController } from "./sessionController";
|
import { SessionController } from "./sessionController";
|
||||||
import { defaultApi, deferred, emptyPage, FakeSocket, oldSession, runPendingAnimationFrames, sessionLookupId, status, workspace, type AppState, type SessionActivity, type SessionInfo } from "./sessionController.testSupport";
|
import { defaultApi, deferred, emptyPage, FakeSocket, oldSession, runPendingAnimationFrames, sessionLookupId, status, workspace, type AppState, type SessionActivity, type SessionInfo } from "./sessionController.testSupport";
|
||||||
|
|
||||||
const REMOTE_MACHINE = { id: "remote", name: "Remote", kind: "remote" as const, createdAt: "now", updatedAt: "now" };
|
|
||||||
|
|
||||||
function startupActivity(patch: Partial<SessionActivity> = {}): SessionActivity {
|
function startupActivity(patch: Partial<SessionActivity> = {}): SessionActivity {
|
||||||
return {
|
return {
|
||||||
sessionId: "backend-session",
|
sessionId: "backend-session",
|
||||||
@@ -12,6 +11,7 @@ function startupActivity(patch: Partial<SessionActivity> = {}): SessionActivity
|
|||||||
label: "Creating session",
|
label: "Creating session",
|
||||||
detail: "Starting the Pi session",
|
detail: "Starting the Pi session",
|
||||||
at: "2026-07-20T00:00:01.000Z",
|
at: "2026-07-20T00:00:01.000Z",
|
||||||
|
startup: true,
|
||||||
...patch,
|
...patch,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -21,8 +21,15 @@ function idleStartupActivity(): SessionActivity {
|
|||||||
return { sessionId: "backend-session", phase: "idle", label: "idle", at: "2026-07-20T00:00:02.000Z" };
|
return { sessionId: "backend-session", phase: "idle", label: "idle", at: "2026-07-20T00:00:02.000Z" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface StartCall {
|
||||||
|
cwd: string;
|
||||||
|
machineId: string | undefined;
|
||||||
|
startupToken: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function pendingStartController(state: { current: AppState }, api: Partial<typeof defaultApi> = {}) {
|
function pendingStartController(state: { current: AppState }, api: Partial<typeof defaultApi> = {}) {
|
||||||
const startRequest = deferred<SessionInfo>();
|
const startRequest = deferred<SessionInfo>();
|
||||||
|
const startCalls: StartCall[] = [];
|
||||||
const controller = new SessionController(
|
const controller = new SessionController(
|
||||||
() => state.current,
|
() => state.current,
|
||||||
(patch) => { state.current = { ...state.current, ...patch }; },
|
(patch) => { state.current = { ...state.current, ...patch }; },
|
||||||
@@ -31,7 +38,10 @@ function pendingStartController(state: { current: AppState }, api: Partial<typeo
|
|||||||
{
|
{
|
||||||
api: {
|
api: {
|
||||||
...defaultApi,
|
...defaultApi,
|
||||||
startSession: () => startRequest.promise,
|
startSession: (cwd: string, machineId?: string, startupToken?: string) => {
|
||||||
|
startCalls.push({ cwd, machineId, startupToken });
|
||||||
|
return startRequest.promise;
|
||||||
|
},
|
||||||
messages: () => Promise.resolve(emptyPage),
|
messages: () => Promise.resolve(emptyPage),
|
||||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||||
...api,
|
...api,
|
||||||
@@ -39,7 +49,7 @@ function pendingStartController(state: { current: AppState }, api: Partial<typeo
|
|||||||
socket: new FakeSocket(),
|
socket: new FakeSocket(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return { controller, startRequest };
|
return { controller, startRequest, startCalls };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("SessionController session startup progress", () => {
|
describe("SessionController session startup progress", () => {
|
||||||
@@ -51,7 +61,7 @@ describe("SessionController session startup progress", () => {
|
|||||||
const temporaryId = state.current.selectedSession?.id;
|
const temporaryId = state.current.selectedSession?.id;
|
||||||
if (temporaryId === undefined) throw new Error("Expected temporary session id");
|
if (temporaryId === undefined) throw new Error("Expected temporary session id");
|
||||||
|
|
||||||
controller.applyGlobalEvent({ type: "session.startup", cwd: workspace.path, activity: startupActivity() });
|
controller.applyGlobalEvent({ type: "session.startup", startupToken: temporaryId, activity: startupActivity() });
|
||||||
runPendingAnimationFrames();
|
runPendingAnimationFrames();
|
||||||
|
|
||||||
// The label changes while the user is waiting, before the start resolves,
|
// The label changes while the user is waiting, before the start resolves,
|
||||||
@@ -59,7 +69,7 @@ describe("SessionController session startup progress", () => {
|
|||||||
expect(state.current.activity).toMatchObject({ sessionId: temporaryId, phase: "active", label: "Creating session", detail: "Starting the Pi session" });
|
expect(state.current.activity).toMatchObject({ sessionId: temporaryId, phase: "active", label: "Creating session", detail: "Starting the Pi session" });
|
||||||
expect(state.current.sessionActivities[temporaryId]).toMatchObject({ detail: "Starting the Pi session" });
|
expect(state.current.sessionActivities[temporaryId]).toMatchObject({ detail: "Starting the Pi session" });
|
||||||
|
|
||||||
controller.applyGlobalEvent({ type: "session.startup", cwd: workspace.path, activity: startupActivity({ detail: "Loading session extensions" }) });
|
controller.applyGlobalEvent({ type: "session.startup", startupToken: temporaryId, activity: startupActivity({ detail: "Loading session extensions" }) });
|
||||||
runPendingAnimationFrames();
|
runPendingAnimationFrames();
|
||||||
|
|
||||||
expect(state.current.activity?.detail).toBe("Loading session extensions");
|
expect(state.current.activity?.detail).toBe("Loading session extensions");
|
||||||
@@ -68,6 +78,28 @@ describe("SessionController session startup progress", () => {
|
|||||||
await start;
|
await start;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps a pending create row's own appearance while it borrows the daemon's phase text", async () => {
|
||||||
|
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } };
|
||||||
|
const { controller, startRequest } = pendingStartController(state);
|
||||||
|
|
||||||
|
const start = controller.startSession();
|
||||||
|
const temporaryId = state.current.selectedSession?.id;
|
||||||
|
if (temporaryId === undefined) throw new Error("Expected temporary session id");
|
||||||
|
const beforeStartupProgress = isSessionActive(state.current.status, state.current.activity);
|
||||||
|
|
||||||
|
controller.applyGlobalEvent({ type: "session.startup", startupToken: temporaryId, activity: startupActivity() });
|
||||||
|
runPendingAnimationFrames();
|
||||||
|
|
||||||
|
// This row is the browser's own pending create, not a session being opened.
|
||||||
|
// Substituting the daemon's phase text into it must not silently change what
|
||||||
|
// the row reports about itself, or its indicator would blink off mid-create.
|
||||||
|
expect(state.current.activity?.phase).toBe("active");
|
||||||
|
expect(isSessionActive(state.current.status, state.current.activity)).toBe(beforeStartupProgress);
|
||||||
|
|
||||||
|
startRequest.resolve({ ...oldSession, id: "backend-session", path: "/tmp/backend-session.jsonl" });
|
||||||
|
await start;
|
||||||
|
});
|
||||||
|
|
||||||
it("restores the generic wording when the daemon has nothing left to attribute", async () => {
|
it("restores the generic wording when the daemon has nothing left to attribute", async () => {
|
||||||
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } };
|
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } };
|
||||||
const { controller, startRequest } = pendingStartController(state);
|
const { controller, startRequest } = pendingStartController(state);
|
||||||
@@ -75,10 +107,10 @@ describe("SessionController session startup progress", () => {
|
|||||||
const start = controller.startSession();
|
const start = controller.startSession();
|
||||||
const temporaryId = state.current.selectedSession?.id;
|
const temporaryId = state.current.selectedSession?.id;
|
||||||
if (temporaryId === undefined) throw new Error("Expected temporary session id");
|
if (temporaryId === undefined) throw new Error("Expected temporary session id");
|
||||||
controller.applyGlobalEvent({ type: "session.startup", cwd: workspace.path, activity: startupActivity() });
|
controller.applyGlobalEvent({ type: "session.startup", startupToken: temporaryId, activity: startupActivity() });
|
||||||
runPendingAnimationFrames();
|
runPendingAnimationFrames();
|
||||||
|
|
||||||
controller.applyGlobalEvent({ type: "session.startup", cwd: workspace.path, activity: idleStartupActivity() });
|
controller.applyGlobalEvent({ type: "session.startup", startupToken: temporaryId, activity: idleStartupActivity() });
|
||||||
runPendingAnimationFrames();
|
runPendingAnimationFrames();
|
||||||
|
|
||||||
expect(state.current.activity).toMatchObject({
|
expect(state.current.activity).toMatchObject({
|
||||||
@@ -98,7 +130,9 @@ describe("SessionController session startup progress", () => {
|
|||||||
|
|
||||||
const start = controller.startSession();
|
const start = controller.startSession();
|
||||||
await controller.send("queued while starting");
|
await controller.send("queued while starting");
|
||||||
controller.applyGlobalEvent({ type: "session.startup", cwd: workspace.path, activity: idleStartupActivity() });
|
const temporaryId = state.current.selectedSession?.id;
|
||||||
|
if (temporaryId === undefined) throw new Error("Expected temporary session id");
|
||||||
|
controller.applyGlobalEvent({ type: "session.startup", startupToken: temporaryId, activity: idleStartupActivity() });
|
||||||
runPendingAnimationFrames();
|
runPendingAnimationFrames();
|
||||||
|
|
||||||
expect(state.current.activity?.detail).toBe("1 queued message will send when the backend session is ready");
|
expect(state.current.activity?.detail).toBe("1 queued message will send when the backend session is ready");
|
||||||
@@ -119,7 +153,6 @@ describe("SessionController session startup progress", () => {
|
|||||||
|
|
||||||
controller.applyGlobalEvent({
|
controller.applyGlobalEvent({
|
||||||
type: "session.startup",
|
type: "session.startup",
|
||||||
cwd: oldSession.cwd,
|
|
||||||
activity: startupActivity({ sessionId: oldSession.id, label: "Opening session" }),
|
activity: startupActivity({ sessionId: oldSession.id, label: "Opening session" }),
|
||||||
});
|
});
|
||||||
runPendingAnimationFrames();
|
runPendingAnimationFrames();
|
||||||
@@ -127,6 +160,29 @@ describe("SessionController session startup progress", () => {
|
|||||||
expect(state.activity).toMatchObject({ sessionId: oldSession.id, label: "Opening session", detail: "Starting the Pi session" });
|
expect(state.activity).toMatchObject({ sessionId: oldSession.id, label: "Opening session", detail: "Starting the Pi session" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows an archived session's opening progress without reporting it as active work", () => {
|
||||||
|
const archived = { ...oldSession, id: "archived-session", archived: true, archivedAt: "2026-05-16T00:00:00.000Z" };
|
||||||
|
let state: AppState = { ...initialAppState(), selectedSession: archived, sessions: [archived] };
|
||||||
|
const controller = new SessionController(
|
||||||
|
() => state,
|
||||||
|
(patch) => { state = { ...state, ...patch }; },
|
||||||
|
() => undefined,
|
||||||
|
undefined,
|
||||||
|
{ socket: new FakeSocket() },
|
||||||
|
);
|
||||||
|
|
||||||
|
controller.applyGlobalEvent({
|
||||||
|
type: "session.startup",
|
||||||
|
activity: startupActivity({ sessionId: archived.id, label: "Opening session" }),
|
||||||
|
});
|
||||||
|
runPendingAnimationFrames();
|
||||||
|
|
||||||
|
// A read-only session cannot be worked on, so opening one must not make it
|
||||||
|
// look busy — while the text a waiting user reads still arrives.
|
||||||
|
expect(state.activity).toMatchObject({ sessionId: archived.id, label: "Opening session", detail: "Starting the Pi session" });
|
||||||
|
expect(isSessionActive(state.status, state.activity)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("gives an existing session's startup its own row rather than a pending start in the same workspace", async () => {
|
it("gives an existing session's startup its own row rather than a pending start in the same workspace", async () => {
|
||||||
const existing = { ...oldSession, id: "existing-session", cwd: workspace.path };
|
const existing = { ...oldSession, id: "existing-session", cwd: workspace.path };
|
||||||
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [existing] } };
|
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [existing] } };
|
||||||
@@ -136,12 +192,11 @@ describe("SessionController session startup progress", () => {
|
|||||||
const temporaryId = state.current.selectedSession?.id;
|
const temporaryId = state.current.selectedSession?.id;
|
||||||
if (temporaryId === undefined) throw new Error("Expected temporary session id");
|
if (temporaryId === undefined) throw new Error("Expected temporary session id");
|
||||||
|
|
||||||
// Opening an existing session in the same workspace publishes the same cwd as
|
// Opening an existing session in the same workspace carries no create token,
|
||||||
// the pending create. The known id is the proof of which row it belongs to, so
|
// so the known id is the only proof of which row it belongs to and the pending
|
||||||
// the pending row must keep its own wording instead of the other row's phase.
|
// row must keep its own wording instead of the other row's phase.
|
||||||
controller.applyGlobalEvent({
|
controller.applyGlobalEvent({
|
||||||
type: "session.startup",
|
type: "session.startup",
|
||||||
cwd: workspace.path,
|
|
||||||
activity: startupActivity({ sessionId: existing.id, label: "Opening session" }),
|
activity: startupActivity({ sessionId: existing.id, label: "Opening session" }),
|
||||||
});
|
});
|
||||||
runPendingAnimationFrames();
|
runPendingAnimationFrames();
|
||||||
@@ -153,7 +208,7 @@ describe("SessionController session startup progress", () => {
|
|||||||
await start;
|
await start;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the generic wording when the startup progress cannot be attributed to one row", async () => {
|
it("keeps the generic wording when no pending row's token matches the startup progress", async () => {
|
||||||
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } };
|
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } };
|
||||||
const { controller, startRequest } = pendingStartController(state);
|
const { controller, startRequest } = pendingStartController(state);
|
||||||
|
|
||||||
@@ -161,29 +216,54 @@ describe("SessionController session startup progress", () => {
|
|||||||
const temporaryId = state.current.selectedSession?.id;
|
const temporaryId = state.current.selectedSession?.id;
|
||||||
if (temporaryId === undefined) throw new Error("Expected temporary session id");
|
if (temporaryId === undefined) throw new Error("Expected temporary session id");
|
||||||
|
|
||||||
// Another workspace's startup.
|
// Another browser tab's create, or another workspace's: its token is one this
|
||||||
controller.applyGlobalEvent({ type: "session.startup", cwd: "/elsewhere", activity: startupActivity() });
|
// browser never minted, so there is no row here that it belongs to.
|
||||||
// The selected machine's socket is the only feed for these events, so a cwd
|
controller.applyGlobalEvent({ type: "session.startup", startupToken: "pending-session-9-other-tab", activity: startupActivity() });
|
||||||
// that matches while another machine is selected belongs to a different row.
|
// A session this browser has not been told about — an agent's spawned
|
||||||
state.current = { ...state.current, selectedMachine: REMOTE_MACHINE };
|
// subsession, say, whose `session.created` a pending create suppresses — is
|
||||||
controller.applyGlobalEvent({ type: "session.startup", cwd: workspace.path, activity: startupActivity() });
|
// opened rather than created, so it carries no token at all.
|
||||||
state.current = { ...state.current, selectedMachine: undefined };
|
controller.applyGlobalEvent({ type: "session.startup", activity: startupActivity({ sessionId: "foreign-session", label: "Opening session", detail: "Loading session extensions" }) });
|
||||||
runPendingAnimationFrames();
|
runPendingAnimationFrames();
|
||||||
|
|
||||||
expect(state.current.activity?.detail).toBe("Waiting for the backend session to be ready");
|
expect(state.current.activity?.detail).toBe("Waiting for the backend session to be ready");
|
||||||
|
expect(state.current.activity?.label).toBe("Creating session");
|
||||||
|
|
||||||
// A second concurrent start in the same workspace makes the target ambiguous,
|
startRequest.resolve({ ...oldSession, id: "backend-session", path: "/tmp/backend-session.jsonl" });
|
||||||
// so neither row is given a phase that might belong to the other.
|
await start;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives each of two concurrent creates only the progress its own token carries", async () => {
|
||||||
|
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } };
|
||||||
|
const { controller, startRequest } = pendingStartController(state);
|
||||||
|
|
||||||
|
const start = controller.startSession();
|
||||||
|
const firstId = state.current.selectedSession?.id;
|
||||||
const secondStart = controller.startSession();
|
const secondStart = controller.startSession();
|
||||||
controller.applyGlobalEvent({ type: "session.startup", cwd: workspace.path, activity: startupActivity() });
|
const secondId = state.current.selectedSession?.id;
|
||||||
|
if (firstId === undefined || secondId === undefined || firstId === secondId) throw new Error("Expected two distinct temporary session ids");
|
||||||
|
|
||||||
|
// Two creates in the same workspace are indistinguishable by workspace path;
|
||||||
|
// the token each request carried is what tells them apart.
|
||||||
|
controller.applyGlobalEvent({ type: "session.startup", startupToken: secondId, activity: startupActivity({ detail: "Loading session extensions" }) });
|
||||||
runPendingAnimationFrames();
|
runPendingAnimationFrames();
|
||||||
|
|
||||||
const secondTemporaryId = state.current.selectedSession?.id;
|
expect(state.current.sessionActivities[secondId]).toMatchObject({ sessionId: secondId, detail: "Loading session extensions" });
|
||||||
expect(secondTemporaryId).not.toBe(temporaryId);
|
expect(state.current.sessionActivities[firstId]?.detail).toBe("Waiting for the backend session to be ready");
|
||||||
expect(state.current.sessionActivities[temporaryId]?.detail).toBe("Waiting for the backend session to be ready");
|
|
||||||
expect(state.current.sessionActivities[secondTemporaryId ?? ""]?.detail).toBe("Waiting for the backend session to be ready");
|
|
||||||
|
|
||||||
startRequest.resolve({ ...oldSession, id: "backend-session", path: "/tmp/backend-session.jsonl" });
|
startRequest.resolve({ ...oldSession, id: "backend-session", path: "/tmp/backend-session.jsonl" });
|
||||||
await Promise.all([start, secondStart]);
|
await Promise.all([start, secondStart]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sends the pending row's own id as the create request's correlation token", async () => {
|
||||||
|
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } };
|
||||||
|
const { controller, startRequest, startCalls } = pendingStartController(state);
|
||||||
|
|
||||||
|
const start = controller.startSession();
|
||||||
|
const temporaryId = state.current.selectedSession?.id;
|
||||||
|
|
||||||
|
expect(startCalls).toEqual([{ cwd: workspace.path, machineId: "local", startupToken: temporaryId }]);
|
||||||
|
|
||||||
|
startRequest.resolve({ ...oldSession, id: "backend-session", path: "/tmp/backend-session.jsonl" });
|
||||||
|
await start;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ export class SessionController {
|
|||||||
this.pendingSessionStarts.set(pending.tempId, pending);
|
this.pendingSessionStarts.set(pending.tempId, pending);
|
||||||
this.insertAndSelectPendingSession(pending.session);
|
this.insertAndSelectPendingSession(pending.session);
|
||||||
try {
|
try {
|
||||||
const session = await this.api.startSession(workspace.path, machineId);
|
const session = await this.api.startSession(workspace.path, machineId, pending.tempId);
|
||||||
await this.resolvePendingSessionStart(pending.tempId, session);
|
await this.resolvePendingSessionStart(pending.tempId, session);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.failPendingSessionStart(pending.tempId, error);
|
this.failPendingSessionStart(pending.tempId, error);
|
||||||
@@ -1387,38 +1387,26 @@ export class SessionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Session startup progress arrives while the daemon is still constructing the
|
// Session startup progress arrives while the daemon is still constructing the
|
||||||
// session, so the target row is resolved by session id when the browser knows
|
// session, so the target row is resolved by exact identity only: a session id
|
||||||
// it and by workspace path when it does not: a pending start knows its cwd but
|
// the browser already knows (an open), else the correlation token this browser
|
||||||
// not the session id the daemon is creating. Once the row is resolved the
|
// minted for its own create and the daemon echoed back. Matching neither means
|
||||||
// progress goes through the normal activity buffer, so it renders exactly like
|
// the row is not one this browser shows — an agent's or another tab's session is
|
||||||
// any other activity and stays batched per frame.
|
// *deliberately* absent while a create is pending — so it is ignored rather than
|
||||||
|
// guessed at. A resolved row goes through the normal activity buffer, rendering
|
||||||
|
// like any other activity and staying batched per frame.
|
||||||
private queueStartupProgress(event: SessionStartupProgressEvent): void {
|
private queueStartupProgress(event: SessionStartupProgressEvent): void {
|
||||||
// A known session id is the strongest possible proof of the target, so it is
|
|
||||||
// checked first: while a create is pending in a workspace, an *existing*
|
|
||||||
// session in that same workspace can be opened too (another row selected,
|
|
||||||
// another tab, a subsession), and that open publishes the same cwd. Matching
|
|
||||||
// on cwd first would paint the pending row with another session's phase.
|
|
||||||
if (this.getState().sessions.some((session) => session.id === event.activity.sessionId)) {
|
if (this.getState().sessions.some((session) => session.id === event.activity.sessionId)) {
|
||||||
this.queueActivityUpdate(event.activity);
|
this.queueActivityUpdate(event.activity);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The id is unknown, so this can only be a create whose id the browser has
|
const pending = event.startupToken === undefined ? undefined : this.pendingSessionStarts.get(event.startupToken);
|
||||||
// not been told yet. Route it by workspace path, the one key both sides share.
|
if (pending === undefined || pending.discarded) return;
|
||||||
const pending = this.startupProgressPendingStart(event.cwd);
|
|
||||||
if (pending === undefined) return;
|
|
||||||
// An idle startup phase means the daemon has nothing left to attribute, so
|
// An idle startup phase means the daemon has nothing left to attribute, so
|
||||||
// restore this row's own generic wording rather than clearing the text of a
|
// restore this row's own generic wording rather than clearing the text of a
|
||||||
// creation request that has not returned yet.
|
// creation request that has not returned yet.
|
||||||
this.queueActivityUpdate(event.activity.phase === "idle"
|
this.queueActivityUpdate(event.activity.phase === "idle"
|
||||||
? creatingPendingSessionActivity(pending.tempId, pending.queuedSends.length)
|
? creatingPendingSessionActivity(pending.tempId, pending.queuedSends.length)
|
||||||
: { ...event.activity, sessionId: pending.tempId });
|
: pendingStartActivity(event.activity, pending.tempId));
|
||||||
}
|
|
||||||
|
|
||||||
private startupProgressPendingStart(cwd: string): PendingSessionStart | undefined {
|
|
||||||
const machineId = selectedMachineId(this.getState());
|
|
||||||
const matches = Array.from(this.pendingSessionStarts.values())
|
|
||||||
.filter((pending) => pending.cwd === cwd && pending.machineId === machineId && !pending.discarded);
|
|
||||||
return matches.length === 1 ? matches[0] : undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private schedulePendingFlush(): void {
|
private schedulePendingFlush(): void {
|
||||||
@@ -1519,6 +1507,24 @@ function isClientPendingStartSessionInfo(session: SessionInfo | undefined): sess
|
|||||||
return session !== undefined && "clientPendingStart" in session && session.clientPendingStart === true;
|
return session !== undefined && "clientPendingStart" in session && session.clientPendingStart === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-label a startup report onto the browser's own pending create row.
|
||||||
|
*
|
||||||
|
* The daemon's startup marker is dropped: this row stands for a create the user
|
||||||
|
* asked for and is waiting on, which the browser has always reported as active
|
||||||
|
* work in progress, rather than for a session the daemon is opening. Only the
|
||||||
|
* phase text is borrowed, so the row keeps its own `creating · ` appearance.
|
||||||
|
*/
|
||||||
|
function pendingStartActivity(activity: SessionActivity, sessionId: string): SessionActivity {
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
phase: activity.phase,
|
||||||
|
label: activity.label,
|
||||||
|
...(activity.detail === undefined ? {} : { detail: activity.detail }),
|
||||||
|
at: activity.at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function creatingPendingSessionActivity(sessionId: string, queuedCount = 0): SessionActivity {
|
function creatingPendingSessionActivity(sessionId: string, queuedCount = 0): SessionActivity {
|
||||||
return {
|
return {
|
||||||
sessionId,
|
sessionId,
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { AppState } from "../appState";
|
||||||
|
import { initialAppState } from "../appState";
|
||||||
|
import type { Machine, Project, SessionInfo, Workspace } from "../api";
|
||||||
|
import type { SessionController } from "./sessionController";
|
||||||
|
import { WorkspaceController } from "./workspaceController";
|
||||||
|
|
||||||
|
function machine(id: string): Machine {
|
||||||
|
return { id, name: id, kind: id === "local" ? "local" : "remote", createdAt: "now", updatedAt: "now" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function project(id: string, path: string): Project {
|
||||||
|
return { id, name: id, path, createdAt: "now" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function workspace(projectId: string, path: string, options: { isMain?: boolean } = {}): Workspace {
|
||||||
|
return { id: path, projectId, path, label: path, isMain: options.isMain ?? false, isGitRepo: true, isGitWorktree: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function session(cwd: string, id = "s1"): SessionInfo {
|
||||||
|
return { id, cwd, path: `${cwd}/.sessions/${id}`, created: "now", modified: "now", messageCount: 1, firstMessage: "hello" };
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoadWorkspaces = (projectId: string, machineId?: string) => Promise<Workspace[]>;
|
||||||
|
|
||||||
|
interface Harness {
|
||||||
|
controller: WorkspaceController;
|
||||||
|
state: () => AppState;
|
||||||
|
clearActiveSession: ReturnType<typeof vi.fn>;
|
||||||
|
updateUrl: ReturnType<typeof vi.fn>;
|
||||||
|
backgroundErrors: { message: string; error: unknown }[];
|
||||||
|
setState: (patch: Partial<AppState>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function harness(initial: Partial<AppState>, loadWorkspaces: LoadWorkspaces): Harness {
|
||||||
|
let state: AppState = { ...initialAppState(), ...initial };
|
||||||
|
const setState = (patch: Partial<AppState>) => { state = { ...state, ...patch }; };
|
||||||
|
const clearActiveSession = vi.fn();
|
||||||
|
const sessions: Pick<SessionController, "clearActiveSession" | "preferredSession" | "selectSession"> = {
|
||||||
|
clearActiveSession,
|
||||||
|
preferredSession: vi.fn(),
|
||||||
|
selectSession: vi.fn(),
|
||||||
|
};
|
||||||
|
const updateUrl = vi.fn();
|
||||||
|
const backgroundErrors: { message: string; error: unknown }[] = [];
|
||||||
|
const controller = new WorkspaceController(
|
||||||
|
() => state,
|
||||||
|
setState,
|
||||||
|
updateUrl,
|
||||||
|
sessions,
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
api: { workspaces: loadWorkspaces, sessions: vi.fn<(path: string, machineId?: string) => Promise<SessionInfo[]>>().mockResolvedValue([]) },
|
||||||
|
onBackgroundError: (message, error) => { backgroundErrors.push({ message, error }); },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return { controller, state: () => state, clearActiveSession, updateUrl, backgroundErrors, setState };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("WorkspaceController.refreshSelectedProjectTopology", () => {
|
||||||
|
it("surfaces a worktree created outside PI WEB in both the selected list and the per-project cache", async () => {
|
||||||
|
const repo = project("p1", "/repo");
|
||||||
|
const main = workspace(repo.id, repo.path, { isMain: true });
|
||||||
|
const created = workspace(repo.id, "/repo-feature");
|
||||||
|
const loadWorkspaces = vi.fn().mockResolvedValue([main, created]);
|
||||||
|
const test = harness(
|
||||||
|
{
|
||||||
|
selectedMachine: machine("local"),
|
||||||
|
projects: [repo],
|
||||||
|
selectedProject: repo,
|
||||||
|
selectedWorkspace: main,
|
||||||
|
workspaces: [main],
|
||||||
|
workspacesByProjectId: { [repo.id]: [main] },
|
||||||
|
},
|
||||||
|
loadWorkspaces,
|
||||||
|
);
|
||||||
|
|
||||||
|
await test.controller.refreshSelectedProjectTopology();
|
||||||
|
|
||||||
|
expect(loadWorkspaces).toHaveBeenCalledWith(repo.id, "local");
|
||||||
|
expect(test.state().workspaces).toEqual([main, created]);
|
||||||
|
expect(test.state().workspacesByProjectId[repo.id]).toEqual([main, created]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the selection and workspace-scoped state when the selected workspace still exists", async () => {
|
||||||
|
const repo = project("p1", "/repo");
|
||||||
|
const main = workspace(repo.id, repo.path, { isMain: true });
|
||||||
|
const selected = workspace(repo.id, "/repo-feature");
|
||||||
|
const loadWorkspaces = vi.fn().mockResolvedValue([main, selected, workspace(repo.id, "/repo-other")]);
|
||||||
|
const test = harness(
|
||||||
|
{
|
||||||
|
selectedMachine: machine("local"),
|
||||||
|
projects: [repo],
|
||||||
|
selectedProject: repo,
|
||||||
|
selectedWorkspace: selected,
|
||||||
|
workspaces: [main, selected],
|
||||||
|
workspacesByProjectId: { [repo.id]: [main, selected] },
|
||||||
|
selectedSession: session(selected.path),
|
||||||
|
sessions: [session(selected.path)],
|
||||||
|
selectedFilePath: "src/index.ts",
|
||||||
|
expandedDirs: { src: [] },
|
||||||
|
selectedTerminalId: "t1",
|
||||||
|
},
|
||||||
|
loadWorkspaces,
|
||||||
|
);
|
||||||
|
const before = test.state();
|
||||||
|
|
||||||
|
await test.controller.refreshSelectedProjectTopology();
|
||||||
|
|
||||||
|
const after = test.state();
|
||||||
|
expect(after.selectedWorkspace).toBe(selected);
|
||||||
|
expect(after.selectedSession).toBe(before.selectedSession);
|
||||||
|
expect(after.sessions).toBe(before.sessions);
|
||||||
|
expect(after.selectedFilePath).toBe("src/index.ts");
|
||||||
|
expect(after.expandedDirs).toBe(before.expandedDirs);
|
||||||
|
expect(after.selectedTerminalId).toBe("t1");
|
||||||
|
expect(test.clearActiveSession).not.toHaveBeenCalled();
|
||||||
|
expect(test.updateUrl).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the selection alone when the selected workspace disappeared", async () => {
|
||||||
|
const repo = project("p1", "/repo");
|
||||||
|
const main = workspace(repo.id, repo.path, { isMain: true });
|
||||||
|
const removed = workspace(repo.id, "/repo-gone");
|
||||||
|
const loadWorkspaces = vi.fn().mockResolvedValue([main]);
|
||||||
|
const test = harness(
|
||||||
|
{
|
||||||
|
selectedMachine: machine("local"),
|
||||||
|
projects: [repo],
|
||||||
|
selectedProject: repo,
|
||||||
|
selectedWorkspace: removed,
|
||||||
|
workspaces: [main, removed],
|
||||||
|
workspacesByProjectId: { [repo.id]: [main, removed] },
|
||||||
|
},
|
||||||
|
loadWorkspaces,
|
||||||
|
);
|
||||||
|
|
||||||
|
await test.controller.refreshSelectedProjectTopology();
|
||||||
|
|
||||||
|
expect(test.state().selectedWorkspace).toBe(removed);
|
||||||
|
expect(test.state().workspaces).toEqual([main]);
|
||||||
|
expect(test.clearActiveSession).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("discards a response for a project the user has since left", async () => {
|
||||||
|
const repo = project("p1", "/repo");
|
||||||
|
const other = project("p2", "/other");
|
||||||
|
const main = workspace(repo.id, repo.path, { isMain: true });
|
||||||
|
const created = workspace(repo.id, "/repo-feature");
|
||||||
|
let resolveWorkspaces: ((workspaces: Workspace[]) => void) | undefined;
|
||||||
|
const loadWorkspaces = vi.fn().mockReturnValue(new Promise<Workspace[]>((resolve) => { resolveWorkspaces = resolve; }));
|
||||||
|
const test = harness(
|
||||||
|
{
|
||||||
|
selectedMachine: machine("local"),
|
||||||
|
projects: [repo, other],
|
||||||
|
selectedProject: repo,
|
||||||
|
selectedWorkspace: main,
|
||||||
|
workspaces: [main],
|
||||||
|
workspacesByProjectId: { [repo.id]: [main] },
|
||||||
|
},
|
||||||
|
loadWorkspaces,
|
||||||
|
);
|
||||||
|
|
||||||
|
const pending = test.controller.refreshSelectedProjectTopology();
|
||||||
|
test.setState({ selectedProject: other, selectedWorkspace: undefined, workspaces: [] });
|
||||||
|
resolveWorkspaces?.([main, created]);
|
||||||
|
await pending;
|
||||||
|
|
||||||
|
expect(test.state().workspaces).toEqual([]);
|
||||||
|
expect(test.state().workspacesByProjectId[repo.id]).toEqual([main]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("discards a response after the selected machine changed", async () => {
|
||||||
|
const repo = project("p1", "/repo");
|
||||||
|
const main = workspace(repo.id, repo.path, { isMain: true });
|
||||||
|
let resolveWorkspaces: ((workspaces: Workspace[]) => void) | undefined;
|
||||||
|
const loadWorkspaces = vi.fn().mockReturnValue(new Promise<Workspace[]>((resolve) => { resolveWorkspaces = resolve; }));
|
||||||
|
const test = harness(
|
||||||
|
{
|
||||||
|
selectedMachine: machine("local"),
|
||||||
|
projects: [repo],
|
||||||
|
selectedProject: repo,
|
||||||
|
selectedWorkspace: main,
|
||||||
|
workspaces: [main],
|
||||||
|
workspacesByProjectId: { [repo.id]: [main] },
|
||||||
|
},
|
||||||
|
loadWorkspaces,
|
||||||
|
);
|
||||||
|
|
||||||
|
const pending = test.controller.refreshSelectedProjectTopology();
|
||||||
|
test.setState({ selectedMachine: machine("remote") });
|
||||||
|
resolveWorkspaces?.([main, workspace(repo.id, "/repo-feature")]);
|
||||||
|
await pending;
|
||||||
|
|
||||||
|
expect(test.state().workspaces).toEqual([main]);
|
||||||
|
expect(test.state().workspacesByProjectId[repo.id]).toEqual([main]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a failed refresh to the background error sink without painting an error banner", async () => {
|
||||||
|
const repo = project("p1", "/repo");
|
||||||
|
const main = workspace(repo.id, repo.path, { isMain: true });
|
||||||
|
const failure = new Error("git worktree list failed");
|
||||||
|
const loadWorkspaces = vi.fn().mockRejectedValue(failure);
|
||||||
|
const test = harness(
|
||||||
|
{
|
||||||
|
selectedMachine: machine("local"),
|
||||||
|
projects: [repo],
|
||||||
|
selectedProject: repo,
|
||||||
|
selectedWorkspace: main,
|
||||||
|
workspaces: [main],
|
||||||
|
workspacesByProjectId: { [repo.id]: [main] },
|
||||||
|
},
|
||||||
|
loadWorkspaces,
|
||||||
|
);
|
||||||
|
|
||||||
|
await test.controller.refreshSelectedProjectTopology();
|
||||||
|
|
||||||
|
expect(test.state().error).toBe("");
|
||||||
|
expect(test.state().workspaces).toEqual([main]);
|
||||||
|
expect(test.backgroundErrors).toEqual([{ message: `Failed to refresh workspaces for project ${repo.id} on local`, error: failure }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-points the selected workspace at refreshed metadata when its branch changed outside PI WEB", async () => {
|
||||||
|
const repo = project("p1", "/repo");
|
||||||
|
const main = workspace(repo.id, repo.path, { isMain: true });
|
||||||
|
const selected = { ...workspace(repo.id, "/repo-feature"), label: "feature-a", branch: "feature-a" };
|
||||||
|
const switched = { ...selected, label: "feature-b", branch: "feature-b" };
|
||||||
|
const loadWorkspaces = vi.fn().mockResolvedValue([main, switched]);
|
||||||
|
const test = harness(
|
||||||
|
{
|
||||||
|
selectedMachine: machine("local"),
|
||||||
|
projects: [repo],
|
||||||
|
selectedProject: repo,
|
||||||
|
selectedWorkspace: selected,
|
||||||
|
workspaces: [main, selected],
|
||||||
|
workspacesByProjectId: { [repo.id]: [main, selected] },
|
||||||
|
selectedSession: session(selected.path),
|
||||||
|
},
|
||||||
|
loadWorkspaces,
|
||||||
|
);
|
||||||
|
|
||||||
|
await test.controller.refreshSelectedProjectTopology();
|
||||||
|
|
||||||
|
// Same workspace (same id/path), so the session must survive; only the stale label moves.
|
||||||
|
expect(test.state().selectedWorkspace).toEqual(switched);
|
||||||
|
expect(test.state().selectedWorkspace?.id).toBe(selected.id);
|
||||||
|
expect(test.state().selectedSession).toBeDefined();
|
||||||
|
expect(test.clearActiveSession).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the selected workspace object untouched when the refresh returns identical metadata", async () => {
|
||||||
|
const repo = project("p1", "/repo");
|
||||||
|
const main = workspace(repo.id, repo.path, { isMain: true });
|
||||||
|
const selected = workspace(repo.id, "/repo-feature");
|
||||||
|
// A fresh, equal object, exactly what a real HTTP response produces every resume.
|
||||||
|
const loadWorkspaces = vi.fn().mockResolvedValue([{ ...main }, { ...selected }]);
|
||||||
|
const test = harness(
|
||||||
|
{
|
||||||
|
selectedMachine: machine("local"),
|
||||||
|
projects: [repo],
|
||||||
|
selectedProject: repo,
|
||||||
|
selectedWorkspace: selected,
|
||||||
|
workspaces: [main, selected],
|
||||||
|
workspacesByProjectId: { [repo.id]: [main, selected] },
|
||||||
|
},
|
||||||
|
loadWorkspaces,
|
||||||
|
);
|
||||||
|
|
||||||
|
await test.controller.refreshSelectedProjectTopology();
|
||||||
|
|
||||||
|
// Identity preserved: an unchanged resume must not churn selected-workspace identity
|
||||||
|
// into state, or every focus would re-render surfaces keyed on this object.
|
||||||
|
expect(test.state().selectedWorkspace).toBe(selected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serializes overlapping refreshes so an earlier response cannot overwrite a newer list", async () => {
|
||||||
|
const repo = project("p1", "/repo");
|
||||||
|
const main = workspace(repo.id, repo.path, { isMain: true });
|
||||||
|
const created = workspace(repo.id, "/repo-feature");
|
||||||
|
const gates: ((workspaces: Workspace[]) => void)[] = [];
|
||||||
|
let inFlight = 0;
|
||||||
|
let maxInFlight = 0;
|
||||||
|
const loadWorkspaces = vi.fn(() => {
|
||||||
|
inFlight += 1;
|
||||||
|
maxInFlight = Math.max(maxInFlight, inFlight);
|
||||||
|
return new Promise<Workspace[]>((resolve) => {
|
||||||
|
gates.push((workspaces) => { inFlight -= 1; resolve(workspaces); });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const test = harness(
|
||||||
|
{
|
||||||
|
selectedMachine: machine("local"),
|
||||||
|
projects: [repo],
|
||||||
|
selectedProject: repo,
|
||||||
|
selectedWorkspace: main,
|
||||||
|
workspaces: [main],
|
||||||
|
workspacesByProjectId: { [repo.id]: [main] },
|
||||||
|
},
|
||||||
|
loadWorkspaces,
|
||||||
|
);
|
||||||
|
|
||||||
|
const resumeRefresh = test.controller.refreshSelectedProjectTopology();
|
||||||
|
await Promise.resolve();
|
||||||
|
const appDataRefresh = test.controller.refreshSelectedProjectTopology();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
// The second caller does not open its own request while the first is in flight; it gets
|
||||||
|
// one trailing pass afterwards. Without this, two responses race and the slower-but-older
|
||||||
|
// one can land last, making a just-created worktree disappear again.
|
||||||
|
expect(maxInFlight).toBe(1);
|
||||||
|
gates[0]?.([main]);
|
||||||
|
await vi.waitFor(() => { expect(gates).toHaveLength(2); });
|
||||||
|
gates[1]?.([main, created]);
|
||||||
|
await Promise.all([resumeRefresh, appDataRefresh]);
|
||||||
|
|
||||||
|
// The last response wins, so the newly created worktree stays visible.
|
||||||
|
expect(test.state().workspaces).toEqual([main, created]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not request anything when no project is selected", async () => {
|
||||||
|
const loadWorkspaces = vi.fn();
|
||||||
|
const test = harness({ selectedMachine: machine("local") }, loadWorkspaces);
|
||||||
|
|
||||||
|
await test.controller.refreshSelectedProjectTopology();
|
||||||
|
|
||||||
|
expect(loadWorkspaces).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,17 +1,21 @@
|
|||||||
import { api as defaultApi, type Project, type Workspace } from "../api";
|
import { api as defaultApi, type Project, type Workspace } from "../api";
|
||||||
import { resetWorkspaceScopedState } from "../appState";
|
import { resetWorkspaceScopedState, type AppState } from "../appState";
|
||||||
import { mergeCachedNewSessions } from "../cachedNewSessions";
|
import { mergeCachedNewSessions } from "../cachedNewSessions";
|
||||||
import { machineProjectKey } from "../machineKeys";
|
import { machineProjectKey } from "../machineKeys";
|
||||||
import { selectedMachineId, type GetState, type RouteTarget, type SetState, type UpdateUrl } from "./types";
|
import { selectedMachineId, type GetState, type RouteTarget, type SetState, type UpdateUrl } from "./types";
|
||||||
import type { SessionController } from "./sessionController";
|
import type { SessionController } from "./sessionController";
|
||||||
|
import { TrailingRefreshCoordinator } from "./trailingRefreshCoordinator";
|
||||||
import { InMemoryWorkspaceSelectionMemory, selectPreferredWorkspace, type WorkspaceSelectionMemory } from "./workspaceSelection";
|
import { InMemoryWorkspaceSelectionMemory, selectPreferredWorkspace, type WorkspaceSelectionMemory } from "./workspaceSelection";
|
||||||
|
|
||||||
export interface WorkspaceControllerDependencies {
|
export interface WorkspaceControllerDependencies {
|
||||||
api?: Pick<typeof defaultApi, "sessions" | "workspaces">;
|
api?: Pick<typeof defaultApi, "sessions" | "workspaces">;
|
||||||
|
onBackgroundError?: (message: string, error: unknown) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class WorkspaceController {
|
export class WorkspaceController {
|
||||||
private readonly api: Pick<typeof defaultApi, "sessions" | "workspaces">;
|
private readonly api: Pick<typeof defaultApi, "sessions" | "workspaces">;
|
||||||
|
private readonly onBackgroundError: (message: string, error: unknown) => void;
|
||||||
|
private readonly topologyRefreshes = new TrailingRefreshCoordinator<string>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly getState: GetState,
|
private readonly getState: GetState,
|
||||||
@@ -22,6 +26,7 @@ export class WorkspaceController {
|
|||||||
deps: WorkspaceControllerDependencies = {},
|
deps: WorkspaceControllerDependencies = {},
|
||||||
) {
|
) {
|
||||||
this.api = deps.api ?? defaultApi;
|
this.api = deps.api ?? defaultApi;
|
||||||
|
this.onBackgroundError = deps.onBackgroundError ?? ((message, error) => { console.warn(message, error); });
|
||||||
}
|
}
|
||||||
|
|
||||||
clearSelection(options?: { updateUrl?: boolean | undefined }) {
|
clearSelection(options?: { updateUrl?: boolean | undefined }) {
|
||||||
@@ -78,6 +83,40 @@ export class WorkspaceController {
|
|||||||
return workspaces;
|
return workspaces;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-lists the selected project's workspaces so worktrees created or removed outside
|
||||||
|
* PI WEB become visible, without disturbing the current selection.
|
||||||
|
*
|
||||||
|
* Deliberately never routes through `selectWorkspace`: that has no already-selected
|
||||||
|
* guard, so re-picking the same workspace would still call `clearActiveSession()` and
|
||||||
|
* `resetWorkspaceScopedState()`, closing the session socket and blanking chat, file
|
||||||
|
* tree, git status, and terminal selection. Callers run this on every browser resume,
|
||||||
|
* so applying the list through `applyProjectWorkspaces` alone is the invariant.
|
||||||
|
*
|
||||||
|
* If the selected workspace disappeared, the selection is left alone: the user is
|
||||||
|
* working there and the existing deletion path owns recovery.
|
||||||
|
*/
|
||||||
|
async refreshSelectedProjectTopology(): Promise<void> {
|
||||||
|
const state = this.getState();
|
||||||
|
const project = state.selectedProject;
|
||||||
|
if (project === undefined) return;
|
||||||
|
const machineId = selectedMachineId(state);
|
||||||
|
// Callers are independent (browser resume and the plugin-facing app refresh), so two
|
||||||
|
// refreshes for the same machine+project can overlap. Sharing one request keeps a slow
|
||||||
|
// earlier response from landing last and overwriting a newer list, which would make a
|
||||||
|
// just-created worktree disappear again.
|
||||||
|
await this.topologyRefreshes.request(machineProjectKey(machineId, project.id), async () => {
|
||||||
|
try {
|
||||||
|
const workspaces = await this.api.workspaces(project.id, machineId);
|
||||||
|
const current = this.getState();
|
||||||
|
if (selectedMachineId(current) !== machineId || current.selectedProject?.id !== project.id) return;
|
||||||
|
this.applyProjectWorkspaces(project.id, workspaces);
|
||||||
|
} catch (error) {
|
||||||
|
this.onBackgroundError(`Failed to refresh workspaces for project ${project.id} on ${machineId}`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async refreshAfterWorkspaceDeleted(projectId: string, workspaceId: string): Promise<void> {
|
async refreshAfterWorkspaceDeleted(projectId: string, workspaceId: string): Promise<void> {
|
||||||
const workspaces = await this.refreshProjectWorkspaces(projectId);
|
const workspaces = await this.refreshProjectWorkspaces(projectId);
|
||||||
const state = this.getState();
|
const state = this.getState();
|
||||||
@@ -91,8 +130,26 @@ export class WorkspaceController {
|
|||||||
private applyProjectWorkspaces(projectId: string, workspaces: Workspace[]): void {
|
private applyProjectWorkspaces(projectId: string, workspaces: Workspace[]): void {
|
||||||
const state = this.getState();
|
const state = this.getState();
|
||||||
const workspacesByProjectId = { ...state.workspacesByProjectId, [projectId]: workspaces };
|
const workspacesByProjectId = { ...state.workspacesByProjectId, [projectId]: workspaces };
|
||||||
if (state.selectedProject?.id === projectId) this.setState({ workspaces, workspacesByProjectId });
|
if (state.selectedProject?.id !== projectId) {
|
||||||
else this.setState({ workspacesByProjectId });
|
this.setState({ workspacesByProjectId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.setState({ workspaces, workspacesByProjectId, ...this.refreshedSelection(state.selectedWorkspace, workspaces) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-points `selectedWorkspace` at its refreshed entry when metadata changed outside PI WEB
|
||||||
|
* (a branch switched in the worktree, say), so the workspace list and the surfaces that read
|
||||||
|
* the selected workspace cannot disagree. Keyed by id, which is derived from the path, so
|
||||||
|
* this never changes *which* workspace is selected and never triggers the session/terminal
|
||||||
|
* teardown in `handleWorkspaceChange`. Returns nothing when the entry is gone or unchanged,
|
||||||
|
* so an unchanged refresh does not churn object identity into state.
|
||||||
|
*/
|
||||||
|
private refreshedSelection(selected: Workspace | undefined, workspaces: Workspace[]): Pick<AppState, "selectedWorkspace"> | undefined {
|
||||||
|
if (selected === undefined) return undefined;
|
||||||
|
const refreshed = workspaces.find((candidate) => candidate.id === selected.id);
|
||||||
|
if (refreshed === undefined || sameWorkspaceMetadata(selected, refreshed)) return undefined;
|
||||||
|
return { selectedWorkspace: refreshed };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,3 +161,12 @@ function selectFallbackWorkspace(workspaces: Workspace[]): Workspace | undefined
|
|||||||
return workspaces.find((workspace) => workspace.isMain) ?? workspaces[0];
|
return workspaces.find((workspace) => workspace.isMain) ?? workspaces[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sameWorkspaceMetadata(left: Workspace, right: Workspace): boolean {
|
||||||
|
return left.path === right.path
|
||||||
|
&& left.label === right.label
|
||||||
|
&& left.branch === right.branch
|
||||||
|
&& left.isMain === right.isMain
|
||||||
|
&& left.isGitRepo === right.isGitRepo
|
||||||
|
&& left.isGitWorktree === right.isGitWorktree;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -167,6 +167,22 @@ export function createCoreActions(): PluginAction[] {
|
|||||||
enabled: hasWorkspace,
|
enabled: hasWorkspace,
|
||||||
run: (context) => context.startSession(),
|
run: (context) => context.startSession(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "model.select",
|
||||||
|
title: "Select Model",
|
||||||
|
description: "Choose the model for the selected session",
|
||||||
|
group: "Session",
|
||||||
|
enabled: hasSelectableSession,
|
||||||
|
run: (context) => context.openModelPicker(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "thinking.select",
|
||||||
|
title: "Select Thinking Level",
|
||||||
|
description: "Choose the thinking level for the selected session",
|
||||||
|
group: "Session",
|
||||||
|
enabled: hasSelectableSession,
|
||||||
|
run: (context) => context.openThinkingLevelPicker(),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "session.archive",
|
id: "session.archive",
|
||||||
title: "Archive Session",
|
title: "Archive Session",
|
||||||
@@ -216,6 +232,11 @@ function hasDeletableWorkspace(context: { state: AppState }): boolean {
|
|||||||
return workspace !== undefined && workspace.isGitWorktree && !workspace.isMain && !isWorkspaceDeletionPending(context.state, workspace);
|
return workspace !== undefined && workspace.isGitWorktree && !workspace.isMain && !isWorkspaceDeletionPending(context.state, workspace);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasSelectableSession(context: { state: AppState }): boolean {
|
||||||
|
const session = context.state.selectedSession;
|
||||||
|
return session !== undefined && session.archived !== true;
|
||||||
|
}
|
||||||
|
|
||||||
function hasArchivableSession(context: { state: AppState }): boolean {
|
function hasArchivableSession(context: { state: AppState }): boolean {
|
||||||
return isArchivableSessionInfo(context.state.selectedSession, context.state.status, sessionPersistenceOptions(context.state));
|
return isArchivableSessionInfo(context.state.selectedSession, context.state.status, sessionPersistenceOptions(context.state));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ function createContext(statePatch: Partial<AppState> = {}) {
|
|||||||
configureAuth: vi.fn(() => { calls.push("configureAuth"); }),
|
configureAuth: vi.fn(() => { calls.push("configureAuth"); }),
|
||||||
logoutAuth: vi.fn(() => { calls.push("logoutAuth"); }),
|
logoutAuth: vi.fn(() => { calls.push("logoutAuth"); }),
|
||||||
openThemePicker: vi.fn(() => { calls.push("openThemePicker"); }),
|
openThemePicker: vi.fn(() => { calls.push("openThemePicker"); }),
|
||||||
|
openModelPicker: vi.fn(() => { calls.push("openModelPicker"); }),
|
||||||
|
openThinkingLevelPicker: vi.fn(() => { calls.push("openThinkingLevelPicker"); }),
|
||||||
selectMainView: vi.fn((view: AppState["mainView"]) => { calls.push(`selectMainView:${view}`); }),
|
selectMainView: vi.fn((view: AppState["mainView"]) => { calls.push(`selectMainView:${view}`); }),
|
||||||
selectWorkspaceTool: vi.fn((tool: AppState["workspaceTool"]) => { calls.push(`selectWorkspaceTool:${tool}`); }),
|
selectWorkspaceTool: vi.fn((tool: AppState["workspaceTool"]) => { calls.push(`selectWorkspaceTool:${tool}`); }),
|
||||||
openTerminal: vi.fn((options?: { terminalId?: string | undefined }) => { calls.push(`openTerminal:${options?.terminalId ?? ""}`); }),
|
openTerminal: vi.fn((options?: { terminalId?: string | undefined }) => { calls.push(`openTerminal:${options?.terminalId ?? ""}`); }),
|
||||||
@@ -249,6 +251,25 @@ describe("PluginRegistry", () => {
|
|||||||
expect(busy.find((action) => action.id === "core:session.reload")?.enabled).toBe(false);
|
expect(busy.find((action) => action.id === "core:session.reload")?.enabled).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("treats a session that is only starting up as having no work to stop or block", () => {
|
||||||
|
const registry = new PluginRegistry();
|
||||||
|
registry.register({ id: "core", plugin: corePlugin });
|
||||||
|
const reloadRuntime = { local: { machineId: "local", ok: true as const, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.sessionsPersistedState] } };
|
||||||
|
const startupActivity = { sessionId: "s1", phase: "active" as const, label: "Opening session", detail: "Starting the Pi session", at: "now", startup: true };
|
||||||
|
|
||||||
|
const opening = registry.getActions(createContext({ selectedSession: testSession({ persisted: true }), status: testStatus({ persisted: true }), activity: startupActivity, machineRuntimes: reloadRuntime }).context);
|
||||||
|
|
||||||
|
// Nothing is being worked on, so there is nothing to stop and no reason to
|
||||||
|
// block a reload with "Stop current session activity before reloading".
|
||||||
|
expect(opening.find((action) => action.id === "core:session.stop")?.enabled).toBe(false);
|
||||||
|
expect(opening.find((action) => action.id === "core:session.reload")?.enabled).toBe(true);
|
||||||
|
|
||||||
|
// Real work is still real work, whatever else the session is doing.
|
||||||
|
const working = registry.getActions(createContext({ selectedSession: testSession({ persisted: true }), status: testStatus({ persisted: true, isStreaming: true }), activity: startupActivity, machineRuntimes: reloadRuntime }).context);
|
||||||
|
expect(working.find((action) => action.id === "core:session.stop")?.enabled).toBe(true);
|
||||||
|
expect(working.find((action) => action.id === "core:session.reload")?.enabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("routes session reload through the runtime context", () => {
|
it("routes session reload through the runtime context", () => {
|
||||||
const registry = new PluginRegistry();
|
const registry = new PluginRegistry();
|
||||||
registry.register({ id: "core", plugin: corePlugin });
|
registry.register({ id: "core", plugin: corePlugin });
|
||||||
@@ -271,6 +292,34 @@ describe("PluginRegistry", () => {
|
|||||||
expect(calls).toEqual(["deleteCachedNewSession"]);
|
expect(calls).toEqual(["deleteCachedNewSession"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("exposes model and thinking selectors as configurable actions for writable sessions", () => {
|
||||||
|
const registry = new PluginRegistry();
|
||||||
|
registry.register({ id: "core", plugin: corePlugin });
|
||||||
|
|
||||||
|
const unavailable = registry.getActions(createContext().context);
|
||||||
|
expect(unavailable.find((action) => action.id === "core:model.select")?.enabled).toBe(false);
|
||||||
|
expect(unavailable.find((action) => action.id === "core:thinking.select")?.enabled).toBe(false);
|
||||||
|
|
||||||
|
const archivedSession = { ...testSession(), archived: true, archivedAt: "2026-05-20T00:00:00.000Z" };
|
||||||
|
const archived = registry.getActions(createContext({ selectedSession: archivedSession }).context);
|
||||||
|
expect(archived.find((action) => action.id === "core:model.select")?.enabled).toBe(false);
|
||||||
|
expect(archived.find((action) => action.id === "core:thinking.select")?.enabled).toBe(false);
|
||||||
|
|
||||||
|
const { context, calls } = createContext({ selectedSession: testSession() });
|
||||||
|
const actions = registry.getActions(context);
|
||||||
|
const modelAction = actions.find((action) => action.id === "core:model.select");
|
||||||
|
const thinkingAction = actions.find((action) => action.id === "core:thinking.select");
|
||||||
|
expect(modelAction).toMatchObject({ title: "Select Model", enabled: true });
|
||||||
|
expect(modelAction?.shortcut).toBeUndefined();
|
||||||
|
expect(thinkingAction).toMatchObject({ title: "Select Thinking Level", enabled: true });
|
||||||
|
expect(thinkingAction?.shortcut).toBeUndefined();
|
||||||
|
|
||||||
|
if (modelAction !== undefined) void modelAction.run();
|
||||||
|
if (thinkingAction !== undefined) void thinkingAction.run();
|
||||||
|
|
||||||
|
expect(calls).toEqual(["openModelPicker", "openThinkingLevelPicker"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("routes refresh current to the active core workspace panel", () => {
|
it("routes refresh current to the active core workspace panel", () => {
|
||||||
const registry = new PluginRegistry();
|
const registry = new PluginRegistry();
|
||||||
registry.register({ id: "core", plugin: corePlugin });
|
registry.register({ id: "core", plugin: corePlugin });
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ export interface PluginRuntimeContext {
|
|||||||
configureAuth: () => void | Promise<void>;
|
configureAuth: () => void | Promise<void>;
|
||||||
logoutAuth: () => void | Promise<void>;
|
logoutAuth: () => void | Promise<void>;
|
||||||
openThemePicker: () => void;
|
openThemePicker: () => void;
|
||||||
|
openModelPicker: () => void | Promise<void>;
|
||||||
|
openThinkingLevelPicker: () => void | Promise<void>;
|
||||||
selectMainView: (view: AppState["mainView"]) => void;
|
selectMainView: (view: AppState["mainView"]) => void;
|
||||||
selectWorkspaceTool: (tool: QualifiedContributionId) => void;
|
selectWorkspaceTool: (tool: QualifiedContributionId) => void;
|
||||||
openTerminal: (options?: { terminalId?: string | undefined }) => void;
|
openTerminal: (options?: { terminalId?: string | undefined }) => void;
|
||||||
|
|||||||
@@ -90,17 +90,28 @@ describe("notification socket guards", () => {
|
|||||||
})).toBeUndefined();
|
})).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("carries the startup marker through the socket boundary, marker and all", () => {
|
||||||
|
const activity = { sessionId: "session-1", phase: "active", label: "Opening session", detail: "Starting the Pi session", at: "2026-07-20T00:00:01.000Z", startup: true };
|
||||||
|
|
||||||
|
// The marker is what stops an opening session being treated as a working
|
||||||
|
// one, so dropping it in transit would restore the defect for every frame,
|
||||||
|
// including those relayed from a remote machine.
|
||||||
|
expect(parseRealtimeSocketEvent({ type: "session.startup", activity })).toMatchObject({ type: "session.startup", activity: { startup: true } });
|
||||||
|
expect(parseRealtimeSocketEvent({ type: "session.startup", activity: { ...activity, startup: 1 } })).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("accepts validated session startup progress and drops malformed frames", () => {
|
it("accepts validated session startup progress and drops malformed frames", () => {
|
||||||
const activity = { sessionId: "session-1", phase: "active", label: "Creating session", detail: "Starting the Pi session", at: "2026-07-20T00:00:01.000Z" };
|
const activity = { sessionId: "session-1", phase: "active", label: "Creating session", detail: "Starting the Pi session", at: "2026-07-20T00:00:01.000Z" };
|
||||||
|
|
||||||
expect(parseRealtimeSocketEvent({ type: "session.startup", cwd: "/repo", activity }))
|
expect(parseRealtimeSocketEvent({ type: "session.startup", startupToken: "pending-session-1-abc", activity }))
|
||||||
.toMatchObject({ type: "session.startup", cwd: "/repo", activity });
|
.toMatchObject({ type: "session.startup", startupToken: "pending-session-1-abc", activity });
|
||||||
expect(parseRealtimeSocketEvent({ type: "session.startup", cwd: "", activity })).toBeUndefined();
|
expect(parseRealtimeSocketEvent({ type: "session.startup", activity })).toMatchObject({ type: "session.startup", activity });
|
||||||
expect(parseRealtimeSocketEvent({ type: "session.startup", cwd: "/repo" })).toBeUndefined();
|
expect(parseRealtimeSocketEvent({ type: "session.startup", startupToken: "", activity })).toBeUndefined();
|
||||||
expect(parseRealtimeSocketEvent({ type: "session.startup", cwd: "/repo", activity: { ...activity, phase: "waiting" } })).toBeUndefined();
|
expect(parseRealtimeSocketEvent({ type: "session.startup" })).toBeUndefined();
|
||||||
|
expect(parseRealtimeSocketEvent({ type: "session.startup", activity: { ...activity, phase: "waiting" } })).toBeUndefined();
|
||||||
// Startup progress is global-only, so it must not be accepted as a
|
// Startup progress is global-only, so it must not be accepted as a
|
||||||
// per-session frame even when it is well formed.
|
// per-session frame even when it is well formed.
|
||||||
expect(parseSessionSocketEvent({ type: "session.startup", cwd: "/repo", activity })).toBeUndefined();
|
expect(parseSessionSocketEvent({ type: "session.startup", activity })).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts validated ask frames and drops malformed ones", () => {
|
it("accepts validated ask frames and drops malformed ones", () => {
|
||||||
|
|||||||
@@ -32,6 +32,19 @@ describe("WorkspaceActivityService", () => {
|
|||||||
expect(events.at(-1)).toMatchObject({ type: "workspace.activity", activity: { cwd: "/repo", hasSessionActivity: false, hasTerminalActivity: false } });
|
expect(events.at(-1)).toMatchObject({ type: "workspace.activity", activity: { cwd: "/repo", hasSessionActivity: false, hasTerminalActivity: false } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not report a workspace active for a session that is only starting up", () => {
|
||||||
|
const events: RealtimeEvent[] = [];
|
||||||
|
const service = new WorkspaceActivityService({ publishRealtime: (event) => events.push(event) });
|
||||||
|
|
||||||
|
// Startup progress names a phase the daemon is inside; it is not work, so the
|
||||||
|
// workspace (and the project indicators and remote machines that read it)
|
||||||
|
// must not be reported as busy because of it.
|
||||||
|
service.applySessionActivity("/repo", { sessionId: "s1", phase: "active", label: "Opening session", detail: "Starting the Pi session", at: "now", startup: true });
|
||||||
|
|
||||||
|
expect(service.snapshot().workspaces).toEqual([]);
|
||||||
|
expect(events.at(-1)).toMatchObject({ type: "workspace.activity", activity: { cwd: "/repo", hasSessionActivity: false } });
|
||||||
|
});
|
||||||
|
|
||||||
it("clears stale active activity when an idle status arrives", () => {
|
it("clears stale active activity when an idle status arrives", () => {
|
||||||
const events: RealtimeEvent[] = [];
|
const events: RealtimeEvent[] = [];
|
||||||
const service = new WorkspaceActivityService({ publishRealtime: (event) => events.push(event) });
|
const service = new WorkspaceActivityService({ publishRealtime: (event) => events.push(event) });
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { SESSIOND_RUNTIME_CAPABILITIES } from "../shared/capabilities.js";
|
|||||||
import { agentSessionDirEnvKeys, effectivePiWebConfig, maxUploadBytes, offlineModeEnabled } from "../config.js";
|
import { agentSessionDirEnvKeys, effectivePiWebConfig, maxUploadBytes, offlineModeEnabled } from "../config.js";
|
||||||
import { createActiveAgentProfileDescriptor } from "../sessiond/activeAgentProfile.js";
|
import { createActiveAgentProfileDescriptor } from "../sessiond/activeAgentProfile.js";
|
||||||
import { runSessionDaemonStartup } from "./sessiond/sessionDaemonStartup.js";
|
import { runSessionDaemonStartup } from "./sessiond/sessionDaemonStartup.js";
|
||||||
|
import { sessionServiceDependencies } from "./sessiond/sessionServiceDependencies.js";
|
||||||
|
|
||||||
const daemonEnvironment: NodeJS.ProcessEnv = Object.freeze({ ...process.env });
|
const daemonEnvironment: NodeJS.ProcessEnv = Object.freeze({ ...process.env });
|
||||||
const { config } = effectivePiWebConfig({ env: daemonEnvironment });
|
const { config } = effectivePiWebConfig({ env: daemonEnvironment });
|
||||||
@@ -70,25 +71,23 @@ await runSessionDaemonStartup({
|
|||||||
const spawnTargets = config.spawnSessions
|
const spawnTargets = config.spawnSessions
|
||||||
? new ProjectScopedSpawnTargetResolver({ projects: new ProjectService(new ProjectStore()), workspaces: new WorkspaceService() })
|
? new ProjectScopedSpawnTargetResolver({ projects: new ProjectService(new ProjectStore()), workspaces: new WorkspaceService() })
|
||||||
: undefined;
|
: undefined;
|
||||||
const sessions = new PiSessionService(eventHub, {
|
const sessions = new PiSessionService(eventHub, sessionServiceDependencies({
|
||||||
modelRuntime: auth.runtime,
|
modelRuntime: auth.runtime,
|
||||||
agentDir: activeAgentProfile.dir,
|
agentDir: activeAgentProfile.dir,
|
||||||
workspaceActivity,
|
workspaceActivity,
|
||||||
logger: app.log,
|
logger: app.log,
|
||||||
...(spawnTargets === undefined ? {} : { spawnTargets }),
|
...(spawnTargets === undefined ? {} : { spawnTargets }),
|
||||||
subsessionsEnabled: spawnTargets !== undefined && config.subsessions,
|
subsessionsEnabled: config.subsessions,
|
||||||
askUserEnabled: config.askUser,
|
askUserEnabled: config.askUser,
|
||||||
notificationStore,
|
notificationStore,
|
||||||
unreadStore,
|
unreadStore,
|
||||||
// Read-only, so session startup can tell a waiting user that provider
|
|
||||||
// model lists are refreshing at the same time.
|
|
||||||
catalogRefreshStatus: catalogRefresher,
|
catalogRefreshStatus: catalogRefresher,
|
||||||
sessionManager: createPiSessionManagerGateway({
|
sessionManager: createPiSessionManagerGateway({
|
||||||
agentDir: activeAgentProfile.dir,
|
agentDir: activeAgentProfile.dir,
|
||||||
env: daemonEnvironment,
|
env: daemonEnvironment,
|
||||||
sessionDirEnvKeys: activeAgentProfile.sessionDirEnvKeys,
|
sessionDirEnvKeys: activeAgentProfile.sessionDirEnvKeys,
|
||||||
}),
|
}),
|
||||||
});
|
}));
|
||||||
auth.subscribe((change) => { sessions.applyAuthChange(change); });
|
auth.subscribe((change) => { sessions.applyAuthChange(change); });
|
||||||
const terminals = new TerminalService(eventHub, workspaceActivity);
|
const terminals = new TerminalService(eventHub, workspaceActivity);
|
||||||
const runtimeComponent = Object.freeze({
|
const runtimeComponent = Object.freeze({
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
|
||||||
|
import { SessionNotificationStore } from "../sessions/sessionNotificationStore.js";
|
||||||
|
import { SessionUnreadStore } from "../sessions/sessionUnreadStore.js";
|
||||||
|
import { PiSessionService, type PiSessionServiceDependencies } from "../sessions/piSessionService.js";
|
||||||
|
import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, sessionGateway, testModelRuntime } from "../sessions/piSessionService.testSupport.js";
|
||||||
|
import { sessionServiceDependencies, type SessionServiceDependencyInput } from "./sessionServiceDependencies.js";
|
||||||
|
|
||||||
|
const AGENT_DIR = "/tmp/pi-web-test-agent";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The collaborators sessiond hands the assembly, with only the process-facing
|
||||||
|
* ones (agent dir, session store gateway) replaced. Everything the assembly
|
||||||
|
* decides is left to the assembly.
|
||||||
|
*/
|
||||||
|
function daemonCollaborators(patch: Partial<SessionServiceDependencyInput> = {}): SessionServiceDependencyInput {
|
||||||
|
return {
|
||||||
|
agentDir: AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
|
sessionManager: sessionGateway([]),
|
||||||
|
workspaceActivity: new WorkspaceActivityService(),
|
||||||
|
logger: { info() { /* no-op */ } },
|
||||||
|
notificationStore: new SessionNotificationStore(),
|
||||||
|
unreadStore: new SessionUnreadStore(),
|
||||||
|
catalogRefreshStatus: { isRefreshInFlight: () => false },
|
||||||
|
subsessionsEnabled: false,
|
||||||
|
askUserEnabled: true,
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a session through a service built by the real assembly and collect what
|
||||||
|
* the user is told while waiting. Only test-local seams are patched onto the
|
||||||
|
* assembled dependencies, never anything the assembly is responsible for
|
||||||
|
* supplying, so a dependency the assembly stops passing cannot be masked here.
|
||||||
|
*/
|
||||||
|
async function startupDetails(deps: PiSessionServiceDependencies): Promise<string[]> {
|
||||||
|
const hub = new CapturingSessionEventHub();
|
||||||
|
const fake = fakeRuntime();
|
||||||
|
const service = new PiSessionService(hub, {
|
||||||
|
...deps,
|
||||||
|
archiveStore: emptyArchiveStore(),
|
||||||
|
createAgentRuntime: () => Promise.resolve(fake.runtime),
|
||||||
|
heartbeatIntervalMs: 60_000,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await service.start("/workspace");
|
||||||
|
} finally {
|
||||||
|
await service.dispose();
|
||||||
|
}
|
||||||
|
return hub.globalEvents.flatMap((event) =>
|
||||||
|
event.type === "session.startup" && event.activity.detail !== undefined ? [event.activity.detail] : [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("sessiond session service dependency assembly", () => {
|
||||||
|
it("reports a concurrent provider model list refresh to a waiting user", async () => {
|
||||||
|
// The note is only reachable in the product because the assembly hands the
|
||||||
|
// refresher to the session service. Asserting the note rather than the
|
||||||
|
// property means dropping that line fails here instead of passing silently.
|
||||||
|
const details = await startupDetails(sessionServiceDependencies(daemonCollaborators({
|
||||||
|
catalogRefreshStatus: { isRefreshInFlight: () => true },
|
||||||
|
})));
|
||||||
|
|
||||||
|
expect(details).toEqual([
|
||||||
|
"Starting the Pi session · provider model lists are refreshing",
|
||||||
|
"Loading session extensions · provider model lists are refreshing",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("states the startup phase alone when no refresh is running", async () => {
|
||||||
|
const details = await startupDetails(sessionServiceDependencies(daemonCollaborators()));
|
||||||
|
|
||||||
|
// Pins the note to the refresher's answer, so the test above cannot pass on
|
||||||
|
// wording that is always appended.
|
||||||
|
expect(details).toEqual(["Starting the Pi session", "Loading session extensions"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps tracked subsessions off unless spawning is configured as well", () => {
|
||||||
|
const spawnTargets = { resolveSpawnTarget: () => Promise.reject(new Error("not used")) };
|
||||||
|
|
||||||
|
const withoutSpawnTargets = sessionServiceDependencies(daemonCollaborators({ subsessionsEnabled: true }));
|
||||||
|
const withSpawnTargets = sessionServiceDependencies(daemonCollaborators({ subsessionsEnabled: true, spawnTargets }));
|
||||||
|
|
||||||
|
expect(withoutSpawnTargets.spawnTargets).toBeUndefined();
|
||||||
|
expect(withoutSpawnTargets.subsessionsEnabled).toBe(false);
|
||||||
|
expect(withSpawnTargets.spawnTargets).toBe(spawnTargets);
|
||||||
|
expect(withSpawnTargets.subsessionsEnabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes the ask-user preference through to the session service", () => {
|
||||||
|
expect(sessionServiceDependencies(daemonCollaborators({ askUserEnabled: true })).askUserEnabled).toBe(true);
|
||||||
|
expect(sessionServiceDependencies(daemonCollaborators({ askUserEnabled: false })).askUserEnabled).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { PiSessionServiceDependencies } from "../sessions/piSessionService.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The collaborators sessiond constructs, in the shape the assembly needs them.
|
||||||
|
*
|
||||||
|
* Every field is required unless the capability itself is optional, so a
|
||||||
|
* collaborator the daemon stops constructing cannot silently vanish from the
|
||||||
|
* session service.
|
||||||
|
*/
|
||||||
|
export interface SessionServiceDependencyInput {
|
||||||
|
agentDir: string;
|
||||||
|
sessionManager: PiSessionServiceDependencies["sessionManager"];
|
||||||
|
modelRuntime: PiSessionServiceDependencies["modelRuntime"];
|
||||||
|
workspaceActivity: NonNullable<PiSessionServiceDependencies["workspaceActivity"]>;
|
||||||
|
logger: NonNullable<PiSessionServiceDependencies["logger"]>;
|
||||||
|
notificationStore: NonNullable<PiSessionServiceDependencies["notificationStore"]>;
|
||||||
|
unreadStore: NonNullable<PiSessionServiceDependencies["unreadStore"]>;
|
||||||
|
/** Read-only view of the background refresher; see the assembly below. */
|
||||||
|
catalogRefreshStatus: NonNullable<PiSessionServiceDependencies["catalogRefreshStatus"]>;
|
||||||
|
/** Omitted when the operator has not enabled session spawning. */
|
||||||
|
spawnTargets?: NonNullable<PiSessionServiceDependencies["spawnTargets"]>;
|
||||||
|
/** The operator's subsessions preference, which also requires spawning. */
|
||||||
|
subsessionsEnabled: boolean;
|
||||||
|
/** Whether agents may post structured question sets to the browser. */
|
||||||
|
askUserEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map sessiond's constructed collaborators onto the session service's
|
||||||
|
* dependencies.
|
||||||
|
*
|
||||||
|
* Extracted from `sessiond.ts`, which starts a daemon as an import side effect
|
||||||
|
* and so cannot be loaded by a test. This function performs no side effects and
|
||||||
|
* constructs nothing, so lifting it changes no startup ordering; it exists only
|
||||||
|
* so a test can build a service the way the daemon does and assert what a user
|
||||||
|
* is actually told.
|
||||||
|
*/
|
||||||
|
export function sessionServiceDependencies(input: SessionServiceDependencyInput): PiSessionServiceDependencies {
|
||||||
|
return {
|
||||||
|
modelRuntime: input.modelRuntime,
|
||||||
|
agentDir: input.agentDir,
|
||||||
|
workspaceActivity: input.workspaceActivity,
|
||||||
|
logger: input.logger,
|
||||||
|
...(input.spawnTargets === undefined ? {} : { spawnTargets: input.spawnTargets }),
|
||||||
|
// Tracked subsessions share the spawn capability's project-scope resolver,
|
||||||
|
// so they stay off unless spawning is configured too.
|
||||||
|
subsessionsEnabled: input.spawnTargets !== undefined && input.subsessionsEnabled,
|
||||||
|
askUserEnabled: input.askUserEnabled,
|
||||||
|
notificationStore: input.notificationStore,
|
||||||
|
unreadStore: input.unreadStore,
|
||||||
|
// Read-only, so session startup can tell a waiting user that provider
|
||||||
|
// model lists are refreshing at the same time.
|
||||||
|
catalogRefreshStatus: input.catalogRefreshStatus,
|
||||||
|
sessionManager: input.sessionManager,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { PiSessionService, type PiSessionRuntime } from "./piSessionService.js";
|
import { PiSessionService, type PiSessionRuntime } from "./piSessionService.js";
|
||||||
import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, sessionGateway, sessionRecord, sessionRef, testModelRuntime } from "./piSessionService.testSupport.js";
|
import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, sessionGateway, sessionRecord, sessionRef, testModelRuntime } from "./piSessionService.testSupport.js";
|
||||||
|
import { isSessionActive } from "../../shared/activity.js";
|
||||||
import type { SessionActivity, SessionStartupProgressEvent } from "../../shared/apiTypes.js";
|
import type { SessionActivity, SessionStartupProgressEvent } from "../../shared/apiTypes.js";
|
||||||
|
|
||||||
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
|
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
|
||||||
@@ -77,7 +78,7 @@ describe("PiSessionService session startup progress", () => {
|
|||||||
// The proof that matters: the user is told what is being waited on before
|
// The proof that matters: the user is told what is being waited on before
|
||||||
// the wait ends, not after it.
|
// the wait ends, not after it.
|
||||||
expect(startupText(hub)).toEqual(["Creating session: Starting the Pi session"]);
|
expect(startupText(hub)).toEqual(["Creating session: Starting the Pi session"]);
|
||||||
expect(startupEvents(hub).at(0)).toMatchObject({ cwd: "/workspace", activity: { sessionId: "session-1", phase: "active" } });
|
expect(startupEvents(hub).at(0)).toMatchObject({ activity: { sessionId: "session-1", phase: "active" } });
|
||||||
|
|
||||||
runtimeResult.resolve(fake.runtime);
|
runtimeResult.resolve(fake.runtime);
|
||||||
await started;
|
await started;
|
||||||
@@ -157,11 +158,42 @@ describe("PiSessionService session startup progress", () => {
|
|||||||
|
|
||||||
await service.start("/workspace");
|
await service.start("/workspace");
|
||||||
|
|
||||||
expect(startupEvents(hub).at(-1)).toMatchObject({ cwd: "/workspace", activity: { sessionId: "session-1", phase: "idle", label: "idle" } });
|
expect(startupEvents(hub).at(-1)).toMatchObject({ activity: { sessionId: "session-1", phase: "idle", label: "idle" } });
|
||||||
expect(startupEvents(hub).at(-1)?.activity.detail).toBeUndefined();
|
expect(startupEvents(hub).at(-1)?.activity.detail).toBeUndefined();
|
||||||
await service.dispose();
|
await service.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("echoes a create's correlation token on every startup report of that construction", async () => {
|
||||||
|
const { hub, service } = startupService();
|
||||||
|
|
||||||
|
await service.start("/workspace", { startupToken: "pending-session-3-k2x9" });
|
||||||
|
|
||||||
|
// The token labels the browser row that is waiting, so it must ride every
|
||||||
|
// report of this construction, the closing idle one included.
|
||||||
|
expect(startupEvents(hub).map((event) => event.startupToken)).toEqual([
|
||||||
|
"pending-session-3-k2x9",
|
||||||
|
"pending-session-3-k2x9",
|
||||||
|
"pending-session-3-k2x9",
|
||||||
|
]);
|
||||||
|
// The token is an opaque throwaway label, never the session's identity.
|
||||||
|
expect(startupEvents(hub).map((event) => event.activity.sessionId)).toEqual(["session-1", "session-1", "session-1"]);
|
||||||
|
await service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("publishes no correlation token when a create supplies none, and none for an open", async () => {
|
||||||
|
const created = startupService();
|
||||||
|
await created.service.start("/workspace");
|
||||||
|
const opened = startupService({ sessionRecords: [sessionRecord("session-1")] });
|
||||||
|
await opened.service.status(sessionRef("session-1"));
|
||||||
|
|
||||||
|
for (const hub of [created.hub, opened.hub]) {
|
||||||
|
expect(startupEvents(hub).length).toBeGreaterThan(0);
|
||||||
|
expect(startupEvents(hub).every((event) => event.startupToken === undefined)).toBe(true);
|
||||||
|
}
|
||||||
|
await created.service.dispose();
|
||||||
|
await opened.service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
it("ends the startup window when the runtime construction itself fails", async () => {
|
it("ends the startup window when the runtime construction itself fails", async () => {
|
||||||
const failure = new Error("runtime unavailable");
|
const failure = new Error("runtime unavailable");
|
||||||
const { hub, service } = startupService({ createAgentRuntime: () => Promise.reject(failure) });
|
const { hub, service } = startupService({ createAgentRuntime: () => Promise.reject(failure) });
|
||||||
@@ -204,6 +236,34 @@ describe("PiSessionService session startup progress", () => {
|
|||||||
await service.dispose();
|
await service.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reports startup progress as starting rather than as work in progress", async () => {
|
||||||
|
const { hub, service } = startupService();
|
||||||
|
|
||||||
|
await service.start("/workspace");
|
||||||
|
|
||||||
|
// Startup phases are published with an "active" phase so the waiting user
|
||||||
|
// sees them, but opening a session is not work: nothing that decides whether
|
||||||
|
// work is in progress may count them.
|
||||||
|
const phases = startupEvents(hub).filter((event) => event.activity.phase === "active");
|
||||||
|
expect(phases).toHaveLength(2);
|
||||||
|
expect(phases.map((event) => isSessionActive(undefined, event.activity))).toEqual([false, false]);
|
||||||
|
await service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still reports a real activity published during startup as work", async () => {
|
||||||
|
const { hub, fake, service } = startupService();
|
||||||
|
|
||||||
|
await service.start("/workspace");
|
||||||
|
fake.emit({ type: "tool_execution_start", toolName: "bash" });
|
||||||
|
|
||||||
|
// The marker belongs to the startup channel alone; an ordinary activity for
|
||||||
|
// the same session still counts, or the fix would hide real work.
|
||||||
|
const running = activityUpdates(hub).filter((activity) => activity.phase === "active");
|
||||||
|
expect(running.length).toBeGreaterThan(0);
|
||||||
|
expect(running.every((activity) => isSessionActive(undefined, activity))).toBe(true);
|
||||||
|
await service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps startup reporting event-only, writing no session or workspace activity state", async () => {
|
it("keeps startup reporting event-only, writing no session or workspace activity state", async () => {
|
||||||
const recorder = recordingWorkspaceActivity();
|
const recorder = recordingWorkspaceActivity();
|
||||||
const failure = new Error("runtime unavailable");
|
const failure = new Error("runtime unavailable");
|
||||||
|
|||||||
@@ -204,6 +204,11 @@ type SessionCreationProvenance = "tracked-subsession";
|
|||||||
interface StartSessionOptions {
|
interface StartSessionOptions {
|
||||||
parentSession?: string;
|
parentSession?: string;
|
||||||
initialModel?: AgentModel;
|
initialModel?: AgentModel;
|
||||||
|
/**
|
||||||
|
* Opaque label, echoed on this construction's startup progress so a browser
|
||||||
|
* row with no session id yet can recognise its own.
|
||||||
|
*/
|
||||||
|
startupToken?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InternalStartSessionOptions extends StartSessionOptions {
|
interface InternalStartSessionOptions extends StartSessionOptions {
|
||||||
@@ -395,7 +400,7 @@ interface PendingSessionOpen {
|
|||||||
promise: Promise<ActiveSession<PiSessionRuntime>>;
|
promise: Promise<ActiveSession<PiSessionRuntime>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CreateSessionRuntimeOptions extends Pick<InternalStartSessionOptions, "initialModel" | "creationProvenance"> {
|
interface CreateSessionRuntimeOptions extends Pick<InternalStartSessionOptions, "initialModel" | "creationProvenance" | "startupToken"> {
|
||||||
notificationGeneration?: SessionNotificationGeneration;
|
notificationGeneration?: SessionNotificationGeneration;
|
||||||
notifications?: "enabled" | "disabled";
|
notifications?: "enabled" | "disabled";
|
||||||
/**
|
/**
|
||||||
@@ -1016,6 +1021,7 @@ export class PiSessionService implements SessionRouteService {
|
|||||||
cwd,
|
cwd,
|
||||||
{
|
{
|
||||||
startupIntent: "create",
|
startupIntent: "create",
|
||||||
|
...(options.startupToken === undefined ? {} : { startupToken: options.startupToken }),
|
||||||
...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
|
...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
|
||||||
...(options.creationProvenance === undefined ? {} : { creationProvenance: options.creationProvenance }),
|
...(options.creationProvenance === undefined ? {} : { creationProvenance: options.creationProvenance }),
|
||||||
},
|
},
|
||||||
@@ -2459,7 +2465,7 @@ export class PiSessionService implements SessionRouteService {
|
|||||||
cwd: string,
|
cwd: string,
|
||||||
options: CreateSessionRuntimeOptions = {},
|
options: CreateSessionRuntimeOptions = {},
|
||||||
): Promise<ActiveSession<PiSessionRuntime>> {
|
): Promise<ActiveSession<PiSessionRuntime>> {
|
||||||
const startup = this.startupProgress(sessionManager, cwd, options.startupIntent ?? "open");
|
const startup = this.startupProgress(sessionManager, options.startupIntent ?? "open", options.startupToken);
|
||||||
try {
|
try {
|
||||||
return await this.createSessionRuntime(sessionManager, cwd, options, startup);
|
return await this.createSessionRuntime(sessionManager, cwd, options, startup);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -3105,23 +3111,23 @@ export class PiSessionService implements SessionRouteService {
|
|||||||
/**
|
/**
|
||||||
* Build the reporter for one session construction.
|
* Build the reporter for one session construction.
|
||||||
*
|
*
|
||||||
* The session id and cwd are both known before any await — a `SessionManager`
|
* The session id is known before any await — a `SessionManager` has its id
|
||||||
* has its id from construction — so the daemon can name what it is starting
|
* from construction — so the daemon can name what it is starting even though
|
||||||
* even though the `PiAgentSession` that {@link publishActivity} needs does not
|
* the `PiAgentSession` that {@link publishActivity} needs does not exist yet.
|
||||||
* exist yet. When either is missing there is nothing honest to route on, so
|
* Without an id there is nothing to report against, so the reporter stays
|
||||||
* the reporter stays silent and the browser keeps its own generic wording.
|
* silent and the browser keeps its own generic wording.
|
||||||
*/
|
*/
|
||||||
private startupProgress(sessionManager: PiSessionManager, cwd: string, intent: "create" | "open"): SessionStartupProgressReporter {
|
private startupProgress(sessionManager: PiSessionManager, intent: "create" | "open", startupToken: string | undefined): SessionStartupProgressReporter {
|
||||||
const sessionId = sessionManager.getSessionId();
|
const sessionId = sessionManager.getSessionId();
|
||||||
if (sessionId === "" || cwd === "") return { report: noop, end: noop };
|
if (sessionId === "") return { report: noop, end: noop };
|
||||||
const label = intent === "create" ? "Creating session" : "Opening session";
|
const label = intent === "create" ? "Creating session" : "Opening session";
|
||||||
return {
|
return {
|
||||||
report: (phase) => { this.publishStartupProgress(sessionId, cwd, label, "active", this.startupDetail(phase)); },
|
report: (phase) => { this.publishStartupProgress(sessionId, startupToken, label, "active", this.startupDetail(phase)); },
|
||||||
end: () => {
|
end: () => {
|
||||||
// A real activity published during the window (an extension error, say)
|
// A real activity published during the window (an extension error, say)
|
||||||
// is the truth about this session and must survive the clear.
|
// is the truth about this session and must survive the clear.
|
||||||
if (this.activities.has(sessionId)) return;
|
if (this.activities.has(sessionId)) return;
|
||||||
this.publishStartupProgress(sessionId, cwd, "idle", "idle", undefined);
|
this.publishStartupProgress(sessionId, startupToken, "idle", "idle", undefined);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -3133,17 +3139,22 @@ export class PiSessionService implements SessionRouteService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Report startup progress on the global channel only, keyed by `cwd` so a
|
* Report startup progress on the global channel only, echoing the caller's
|
||||||
* browser row that has no session id yet can find it.
|
* correlation token so a waiting browser row recognises its own construction.
|
||||||
*
|
*
|
||||||
* Unlike {@link publishActivity} this deliberately records nothing: no
|
* Unlike {@link publishActivity} this deliberately records nothing: no
|
||||||
* `activities` entry, no workspace activity, no unread observation. There is
|
* `activities` entry, no workspace activity, no unread observation. There is
|
||||||
* no session to own that state, and a failed creation would leave it stranded.
|
* no session to own that state, and a failed creation would leave it stranded.
|
||||||
|
*
|
||||||
|
* Every report is marked `startup`, which is what keeps a session that is
|
||||||
|
* merely opening from counting as one doing work. This is the only publisher
|
||||||
|
* that sets the marker, and because it writes no `activities` entry no later
|
||||||
|
* heartbeat re-publication can carry it.
|
||||||
*/
|
*/
|
||||||
private publishStartupProgress(sessionId: string, cwd: string, label: string, phase: "active" | "idle", detail: string | undefined): void {
|
private publishStartupProgress(sessionId: string, startupToken: string | undefined, label: string, phase: "active" | "idle", detail: string | undefined): void {
|
||||||
const at = new Date().toISOString();
|
const at = new Date().toISOString();
|
||||||
const activity = detail === undefined ? { sessionId, phase, label, at } : { sessionId, phase, label, detail, at };
|
const activity = detail === undefined ? { sessionId, phase, label, at, startup: true } : { sessionId, phase, label, detail, at, startup: true };
|
||||||
this.events.publishGlobal({ type: "session.startup", cwd, activity });
|
this.events.publishGlobal(startupToken === undefined ? { type: "session.startup", activity } : { type: "session.startup", startupToken, activity });
|
||||||
}
|
}
|
||||||
|
|
||||||
private publishActivity(session: PiAgentSession, label: string, phase: "active" | "idle" | "error", detail?: string): void {
|
private publishActivity(session: PiAgentSession, label: string, phase: "active" | "idle" | "error", detail?: string): void {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { PiSessionService, type PiSessionManagerGateway } from "./piSessionServi
|
|||||||
import { testModelRuntime } from "./piSessionService.testSupport.js";
|
import { testModelRuntime } from "./piSessionService.testSupport.js";
|
||||||
import { SessionNotificationStore } from "./sessionNotificationStore.js";
|
import { SessionNotificationStore } from "./sessionNotificationStore.js";
|
||||||
import type { SessionRouteLookup, SessionRouteService } from "./sessionService.js";
|
import type { SessionRouteLookup, SessionRouteService } from "./sessionService.js";
|
||||||
|
import type { ClientSession } from "../types.js";
|
||||||
import { registerSessionRoutes } from "./sessionRoutes.js";
|
import { registerSessionRoutes } from "./sessionRoutes.js";
|
||||||
import type { NormalizedSessionCleanupRequest } from "./sessionCleanup.js";
|
import type { NormalizedSessionCleanupRequest } from "./sessionCleanup.js";
|
||||||
|
|
||||||
@@ -762,6 +763,35 @@ describe("session routes", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("forwards a create's optional correlation token alongside the normalized cwd", async () => {
|
||||||
|
const routeApp = Fastify({ logger: false });
|
||||||
|
await routeApp.register(fastifyWebsocket);
|
||||||
|
const eventHub = new SessionEventHub();
|
||||||
|
const routeService = new CapturingRouteSessionService();
|
||||||
|
registerSessionRoutes(routeApp, routeService, eventHub);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requestCwd = resolve("/repo");
|
||||||
|
const withToken = await routeApp.inject({ method: "POST", url: "/sessions", payload: { cwd: requestCwd, startupToken: "pending-session-3-k2x9" } });
|
||||||
|
const withoutToken = await routeApp.inject({ method: "POST", url: "/sessions", payload: { cwd: requestCwd } });
|
||||||
|
// An older browser, or any non-browser caller, sends no token; and a
|
||||||
|
// malformed one must not reach the service as a label it would echo.
|
||||||
|
const malformedToken = await routeApp.inject({ method: "POST", url: "/sessions", payload: { cwd: requestCwd, startupToken: 7 } });
|
||||||
|
|
||||||
|
expect(withToken.statusCode).toBe(200);
|
||||||
|
expect(withoutToken.statusCode).toBe(200);
|
||||||
|
expect(malformedToken.statusCode).toBe(400);
|
||||||
|
expect(malformedToken.json()).toEqual({ error: "startupToken field must be a string" });
|
||||||
|
expect(routeService.startCalls).toEqual([
|
||||||
|
{ cwd: requestCwd, startupToken: "pending-session-3-k2x9" },
|
||||||
|
{ cwd: requestCwd, startupToken: undefined },
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
await routeService.dispose();
|
||||||
|
await routeApp.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects malformed bulk mutation bodies before calling the service", async () => {
|
it("rejects malformed bulk mutation bodies before calling the service", async () => {
|
||||||
const routeApp = Fastify({ logger: false });
|
const routeApp = Fastify({ logger: false });
|
||||||
await routeApp.register(fastifyWebsocket);
|
await routeApp.register(fastifyWebsocket);
|
||||||
@@ -804,6 +834,7 @@ class CapturingRouteSessionService implements SessionRouteService {
|
|||||||
readonly navigateTreeCalls: { lookup: SessionRouteLookup; request: SessionTreeNavigateRequest }[] = [];
|
readonly navigateTreeCalls: { lookup: SessionRouteLookup; request: SessionTreeNavigateRequest }[] = [];
|
||||||
readonly submitAskCalls: { lookup: SessionRouteLookup; askId: string; submission: AskUserSubmission }[] = [];
|
readonly submitAskCalls: { lookup: SessionRouteLookup; askId: string; submission: AskUserSubmission }[] = [];
|
||||||
readonly cancelAskCalls: { lookup: SessionRouteLookup; askId: string }[] = [];
|
readonly cancelAskCalls: { lookup: SessionRouteLookup; askId: string }[] = [];
|
||||||
|
readonly startCalls: { cwd: string; startupToken: string | undefined }[] = [];
|
||||||
askError: Error | undefined;
|
askError: Error | undefined;
|
||||||
reloadError: Error | undefined;
|
reloadError: Error | undefined;
|
||||||
clearQueueError: Error | undefined;
|
clearQueueError: Error | undefined;
|
||||||
@@ -883,7 +914,11 @@ class CapturingRouteSessionService implements SessionRouteService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
list(): never { throw unusedRouteMethod("list"); }
|
list(): never { throw unusedRouteMethod("list"); }
|
||||||
start(): never { throw unusedRouteMethod("start"); }
|
|
||||||
|
start(cwd: string, options?: { startupToken?: string }): Promise<ClientSession> {
|
||||||
|
this.startCalls.push({ cwd, startupToken: options?.startupToken });
|
||||||
|
return Promise.resolve({ id: "session-1", path: "/tmp/session-1.jsonl", cwd, created: "2026-06-25T00:00:00.000Z", modified: "2026-06-25T00:00:00.000Z", messageCount: 0, firstMessage: "" });
|
||||||
|
}
|
||||||
|
|
||||||
dismissWarning(lookup: SessionRouteLookup, dismissId: string): Promise<SessionStatus> {
|
dismissWarning(lookup: SessionRouteLookup, dismissId: string): Promise<SessionStatus> {
|
||||||
this.dismissWarningCalls.push({ lookup, dismissId });
|
this.dismissWarningCalls.push({ lookup, dismissId });
|
||||||
|
|||||||
@@ -45,10 +45,14 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: SessionRou
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post<{ Body: { cwd?: unknown } | undefined }>(`${prefix}/sessions`, async (request, reply) => {
|
app.post<{ Body: { cwd?: unknown; startupToken?: unknown } | undefined }>(`${prefix}/sessions`, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const body = requireRecord(request.body);
|
const body = requireRecord(request.body);
|
||||||
return await sessions.start(normalizeRequestCwd(requireString(body, "cwd")));
|
// An opaque label the caller uses to recognise its own construction's
|
||||||
|
// startup reports. Optional: only a browser row waiting for a session id
|
||||||
|
// has anything to correlate.
|
||||||
|
const startupToken = body["startupToken"] === undefined ? undefined : requireNonEmptyString(body, "startupToken");
|
||||||
|
return await sessions.start(normalizeRequestCwd(requireString(body, "cwd")), optionalField("startupToken", startupToken));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return reply.code(400).send({ error: errorMessage(error) });
|
return reply.code(400).send({ error: errorMessage(error) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,12 @@ export type SessionRouteLookup = string | SessionRouteRef;
|
|||||||
*/
|
*/
|
||||||
export interface SessionRouteService {
|
export interface SessionRouteService {
|
||||||
list(cwd: string): Promise<ClientSession[]>;
|
list(cwd: string): Promise<ClientSession[]>;
|
||||||
start(cwd: string): Promise<ClientSession>;
|
/**
|
||||||
|
* Create a session. `startupToken` is an opaque label the caller supplies so
|
||||||
|
* it can recognise this construction's startup progress reports; the service
|
||||||
|
* echoes it and never interprets it.
|
||||||
|
*/
|
||||||
|
start(cwd: string, options?: { startupToken?: string }): Promise<ClientSession>;
|
||||||
messages(ref: SessionRouteLookup, page?: { before?: number; limit?: number }): Promise<unknown[] | ClientMessagePage>;
|
messages(ref: SessionRouteLookup, page?: { before?: number; limit?: number }): Promise<unknown[] | ClientMessagePage>;
|
||||||
status(ref: SessionRouteLookup): Promise<ClientSessionStatus>;
|
status(ref: SessionRouteLookup): Promise<ClientSessionStatus>;
|
||||||
streamSnapshot(ref: SessionRouteLookup): Promise<SessionStreamSnapshot>;
|
streamSnapshot(ref: SessionRouteLookup): Promise<SessionStreamSnapshot>;
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { parseGitWorktreeList } from "./gitWorktreeDiscovery.js";
|
||||||
|
|
||||||
|
// Fixtures below are verbatim `git worktree list --porcelain` output captured from real git.
|
||||||
|
const mainAndLinked = [
|
||||||
|
"worktree /repo",
|
||||||
|
"HEAD ad580ab86e1eba35a121fa6b9e8be1126aaf18de",
|
||||||
|
"branch refs/heads/main",
|
||||||
|
"",
|
||||||
|
"worktree /repo-worktrees/feature",
|
||||||
|
"HEAD ad580ab86e1eba35a121fa6b9e8be1126aaf18de",
|
||||||
|
"branch refs/heads/feat/thing",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const removedAndLocked = [
|
||||||
|
"worktree /repo",
|
||||||
|
"HEAD ad580ab86e1eba35a121fa6b9e8be1126aaf18de",
|
||||||
|
"branch refs/heads/main",
|
||||||
|
"",
|
||||||
|
"worktree /repo-worktrees/gone",
|
||||||
|
"HEAD ad580ab86e1eba35a121fa6b9e8be1126aaf18de",
|
||||||
|
"branch refs/heads/gone",
|
||||||
|
"prunable gitdir file points to non-existent location",
|
||||||
|
"",
|
||||||
|
"worktree /repo-worktrees/kept",
|
||||||
|
"HEAD ad580ab86e1eba35a121fa6b9e8be1126aaf18de",
|
||||||
|
"branch refs/heads/kept",
|
||||||
|
"locked keep me",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
describe("parseGitWorktreeList", () => {
|
||||||
|
it("reads paths and short branch names for the main and linked worktrees", () => {
|
||||||
|
expect(parseGitWorktreeList(mainAndLinked)).toEqual([
|
||||||
|
{ path: "/repo", branch: "main" },
|
||||||
|
{ path: "/repo-worktrees/feature", branch: "feat/thing" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports prunable, and leaves a locked worktree looking like the usable checkout it is", () => {
|
||||||
|
expect(parseGitWorktreeList(removedAndLocked)).toEqual([
|
||||||
|
{ path: "/repo", branch: "main" },
|
||||||
|
{ path: "/repo-worktrees/gone", branch: "gone", prunable: true },
|
||||||
|
// `locked` is ignored: a locked worktree is a real checkout and stays a usable
|
||||||
|
// workspace, so nothing downstream needs to distinguish it.
|
||||||
|
{ path: "/repo-worktrees/kept", branch: "kept" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const bareLocked = ["worktree /repo-worktrees/kept", "HEAD abc", "detached", "locked", ""].join("\n");
|
||||||
|
expect(parseGitWorktreeList(bareLocked)).toEqual([{ path: "/repo-worktrees/kept", detached: true }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads bare repositories and ignores chunks without a worktree path", () => {
|
||||||
|
const bare = ["worktree /repo.git", "bare", "", "HEAD abc", ""].join("\n");
|
||||||
|
expect(parseGitWorktreeList(bare)).toEqual([{ path: "/repo.git", bare: true }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns nothing for empty output", () => {
|
||||||
|
expect(parseGitWorktreeList("\n")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,8 @@ export interface GitWorktreeInfo {
|
|||||||
branch?: string;
|
branch?: string;
|
||||||
bare?: boolean;
|
bare?: boolean;
|
||||||
detached?: boolean;
|
detached?: boolean;
|
||||||
|
/** Git reports a linked worktree as prunable when its checkout directory no longer exists. */
|
||||||
|
prunable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function isGitRepository(path: string): Promise<boolean> {
|
export async function isGitRepository(path: string): Promise<boolean> {
|
||||||
@@ -22,6 +24,14 @@ export async function isGitRepository(path: string): Promise<boolean> {
|
|||||||
|
|
||||||
export async function discoverGitWorktrees(path: string): Promise<GitWorktreeInfo[]> {
|
export async function discoverGitWorktrees(path: string): Promise<GitWorktreeInfo[]> {
|
||||||
const { stdout } = await execFileAsync("git", ["-C", path, "worktree", "list", "--porcelain"], { env: sanitizedGitEnv() });
|
const { stdout } = await execFileAsync("git", ["-C", path, "worktree", "list", "--porcelain"], { env: sanitizedGitEnv() });
|
||||||
|
return parseGitWorktreeList(stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses `git worktree list --porcelain` output into facts only. Deciding which worktrees a
|
||||||
|
* project should show (for example hiding prunable ones) is workspace policy, not parsing.
|
||||||
|
*/
|
||||||
|
export function parseGitWorktreeList(stdout: string): GitWorktreeInfo[] {
|
||||||
const chunks = stdout.trim().split(/\n\s*\n/).filter(Boolean);
|
const chunks = stdout.trim().split(/\n\s*\n/).filter(Boolean);
|
||||||
|
|
||||||
return chunks.map((chunk) => {
|
return chunks.map((chunk) => {
|
||||||
@@ -33,6 +43,7 @@ export async function discoverGitWorktrees(path: string): Promise<GitWorktreeInf
|
|||||||
if (key === "branch") info.branch = value.replace(/^refs\/heads\//, "");
|
if (key === "branch") info.branch = value.replace(/^refs\/heads\//, "");
|
||||||
if (key === "bare") info.bare = true;
|
if (key === "bare") info.bare = true;
|
||||||
if (key === "detached") info.detached = true;
|
if (key === "detached") info.detached = true;
|
||||||
|
if (key === "prunable") info.prunable = true;
|
||||||
}
|
}
|
||||||
return info;
|
return info;
|
||||||
}).filter((w) => w.path);
|
}).filter((w) => w.path);
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { Project } from "../types.js";
|
||||||
|
import type { GitWorktreeInfo } from "./gitWorktreeDiscovery.js";
|
||||||
|
import { WorkspaceService, type WorkspaceGitPort } from "./workspaceService.js";
|
||||||
|
|
||||||
|
const project: Project = {
|
||||||
|
id: "p1",
|
||||||
|
name: "Project",
|
||||||
|
path: "/repo",
|
||||||
|
createdAt: "2026-05-25T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
function serviceFor(worktrees: GitWorktreeInfo[], isGitRepo = true): WorkspaceService {
|
||||||
|
const git: WorkspaceGitPort = {
|
||||||
|
isGitRepository: () => Promise.resolve(isGitRepo),
|
||||||
|
discoverGitWorktrees: () => Promise.resolve(worktrees),
|
||||||
|
};
|
||||||
|
return new WorkspaceService(git);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("WorkspaceService.list", () => {
|
||||||
|
it("hides a linked worktree whose checkout directory was removed outside PI WEB", async () => {
|
||||||
|
const service = serviceFor([
|
||||||
|
{ path: "/repo", branch: "main" },
|
||||||
|
{ path: "/repo-worktrees/gone", branch: "gone", prunable: true },
|
||||||
|
{ path: "/repo-worktrees/live", branch: "live" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const workspaces = await service.list(project);
|
||||||
|
|
||||||
|
expect(workspaces.map((workspace) => workspace.path)).toEqual(["/repo", "/repo-worktrees/live"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a worktree that is present but not prunable, such as a locked one", async () => {
|
||||||
|
const service = serviceFor([
|
||||||
|
{ path: "/repo", branch: "main" },
|
||||||
|
{ path: "/repo-worktrees/kept", branch: "kept" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const workspaces = await service.list(project);
|
||||||
|
|
||||||
|
expect(workspaces.map((workspace) => workspace.path)).toEqual(["/repo", "/repo-worktrees/kept"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the project's own worktree even if git marks it prunable, so a project is never empty", async () => {
|
||||||
|
const service = serviceFor([{ path: "/repo", branch: "main", prunable: true }]);
|
||||||
|
|
||||||
|
const workspaces = await service.list(project);
|
||||||
|
|
||||||
|
expect(workspaces).toEqual([expect.objectContaining({ path: "/repo", label: "main", isMain: true, isGitWorktree: true })]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the project itself when every linked worktree is filtered away", async () => {
|
||||||
|
const service = serviceFor([{ path: "/repo-worktrees/gone", branch: "gone", prunable: true }]);
|
||||||
|
|
||||||
|
const workspaces = await service.list(project);
|
||||||
|
|
||||||
|
expect(workspaces).toEqual([expect.objectContaining({ path: "/repo", label: "Project", isMain: true, isGitRepo: true, isGitWorktree: false })]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels detached and unnamed worktrees without inventing a branch", async () => {
|
||||||
|
const service = serviceFor([
|
||||||
|
{ path: "/repo", branch: "main" },
|
||||||
|
{ path: "/repo-worktrees/detached", detached: true },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const workspaces = await service.list(project);
|
||||||
|
|
||||||
|
expect(workspaces.map((workspace) => ({ label: workspace.label, branch: workspace.branch }))).toEqual([
|
||||||
|
{ label: "main", branch: "main" },
|
||||||
|
{ label: "detached", branch: undefined },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a single non-git workspace when the project is not a repository", async () => {
|
||||||
|
const service = serviceFor([], false);
|
||||||
|
|
||||||
|
expect(await service.list(project)).toEqual([expect.objectContaining({ path: "/repo", isGitRepo: false, isGitWorktree: false })]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,18 +1,28 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import type { Project } from "../types.js";
|
import type { Project } from "../types.js";
|
||||||
import type { Workspace } from "../types.js";
|
import type { Workspace } from "../types.js";
|
||||||
import { discoverGitWorktrees, isGitRepository } from "./gitWorktreeDiscovery.js";
|
import { discoverGitWorktrees, isGitRepository, type GitWorktreeInfo } from "./gitWorktreeDiscovery.js";
|
||||||
|
|
||||||
const idFor = (value: string) => createHash("sha1").update(value).digest("hex").slice(0, 12);
|
const idFor = (value: string) => createHash("sha1").update(value).digest("hex").slice(0, 12);
|
||||||
|
|
||||||
|
/** The git facts this service needs, injectable so workspace policy is testable without a real repo. */
|
||||||
|
export interface WorkspaceGitPort {
|
||||||
|
isGitRepository(path: string): Promise<boolean>;
|
||||||
|
discoverGitWorktrees(path: string): Promise<GitWorktreeInfo[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const realGit: WorkspaceGitPort = { isGitRepository, discoverGitWorktrees };
|
||||||
|
|
||||||
export class WorkspaceService {
|
export class WorkspaceService {
|
||||||
|
constructor(private readonly git: WorkspaceGitPort = realGit) {}
|
||||||
|
|
||||||
async list(project: Project): Promise<Workspace[]> {
|
async list(project: Project): Promise<Workspace[]> {
|
||||||
const isGitRepo = await isGitRepository(project.path);
|
const isGitRepo = await this.git.isGitRepository(project.path);
|
||||||
if (!isGitRepo) {
|
if (!isGitRepo) {
|
||||||
return [this.single(project, false)];
|
return [this.single(project, false)];
|
||||||
}
|
}
|
||||||
|
|
||||||
const worktrees = await discoverGitWorktrees(project.path);
|
const worktrees = this.selectable(await this.git.discoverGitWorktrees(project.path), project);
|
||||||
if (worktrees.length === 0) return [this.single(project, true)];
|
if (worktrees.length === 0) return [this.single(project, true)];
|
||||||
|
|
||||||
return worktrees.map((worktree) => {
|
return worktrees.map((worktree) => {
|
||||||
@@ -30,6 +40,16 @@ export class WorkspaceService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Git keeps listing a linked worktree after its checkout directory is deleted outside PI WEB,
|
||||||
|
* marking it `prunable`. Such an entry is not a usable workspace, so it is hidden rather than
|
||||||
|
* offered as a selectable ghost. Listing stays read-only: we never run `git worktree prune`.
|
||||||
|
* The project's own path is always kept so a project cannot end up with no workspace at all.
|
||||||
|
*/
|
||||||
|
private selectable(worktrees: GitWorktreeInfo[], project: Project): GitWorktreeInfo[] {
|
||||||
|
return worktrees.filter((worktree) => worktree.prunable !== true || worktree.path === project.path);
|
||||||
|
}
|
||||||
|
|
||||||
private single(project: Project, isGitRepo: boolean): Workspace {
|
private single(project: Project, isGitRepo: boolean): Workspace {
|
||||||
return {
|
return {
|
||||||
id: idFor(`${project.id}:${project.path}`),
|
id: idFor(`${project.id}:${project.path}`),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { isSessionActive, isWorkspaceActivityActive } from "./activity";
|
import { isSessionActive, isWorkspaceActivityActive } from "./activity";
|
||||||
import type { SessionStatus, WorkspaceActivity } from "./apiTypes";
|
import type { SessionActivity, SessionStatus, WorkspaceActivity } from "./apiTypes";
|
||||||
|
|
||||||
const idleStatus: SessionStatus = {
|
const idleStatus: SessionStatus = {
|
||||||
sessionId: "s1",
|
sessionId: "s1",
|
||||||
@@ -20,6 +20,29 @@ describe("activity helpers", () => {
|
|||||||
expect(isSessionActive({ ...idleStatus, pendingMessageCount: 2 })).toBe(true);
|
expect(isSessionActive({ ...idleStatus, pendingMessageCount: 2 })).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not count a session that is only starting up as doing work", () => {
|
||||||
|
// Startup is reported on the activity channel with an "active" phase because
|
||||||
|
// a phase really is in progress, but opening a session is not work a user
|
||||||
|
// can stop, so the marker is what separates starting from working.
|
||||||
|
const startup: SessionActivity = { sessionId: "s1", phase: "active", label: "Opening session", detail: "Starting the Pi session", at: "now", startup: true };
|
||||||
|
|
||||||
|
expect(isSessionActive(undefined, startup)).toBe(false);
|
||||||
|
expect(isSessionActive(idleStatus, startup)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still reports genuine work happening while a session starts up", () => {
|
||||||
|
const startup: SessionActivity = { sessionId: "s1", phase: "active", label: "Opening session", at: "now", startup: true };
|
||||||
|
|
||||||
|
// The marker only removes the activity-phase reason for being active, so
|
||||||
|
// work the status proves is unaffected by it.
|
||||||
|
expect(isSessionActive({ ...idleStatus, isStreaming: true }, startup)).toBe(true);
|
||||||
|
expect(isSessionActive({ ...idleStatus, isBashRunning: true }, startup)).toBe(true);
|
||||||
|
expect(isSessionActive({ ...idleStatus, isCompacting: true }, startup)).toBe(true);
|
||||||
|
expect(isSessionActive({ ...idleStatus, pendingMessageCount: 1 }, startup)).toBe(true);
|
||||||
|
// An unmarked active activity is ordinary work and keeps counting.
|
||||||
|
expect(isSessionActive(idleStatus, { sessionId: "s1", phase: "active", label: "running tool", at: "now" })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("detects workspace activity presence without exposing details", () => {
|
it("detects workspace activity presence without exposing details", () => {
|
||||||
const idle: WorkspaceActivity = { cwd: "/repo", hasSessionActivity: false, hasTerminalActivity: false, updatedAt: "now" };
|
const idle: WorkspaceActivity = { cwd: "/repo", hasSessionActivity: false, hasTerminalActivity: false, updatedAt: "now" };
|
||||||
expect(isWorkspaceActivityActive(idle)).toBe(false);
|
expect(isWorkspaceActivityActive(idle)).toBe(false);
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import type { SessionActivity, SessionStatus, WorkspaceActivity } from "./apiTypes.js";
|
import type { SessionActivity, SessionStatus, WorkspaceActivity } from "./apiTypes.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a session has work in progress a user could stop or must wait for.
|
||||||
|
*
|
||||||
|
* A startup activity is excluded: the session is being opened, not worked in.
|
||||||
|
* The exclusion can only ever remove the activity-phase reason for being
|
||||||
|
* active, so streaming, bash, compaction, and queued prompts still count even
|
||||||
|
* while a startup report is the latest activity.
|
||||||
|
*/
|
||||||
export function isSessionActive(status?: SessionStatus, activity?: SessionActivity): boolean {
|
export function isSessionActive(status?: SessionStatus, activity?: SessionActivity): boolean {
|
||||||
return activity?.phase === "active"
|
return (activity?.phase === "active" && activity.startup !== true)
|
||||||
|| status?.isStreaming === true
|
|| status?.isStreaming === true
|
||||||
|| status?.isBashRunning === true
|
|| status?.isBashRunning === true
|
||||||
|| status?.isCompacting === true
|
|| status?.isCompacting === true
|
||||||
|
|||||||
+14
-6
@@ -433,6 +433,14 @@ export interface SessionActivity {
|
|||||||
label: string;
|
label: string;
|
||||||
detail?: string;
|
detail?: string;
|
||||||
at: string;
|
at: string;
|
||||||
|
/**
|
||||||
|
* Set only on the startup window's own reports. A startup phase is genuinely
|
||||||
|
* in progress, so it is published as `active` and rendered like any other
|
||||||
|
* activity, but *starting* a session is not *working* in it: there is nothing
|
||||||
|
* to stop, nothing that blocks reloading from disk, and no workspace-level
|
||||||
|
* work to report. `isSessionActive()` reads this to keep the two apart.
|
||||||
|
*/
|
||||||
|
startup?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QueuedSessionMessage {
|
export interface QueuedSessionMessage {
|
||||||
@@ -570,18 +578,18 @@ export interface AskUserCloseResponse {
|
|||||||
* constructing the agent session and no `PiAgentSession` exists yet, so
|
* constructing the agent session and no `PiAgentSession` exists yet, so
|
||||||
* `activity.update` cannot be published for it.
|
* `activity.update` cannot be published for it.
|
||||||
*
|
*
|
||||||
* `cwd` is the routing key for a browser row that is still waiting for a
|
* `startupToken` is the opaque label a create request supplied, echoed back so a
|
||||||
* session id: a client-invented pending start knows its workspace path but not
|
* browser row still waiting for a session id recognises its own construction.
|
||||||
* the daemon's session id. `activity.sessionId` carries the daemon's real id, so
|
* The daemon never interprets it and it never becomes the session id:
|
||||||
* the same event also serves the case where the browser already knows it (an
|
* `activity.sessionId` always carries the real id, which is how an *open* of a
|
||||||
* open of an existing session).
|
* session the browser already knows is routed instead.
|
||||||
*
|
*
|
||||||
* `activity.phase === "idle"` means the startup window ended with nothing left
|
* `activity.phase === "idle"` means the startup window ended with nothing left
|
||||||
* to report, so a browser that substituted its own text should restore it.
|
* to report, so a browser that substituted its own text should restore it.
|
||||||
*/
|
*/
|
||||||
export interface SessionStartupProgressEvent {
|
export interface SessionStartupProgressEvent {
|
||||||
type: "session.startup";
|
type: "session.startup";
|
||||||
cwd: string;
|
startupToken?: string;
|
||||||
activity: SessionActivity;
|
activity: SessionActivity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user