import { buildProjectIndex } from '../project-index.js'; import { readProjectRunState } from '../run-state/read.js'; import { discoverLooseDeliverables, discoverProjects, discoverWorkspaceRoots } from './discovery.js'; import type { LooseDeliverable, MonitorProject, SectionKey, WorkspaceModel } from './types.js'; export interface BuildWorkspaceModelOptions { roots?: string[]; extraRoots?: string[]; showAll?: boolean; generatedAt?: string; // injectable for deterministic tests /** Scan watched bases for loose deliverables (default true; opt out for hermetic tests). */ includeLooseDeliverables?: boolean; } export async function buildWorkspaceModel(options: BuildWorkspaceModelOptions = {}): Promise { const roots = options.roots ?? (await discoverWorkspaceRoots(options.extraRoots ?? [])); const refs = await discoverProjects(roots); // Per-root review-state lookup from the existing index (one pass per root). const reviewByKey = new Map(); for (const root of roots) { const index = await buildProjectIndex(root).catch(() => null); for (const entry of index?.projects ?? []) reviewByKey.set(`${root}::${entry.slug}`, entry.storyboardReviewState); } const projects: MonitorProject[] = []; for (const ref of refs) { const reviewState = reviewByKey.get(`${ref.workspaceRoot}::${ref.slug}`); const run = await readProjectRunState(ref.workspaceRoot, ref.slug, { reviewState }); projects.push({ ...ref, run, provider: run.routeId }); } const visible = projects.filter((p) => options.showAll || p.isMeaningful); const scratchHidden = projects.length - visible.length; // Order newest-first by last activity so the most recently touched projects lead // every section. `lastActivity` is the project.json `updatedAt` (or dir mtime) — // the same value each card shows. Sorting `visible` in place propagates the order // to all sections below, since each is a filter over this array. Missing dates // sort last; ties break by slug so the listing stays deterministic. visible.sort(compareByActivityDesc); // Loose deliverables — rendered videos from watch-config roots only. // We deliberately do NOT scan ~/.videoclaw-* or cwd here; those are managed // workspaces whose loose mp4s are intermediates already shown as project cards. let looseDeliverables: LooseDeliverable[] = []; if (options.includeLooseDeliverables !== false) { looseDeliverables = await discoverLooseDeliverables(); } const sections: Record = { rendering: visible.filter((p) => p.run.headline === 'rendering'), 'needs-you': visible.filter((p) => p.run.headline === 'awaiting-review' || p.run.headline === 'blocked'), delivered: visible.filter((p) => p.run.headline === 'delivered'), all: visible, }; return { generatedAt: options.generatedAt ?? new Date().toISOString(), roots, counts: { rendering: sections.rendering.length, needsYou: sections['needs-you'].length, delivered: sections.delivered.length, total: visible.length, scratchHidden, }, sections, looseDeliverables, }; } /** Newest-first by `lastActivity` (ISO). Missing/unparseable dates sort last; ties break by slug. */ function compareByActivityDesc(a: MonitorProject, b: MonitorProject): number { const va = a.lastActivity ? Date.parse(a.lastActivity) : NaN; const vb = b.lastActivity ? Date.parse(b.lastActivity) : NaN; const ka = Number.isNaN(va) ? -Infinity : va; const kb = Number.isNaN(vb) ? -Infinity : vb; if (ka !== kb) return kb - ka; return a.slug.localeCompare(b.slug); }