refactor(server): extract WorkspaceDirectory from session.ts (#667)
Move workspace descriptor map building, fetch_workspaces filtering / sorting / cursor paging, and archiving overlay state out of the Session god class into a dedicated WorkspaceDirectory module. Session keeps thin delegation methods so existing tests and callers see the same surface; cursor errors are still mapped to SessionRequestError at the session boundary.
This commit is contained in:
@@ -31,7 +31,6 @@ import {
|
|||||||
type ProjectPlacementPayload,
|
type ProjectPlacementPayload,
|
||||||
type WorkspaceSetupSnapshot,
|
type WorkspaceSetupSnapshot,
|
||||||
type WorkspaceDescriptorPayload,
|
type WorkspaceDescriptorPayload,
|
||||||
type WorkspaceStateBucket,
|
|
||||||
} from "./messages.js";
|
} from "./messages.js";
|
||||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||||
import { TerminalSessionController } from "../terminal/terminal-session-controller.js";
|
import { TerminalSessionController } from "../terminal/terminal-session-controller.js";
|
||||||
@@ -196,6 +195,11 @@ import {
|
|||||||
type GitHubService,
|
type GitHubService,
|
||||||
type PullRequestTimelineItem,
|
type PullRequestTimelineItem,
|
||||||
} from "../services/github-service.js";
|
} from "../services/github-service.js";
|
||||||
|
import {
|
||||||
|
summarizeFetchWorkspacesEntries,
|
||||||
|
WorkspaceDirectory,
|
||||||
|
type WorkspaceUpdatesFilter,
|
||||||
|
} from "./workspace-directory.js";
|
||||||
import {
|
import {
|
||||||
attemptFirstAgentBranchAutoName,
|
attemptFirstAgentBranchAutoName,
|
||||||
createPaseoWorktree,
|
createPaseoWorktree,
|
||||||
@@ -420,12 +424,6 @@ function getFirstUserMessageText(timeline: readonly AgentTimelineItem[]): string
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FETCH_AGENTS_SORT_KEYS = ["status_priority", "created_at", "updated_at", "title"] as const;
|
const FETCH_AGENTS_SORT_KEYS = ["status_priority", "created_at", "updated_at", "title"] as const;
|
||||||
const FETCH_WORKSPACES_SORT_KEYS = [
|
|
||||||
"status_priority",
|
|
||||||
"activity_at",
|
|
||||||
"name",
|
|
||||||
"project_id",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export function resolveWaitForFinishError(options: {
|
export function resolveWaitForFinishError(options: {
|
||||||
status: "permission" | "error" | "idle";
|
status: "permission" | "error" | "idle";
|
||||||
@@ -484,7 +482,6 @@ type FetchWorkspacesRequestMessage = Extract<
|
|||||||
{ type: "fetch_workspaces_request" }
|
{ type: "fetch_workspaces_request" }
|
||||||
>;
|
>;
|
||||||
type FetchWorkspacesRequestFilter = NonNullable<FetchWorkspacesRequestMessage["filter"]>;
|
type FetchWorkspacesRequestFilter = NonNullable<FetchWorkspacesRequestMessage["filter"]>;
|
||||||
type FetchWorkspacesRequestSort = NonNullable<FetchWorkspacesRequestMessage["sort"]>[number];
|
|
||||||
type FetchWorkspacesResponsePayload = Extract<
|
type FetchWorkspacesResponsePayload = Extract<
|
||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
{ type: "fetch_workspaces_response" }
|
{ type: "fetch_workspaces_response" }
|
||||||
@@ -495,7 +492,6 @@ type WorkspaceUpdatePayload = Extract<
|
|||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
{ type: "workspace_update" }
|
{ type: "workspace_update" }
|
||||||
>["payload"];
|
>["payload"];
|
||||||
type WorkspaceUpdatesFilter = FetchWorkspacesRequestFilter;
|
|
||||||
interface WorkspaceUpdatesSubscriptionState {
|
interface WorkspaceUpdatesSubscriptionState {
|
||||||
subscriptionId: string;
|
subscriptionId: string;
|
||||||
filter?: WorkspaceUpdatesFilter;
|
filter?: WorkspaceUpdatesFilter;
|
||||||
@@ -503,41 +499,6 @@ interface WorkspaceUpdatesSubscriptionState {
|
|||||||
pendingUpdatesByWorkspaceId: Map<string, WorkspaceUpdatePayload>;
|
pendingUpdatesByWorkspaceId: Map<string, WorkspaceUpdatePayload>;
|
||||||
lastEmittedByWorkspaceId: Map<string, WorkspaceUpdatePayload>;
|
lastEmittedByWorkspaceId: Map<string, WorkspaceUpdatePayload>;
|
||||||
}
|
}
|
||||||
function summarizeFetchWorkspacesEntries(entries: Iterable<FetchWorkspacesResponseEntry>): {
|
|
||||||
count: number;
|
|
||||||
projectIds: string[];
|
|
||||||
statusCounts: Record<string, number>;
|
|
||||||
workspaces: Array<{
|
|
||||||
id: string;
|
|
||||||
projectId: string;
|
|
||||||
projectDisplayName: string;
|
|
||||||
name: string;
|
|
||||||
status: FetchWorkspacesResponseEntry["status"];
|
|
||||||
workspaceKind: FetchWorkspacesResponseEntry["workspaceKind"];
|
|
||||||
activityAt: string | null;
|
|
||||||
}>;
|
|
||||||
} {
|
|
||||||
const workspaces = Array.from(entries, (entry) => ({
|
|
||||||
id: entry.id,
|
|
||||||
projectId: entry.projectId,
|
|
||||||
projectDisplayName: entry.projectDisplayName,
|
|
||||||
name: entry.name,
|
|
||||||
status: entry.status,
|
|
||||||
workspaceKind: entry.workspaceKind,
|
|
||||||
activityAt: entry.activityAt,
|
|
||||||
}));
|
|
||||||
const statusCounts = new Map<string, number>();
|
|
||||||
for (const workspace of workspaces) {
|
|
||||||
statusCounts.set(workspace.status, (statusCounts.get(workspace.status) ?? 0) + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
count: workspaces.length,
|
|
||||||
projectIds: [...new Set(workspaces.map((workspace) => workspace.projectId))],
|
|
||||||
statusCounts: Object.fromEntries(statusCounts),
|
|
||||||
workspaces,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
class SessionRequestError extends Error {
|
class SessionRequestError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -872,7 +833,7 @@ export class Session {
|
|||||||
private readonly workspaceSetupSnapshots: Map<string, WorkspaceSetupSnapshot>;
|
private readonly workspaceSetupSnapshots: Map<string, WorkspaceSetupSnapshot>;
|
||||||
private readonly workspaceGitFetchSubscriptions = new Map<string, () => void>();
|
private readonly workspaceGitFetchSubscriptions = new Map<string, () => void>();
|
||||||
private readonly workspaceGitSubscriptions = new Map<string, () => void>();
|
private readonly workspaceGitSubscriptions = new Map<string, () => void>();
|
||||||
private readonly archivingByWorkspaceId = new Map<string, string>();
|
private readonly workspaceDirectory: WorkspaceDirectory;
|
||||||
private registerVoiceSpeakHandler?: (agentId: string, handler: VoiceSpeakHandler) => void;
|
private registerVoiceSpeakHandler?: (agentId: string, handler: VoiceSpeakHandler) => void;
|
||||||
private unregisterVoiceSpeakHandler?: (agentId: string) => void;
|
private unregisterVoiceSpeakHandler?: (agentId: string) => void;
|
||||||
private registerVoiceCallerContext?: (agentId: string, context: VoiceCallerContext) => void;
|
private registerVoiceCallerContext?: (agentId: string, context: VoiceCallerContext) => void;
|
||||||
@@ -974,6 +935,14 @@ export class Session {
|
|||||||
this.providerOverrides = providerOverrides;
|
this.providerOverrides = providerOverrides;
|
||||||
this.isDev = isDev === true;
|
this.isDev = isDev === true;
|
||||||
this.abortController = new AbortController();
|
this.abortController = new AbortController();
|
||||||
|
this.workspaceDirectory = new WorkspaceDirectory({
|
||||||
|
logger: this.sessionLogger,
|
||||||
|
projectRegistry: this.projectRegistry,
|
||||||
|
workspaceRegistry: this.workspaceRegistry,
|
||||||
|
listAgentPayloads: () => this.listAgentPayloads(),
|
||||||
|
isProviderVisibleToClient: (provider) => this.isProviderVisibleToClient(provider),
|
||||||
|
buildWorkspaceDescriptor: (input) => this.buildWorkspaceDescriptor(input),
|
||||||
|
});
|
||||||
|
|
||||||
this.initializePerSessionManagers({ tts, stt, dictation });
|
this.initializePerSessionManagers({ tts, stt, dictation });
|
||||||
|
|
||||||
@@ -5939,14 +5908,6 @@ export class Session {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly workspaceStatePriority: Record<WorkspaceStateBucket, number> = {
|
|
||||||
needs_input: 0,
|
|
||||||
failed: 1,
|
|
||||||
running: 2,
|
|
||||||
attention: 3,
|
|
||||||
done: 4,
|
|
||||||
};
|
|
||||||
|
|
||||||
private readonly agentsPager = new SortablePager<
|
private readonly agentsPager = new SortablePager<
|
||||||
AgentSnapshotPayload,
|
AgentSnapshotPayload,
|
||||||
FetchAgentsRequestSort["key"]
|
FetchAgentsRequestSort["key"]
|
||||||
@@ -5969,28 +5930,6 @@ export class Session {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
private readonly workspacesPager = new SortablePager<
|
|
||||||
WorkspaceDescriptorPayload,
|
|
||||||
FetchWorkspacesRequestSort["key"]
|
|
||||||
>({
|
|
||||||
validKeys: FETCH_WORKSPACES_SORT_KEYS,
|
|
||||||
defaultSort: [{ key: "activity_at", direction: "desc" }],
|
|
||||||
label: "fetch_workspaces",
|
|
||||||
getId: (workspace) => workspace.id,
|
|
||||||
getSortValue: (workspace, key) => {
|
|
||||||
switch (key) {
|
|
||||||
case "status_priority":
|
|
||||||
return this.workspaceStatePriority[workspace.status];
|
|
||||||
case "activity_at":
|
|
||||||
return workspace.activityAt ? Date.parse(workspace.activityAt) : null;
|
|
||||||
case "name":
|
|
||||||
return workspace.name.toLocaleLowerCase();
|
|
||||||
case "project_id":
|
|
||||||
return workspace.projectId.toLocaleLowerCase();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
private decodeAgentCursor(token: string, sort: SortSpec<FetchAgentsRequestSort["key"]>[]) {
|
private decodeAgentCursor(token: string, sort: SortSpec<FetchAgentsRequestSort["key"]>[]) {
|
||||||
try {
|
try {
|
||||||
return this.agentsPager.decode(token, sort);
|
return this.agentsPager.decode(token, sort);
|
||||||
@@ -6002,37 +5941,6 @@ export class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private decodeWorkspaceCursor(
|
|
||||||
token: string,
|
|
||||||
sort: SortSpec<FetchWorkspacesRequestSort["key"]>[],
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
return this.workspacesPager.decode(token, sort);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof CursorError) {
|
|
||||||
throw new SessionRequestError("invalid_cursor", error.message);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private deriveWorkspaceStateBucket(agent: AgentSnapshotPayload): WorkspaceStateBucket {
|
|
||||||
const pendingPermissionCount = agent.pendingPermissions?.length ?? 0;
|
|
||||||
if (pendingPermissionCount > 0 || agent.attentionReason === "permission") {
|
|
||||||
return "needs_input";
|
|
||||||
}
|
|
||||||
if (agent.status === "error" || agent.attentionReason === "error") {
|
|
||||||
return "failed";
|
|
||||||
}
|
|
||||||
if (agent.status === "running") {
|
|
||||||
return "running";
|
|
||||||
}
|
|
||||||
if (agent.requiresAttention) {
|
|
||||||
return "attention";
|
|
||||||
}
|
|
||||||
return "done";
|
|
||||||
}
|
|
||||||
|
|
||||||
private async describeWorkspaceRecord(
|
private async describeWorkspaceRecord(
|
||||||
workspace: PersistedWorkspaceRecord,
|
workspace: PersistedWorkspaceRecord,
|
||||||
projectRecord?: PersistedProjectRecord | null,
|
projectRecord?: PersistedProjectRecord | null,
|
||||||
@@ -6173,160 +6081,32 @@ export class Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
markWorkspaceArchiving(workspaceIds: Iterable<string>, archivingAt: string): void {
|
markWorkspaceArchiving(workspaceIds: Iterable<string>, archivingAt: string): void {
|
||||||
for (const workspaceId of workspaceIds) {
|
this.workspaceDirectory.markArchiving(workspaceIds, archivingAt);
|
||||||
this.archivingByWorkspaceId.set(workspaceId, archivingAt);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
clearWorkspaceArchiving(workspaceIds: Iterable<string>): void {
|
clearWorkspaceArchiving(workspaceIds: Iterable<string>): void {
|
||||||
for (const workspaceId of workspaceIds) {
|
this.workspaceDirectory.clearArchiving(workspaceIds);
|
||||||
this.archivingByWorkspaceId.delete(workspaceId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async buildWorkspaceDescriptorMap(options: {
|
private async buildWorkspaceDescriptorMap(options: {
|
||||||
includeGitData: boolean;
|
includeGitData: boolean;
|
||||||
workspaceIds?: Iterable<string>;
|
workspaceIds?: Iterable<string>;
|
||||||
}): Promise<Map<string, WorkspaceDescriptorPayload>> {
|
}): Promise<Map<string, WorkspaceDescriptorPayload>> {
|
||||||
const [agents, persistedWorkspaces, persistedProjects] = await Promise.all([
|
return this.workspaceDirectory.buildDescriptorMap(options);
|
||||||
this.listAgentPayloads(),
|
|
||||||
this.workspaceRegistry.list(),
|
|
||||||
this.projectRegistry.list(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const activeProjects = new Map(
|
|
||||||
persistedProjects
|
|
||||||
.filter((project) => !project.archivedAt)
|
|
||||||
.map((project) => [project.projectId, project] as const),
|
|
||||||
);
|
|
||||||
const archivedProjectIds = new Set(
|
|
||||||
persistedProjects.filter((project) => project.archivedAt).map((project) => project.projectId),
|
|
||||||
);
|
|
||||||
const activeRecords = persistedWorkspaces.filter(
|
|
||||||
(workspace) => !workspace.archivedAt && !archivedProjectIds.has(workspace.projectId),
|
|
||||||
);
|
|
||||||
const descriptorsByWorkspaceId = new Map<string, WorkspaceDescriptorPayload>();
|
|
||||||
const workspaceIds = options.workspaceIds ? new Set(options.workspaceIds) : null;
|
|
||||||
const workspaceIdsByDirectory = new Map(
|
|
||||||
activeRecords.map(
|
|
||||||
(workspace) =>
|
|
||||||
[normalizePersistedWorkspaceId(workspace.cwd), workspace.workspaceId] as const,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const includedWorkspaces = activeRecords.filter(
|
|
||||||
(workspace) => !workspaceIds || workspaceIds.has(workspace.workspaceId),
|
|
||||||
);
|
|
||||||
const workspaceDescriptors = await Promise.all(
|
|
||||||
includedWorkspaces.map((workspace) =>
|
|
||||||
this.buildWorkspaceDescriptor({
|
|
||||||
workspace,
|
|
||||||
projectRecord: activeProjects.get(workspace.projectId) ?? null,
|
|
||||||
includeGitData: options.includeGitData,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
for (let i = 0; i < includedWorkspaces.length; i += 1) {
|
|
||||||
const workspaceId = includedWorkspaces[i]!.workspaceId;
|
|
||||||
descriptorsByWorkspaceId.set(workspaceId, {
|
|
||||||
...workspaceDescriptors[i]!,
|
|
||||||
archivingAt: this.archivingByWorkspaceId.get(workspaceId) ?? null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const agent of agents) {
|
|
||||||
if (agent.archivedAt) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!this.isProviderVisibleToClient(agent.provider)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspaceId = workspaceIdsByDirectory.get(normalizePersistedWorkspaceId(agent.cwd));
|
|
||||||
if (workspaceId === undefined) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const existing = descriptorsByWorkspaceId.get(workspaceId);
|
|
||||||
if (!existing) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bucket = this.deriveWorkspaceStateBucket(agent);
|
|
||||||
if (this.workspaceStatePriority[bucket] < this.workspaceStatePriority[existing.status]) {
|
|
||||||
existing.status = bucket;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return descriptorsByWorkspaceId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveRegisteredWorkspaceIdForCwd(
|
private resolveRegisteredWorkspaceIdForCwd(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
workspaces: PersistedWorkspaceRecord[],
|
workspaces: PersistedWorkspaceRecord[],
|
||||||
): string {
|
): string {
|
||||||
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
|
return this.workspaceDirectory.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces);
|
||||||
const exact = workspaces.find((workspace) => workspace.cwd === normalizedCwd);
|
|
||||||
if (exact) {
|
|
||||||
return exact.workspaceId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userHome = homedir();
|
|
||||||
let bestMatch: PersistedWorkspaceRecord | null = null;
|
|
||||||
for (const workspace of workspaces) {
|
|
||||||
if (workspace.cwd === userHome) continue;
|
|
||||||
if (workspace.archivedAt) continue;
|
|
||||||
const prefix = workspace.cwd.endsWith(sep) ? workspace.cwd : `${workspace.cwd}${sep}`;
|
|
||||||
if (!normalizedCwd.startsWith(prefix)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!bestMatch || workspace.cwd.length > bestMatch.cwd.length) {
|
|
||||||
bestMatch = workspace;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return bestMatch?.workspaceId ?? normalizedCwd;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async listWorkspaceDescriptors(): Promise<WorkspaceDescriptorPayload[]> {
|
|
||||||
return Array.from(
|
|
||||||
(
|
|
||||||
await this.buildWorkspaceDescriptorMap({
|
|
||||||
includeGitData: true,
|
|
||||||
})
|
|
||||||
).values(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private matchesWorkspaceFilter(input: {
|
private matchesWorkspaceFilter(input: {
|
||||||
workspace: WorkspaceDescriptorPayload;
|
workspace: WorkspaceDescriptorPayload;
|
||||||
filter: FetchWorkspacesRequestFilter | undefined;
|
filter: FetchWorkspacesRequestFilter | undefined;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
const { workspace, filter } = input;
|
return this.workspaceDirectory.matchesFilter(input);
|
||||||
if (!filter) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.projectId && filter.projectId.trim().length > 0) {
|
|
||||||
if (workspace.projectId !== filter.projectId.trim()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.idPrefix && filter.idPrefix.trim().length > 0) {
|
|
||||||
if (!String(workspace.id).startsWith(filter.idPrefix.trim())) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.query && filter.query.trim().length > 0) {
|
|
||||||
const query = filter.query.trim().toLocaleLowerCase();
|
|
||||||
const haystacks = [workspace.name, String(workspace.projectId), String(workspace.id)];
|
|
||||||
if (!haystacks.some((value) => value.toLocaleLowerCase().includes(query))) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async listFetchWorkspacesEntries(
|
private async listFetchWorkspacesEntries(
|
||||||
@@ -6335,53 +6115,14 @@ export class Session {
|
|||||||
entries: FetchWorkspacesResponseEntry[];
|
entries: FetchWorkspacesResponseEntry[];
|
||||||
pageInfo: FetchWorkspacesResponsePageInfo;
|
pageInfo: FetchWorkspacesResponsePageInfo;
|
||||||
}> {
|
}> {
|
||||||
const filter = request.filter;
|
try {
|
||||||
const sort = this.workspacesPager.normalizeSort(request.sort);
|
return await this.workspaceDirectory.listFetchEntries(request);
|
||||||
let entries = await this.listWorkspaceDescriptors();
|
} catch (error) {
|
||||||
const listedCount = entries.length;
|
if (error instanceof CursorError) {
|
||||||
entries = entries.filter((workspace) => this.matchesWorkspaceFilter({ workspace, filter }));
|
throw new SessionRequestError("invalid_cursor", error.message);
|
||||||
const filteredCount = entries.length;
|
}
|
||||||
entries.sort((left, right) => this.workspacesPager.compare(left, right, sort));
|
throw error;
|
||||||
|
|
||||||
const cursorToken = request.page?.cursor;
|
|
||||||
if (cursorToken) {
|
|
||||||
const cursor = this.decodeWorkspaceCursor(cursorToken, sort);
|
|
||||||
entries = entries.filter(
|
|
||||||
(workspace) => this.workspacesPager.compareWithCursor(workspace, cursor, sort) > 0,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const limit = request.page?.limit ?? 200;
|
|
||||||
const pagedEntries = entries.slice(0, limit);
|
|
||||||
const hasMore = entries.length > limit;
|
|
||||||
const nextCursor =
|
|
||||||
hasMore && pagedEntries.length > 0
|
|
||||||
? this.workspacesPager.encode(pagedEntries[pagedEntries.length - 1], sort)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
this.sessionLogger.debug(
|
|
||||||
{
|
|
||||||
requestId: request.requestId,
|
|
||||||
filter: request.filter ?? null,
|
|
||||||
sort,
|
|
||||||
page: request.page ?? null,
|
|
||||||
listedCount,
|
|
||||||
filteredCount,
|
|
||||||
returnedCount: pagedEntries.length,
|
|
||||||
hasMore,
|
|
||||||
nextCursor,
|
|
||||||
},
|
|
||||||
"fetch_workspaces_entries_listed",
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
entries: pagedEntries,
|
|
||||||
pageInfo: {
|
|
||||||
nextCursor,
|
|
||||||
prevCursor: request.page?.cursor ?? null,
|
|
||||||
hasMore,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private bufferOrEmitWorkspaceUpdate(
|
private bufferOrEmitWorkspaceUpdate(
|
||||||
|
|||||||
@@ -0,0 +1,347 @@
|
|||||||
|
import { homedir } from "node:os";
|
||||||
|
import { sep } from "node:path";
|
||||||
|
import type pino from "pino";
|
||||||
|
import type {
|
||||||
|
AgentSnapshotPayload,
|
||||||
|
SessionInboundMessage,
|
||||||
|
SessionOutboundMessage,
|
||||||
|
WorkspaceDescriptorPayload,
|
||||||
|
WorkspaceStateBucket,
|
||||||
|
} from "./messages.js";
|
||||||
|
import { SortablePager } from "./pagination/sortable-pager.js";
|
||||||
|
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||||
|
import { normalizeWorkspaceId } from "./workspace-registry-model.js";
|
||||||
|
|
||||||
|
const FETCH_WORKSPACES_SORT_KEYS = [
|
||||||
|
"status_priority",
|
||||||
|
"activity_at",
|
||||||
|
"name",
|
||||||
|
"project_id",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type FetchWorkspacesRequestMessage = Extract<
|
||||||
|
SessionInboundMessage,
|
||||||
|
{ type: "fetch_workspaces_request" }
|
||||||
|
>;
|
||||||
|
type FetchWorkspacesRequestFilter = NonNullable<FetchWorkspacesRequestMessage["filter"]>;
|
||||||
|
type FetchWorkspacesRequestSort = NonNullable<FetchWorkspacesRequestMessage["sort"]>[number];
|
||||||
|
type FetchWorkspacesResponsePayload = Extract<
|
||||||
|
SessionOutboundMessage,
|
||||||
|
{ type: "fetch_workspaces_response" }
|
||||||
|
>["payload"];
|
||||||
|
type FetchWorkspacesResponseEntry = FetchWorkspacesResponsePayload["entries"][number];
|
||||||
|
type FetchWorkspacesResponsePageInfo = FetchWorkspacesResponsePayload["pageInfo"];
|
||||||
|
|
||||||
|
export type WorkspaceUpdatesFilter = FetchWorkspacesRequestFilter;
|
||||||
|
|
||||||
|
export interface WorkspaceDirectoryDeps {
|
||||||
|
logger: pino.Logger;
|
||||||
|
projectRegistry: {
|
||||||
|
list(): Promise<PersistedProjectRecord[]>;
|
||||||
|
};
|
||||||
|
workspaceRegistry: {
|
||||||
|
list(): Promise<PersistedWorkspaceRecord[]>;
|
||||||
|
};
|
||||||
|
listAgentPayloads(): Promise<AgentSnapshotPayload[]>;
|
||||||
|
isProviderVisibleToClient(provider: string): boolean;
|
||||||
|
buildWorkspaceDescriptor(input: {
|
||||||
|
workspace: PersistedWorkspaceRecord;
|
||||||
|
projectRecord?: PersistedProjectRecord | null;
|
||||||
|
includeGitData: boolean;
|
||||||
|
}): Promise<WorkspaceDescriptorPayload>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeFetchWorkspacesEntries(entries: Iterable<FetchWorkspacesResponseEntry>): {
|
||||||
|
count: number;
|
||||||
|
projectIds: string[];
|
||||||
|
statusCounts: Record<string, number>;
|
||||||
|
workspaces: Array<{
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
projectDisplayName: string;
|
||||||
|
name: string;
|
||||||
|
status: FetchWorkspacesResponseEntry["status"];
|
||||||
|
workspaceKind: FetchWorkspacesResponseEntry["workspaceKind"];
|
||||||
|
activityAt: string | null;
|
||||||
|
}>;
|
||||||
|
} {
|
||||||
|
const workspaces = Array.from(entries, (entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
projectId: entry.projectId,
|
||||||
|
projectDisplayName: entry.projectDisplayName,
|
||||||
|
name: entry.name,
|
||||||
|
status: entry.status,
|
||||||
|
workspaceKind: entry.workspaceKind,
|
||||||
|
activityAt: entry.activityAt,
|
||||||
|
}));
|
||||||
|
const statusCounts = new Map<string, number>();
|
||||||
|
for (const workspace of workspaces) {
|
||||||
|
statusCounts.set(workspace.status, (statusCounts.get(workspace.status) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
count: workspaces.length,
|
||||||
|
projectIds: [...new Set(workspaces.map((workspace) => workspace.projectId))],
|
||||||
|
statusCounts: Object.fromEntries(statusCounts),
|
||||||
|
workspaces,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WorkspaceDirectory {
|
||||||
|
private readonly archivingByWorkspaceId = new Map<string, string>();
|
||||||
|
|
||||||
|
private readonly workspaceStatePriority: Record<WorkspaceStateBucket, number> = {
|
||||||
|
needs_input: 0,
|
||||||
|
failed: 1,
|
||||||
|
running: 2,
|
||||||
|
attention: 3,
|
||||||
|
done: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly pager = new SortablePager<
|
||||||
|
WorkspaceDescriptorPayload,
|
||||||
|
FetchWorkspacesRequestSort["key"]
|
||||||
|
>({
|
||||||
|
validKeys: FETCH_WORKSPACES_SORT_KEYS,
|
||||||
|
defaultSort: [{ key: "activity_at", direction: "desc" }],
|
||||||
|
label: "fetch_workspaces",
|
||||||
|
getId: (workspace) => workspace.id,
|
||||||
|
getSortValue: (workspace, key) => {
|
||||||
|
switch (key) {
|
||||||
|
case "status_priority":
|
||||||
|
return this.workspaceStatePriority[workspace.status];
|
||||||
|
case "activity_at":
|
||||||
|
return workspace.activityAt ? Date.parse(workspace.activityAt) : null;
|
||||||
|
case "name":
|
||||||
|
return workspace.name.toLocaleLowerCase();
|
||||||
|
case "project_id":
|
||||||
|
return workspace.projectId.toLocaleLowerCase();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor(private readonly deps: WorkspaceDirectoryDeps) {}
|
||||||
|
|
||||||
|
markArchiving(workspaceIds: Iterable<string>, archivingAt: string): void {
|
||||||
|
for (const workspaceId of workspaceIds) {
|
||||||
|
this.archivingByWorkspaceId.set(workspaceId, archivingAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearArchiving(workspaceIds: Iterable<string>): void {
|
||||||
|
for (const workspaceId of workspaceIds) {
|
||||||
|
this.archivingByWorkspaceId.delete(workspaceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async buildDescriptorMap(options: {
|
||||||
|
includeGitData: boolean;
|
||||||
|
workspaceIds?: Iterable<string>;
|
||||||
|
}): Promise<Map<string, WorkspaceDescriptorPayload>> {
|
||||||
|
const [agents, persistedWorkspaces, persistedProjects] = await Promise.all([
|
||||||
|
this.deps.listAgentPayloads(),
|
||||||
|
this.deps.workspaceRegistry.list(),
|
||||||
|
this.deps.projectRegistry.list(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const activeProjects = new Map(
|
||||||
|
persistedProjects
|
||||||
|
.filter((project) => !project.archivedAt)
|
||||||
|
.map((project) => [project.projectId, project] as const),
|
||||||
|
);
|
||||||
|
const archivedProjectIds = new Set(
|
||||||
|
persistedProjects.filter((project) => project.archivedAt).map((project) => project.projectId),
|
||||||
|
);
|
||||||
|
const activeRecords = persistedWorkspaces.filter(
|
||||||
|
(workspace) => !workspace.archivedAt && !archivedProjectIds.has(workspace.projectId),
|
||||||
|
);
|
||||||
|
const descriptorsByWorkspaceId = new Map<string, WorkspaceDescriptorPayload>();
|
||||||
|
const workspaceIds = options.workspaceIds ? new Set(options.workspaceIds) : null;
|
||||||
|
const workspaceIdsByDirectory = new Map(
|
||||||
|
activeRecords.map(
|
||||||
|
(workspace) => [normalizeWorkspaceId(workspace.cwd), workspace.workspaceId] as const,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const includedWorkspaces = activeRecords.filter(
|
||||||
|
(workspace) => !workspaceIds || workspaceIds.has(workspace.workspaceId),
|
||||||
|
);
|
||||||
|
const workspaceDescriptors = await Promise.all(
|
||||||
|
includedWorkspaces.map((workspace) =>
|
||||||
|
this.deps.buildWorkspaceDescriptor({
|
||||||
|
workspace,
|
||||||
|
projectRecord: activeProjects.get(workspace.projectId) ?? null,
|
||||||
|
includeGitData: options.includeGitData,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (let i = 0; i < includedWorkspaces.length; i += 1) {
|
||||||
|
const workspaceId = includedWorkspaces[i]!.workspaceId;
|
||||||
|
descriptorsByWorkspaceId.set(workspaceId, {
|
||||||
|
...workspaceDescriptors[i]!,
|
||||||
|
archivingAt: this.archivingByWorkspaceId.get(workspaceId) ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const agent of agents) {
|
||||||
|
if (agent.archivedAt) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!this.deps.isProviderVisibleToClient(agent.provider)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceId = workspaceIdsByDirectory.get(normalizeWorkspaceId(agent.cwd));
|
||||||
|
if (workspaceId === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const existing = descriptorsByWorkspaceId.get(workspaceId);
|
||||||
|
if (!existing) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bucket = this.deriveStateBucket(agent);
|
||||||
|
if (this.workspaceStatePriority[bucket] < this.workspaceStatePriority[existing.status]) {
|
||||||
|
existing.status = bucket;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return descriptorsByWorkspaceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveRegisteredWorkspaceIdForCwd(cwd: string, workspaces: PersistedWorkspaceRecord[]): string {
|
||||||
|
const normalizedCwd = normalizeWorkspaceId(cwd);
|
||||||
|
const exact = workspaces.find((workspace) => workspace.cwd === normalizedCwd);
|
||||||
|
if (exact) {
|
||||||
|
return exact.workspaceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userHome = homedir();
|
||||||
|
let bestMatch: PersistedWorkspaceRecord | null = null;
|
||||||
|
for (const workspace of workspaces) {
|
||||||
|
if (workspace.cwd === userHome) continue;
|
||||||
|
if (workspace.archivedAt) continue;
|
||||||
|
const prefix = workspace.cwd.endsWith(sep) ? workspace.cwd : `${workspace.cwd}${sep}`;
|
||||||
|
if (!normalizedCwd.startsWith(prefix)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!bestMatch || workspace.cwd.length > bestMatch.cwd.length) {
|
||||||
|
bestMatch = workspace;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestMatch?.workspaceId ?? normalizedCwd;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listDescriptors(): Promise<WorkspaceDescriptorPayload[]> {
|
||||||
|
return Array.from(
|
||||||
|
(
|
||||||
|
await this.buildDescriptorMap({
|
||||||
|
includeGitData: true,
|
||||||
|
})
|
||||||
|
).values(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
matchesFilter(input: {
|
||||||
|
workspace: WorkspaceDescriptorPayload;
|
||||||
|
filter: FetchWorkspacesRequestFilter | undefined;
|
||||||
|
}): boolean {
|
||||||
|
const { workspace, filter } = input;
|
||||||
|
if (!filter) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.projectId && filter.projectId.trim().length > 0) {
|
||||||
|
if (workspace.projectId !== filter.projectId.trim()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.idPrefix && filter.idPrefix.trim().length > 0) {
|
||||||
|
if (!String(workspace.id).startsWith(filter.idPrefix.trim())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.query && filter.query.trim().length > 0) {
|
||||||
|
const query = filter.query.trim().toLocaleLowerCase();
|
||||||
|
const haystacks = [workspace.name, String(workspace.projectId), String(workspace.id)];
|
||||||
|
if (!haystacks.some((value) => value.toLocaleLowerCase().includes(query))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listFetchEntries(request: FetchWorkspacesRequestMessage): Promise<{
|
||||||
|
entries: FetchWorkspacesResponseEntry[];
|
||||||
|
pageInfo: FetchWorkspacesResponsePageInfo;
|
||||||
|
}> {
|
||||||
|
const filter = request.filter;
|
||||||
|
const sort = this.pager.normalizeSort(request.sort);
|
||||||
|
let entries = await this.listDescriptors();
|
||||||
|
const listedCount = entries.length;
|
||||||
|
entries = entries.filter((workspace) => this.matchesFilter({ workspace, filter }));
|
||||||
|
const filteredCount = entries.length;
|
||||||
|
entries.sort((left, right) => this.pager.compare(left, right, sort));
|
||||||
|
|
||||||
|
const cursorToken = request.page?.cursor;
|
||||||
|
if (cursorToken) {
|
||||||
|
const cursor = this.pager.decode(cursorToken, sort);
|
||||||
|
entries = entries.filter(
|
||||||
|
(workspace) => this.pager.compareWithCursor(workspace, cursor, sort) > 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = request.page?.limit ?? 200;
|
||||||
|
const pagedEntries = entries.slice(0, limit);
|
||||||
|
const hasMore = entries.length > limit;
|
||||||
|
const nextCursor =
|
||||||
|
hasMore && pagedEntries.length > 0
|
||||||
|
? this.pager.encode(pagedEntries[pagedEntries.length - 1], sort)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
this.deps.logger.debug(
|
||||||
|
{
|
||||||
|
requestId: request.requestId,
|
||||||
|
filter: request.filter ?? null,
|
||||||
|
sort,
|
||||||
|
page: request.page ?? null,
|
||||||
|
listedCount,
|
||||||
|
filteredCount,
|
||||||
|
returnedCount: pagedEntries.length,
|
||||||
|
hasMore,
|
||||||
|
nextCursor,
|
||||||
|
},
|
||||||
|
"fetch_workspaces_entries_listed",
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
entries: pagedEntries,
|
||||||
|
pageInfo: {
|
||||||
|
nextCursor,
|
||||||
|
prevCursor: request.page?.cursor ?? null,
|
||||||
|
hasMore,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private deriveStateBucket(agent: AgentSnapshotPayload): WorkspaceStateBucket {
|
||||||
|
const pendingPermissionCount = agent.pendingPermissions?.length ?? 0;
|
||||||
|
if (pendingPermissionCount > 0 || agent.attentionReason === "permission") {
|
||||||
|
return "needs_input";
|
||||||
|
}
|
||||||
|
if (agent.status === "error" || agent.attentionReason === "error") {
|
||||||
|
return "failed";
|
||||||
|
}
|
||||||
|
if (agent.status === "running") {
|
||||||
|
return "running";
|
||||||
|
}
|
||||||
|
if (agent.requiresAttention) {
|
||||||
|
return "attention";
|
||||||
|
}
|
||||||
|
return "done";
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user