Archived
feat(sessions): surface cross-worktree parent and child sessions
A session spawned into another worktree recorded a parent that no
listing contained, so the row showed only "parent unavailable" and its
parent's row looked childless. Both facts were accurate and useless:
neither said where the related session actually was.
Report both directions from the session store instead. A missing
parent's cwd and id come from its own file header, so one 4 KB read per
distinct missing parent resolves it without listing other workspaces;
children are counted by listing sibling workspaces and matching the
parent path they already recorded, needing no header reads. Reads are
memoized per path because Pi writes headers once, and the cache is
released on dispose. Both directions are best-effort: an unreadable
header or an unlistable worktree leaves a session unannotated rather
than failing the listing.
In the browser, an orphan child keeps the same child marker as a nested
one, dimmed, so it no longer renders as a root; whereabouts are stated
once on the meta line ("parent in feature/foo", "2 children
elsewhere"), where a clamped title cannot hide them. A "Go to parent
session" action switches to the owning workspace and selects the
parent. Live session.created events keep child counts current instead
of leaving them stale until the next listing.
Session and workspace paths reach the browser from two producers: store
enumeration for a listing, and the live runtime for a broadcast. They
are now compared through one normalizing helper, so tree nesting and
child counts cannot silently miss a link when only a trailing separator
differs.
Extract the shared "workspaces of the project containing this cwd"
lookup out of ProjectScopedSpawnTargetResolver so spawn targeting and
child counting share one implementation, and register it regardless of
whether spawning is enabled: children can predate a config change, and
the tree should stay honest about them either way.
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { countOutOfListingChildren, locateOutOfListingParents } from "./parentSessionLocator.js";
|
||||
import type { SessionHeaderSummary } from "./sessionFileHeader.js";
|
||||
|
||||
const PARENT_PATH = "/sessions/--srv-other--/parent.jsonl";
|
||||
const LISTING_CWD = "/srv/dev/pi-web";
|
||||
const PARENT_CWD = "/srv/other-worktree";
|
||||
|
||||
describe("locateOutOfListingParents", () => {
|
||||
it("reports the cwd and id of a parent that is not in the listing", async () => {
|
||||
const readHeader = headerReader({ [PARENT_PATH]: { id: "parent-id", cwd: PARENT_CWD } });
|
||||
|
||||
const located = await locateOutOfListingParents([child(PARENT_PATH)], LISTING_CWD, readHeader);
|
||||
|
||||
expect(located.get(PARENT_PATH)).toEqual({ parentSessionId: "parent-id", parentSessionCwd: PARENT_CWD });
|
||||
});
|
||||
|
||||
it("does not read headers for parents already present in the listing", async () => {
|
||||
const readHeader = headerReader({});
|
||||
|
||||
const located = await locateOutOfListingParents([{ path: PARENT_PATH }, child(PARENT_PATH)], LISTING_CWD, readHeader);
|
||||
|
||||
expect(located.size).toBe(0);
|
||||
expect(readHeader).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reads each distinct missing parent once even when several children share it", async () => {
|
||||
const readHeader = headerReader({ [PARENT_PATH]: { id: "parent-id", cwd: PARENT_CWD } });
|
||||
|
||||
await locateOutOfListingParents([child(PARENT_PATH), child(PARENT_PATH), child(PARENT_PATH)], LISTING_CWD, readHeader);
|
||||
|
||||
expect(readHeader).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("omits parents whose header cannot be read, so callers keep the generic unavailable state", async () => {
|
||||
const readHeader = headerReader({});
|
||||
|
||||
const located = await locateOutOfListingParents([child(PARENT_PATH)], LISTING_CWD, readHeader);
|
||||
|
||||
expect(located.size).toBe(0);
|
||||
});
|
||||
|
||||
it("omits parents whose header carries no cwd, as in very old session files", async () => {
|
||||
const readHeader = headerReader({ [PARENT_PATH]: { id: "parent-id" } });
|
||||
|
||||
const located = await locateOutOfListingParents([child(PARENT_PATH)], LISTING_CWD, readHeader);
|
||||
|
||||
expect(located.size).toBe(0);
|
||||
});
|
||||
|
||||
it("ignores sessions without a recorded parent", async () => {
|
||||
const readHeader = headerReader({});
|
||||
|
||||
const located = await locateOutOfListingParents([{ path: "/sessions/root.jsonl" }], LISTING_CWD, readHeader);
|
||||
|
||||
expect(located.size).toBe(0);
|
||||
expect(readHeader).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("locateOutOfListingParents cwd comparison", () => {
|
||||
it("omits a parent that resolves to the listing's own cwd, which needs no jump target", async () => {
|
||||
const readHeader = headerReader({ [PARENT_PATH]: { id: "parent-id", cwd: LISTING_CWD } });
|
||||
|
||||
const located = await locateOutOfListingParents([child(PARENT_PATH)], LISTING_CWD, readHeader);
|
||||
|
||||
expect(located.size).toBe(0);
|
||||
});
|
||||
|
||||
it("treats a cwd differing only by a trailing separator as the same workspace", async () => {
|
||||
const readHeader = headerReader({ [PARENT_PATH]: { id: "parent-id", cwd: `${LISTING_CWD}/` } });
|
||||
|
||||
const located = await locateOutOfListingParents([child(PARENT_PATH)], LISTING_CWD, readHeader);
|
||||
|
||||
expect(located.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("countOutOfListingChildren", () => {
|
||||
it("counts children in other workspaces per listed parent session", () => {
|
||||
const parentA = { path: "/sessions/--srv-dev--/parent-a.jsonl" };
|
||||
const parentB = { path: "/sessions/--srv-dev--/parent-b.jsonl" };
|
||||
|
||||
const counts = countOutOfListingChildren([parentA, parentB], [parentA.path, parentA.path, parentB.path]);
|
||||
|
||||
expect(counts.get(parentA.path)).toBe(2);
|
||||
expect(counts.get(parentB.path)).toBe(1);
|
||||
});
|
||||
|
||||
it("ignores children pointing at sessions that are not in the listing", () => {
|
||||
const counts = countOutOfListingChildren(
|
||||
[{ path: "/sessions/--srv-dev--/listed.jsonl" }],
|
||||
["/sessions/--srv-other--/unlisted.jsonl"],
|
||||
);
|
||||
|
||||
expect(counts.size).toBe(0);
|
||||
});
|
||||
|
||||
it("matches parent paths that differ only by normalization", () => {
|
||||
const counts = countOutOfListingChildren(
|
||||
[{ path: "/sessions/--srv-dev--/parent.jsonl" }],
|
||||
["/sessions/--srv-dev--/./parent.jsonl"],
|
||||
);
|
||||
|
||||
expect(counts.get("/sessions/--srv-dev--/parent.jsonl")).toBe(1);
|
||||
});
|
||||
|
||||
it("reports nothing when no sessions elsewhere claim a parent", () => {
|
||||
expect(countOutOfListingChildren([{ path: "/sessions/a.jsonl" }], []).size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
function child(parentSessionPath: string) {
|
||||
return { path: `/sessions/--srv-dev--/child-${Math.random().toString(36).slice(2)}.jsonl`, parentSessionPath };
|
||||
}
|
||||
|
||||
function headerReader(headers: Record<string, SessionHeaderSummary>) {
|
||||
return vi.fn((sessionFile: string) => Promise.resolve(headers[sessionFile]));
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
|
||||
import type { SessionHeaderSummary } from "./sessionFileHeader.js";
|
||||
|
||||
/** Reads a session file header; injected so locating parents is testable without a filesystem. */
|
||||
export type SessionHeaderReader = (sessionFile: string) => Promise<SessionHeaderSummary | undefined>;
|
||||
|
||||
/** The subset of a listed session this locator needs. */
|
||||
export interface ParentLocatableSession {
|
||||
path: string;
|
||||
parentSessionPath?: string;
|
||||
}
|
||||
|
||||
/** Where an out-of-listing parent session lives, as far as its file header reveals. */
|
||||
export interface ParentSessionLocation {
|
||||
parentSessionId: string;
|
||||
parentSessionCwd: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the parents of `sessions` that live in a different working directory,
|
||||
* keyed by the `parentSessionPath` the child recorded.
|
||||
*
|
||||
* Sessions are listed per working directory, so a child spawned into another
|
||||
* worktree of the same project has a `parentSessionPath` that resolves to
|
||||
* nothing in its own list. The parent's cwd and id are recorded in the parent
|
||||
* session file's header, so one small header read per distinct missing parent is
|
||||
* enough to point at it — no cross-workspace listing required.
|
||||
*
|
||||
* Parents already present in `sessions` are skipped without a read: they are
|
||||
* linkable by path already, so the IO would buy nothing. Headers for a given
|
||||
* path are immutable in practice, so `readHeader` is free to cache; this
|
||||
* function stays stateless.
|
||||
*/
|
||||
export async function locateOutOfListingParents(
|
||||
sessions: readonly ParentLocatableSession[],
|
||||
listingCwd: string,
|
||||
readHeader: SessionHeaderReader,
|
||||
): Promise<Map<string, ParentSessionLocation>> {
|
||||
const located = new Map<string, ParentSessionLocation>();
|
||||
for (const parentSessionPath of missingParentPaths(sessions)) {
|
||||
const location = await parentLocation(parentSessionPath, readHeader);
|
||||
// A parent sharing the listing's cwd is not "elsewhere": it is absent for
|
||||
// some other reason (archived and moved, or simply not listed), and offering
|
||||
// a jump to this same workspace would not help.
|
||||
if (location === undefined || cwdPathsEqual(location.parentSessionCwd, listingCwd)) continue;
|
||||
located.set(parentSessionPath, location);
|
||||
}
|
||||
return located;
|
||||
}
|
||||
|
||||
/** Distinct recorded parent paths that no session in the listing occupies. */
|
||||
function missingParentPaths(sessions: readonly ParentLocatableSession[]): Set<string> {
|
||||
const listedPaths = new Set(sessions.map((session) => canonicalizeStoredCwd(session.path)));
|
||||
const missing = new Set<string>();
|
||||
for (const session of sessions) {
|
||||
const parentSessionPath = session.parentSessionPath;
|
||||
if (parentSessionPath === undefined || parentSessionPath === "") continue;
|
||||
if (listedPaths.has(canonicalizeStoredCwd(parentSessionPath))) continue;
|
||||
missing.add(parentSessionPath);
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* A parent whose header is unreadable or carries no cwd (very old session files)
|
||||
* has no reportable location, so the browser keeps saying only that the parent is
|
||||
* unavailable.
|
||||
*/
|
||||
async function parentLocation(parentSessionPath: string, readHeader: SessionHeaderReader): Promise<ParentSessionLocation | undefined> {
|
||||
const header = await readHeader(parentSessionPath);
|
||||
if (header?.cwd === undefined) return undefined;
|
||||
return { parentSessionId: header.id, parentSessionCwd: canonicalizeStoredCwd(header.cwd) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Count, per listed session path, how many sessions outside this listing record
|
||||
* it as their parent, so a parent row can show that it has children that are not
|
||||
* visible beneath it.
|
||||
*
|
||||
* Children are identified only by the parent session *file* path they recorded,
|
||||
* which is exactly the link Pi writes into the child header; neither a header
|
||||
* read of the parent nor the child's own location is required.
|
||||
*/
|
||||
export function countOutOfListingChildren(
|
||||
sessions: readonly ParentLocatableSession[],
|
||||
childParentSessionPaths: readonly string[],
|
||||
): Map<string, number> {
|
||||
const listedPaths = new Map(sessions.map((session) => [canonicalizeStoredCwd(session.path), session.path]));
|
||||
const counts = new Map<string, number>();
|
||||
for (const parentSessionPath of childParentSessionPaths) {
|
||||
const listedPath = listedPaths.get(canonicalizeStoredCwd(parentSessionPath));
|
||||
if (listedPath === undefined) continue;
|
||||
counts.set(listedPath, (counts.get(listedPath) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { PiSessionService, type PiSessionListEntry } from "./piSessionService.js";
|
||||
import { CapturingSessionEventHub, emptyArchiveStore, fakeSessionManager, sessionRecord, testModelRuntime, type SessionGateway } from "./piSessionService.testSupport.js";
|
||||
|
||||
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
|
||||
const CHILD_CWD = "/srv/dev/pi-web";
|
||||
const PARENT_CWD = "/srv/dev/pi-web-feature";
|
||||
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "pi-web-parent-location-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("PiSessionService.list parent locations", () => {
|
||||
it("reports the cwd and id of a parent living in another worktree", async () => {
|
||||
const parentFile = await parentSessionFile({ id: "parent-id", cwd: PARENT_CWD });
|
||||
const service = serviceListing([childRecord(parentFile)]);
|
||||
|
||||
const [child] = await service.list(CHILD_CWD);
|
||||
|
||||
expect(child).toMatchObject({ id: "child", parentSessionCwd: PARENT_CWD, parentSessionId: "parent-id" });
|
||||
});
|
||||
|
||||
it("leaves sessions untouched when the parent is in the same workspace listing", async () => {
|
||||
const parent = sessionRecord("parent", CHILD_CWD);
|
||||
const child = { ...sessionRecord("child", CHILD_CWD), parentSessionPath: parent.path };
|
||||
const service = serviceListing([parent, child]);
|
||||
|
||||
const listed = await service.list(CHILD_CWD);
|
||||
|
||||
expect(listed.find((session) => session.id === "child")).not.toHaveProperty("parentSessionCwd");
|
||||
});
|
||||
|
||||
it("does not annotate a parent whose file records the same cwd, since it is not elsewhere", async () => {
|
||||
const parentFile = await parentSessionFile({ id: "parent-id", cwd: CHILD_CWD });
|
||||
const service = serviceListing([childRecord(parentFile)]);
|
||||
|
||||
const [child] = await service.list(CHILD_CWD);
|
||||
|
||||
expect(child).not.toHaveProperty("parentSessionCwd");
|
||||
expect(child).toHaveProperty("parentSessionPath", parentFile);
|
||||
});
|
||||
|
||||
it("still lists a child whose parent file is gone, without location fields", async () => {
|
||||
const service = serviceListing([childRecord(join(tempDir, "deleted-parent.jsonl"))]);
|
||||
|
||||
const [child] = await service.list(CHILD_CWD);
|
||||
|
||||
expect(child).toMatchObject({ id: "child" });
|
||||
expect(child).not.toHaveProperty("parentSessionCwd");
|
||||
expect(child).not.toHaveProperty("parentSessionId");
|
||||
});
|
||||
|
||||
it("reads each parent header only once across repeated listings", async () => {
|
||||
const parentFile = await parentSessionFile({ id: "parent-id", cwd: PARENT_CWD });
|
||||
const service = serviceListing([childRecord(parentFile)]);
|
||||
await service.list(CHILD_CWD);
|
||||
|
||||
// A cached header keeps the annotation after the file is removed, proving no
|
||||
// second read happened for the same path.
|
||||
await rm(parentFile);
|
||||
const [child] = await service.list(CHILD_CWD);
|
||||
|
||||
expect(child).toMatchObject({ parentSessionCwd: PARENT_CWD, parentSessionId: "parent-id" });
|
||||
});
|
||||
|
||||
it("releases cached headers on dispose so the cache cannot outlive the service", async () => {
|
||||
const parentFile = await parentSessionFile({ id: "parent-id", cwd: PARENT_CWD });
|
||||
const service = serviceListing([childRecord(parentFile)]);
|
||||
await service.list(CHILD_CWD);
|
||||
|
||||
await service.dispose();
|
||||
await rm(parentFile);
|
||||
const [child] = await service.list(CHILD_CWD);
|
||||
|
||||
expect(child).not.toHaveProperty("parentSessionCwd");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PiSessionService.list children in sibling workspaces", () => {
|
||||
it("counts children that live in other workspaces of the same project", async () => {
|
||||
const parent = sessionRecord("parent", CHILD_CWD);
|
||||
const service = serviceListing({
|
||||
[CHILD_CWD]: [parent],
|
||||
[PARENT_CWD]: [{ ...sessionRecord("child-a", PARENT_CWD), parentSessionPath: parent.path }, { ...sessionRecord("child-b", PARENT_CWD), parentSessionPath: parent.path }],
|
||||
}, [CHILD_CWD, PARENT_CWD]);
|
||||
|
||||
const [listed] = await service.list(CHILD_CWD);
|
||||
|
||||
expect(listed).toMatchObject({ id: "parent", childSessionsElsewhere: 2 });
|
||||
});
|
||||
|
||||
it("does not count children nested in the same workspace listing", async () => {
|
||||
const parent = sessionRecord("parent", CHILD_CWD);
|
||||
const service = serviceListing({
|
||||
[CHILD_CWD]: [parent, { ...sessionRecord("child", CHILD_CWD), parentSessionPath: parent.path }],
|
||||
[PARENT_CWD]: [],
|
||||
}, [CHILD_CWD, PARENT_CWD]);
|
||||
|
||||
const listed = await service.list(CHILD_CWD);
|
||||
|
||||
expect(listed.find((session) => session.id === "parent")).not.toHaveProperty("childSessionsElsewhere");
|
||||
});
|
||||
|
||||
it("skips sibling scanning when the cwd belongs to no registered project", async () => {
|
||||
const parent = sessionRecord("parent", CHILD_CWD);
|
||||
const service = serviceListing({
|
||||
[CHILD_CWD]: [parent],
|
||||
[PARENT_CWD]: [{ ...sessionRecord("child", PARENT_CWD), parentSessionPath: parent.path }],
|
||||
}, undefined);
|
||||
|
||||
const [listed] = await service.list(CHILD_CWD);
|
||||
|
||||
expect(listed).not.toHaveProperty("childSessionsElsewhere");
|
||||
});
|
||||
|
||||
it("still lists sessions when a sibling workspace cannot be listed", async () => {
|
||||
const parent = sessionRecord("parent", CHILD_CWD);
|
||||
const service = serviceListing({ [CHILD_CWD]: [parent] }, [CHILD_CWD, PARENT_CWD], {
|
||||
listCwd: (cwd) => {
|
||||
if (cwd === PARENT_CWD) throw new Error("sibling workspace is gone");
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
|
||||
const [listed] = await service.list(CHILD_CWD);
|
||||
|
||||
expect(listed).toMatchObject({ id: "parent" });
|
||||
expect(listed).not.toHaveProperty("childSessionsElsewhere");
|
||||
});
|
||||
|
||||
it("reports both an out-of-workspace parent and children elsewhere on one listing", async () => {
|
||||
const grandparentFile = await parentSessionFile({ id: "grandparent-id", cwd: PARENT_CWD });
|
||||
const middle = { ...sessionRecord("middle", CHILD_CWD), parentSessionPath: grandparentFile };
|
||||
const service = serviceListing({
|
||||
[CHILD_CWD]: [middle],
|
||||
[PARENT_CWD]: [{ ...sessionRecord("grandchild", PARENT_CWD), parentSessionPath: middle.path }],
|
||||
}, [CHILD_CWD, PARENT_CWD]);
|
||||
|
||||
const [listed] = await service.list(CHILD_CWD);
|
||||
|
||||
expect(listed).toMatchObject({ id: "middle", parentSessionCwd: PARENT_CWD, parentSessionId: "grandparent-id", childSessionsElsewhere: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
function childRecord(parentSessionPath: string) {
|
||||
return { ...sessionRecord("child", CHILD_CWD), parentSessionPath };
|
||||
}
|
||||
|
||||
async function parentSessionFile(header: { id: string; cwd: string }): Promise<string> {
|
||||
const path = join(tempDir, `${header.id}.jsonl`);
|
||||
const lines = [
|
||||
JSON.stringify({ type: "session", version: 3, ...header }),
|
||||
JSON.stringify({ type: "model_change", id: "m1", parentId: null }),
|
||||
];
|
||||
await writeFile(path, `${lines.join("\n")}\n`, "utf8");
|
||||
return path;
|
||||
}
|
||||
|
||||
type SessionRecord = PiSessionListEntry;
|
||||
|
||||
/**
|
||||
* Build a service over per-cwd session listings. `projectCwds` is the workspace
|
||||
* set of the containing project, or undefined to model an unregistered cwd.
|
||||
*/
|
||||
function serviceListing(
|
||||
recordsByCwd: SessionRecord[] | Record<string, SessionRecord[]>,
|
||||
projectCwds?: string[],
|
||||
options: { listCwd?: (cwd: string) => void } = {},
|
||||
): PiSessionService {
|
||||
const listings = Array.isArray(recordsByCwd) ? { [CHILD_CWD]: recordsByCwd } : recordsByCwd;
|
||||
const gateway: SessionGateway = {
|
||||
create: () => fakeSessionManager(),
|
||||
list: (cwd: string) => {
|
||||
options.listCwd?.(cwd);
|
||||
return Promise.resolve(listings[cwd] ?? []);
|
||||
},
|
||||
open: () => fakeSessionManager(),
|
||||
};
|
||||
return new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
archiveStore: emptyArchiveStore(),
|
||||
sessionManager: gateway,
|
||||
heartbeatIntervalMs: 60_000,
|
||||
projectWorkspaces: { forCwd: () => Promise.resolve(projectCwds) },
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { open, readFile, writeFile } from "node:fs/promises";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import type { ImageContent } from "@earendil-works/pi-ai";
|
||||
import type { StreamFn } from "@earendil-works/pi-agent-core";
|
||||
import {
|
||||
@@ -56,6 +56,9 @@ import type { SessionRouteLookup, SessionRouteRef, SessionRouteService } from ".
|
||||
|
||||
import { type AuthChange } from "./authService.js";
|
||||
import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
|
||||
import { readSessionHeaderSummary, type SessionHeaderSummary } from "./sessionFileHeader.js";
|
||||
import { countOutOfListingChildren, locateOutOfListingParents, type SessionHeaderReader } from "./parentSessionLocator.js";
|
||||
import { siblingWorkspaceCwds, type ProjectWorkspaceCwds } from "../workspaces/projectWorkspaceCwds.js";
|
||||
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
|
||||
import { createAskUserToolDefinition, type AskUserInvocation, type AskUserToolDeps } from "./askUserTool.js";
|
||||
import { PendingAskStore, renderAskUserAnswersText, type PendingAskCloseResult, type PendingAskOpenResult } from "./pendingAskStore.js";
|
||||
@@ -694,6 +697,13 @@ export interface PiSessionServiceDependencies {
|
||||
* Omit to keep the capability disabled.
|
||||
*/
|
||||
spawnTargets?: SpawnTargetResolver;
|
||||
/**
|
||||
* When provided, session listings report related sessions living in sibling
|
||||
* workspaces of the same project: where an out-of-workspace parent is, and how
|
||||
* many children a listed session has elsewhere. Omit to list each workspace in
|
||||
* isolation.
|
||||
*/
|
||||
projectWorkspaces?: ProjectWorkspaceCwds;
|
||||
/**
|
||||
* Beta: when true (and `spawnTargets` is provided), the tracked-subsession
|
||||
* tools are available to sessions whose creation provenance permits
|
||||
@@ -752,6 +762,8 @@ export class PiSessionService implements SessionRouteService {
|
||||
private readonly subsessionLinks = new Map<string, TrackedSubsessionLink>();
|
||||
/** Parent id/file identities whose persisted links have already been loaded. */
|
||||
private readonly subsessionHydratedParents = new Set<string>();
|
||||
/** Session file path -> its parsed header. Headers are written once, so successful reads are cached. */
|
||||
private readonly sessionHeaderCache = new Map<string, SessionHeaderSummary>();
|
||||
/**
|
||||
* Tracked subsession id -> whether a completion notification is armed.
|
||||
* Armed when the child starts working; firing on completion disarms it so a
|
||||
@@ -766,6 +778,7 @@ export class PiSessionService implements SessionRouteService {
|
||||
private readonly modelRuntime: ModelRuntime;
|
||||
private readonly workspaceActivity: Pick<WorkspaceActivityService, "applySessionStatus" | "applySessionActivity" | "removeSession" | "reconcileSessionActivity"> | undefined;
|
||||
private readonly spawnTargets: SpawnTargetResolver | undefined;
|
||||
private readonly projectWorkspaces: ProjectWorkspaceCwds | undefined;
|
||||
private readonly logger: PiSessionLogger;
|
||||
private readonly now: () => Date;
|
||||
private readonly notificationStore: SessionNotificationStore;
|
||||
@@ -788,6 +801,7 @@ export class PiSessionService implements SessionRouteService {
|
||||
this.sessionManager = deps.sessionManager;
|
||||
this.modelRuntime = deps.modelRuntime;
|
||||
this.spawnTargets = deps.spawnTargets;
|
||||
this.projectWorkspaces = deps.projectWorkspaces;
|
||||
this.logger = deps.logger ?? noopLogger;
|
||||
this.now = deps.now ?? (() => new Date());
|
||||
this.notificationStore = deps.notificationStore ?? new SessionNotificationStore();
|
||||
@@ -974,6 +988,7 @@ export class PiSessionService implements SessionRouteService {
|
||||
this.subsessionLinks.clear();
|
||||
this.subsessionHydratedParents.clear();
|
||||
this.subsessionNotifyArmed.clear();
|
||||
this.sessionHeaderCache.clear();
|
||||
this.notificationStore.clearAll("service-dispose");
|
||||
await Promise.all(activeSessions.map(async (active) => {
|
||||
active.unsubscribe();
|
||||
@@ -1008,9 +1023,78 @@ export class PiSessionService implements SessionRouteService {
|
||||
.sort(compareArchivedRecords)
|
||||
.map((record) => clientSessionFromArchivedRecord(record, sessionsById.get(record.sessionId)))
|
||||
.filter(isDefined);
|
||||
return [...unarchivedSessions, ...archivedSessions];
|
||||
return await this.withRelatedSessionsElsewhere([...unarchivedSessions, ...archivedSessions], cwd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Annotate a listing with the session relationships that cross workspace
|
||||
* boundaries: where an out-of-listing parent lives, and how many children a
|
||||
* listed session has in sibling workspaces.
|
||||
*
|
||||
* Both directions are best-effort. An unreadable parent header or a sibling
|
||||
* workspace that cannot be listed leaves the session unannotated rather than
|
||||
* failing the listing, and the browser falls back to its generic states.
|
||||
*/
|
||||
private async withRelatedSessionsElsewhere(sessions: readonly ClientSession[], cwd: string): Promise<ClientSession[]> {
|
||||
const [parentLocations, childCounts] = await Promise.all([
|
||||
locateOutOfListingParents(sessions, cwd, this.readCachedSessionHeader),
|
||||
this.countChildrenInSiblingWorkspaces(sessions, cwd),
|
||||
]);
|
||||
return sessions.map((session) => {
|
||||
const parent = session.parentSessionPath === undefined ? undefined : parentLocations.get(session.parentSessionPath);
|
||||
const childrenElsewhere = childCounts.get(session.path);
|
||||
if (parent === undefined && childrenElsewhere === undefined) return session;
|
||||
const annotated = { ...session };
|
||||
if (parent !== undefined) {
|
||||
annotated.parentSessionId = parent.parentSessionId;
|
||||
annotated.parentSessionCwd = parent.parentSessionCwd;
|
||||
}
|
||||
if (childrenElsewhere !== undefined) annotated.childSessionsElsewhere = childrenElsewhere;
|
||||
return annotated;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Count children of the listed sessions that live in other workspaces of the
|
||||
* same project.
|
||||
*
|
||||
* Only sibling workspaces are scanned: agents may only spawn into workspaces
|
||||
* of the spawning session's own project, so that bounds where a child can be.
|
||||
* Listing is skipped entirely when no project-workspace locator is configured
|
||||
* or the cwd belongs to no registered project.
|
||||
*/
|
||||
private async countChildrenInSiblingWorkspaces(sessions: readonly ClientSession[], cwd: string): Promise<Map<string, number>> {
|
||||
if (this.projectWorkspaces === undefined || sessions.length === 0) return new Map();
|
||||
try {
|
||||
const siblingCwds = await siblingWorkspaceCwds(this.projectWorkspaces, cwd);
|
||||
if (siblingCwds.length === 0) return new Map();
|
||||
const listings = await Promise.all(siblingCwds.map(async (siblingCwd): Promise<string[]> => {
|
||||
const entries = await this.sessionManager.list(siblingCwd);
|
||||
return entries.flatMap((entry) => entry.parentSessionPath === undefined ? [] : [entry.parentSessionPath]);
|
||||
}));
|
||||
return countOutOfListingChildren(sessions, listings.flat());
|
||||
} catch (error: unknown) {
|
||||
this.logger.info(
|
||||
{ cwd, error: error instanceof Error ? error.message : String(error) },
|
||||
"failed to count child sessions in sibling workspaces",
|
||||
);
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a session file header, memoized per path. Pi writes the header once at
|
||||
* session creation, so a successful read stays valid for the process lifetime;
|
||||
* failures are not cached so a session file that appears later is picked up.
|
||||
*/
|
||||
private readonly readCachedSessionHeader: SessionHeaderReader = async (sessionFile) => {
|
||||
const cached = this.sessionHeaderCache.get(sessionFile);
|
||||
if (cached !== undefined) return cached;
|
||||
const header = await readSessionHeaderSummary(sessionFile);
|
||||
if (header !== undefined) this.sessionHeaderCache.set(sessionFile, header);
|
||||
return header;
|
||||
};
|
||||
|
||||
async start(cwd: string, options: StartSessionOptions = {}): Promise<ClientSession> {
|
||||
return this.startSession(cwd, options);
|
||||
}
|
||||
@@ -3626,30 +3710,6 @@ function trackedLinkParentFileMatches(link: TrackedSubsessionLink, parentSession
|
||||
return link.parentSessionFile !== undefined && sessionPathsEqual(link.parentSessionFile, parentSessionFile);
|
||||
}
|
||||
|
||||
interface SessionHeaderSummary {
|
||||
id: string;
|
||||
parentSession?: string;
|
||||
}
|
||||
|
||||
async function readSessionHeaderSummary(sessionFile: string): Promise<SessionHeaderSummary | undefined> {
|
||||
let file: Awaited<ReturnType<typeof open>> | undefined;
|
||||
try {
|
||||
file = await open(sessionFile, "r");
|
||||
const buffer = Buffer.alloc(4096);
|
||||
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
|
||||
const firstLine = buffer.toString("utf8", 0, bytesRead).split("\n", 1)[0];
|
||||
if (firstLine === undefined || firstLine === "") return undefined;
|
||||
const header: unknown = JSON.parse(firstLine);
|
||||
if (!isRecord(header) || header["type"] !== "session" || typeof header["id"] !== "string") return undefined;
|
||||
const parentSession = getString(header, "parentSession");
|
||||
return { id: header["id"], ...(parentSession === undefined ? {} : { parentSession }) };
|
||||
} catch {
|
||||
return undefined;
|
||||
} finally {
|
||||
await file?.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function sessionFileHeaderMatches(sessionFile: string, expected: { sessionId: string; parentSessionFile?: string | undefined }): Promise<boolean> {
|
||||
const header = await readSessionHeaderSummary(sessionFile);
|
||||
if (header?.id !== expected.sessionId) return false;
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { readSessionHeaderSummary } from "./sessionFileHeader.js";
|
||||
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "pi-web-session-header-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("readSessionHeaderSummary", () => {
|
||||
it("reads id, cwd, and parent session from a real session file header", async () => {
|
||||
const sessionFile = await sessionFileWithLines([
|
||||
{ type: "session", version: 3, id: "child-id", cwd: "/srv/dev/pi-web-feature", parentSession: "/sessions/parent.jsonl" },
|
||||
{ type: "model_change", id: "abc", parentId: null },
|
||||
]);
|
||||
|
||||
expect(await readSessionHeaderSummary(sessionFile)).toEqual({
|
||||
id: "child-id",
|
||||
cwd: "/srv/dev/pi-web-feature",
|
||||
parentSession: "/sessions/parent.jsonl",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits absent and empty optional fields", async () => {
|
||||
const sessionFile = await sessionFileWithLines([{ type: "session", version: 3, id: "root-id", cwd: "" }]);
|
||||
|
||||
expect(await readSessionHeaderSummary(sessionFile)).toEqual({ id: "root-id" });
|
||||
});
|
||||
|
||||
it("returns undefined for a missing file", async () => {
|
||||
expect(await readSessionHeaderSummary(join(tempDir, "absent.jsonl"))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when the first line is not valid JSON", async () => {
|
||||
const sessionFile = join(tempDir, "broken.jsonl");
|
||||
await writeFile(sessionFile, "not json\n", "utf8");
|
||||
|
||||
expect(await readSessionHeaderSummary(sessionFile)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when the first line is not a session header", async () => {
|
||||
const sessionFile = await sessionFileWithLines([{ type: "model_change", id: "abc" }]);
|
||||
|
||||
expect(await readSessionHeaderSummary(sessionFile)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when the header carries no session id", async () => {
|
||||
const sessionFile = await sessionFileWithLines([{ type: "session", version: 3, cwd: "/srv/dev/pi-web" }]);
|
||||
|
||||
expect(await readSessionHeaderSummary(sessionFile)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not read beyond the header line of a large transcript", async () => {
|
||||
const sessionFile = join(tempDir, "large.jsonl");
|
||||
const header = JSON.stringify({ type: "session", version: 3, id: "big-id", cwd: "/srv/dev/pi-web" });
|
||||
const bulk = Array.from({ length: 500 }, (_unused, index) => JSON.stringify({ type: "message", id: String(index), text: "x".repeat(200) }));
|
||||
await writeFile(sessionFile, `${[header, ...bulk].join("\n")}\n`, "utf8");
|
||||
|
||||
expect(await readSessionHeaderSummary(sessionFile)).toEqual({ id: "big-id", cwd: "/srv/dev/pi-web" });
|
||||
});
|
||||
});
|
||||
|
||||
async function sessionFileWithLines(lines: readonly Record<string, unknown>[]): Promise<string> {
|
||||
const sessionFile = join(tempDir, `session-${String(lines.length)}-${Math.random().toString(36).slice(2)}.jsonl`);
|
||||
await writeFile(sessionFile, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`, "utf8");
|
||||
return sessionFile;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { open } from "node:fs/promises";
|
||||
|
||||
/** Bytes read from a session file to parse its single-line JSON header. */
|
||||
const HEADER_READ_BYTES = 4096;
|
||||
|
||||
/**
|
||||
* The header fields PI WEB reads directly from a Pi session file.
|
||||
*
|
||||
* Pi writes this as the first line of the `.jsonl` session file when the
|
||||
* session is created and never rewrites it, except for `parentSession`, which
|
||||
* PI WEB itself can clear when detaching a child. `cwd` and `id` are therefore
|
||||
* safe to treat as immutable for a given path.
|
||||
*/
|
||||
export interface SessionHeaderSummary {
|
||||
id: string;
|
||||
/** Working directory the session was started in. Absent in very old session files. */
|
||||
cwd?: string;
|
||||
/** Session file of the parent session, when this session was spawned or forked from one. */
|
||||
parentSession?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a Pi session file's header without loading the whole transcript.
|
||||
*
|
||||
* Returns undefined for any unreadable, non-JSON, or non-session first line:
|
||||
* callers use this to verify links between sessions, so an unusable header must
|
||||
* behave the same as a missing one rather than throwing.
|
||||
*/
|
||||
export async function readSessionHeaderSummary(sessionFile: string): Promise<SessionHeaderSummary | undefined> {
|
||||
let file: Awaited<ReturnType<typeof open>> | undefined;
|
||||
try {
|
||||
file = await open(sessionFile, "r");
|
||||
const buffer = Buffer.alloc(HEADER_READ_BYTES);
|
||||
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
|
||||
const firstLine = buffer.toString("utf8", 0, bytesRead).split("\n", 1)[0];
|
||||
if (firstLine === undefined || firstLine === "") return undefined;
|
||||
const header: unknown = JSON.parse(firstLine);
|
||||
if (!isRecord(header) || header["type"] !== "session" || typeof header["id"] !== "string") return undefined;
|
||||
const cwd = nonEmptyStringField(header, "cwd");
|
||||
const parentSession = nonEmptyStringField(header, "parentSession");
|
||||
return {
|
||||
id: header["id"],
|
||||
...(cwd === undefined ? {} : { cwd }),
|
||||
...(parentSession === undefined ? {} : { parentSession }),
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
} finally {
|
||||
await file?.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function nonEmptyStringField(header: Record<string, unknown>, key: string): string | undefined {
|
||||
const value = header[key];
|
||||
return typeof value === "string" && value !== "" ? value : undefined;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Project, Workspace } from "../types.js";
|
||||
import { cwdPathsEqual } from "../workingDirectory.js";
|
||||
import { RegisteredProjectWorkspaceCwds, type ProjectWorkspaceCwds, type ProjectWorkspaceCwdsDeps } from "../workspaces/projectWorkspaceCwds.js";
|
||||
|
||||
/**
|
||||
* Decision describing whether a LLM-spawned session may target a given cwd.
|
||||
@@ -33,18 +33,7 @@ export interface SpawnTargetResolver {
|
||||
resolveSpawnTarget(spawningCwd: string, requestedCwd: string | undefined): Promise<SpawnTargetDecision>;
|
||||
}
|
||||
|
||||
interface ProjectLister {
|
||||
list(): Promise<Project[]>;
|
||||
}
|
||||
|
||||
interface WorkspaceLister {
|
||||
list(project: Project): Promise<Workspace[]>;
|
||||
}
|
||||
|
||||
export interface ProjectScopedSpawnTargetResolverDeps {
|
||||
projects: ProjectLister;
|
||||
workspaces: WorkspaceLister;
|
||||
}
|
||||
export type ProjectScopedSpawnTargetResolverDeps = ProjectWorkspaceCwdsDeps;
|
||||
|
||||
/**
|
||||
* Default resolver composing the project registry and live worktree discovery.
|
||||
@@ -52,28 +41,18 @@ export interface ProjectScopedSpawnTargetResolverDeps {
|
||||
* spawning session's cwd, then validates the requested target against that set.
|
||||
*/
|
||||
export class ProjectScopedSpawnTargetResolver implements SpawnTargetResolver {
|
||||
constructor(private readonly deps: ProjectScopedSpawnTargetResolverDeps) {}
|
||||
private readonly projectWorkspaces: ProjectWorkspaceCwds;
|
||||
|
||||
constructor(deps: ProjectScopedSpawnTargetResolverDeps) {
|
||||
this.projectWorkspaces = new RegisteredProjectWorkspaceCwds(deps);
|
||||
}
|
||||
|
||||
async resolveSpawnTarget(spawningCwd: string, requestedCwd: string | undefined): Promise<SpawnTargetDecision> {
|
||||
const allowedCwds = await this.allowedSpawnTargets(spawningCwd);
|
||||
const allowedCwds = await this.projectWorkspaces.forCwd(spawningCwd);
|
||||
if (allowedCwds === undefined) return { allowed: false, reason: "not-registered" };
|
||||
const target = requestedCwd === undefined || requestedCwd === "" ? spawningCwd : requestedCwd;
|
||||
const match = allowedCwds.find((path) => cwdPathsEqual(path, target));
|
||||
if (match === undefined) return { allowed: false, reason: "out-of-project", allowedCwds };
|
||||
return { allowed: true, cwd: match };
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace paths of the registered project that owns `spawningCwd`, or
|
||||
* `undefined` when no registered project contains it.
|
||||
*/
|
||||
private async allowedSpawnTargets(spawningCwd: string): Promise<string[] | undefined> {
|
||||
const projects = await this.deps.projects.list();
|
||||
for (const project of projects) {
|
||||
const workspaces = await this.deps.workspaces.list(project);
|
||||
const paths = workspaces.map((workspace) => workspace.path);
|
||||
if (paths.some((path) => cwdPathsEqual(path, spawningCwd))) return paths;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user