Archived
Merge pull request #102 from jmfederico/feat/worktree-autodetect
feat(workspaces): detect worktrees created or removed outside PI WEB
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -214,7 +214,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,18 +289,18 @@ 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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -308,15 +308,17 @@ describe("API parsers", () => {
|
|||||||
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", () => {
|
||||||
|
|||||||
@@ -275,15 +275,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"]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -3,8 +3,6 @@ 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",
|
||||||
@@ -21,8 +19,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 +36,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 +47,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 +59,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 +67,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");
|
||||||
@@ -75,10 +83,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 +106,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 +129,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();
|
||||||
@@ -136,12 +145,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 +161,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 +169,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;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -185,7 +185,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);
|
||||||
@@ -1309,25 +1309,20 @@ 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.
|
||||||
@@ -1336,13 +1331,6 @@ export class SessionController {
|
|||||||
: { ...event.activity, sessionId: pending.tempId });
|
: { ...event.activity, sessionId: 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 {
|
||||||
if (this.pendingFrame !== undefined) return;
|
if (this.pendingFrame !== undefined) return;
|
||||||
this.pendingFrame = requestAnimationFrame(() => {
|
this.pendingFrame = requestAnimationFrame(() => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,14 +93,15 @@ describe("notification socket guards", () => {
|
|||||||
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("preserves existing event acceptance without treating unknown types as realtime events", () => {
|
it("preserves existing event acceptance without treating unknown types as realtime events", () => {
|
||||||
|
|||||||
@@ -77,7 +77,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 +157,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) });
|
||||||
|
|||||||
@@ -199,6 +199,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 {
|
||||||
@@ -390,7 +395,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";
|
||||||
/**
|
/**
|
||||||
@@ -992,6 +997,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 }),
|
||||||
},
|
},
|
||||||
@@ -2353,7 +2359,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 {
|
||||||
@@ -2999,23 +3005,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);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -3027,17 +3033,17 @@ 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.
|
||||||
*/
|
*/
|
||||||
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 } : { sessionId, phase, label, detail, at };
|
||||||
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 {
|
||||||
|
|||||||
@@ -26,6 +26,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";
|
||||||
|
|
||||||
@@ -666,6 +667,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);
|
||||||
@@ -706,6 +736,7 @@ class CapturingRouteSessionService implements SessionRouteService {
|
|||||||
readonly bulkArchiveCalls: SessionBulkMutationRef[][] = [];
|
readonly bulkArchiveCalls: SessionBulkMutationRef[][] = [];
|
||||||
readonly bulkDeleteCalls: SessionBulkMutationRef[][] = [];
|
readonly bulkDeleteCalls: SessionBulkMutationRef[][] = [];
|
||||||
readonly navigateTreeCalls: { lookup: SessionRouteLookup; request: SessionTreeNavigateRequest }[] = [];
|
readonly navigateTreeCalls: { lookup: SessionRouteLookup; request: SessionTreeNavigateRequest }[] = [];
|
||||||
|
readonly startCalls: { cwd: string; startupToken: string | undefined }[] = [];
|
||||||
reloadError: Error | undefined;
|
reloadError: Error | undefined;
|
||||||
clearQueueError: Error | undefined;
|
clearQueueError: Error | undefined;
|
||||||
|
|
||||||
@@ -772,7 +803,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) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,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}`),
|
||||||
|
|||||||
@@ -438,18 +438,18 @@ export interface QueuedSessionMessage {
|
|||||||
* 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