Cluster T3.a — mechanical fixes for consistent-return and no-floating-promises. Files touched (15): - server/shared/tool-call-display.ts — default throw in buildCanonicalDetailDisplay switch - server/agent/prompt-attachments.ts — default throw in renderPromptAttachmentAsText switch - server/workspace-directory.ts — default throw in getSortValue switch - server/agent/mcp-server.ts — default throw in mcpCreateWorktreeInput switch - server/terminal/terminal-session-controller.ts — default return in dispatch switch - server/daemon-e2e/agent-configs.ts — default return false in isProviderAvailable switch - server/agent/agent-manager.ts — void dispatchSessionEvent fire-and-forget call - server/agent/mcp-shared.ts — void 4 notify() floating calls - app/agent-status-bar.utils.ts — default throw in getStatusSelectorHint switch - app/keyboard-shortcuts.ts — default throw in resolvePayload switch - app/resolve-agent-form.ts — default throw in resolveAgentForm switch - app/ui/dropdown-menu.tsx — void .then() + return undefined for promise/always-return - app/use-agent-form-state.ts — void refreshSnapshot() floating call - cli/worktree/create.ts — default throw in toDaemonCreateInput switch - cli/onboard.ts — return process.exit(1) to satisfy consistent-return in catch Deferred (outside 15-file cap or in EXCLUDE list): - server/worktree-session.ts — getSortValue (same pattern, cap reached) - Multiple files with other errors (unbound-method, unsafe-type-assertion) are separate clusters
350 lines
11 KiB
TypeScript
350 lines
11 KiB
TypeScript
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();
|
|
default:
|
|
throw new Error("unreachable");
|
|
}
|
|
},
|
|
});
|
|
|
|
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 (!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, workspace.projectId, 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";
|
|
}
|
|
}
|