diff --git a/.changeset/durable-project-root-compatibility.md b/.changeset/durable-project-root-compatibility.md new file mode 100644 index 00000000..3f77d760 --- /dev/null +++ b/.changeset/durable-project-root-compatibility.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": patch +--- + +Resolve and retain durable project roots consistently for Studio and System Graph, including descendant sessions after a recent directory is evicted. Compare Windows drive and UNC roots without case sensitivity and migrate legacy case aliases while preserving durable project identities. diff --git a/packages/harness/src/core/studio-project-catalog.test.ts b/packages/harness/src/core/studio-project-catalog.test.ts index bde4b2ea..521d03e5 100644 --- a/packages/harness/src/core/studio-project-catalog.test.ts +++ b/packages/harness/src/core/studio-project-catalog.test.ts @@ -71,15 +71,151 @@ describe("StudioProjectCatalog", () => { { workspaceKey: "parent", cwd: parent }, { workspaceKey: "child", cwd: child }, ]); - const parentProject = reconciled.workspaceScopes.find(({ cwd }) => cwd === parent)?.projectId; - const childProject = reconciled.workspaceScopes.find(({ cwd }) => cwd === child)?.projectId; - expect((await catalog.resolveIdentityForPath(path.join(child, "src")))?.projectId).toBe( - childProject, + const parentProject = reconciled.workspaceScopes.find( + ({ cwd }) => cwd === parent, + )?.projectId; + const childProject = reconciled.workspaceScopes.find( + ({ cwd }) => cwd === child, + )?.projectId; + expect( + (await catalog.resolveIdentityForPath(path.join(child, "src"))) + ?.projectId, + ).toBe(childProject); + expect( + (await catalog.resolveIdentityForPath(path.join(parent, "other"))) + ?.projectId, + ).toBe(parentProject); + expect( + JSON.stringify(await catalog.resolveIdentityForPath(child)), + ).not.toContain(root); + }); + + it("resolves Windows case variants through the most-specific active binding", async () => { + const { catalogPath } = await fixture(); + const catalog = new StudioProjectCatalog(catalogPath); + const project = await catalog.create("Windows project"); + await catalog.addRootBinding( + project.projectId, + "C:\\Users\\Alice\\Project", + ); + await catalog.addRootBinding( + project.projectId, + "C:\\Users\\Alice\\Project\\packages", + ); + const caseEquivalent = await catalog.addRootBinding( + project.projectId, + "c:/users/alice/project/", ); - expect((await catalog.resolveIdentityForPath(path.join(parent, "other")))?.projectId).toBe( - parentProject, + const sibling = await catalog.create("Sibling"); + await catalog.addRootBinding( + sibling.projectId, + "C:\\Users\\Alice\\Project-two", ); - expect(JSON.stringify(await catalog.resolveIdentityForPath(child))).not.toContain(root); + + expect( + ( + await catalog.resolveIdentityForPath( + "c:/users/alice/project/PACKAGES/app/src", + ) + )?.projectId, + ).toBe(project.projectId); + expect( + await catalog.resolveIdentityForPath( + "c:/users/alice/project-two-adjacent/src", + ), + ).toBeNull(); + expect( + await catalog.resolveIdentityForPath("D:/users/alice/project/src"), + ).toBeNull(); + expect(caseEquivalent.bindings).toHaveLength(2); + }); + + it("reconciles reordered case-varied scopes into one multi-root Windows project", async () => { + const { catalogPath } = await fixture(); + const catalog = new StudioProjectCatalog(catalogPath); + const project = await catalog.create("Multi-root Windows project"); + await catalog.addRootBinding(project.projectId, "C:\\Projects\\Research"); + await catalog.addRootBinding(project.projectId, "D:\\Projects\\Publisher"); + + const first = await catalog.reconcile([ + { workspaceKey: "research", cwd: "c:/projects/research/" }, + { workspaceKey: "publisher", cwd: "d:/PROJECTS/PUBLISHER" }, + ]); + const second = await new StudioProjectCatalog(catalogPath).reconcile([ + { workspaceKey: "publisher", cwd: "D:\\projects\\publisher" }, + { workspaceKey: "research", cwd: "C:\\PROJECTS\\RESEARCH" }, + ]); + + expect(first.projects).toHaveLength(1); + expect(second.projects).toHaveLength(1); + expect(second.projects[0]).toMatchObject({ + projectId: project.projectId, + bindings: [ + expect.objectContaining({ status: "active" }), + expect.objectContaining({ status: "active" }), + ], + }); + expect( + new Set(second.workspaceScopes.map((scope) => scope.projectId)), + ).toEqual(new Set([project.projectId])); + }); + + it("loads and persists legacy Windows case aliases without replacing project identity", async () => { + const { catalogPath } = await fixture(); + const catalog = new StudioProjectCatalog(catalogPath); + const project = await catalog.create("Legacy Windows project"); + await catalog.addRootBinding(project.projectId, "C:\\Work\\Project"); + const raw = JSON.parse(await fs.readFile(catalogPath, "utf8")); + const originalBinding = raw.projects[0].rootBindings[0]; + raw.projects[0].rootBindings.push({ + ...originalBinding, + id: "root_00000000-0000-4000-8000-000000000099", + localRootRef: "c:\\work\\project", + }); + await fs.writeFile(catalogPath, JSON.stringify(raw)); + + const restarted = new StudioProjectCatalog(catalogPath); + expect(await restarted.list()).toEqual([ + expect.objectContaining({ + projectId: project.projectId, + bindings: [{ id: originalBinding.id, status: "active" }], + }), + ]); + expect(await restarted.resolveIdentityForPath("c:\\WORK\\PROJECT\\src")) + .toMatchObject({ projectId: project.projectId }); + await restarted.reconcile([{ workspaceKey: "legacy", cwd: "c:\\work\\project" }]); + const persisted = JSON.parse(await fs.readFile(catalogPath, "utf8")); + expect(persisted.projects[0].rootBindings).toEqual([originalBinding]); + expect((await new StudioProjectCatalog(catalogPath).list())[0]?.projectId) + .toBe(project.projectId); + }); + + it("preserves separate legacy project identities when Windows roots become ambiguous", async () => { + const { catalogPath } = await fixture(); + const catalog = new StudioProjectCatalog(catalogPath); + const first = await catalog.create("First legacy project"); + const second = await catalog.create("Second legacy project"); + await catalog.addRootBinding(first.projectId, "C:\\Work\\Project"); + await catalog.addRootBinding(second.projectId, "D:\\Work\\Project"); + const raw = JSON.parse(await fs.readFile(catalogPath, "utf8")); + raw.projects.find((entry: { projectId: string }) => entry.projectId === second.projectId) + .rootBindings[0].localRootRef = "c:\\work\\project"; + await fs.writeFile(catalogPath, JSON.stringify(raw)); + + const restarted = new StudioProjectCatalog(catalogPath); + expect((await restarted.list()).map(({ projectId }) => projectId).sort()) + .toEqual([first.projectId, second.projectId].sort()); + expect(await restarted.resolveIdentityForPath("C:\\Work\\Project\\src")).toBeNull(); + const result = await restarted.reconcile([ + { workspaceKey: "ambiguous", cwd: "C:\\Work\\Project" }, + { workspaceKey: "unrelated", cwd: "/unrelated-project" }, + ]); + expect(result.workspaceScopes.find(({ workspaceKey }) => workspaceKey === "ambiguous")) + .toEqual({ workspaceKey: "ambiguous", cwd: "C:\\Work\\Project" }); + expect(result.workspaceScopes.find(({ workspaceKey }) => workspaceKey === "unrelated")?.projectId) + .toMatch(/^project_/); + expect(await restarted.resolve(first.projectId)).not.toBeNull(); + expect(await restarted.resolve(second.projectId)).not.toBeNull(); }); it("keeps project identity across a root move and an additional repository binding", async () => { @@ -209,17 +345,13 @@ describe("StudioProjectCatalog", () => { () => new Date(), delayedHooks, ); - const winner = new StudioProjectCatalog( - catalogPath, - () => new Date(), - { - isPidAlive: (pid) => pid === process.pid, - afterLockAcquired: async () => { - winnerAcquired.resolve(); - await releaseWinner.promise; - }, + const winner = new StudioProjectCatalog(catalogPath, () => new Date(), { + isPidAlive: (pid) => pid === process.pid, + afterLockAcquired: async () => { + winnerAcquired.resolve(); + await releaseWinner.promise; }, - ); + }); const writeB = delayedB.create("Writer B"); const writeC = delayedC.create("Writer C"); @@ -276,9 +408,9 @@ describe("StudioProjectCatalog", () => { await Promise.all([liveWrite, waiterWrite]); expect( - (await new StudioProjectCatalog(catalogPath).list()).map( - (project) => project.displayName, - ).sort(), + (await new StudioProjectCatalog(catalogPath).list()) + .map((project) => project.displayName) + .sort(), ).toEqual(["Patient writer", "Slow live writer"]); expect( (await fs.readdir(path.dirname(catalogPath))).filter((entry) => diff --git a/packages/harness/src/core/studio-project-catalog.ts b/packages/harness/src/core/studio-project-catalog.ts index 312491ec..a0b90be9 100644 --- a/packages/harness/src/core/studio-project-catalog.ts +++ b/packages/harness/src/core/studio-project-catalog.ts @@ -10,6 +10,8 @@ import { type StudioProjectSummary, } from "../shared/agent-map.js"; import type { WorkspaceScopeSummary } from "../shared/system-graph.js"; +import { resolveProjectRootForPath } from "../shared/project-roots.js"; +import { pathComparisonKey } from "../shared/paths.js"; import { canonicalGraphPath } from "./canonical-graph-path.js"; import { DurableFileLock, @@ -199,8 +201,7 @@ function parseProject(value: unknown): StudioProjectIdentity | null { const keys = value.legacyWorkspaceKeys as string[]; if ( new Set(bindings.map((binding) => binding.id)).size !== bindings.length || - new Set(bindings.map((binding) => binding.localRootRef)).size !== - bindings.length || + new Set(bindings.map((binding) => binding.localRootRef)).size !== bindings.length || new Set(keys).size !== keys.length ) { return null; @@ -216,7 +217,7 @@ function parseProject(value: unknown): StudioProjectIdentity | null { }; } -function parseCatalog(value: unknown): PersistedStudioProjectCatalog { +function parseCatalog(value: unknown): PersistedStudioProjectCatalog & { migrated: boolean } { if ( isRecord(value) && Number.isSafeInteger(value.schemaVersion) && @@ -260,9 +261,29 @@ function parseCatalog(value: unknown): PersistedStudioProjectCatalog { ) { throw new StudioProjectCatalogError("malformed_state"); } + // Older catalogs allowed differently cased Windows spellings of one root. + // Validate that persisted format first, then collapse aliases within their + // existing project. Keep separate project IDs: their map state cannot be + // merged implicitly, and ambiguous roots remain unassigned by reconcile. + let migrated = false; + for (const project of parsed) { + const bindings = new Map(); + for (const binding of project.rootBindings) { + const key = pathComparisonKey(binding.localRootRef); + const previous = bindings.get(key); + if (previous) { + if (binding.status === "active") previous.status = "active"; + migrated = true; + } else { + bindings.set(key, binding); + } + } + project.rootBindings = [...bindings.values()]; + } return { schemaVersion: STUDIO_PROJECT_CATALOG_SCHEMA_VERSION, projects: parsed, + migrated, }; } @@ -315,6 +336,7 @@ export class StudioProjectCatalog { private projects: StudioProjectIdentity[] | null = null; private loadPromise: Promise | null = null; private mutationQueue: Promise = Promise.resolve(); + private migrationPending = false; constructor( private readonly catalogPath: string, @@ -330,6 +352,12 @@ export class StudioProjectCatalog { // the cross-instance lock so a whole-catalog atomic rewrite includes // identities committed by another live host. await this.load(true); + if (this.migrationPending) { + // Read-only callers can use repaired identities immediately. Commit + // the repair only under the same cross-host lock as other writes. + await this.persist(this.projects!); + this.migrationPending = false; + } return await operation(); } finally { await release(); @@ -366,6 +394,7 @@ export class StudioProjectCatalog { } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { this.projects = []; + this.migrationPending = false; return; } throw storageError(); @@ -376,7 +405,9 @@ export class StudioProjectCatalog { } catch { throw new StudioProjectCatalogError("malformed_state"); } - this.projects = parseCatalog(decoded).projects; + const parsed = parseCatalog(decoded); + this.projects = parsed.projects; + this.migrationPending = parsed.migrated; })().finally(() => { this.loadPromise = null; }); @@ -434,30 +465,23 @@ export class StudioProjectCatalog { } catch { return null; } - const matches = this.projects!.flatMap((project) => - project.rootBindings - .filter(({ status }) => status === "active") - .flatMap((binding) => { - try { - const root = canonicalGraphPath(binding.localRootRef); - const relative = path.relative(root, canonical); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) - ? [{ project, specificity: root.length }] - : []; - } catch { - return []; - } - }), - ); - if (matches.length === 0) return null; - const specificity = Math.max(...matches.map((match) => match.specificity)); - const winners = new Map( - matches - .filter((match) => match.specificity === specificity) - .map(({ project }) => [project.projectId, project]), + const match = resolveProjectRootForPath( + canonical, + this.projects!.flatMap((project) => + project.rootBindings + .filter(({ status }) => status === "active") + .flatMap((binding) => { + try { + const root = canonicalGraphPath(binding.localRootRef); + return [{ projectId: project.projectId, cwd: root, project }]; + } catch { + return []; + } + }), + ), ); - if (winners.size !== 1) return null; - const project = [...winners.values()][0]!; + if (!match) return null; + const project = match.project; return { projectId: project.projectId, identityVersion: project.identityVersion, @@ -498,7 +522,10 @@ export class StudioProjectCatalog { return this.enqueue(async () => { await this.load(); const next = cloneProjects(this.projects!); - const dedupedScopes = new Map(); + const dedupedScopes = new Map< + string, + { scope: WorkspaceScopeSummary; canonical: string } + >(); const unassignedScopes: WorkspaceScopeSummary[] = []; const canonicalScopes: Array<{ scope: WorkspaceScopeSummary; @@ -528,7 +555,7 @@ export class StudioProjectCatalog { } canonicalScopes.push({ scope, canonical }); const roots = rootsByLegacyKey.get(scope.workspaceKey) ?? new Set(); - roots.add(canonical); + roots.add(pathComparisonKey(canonical)); rootsByLegacyKey.set(scope.workspaceKey, roots); } @@ -548,11 +575,15 @@ export class StudioProjectCatalog { }); continue; } - if (!dedupedScopes.has(canonical)) { + const comparisonKey = pathComparisonKey(canonical); + if (!dedupedScopes.has(comparisonKey)) { // Canonical form is private matching evidence only. Preserve the // existing lexical cwd in AppState so this additive join cannot // perturb legacy rail/session path equality. - dedupedScopes.set(canonical, { ...scope }); + dedupedScopes.set(comparisonKey, { + scope: { ...scope }, + canonical, + }); } } @@ -561,7 +592,9 @@ export class StudioProjectCatalog { for (const project of next) { let projectChanged = false; for (const binding of project.rootBindings) { - const status = activeRoots.has(binding.localRootRef) + const status = activeRoots.has( + pathComparisonKey(binding.localRootRef), + ) ? "active" : "missing"; if (binding.status !== status) { @@ -577,16 +610,19 @@ export class StudioProjectCatalog { } const reconciledScopes: WorkspaceScopeSummary[] = []; - for (const [canonical, scope] of dedupedScopes) { + for (const { canonical, scope } of dedupedScopes.values()) { const matchingProjects = next.filter( (candidate) => candidate.legacyWorkspaceKeys.includes(scope.workspaceKey) || candidate.rootBindings.some( - (binding) => binding.localRootRef === canonical, + (binding) => + pathComparisonKey(binding.localRootRef) === + pathComparisonKey(canonical), ), ); if (matchingProjects.length > 1) { - throw new StudioProjectCatalogError("malformed_state"); + unassignedScopes.push({ workspaceKey: scope.workspaceKey, cwd: scope.cwd }); + continue; } let project = matchingProjects[0]; if (!project) { @@ -616,7 +652,9 @@ export class StudioProjectCatalog { projectChanged = true; } let binding = project.rootBindings.find( - (candidate) => candidate.localRootRef === canonical, + (candidate) => + pathComparisonKey(candidate.localRootRef) === + pathComparisonKey(canonical), ); if (!binding) { binding = { @@ -705,7 +743,9 @@ export class StudioProjectCatalog { if ( project.rootBindings.some( (candidate) => - candidate.id !== binding.id && candidate.localRootRef === canonical, + candidate.id !== binding.id && + pathComparisonKey(candidate.localRootRef) === + pathComparisonKey(canonical), ) ) { // Reject instead of persisting two private bindings for the same root; @@ -717,7 +757,9 @@ export class StudioProjectCatalog { (candidate) => candidate.projectId !== projectId && (candidate.rootBindings.some( - (candidateBinding) => candidateBinding.localRootRef === canonical, + (candidateBinding) => + pathComparisonKey(candidateBinding.localRootRef) === + pathComparisonKey(canonical), ) || (legacyWorkspaceKey !== undefined && candidate.legacyWorkspaceKeys.includes(legacyWorkspaceKey))), @@ -769,7 +811,9 @@ export class StudioProjectCatalog { (candidate) => candidate.projectId !== projectId && (candidate.rootBindings.some( - (binding) => binding.localRootRef === canonical, + (binding) => + pathComparisonKey(binding.localRootRef) === + pathComparisonKey(canonical), ) || (options.legacyWorkspaceKey !== undefined && candidate.legacyWorkspaceKeys.includes( @@ -780,7 +824,9 @@ export class StudioProjectCatalog { throw new StudioProjectCatalogError("malformed_state"); } const existing = project.rootBindings.find( - (binding) => binding.localRootRef === canonical, + (binding) => + pathComparisonKey(binding.localRootRef) === + pathComparisonKey(canonical), ); if (existing) { existing.status = "active"; diff --git a/packages/harness/src/server/index.ts b/packages/harness/src/server/index.ts index 00382cab..973bc8c6 100644 --- a/packages/harness/src/server/index.ts +++ b/packages/harness/src/server/index.ts @@ -1,3 +1,5 @@ +import { projectRoots, projectSessionRoot } from "../shared/project-roots.js"; +import { samePath } from "../shared/paths.js"; /** * Harness server — integration point for every workstream. * @@ -1210,6 +1212,113 @@ export const startServer = async ( ...(await loadSettings(statePaths.settings)).recentDirs, ...sessionManager.list().map((session) => session.cwd), ]); + const studioWorkspaceScopeCatalog = new LocalWorkspaceScopeCatalog( + async () => { + const settings = await loadSettings(statePaths.settings); + const durableProjects = await studioProjectCatalog.list(); + const durableIdentities = ( + await Promise.all( + durableProjects.map((project) => + studioProjectCatalog.resolveIdentity(project.projectId), + ), + ) + ).filter((project) => project !== null); + const durableRoots = durableIdentities.flatMap((project) => + project.rootBindings.map((binding) => binding.localRootRef), + ); + const durableRootCandidates = durableIdentities.flatMap((project) => + project.rootBindings + .filter((binding) => binding.status === "active") + .map((binding) => ({ + projectId: project.projectId, + cwd: binding.localRootRef, + })), + ); + const retainedProjectSessionRoots = new Set(); + // Pending launches contribute their trusted PROJECT root just like live + // sessions, not a descendant cwd that would mint a competing project. + const pendingCwds: string[] = []; + const sessions = sessionManager + ? sessionManager.list().flatMap((session) => { + if (!session.agentMapIdentity) { + return [ + { + cwd: session.cwd, + createdAt: session.lastActiveAt, + status: session.status, + }, + ]; + } + const root = projectSessionRoot( + { + cwd: session.cwd, + projectId: session.agentMapIdentity.projectId, + }, + durableRootCandidates, + ); + // A neutral project session contributes its trusted project root, + // never its descendant cwd. If its binding is stale, omit it from + // discovery rather than minting a replacement authority from the + // untrusted path. + if (root) { + retainedProjectSessionRoots.add(root); + return [ + { + cwd: root, + createdAt: session.lastActiveAt, + status: session.status, + }, + ]; + } + return []; + }) + : []; + const candidates = [ + ...pendingCwds, + ...settings.recentDirs, + ...sessions.map((session) => session.cwd), + ]; + // Root identity must be final before launch, even when the asynchronous + // workflow scan has not populated its cache yet. Probe only the candidate + // roots themselves; deeper discovery remains the registry's job. + const directlyMarked = ( + await Promise.all( + candidates.map(async (candidate) => ({ + candidate, + marker: await inspectAgentProjectMarker(candidate), + })), + ) + ) + .filter(({ marker }) => marker.status === "valid") + .map(({ candidate }) => candidate); + const visibleRoots = projectRoots({ + recentDirs: settings.recentDirs, + sessions, + pendingCwds, + pinnedRoots: durableRoots, + agentPaths: [ + ...workflowsCache.map((workflow) => workflow.path), + ...directlyMarked, + ], + sort: "recent", + }); + // MRU/rail visibility is not an authority revocation mechanism. An + // existing project session must remain resumable after its recent-dir + // entry is evicted, including an otherwise empty project whose session + // cwd is below the durable root. The browser may still hide an explicitly + // removed project through its local closed-project projection. + return [ + ...visibleRoots, + ...[...retainedProjectSessionRoots].filter( + (root) => + !visibleRoots.some((visible) => + samePath(canonicalGraphPath(visible), canonicalGraphPath(root)), + ), + ), + ]; + }, + ); + const activeSystemGraphScopes = new Map(); const systemGraphInvocations = new CachedAgentInvocationProvider( new SourceAgentInvocationProvider(), @@ -2384,7 +2493,28 @@ export const startServer = async ( ); const listWorkspaceScopesAndRetain = async () => { - const scopes = await workspaceScopeCatalog.list(); + let scopes = await workspaceScopeCatalog.list(); + try { + const studioScopes = await studioWorkspaceScopeCatalog.list(); + const reconciled = (await studioProjectCatalog.reconcile(studioScopes)).workspaceScopes; + // Both catalogs key canonical filesystem roots. Cwd retains its display + // spelling and can be a symlink alias; prefer reconciled project metadata. + const byWorkspaceKey = new Map( + reconciled.map((scope) => [scope.workspaceKey, scope]), + ); + for (const scope of scopes) { + if (!byWorkspaceKey.has(scope.workspaceKey)) { + byWorkspaceKey.set(scope.workspaceKey, scope); + } + } + scopes = [...byWorkspaceKey.values()].sort((left, right) => + left.cwd.localeCompare(right.cwd), + ); + } catch { + // Agent Map is additive in E1. A bad/unavailable new catalog cannot + // strand the legacy rail or System Graph during coexistence. + console.error("[harness] Studio project catalog is unavailable"); + } const retained = new Set(scopes.map((scope) => scope.workspaceKey)); systemGraphWatcher.retain(retained); systemGraphStore.retain(retained); @@ -2393,14 +2523,7 @@ export const startServer = async ( activeSystemGraphScopes.delete(workspaceKey); } } - try { - return (await studioProjectCatalog.reconcile(scopes)).workspaceScopes; - } catch { - // Agent Map is additive in E1. A bad/unavailable new catalog cannot - // strand the legacy rail or System Graph during coexistence. - console.error("[harness] Studio project catalog is unavailable"); - return scopes; - } + return scopes; }; /** Enrich only the bound workflow before a Canvas render. Canvas extraction @@ -2780,7 +2903,7 @@ export const startServer = async ( const annotateStudioSelections = async ( workflows: readonly RegistryWorkflowInfo[], ): Promise => { - const scopes = await workspaceScopeCatalog.list(); + const scopes = await studioWorkspaceScopeCatalog.list(); const reconciled = await studioProjectCatalog.reconcile(scopes); const projects = reconciled.projects; const annotations = new Map< @@ -2990,7 +3113,7 @@ export const startServer = async ( currentUserId: () => localPlanningPrincipal(planningUserId, machineId), listWorkflows: () => workflowsCache, isWorkflowScanComplete, - listWorkspaceScopes: () => workspaceScopeCatalog.list(), + listWorkspaceScopes: () => studioWorkspaceScopeCatalog.list(), planningSessions, plannerGreeting, }), @@ -2998,7 +3121,14 @@ export const startServer = async ( app.use( "/api", createSystemGraphRouter({ - scopeResolver: workspaceScopeCatalog, + scopeResolver: { + resolve: async (workspaceKey) => { + const scope = (await listWorkspaceScopesAndRetain()).find( + (candidate) => candidate.workspaceKey === workspaceKey, + ); + return scope ? { workspaceKey, root: canonicalGraphPath(scope.cwd) } : null; + }, + }, store: systemGraphStore, onScopeAccess: (scope) => { const firstAccess = !activeSystemGraphScopes.has(scope.workspaceKey); diff --git a/packages/harness/src/server/studio-workspace-alias-wiring.test.ts b/packages/harness/src/server/studio-workspace-alias-wiring.test.ts new file mode 100644 index 00000000..58caf789 --- /dev/null +++ b/packages/harness/src/server/studio-workspace-alias-wiring.test.ts @@ -0,0 +1,65 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, expect, it } from "vitest"; + +import { StudioProjectCatalog } from "../core/studio-project-catalog.js"; +import type { AppState } from "../shared/types.js"; +import { startServer, type HarnessServer } from "./index.js"; + +let root: string | undefined; +let server: HarnessServer | undefined; + +afterEach(async () => { + await server?.close(); + server = undefined; + if (root) await fs.rm(root, { recursive: true, force: true }); + root = undefined; +}); + +it("publishes one project-owned scope for a symlink alias of its canonical root", async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), "studio-workspace-alias-")); + const stateRoot = path.join(root, "state"); + const projectRoot = path.join(root, "project"); + const alias = path.join(root, "project-alias"); + await fs.mkdir(stateRoot); + await fs.mkdir(projectRoot); + await fs.symlink(projectRoot, alias, "dir"); + const catalog = new StudioProjectCatalog(path.join(stateRoot, "studio-projects.json")); + const project = (await catalog.reconcile([{ workspaceKey: "seed", cwd: projectRoot }])).projects[0]!; + await fs.writeFile(path.join(stateRoot, "sessions.json"), JSON.stringify([{ + id: "retained-session", agentSessionId: null, harness: "claude-code", + cwd: alias, title: "Retained conversation", status: "exited", + createdAt: "2026-01-01T00:00:00.000Z", lastActiveAt: "2026-01-02T00:00:00.000Z", + exitCode: 0, boundWorkflowPath: null, ready: false, + agentMapIdentity: { projectId: project.projectId, userId: "local:machine-1", sessionId: "retained-session" }, + }])); + await fs.writeFile( + path.join(stateRoot, "settings.json"), + JSON.stringify({ recentDirs: [projectRoot] }), + ); + server = await startServer({ + port: 0, + bootToken: "test-token", + telemetryOptIn: false, + machineId: "machine-1", + adapters: {}, + stateRoot, + launchDir: alias, + autoCreateSession: false, + }); + const response = await fetch(`http://127.0.0.1:${server.port}/api/state`, { + headers: { "X-Harness-Token": "test-token" }, + }); + expect(response.status).toBe(200); + const state = await response.json() as AppState; + const scopes = state.workspaceScopes ?? []; + expect(state.studioProjects).toHaveLength(1); + expect(new Set(scopes.map((scope) => scope.workspaceKey)).size).toBe(scopes.length); + const roots = await Promise.all(scopes.map(async (scope) => ({ + scope, canonical: await fs.realpath(scope.cwd), + }))); + const projectScopes = roots.filter(({ canonical }) => canonical === projectRoot); + expect(projectScopes).toHaveLength(1); + expect(projectScopes[0]?.scope.projectId).toBe(project.projectId); +}); diff --git a/packages/harness/src/server/studio-workspace-wiring.test.ts b/packages/harness/src/server/studio-workspace-wiring.test.ts index f5d7bd8e..446f87b6 100644 --- a/packages/harness/src/server/studio-workspace-wiring.test.ts +++ b/packages/harness/src/server/studio-workspace-wiring.test.ts @@ -1,13 +1,16 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { StudioCurrentWorkspaceResponse, StudioProjectSummary, } from "../shared/agent-map.js"; -import type { AppState } from "../shared/types.js"; +import type { AppState, HarnessAdapter } from "../shared/types.js"; +import { StudioProjectCatalog } from "../core/studio-project-catalog.js"; +import { SystemGraphStore } from "../core/system-graph-store.js"; +import { SystemGraphWatcherManager } from "../core/system-graph-watcher.js"; import { startServer, type HarnessServer } from "./index.js"; describe("real Studio workspace wiring", () => { @@ -19,6 +22,56 @@ describe("real Studio workspace wiring", () => { server = undefined; if (root) await fs.rm(root, { recursive: true, force: true }); root = undefined; + vi.restoreAllMocks(); + }); + + it("resolves and retains a published durable root when only its descendant session remains", async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), "studio-root-scope-wiring-")); + const projectRoot = path.join(root, "project"); + const descendant = path.join(projectRoot, "src"); + await fs.mkdir(descendant, { recursive: true }); + const catalog = new StudioProjectCatalog(path.join(root, "studio-projects.json")); + const project = (await catalog.reconcile([ + { workspaceKey: "legacy-project", cwd: projectRoot }, + ])).projects[0]!; + await fs.writeFile(path.join(root, "settings.json"), JSON.stringify({ recentDirs: [projectRoot] })); + const adapter: HarnessAdapter = { + id: "claude-code", + eventSource: "hooks", + doctor: async () => [], + launch: (opts) => ({ command: "bash", args: [], env: {}, cwd: opts.cwd }), + resume: (_id, opts) => ({ command: "bash", args: [], env: {}, cwd: opts.cwd }), + listPastSessions: async () => [], + canResume: async () => true, + }; + server = await startServer({ + port: 0, + bootToken: "test-token", + telemetryOptIn: false, + adapters: { "claude-code": adapter }, + stateRoot: root, + launchDir: projectRoot, + autoCreateSession: false, + loadSystemPrompt: async () => "", + }); + const session = await server.sessionManager.create({ cwd: descendant, harness: "claude-code" }); + expect(session.agentMapIdentity?.projectId).toBe(project.projectId); + await fs.writeFile(path.join(root, "settings.json"), JSON.stringify({ recentDirs: [] })); + const watcherRetain = vi.spyOn(SystemGraphWatcherManager.prototype, "retain"); + const storeRetain = vi.spyOn(SystemGraphStore.prototype, "retain"); + const headers = { "X-Harness-Token": "test-token" }; + const baseUrl = `http://127.0.0.1:${server.port}`; + const state = await (await fetch(`${baseUrl}/api/state`, { headers })).json() as AppState; + const scope = state.workspaceScopes?.find(({ cwd }) => cwd === projectRoot); + expect(scope?.projectId).toBe(project.projectId); + const graph = await fetch(`${baseUrl}/api/workspaces/${scope!.workspaceKey}/system-graph`, { headers }); + expect(graph.status).toBe(200); + expect(watcherRetain.mock.calls.at(-1)?.[0].has(scope!.workspaceKey)).toBe(true); + expect(storeRetain.mock.calls.at(-1)?.[0].has(scope!.workspaceKey)).toBe(true); + await fetch(`${baseUrl}/api/state`, { headers }); + expect(watcherRetain.mock.calls.at(-1)?.[0].has(scope!.workspaceKey)).toBe(true); + expect(storeRetain.mock.calls.at(-1)?.[0].has(scope!.workspaceKey)).toBe(true); + expect((await fs.readFile(path.join(root, "settings.json"), "utf8"))).not.toContain(projectRoot); }); it("publishes opaque AppState bindings and restores one across a null-definition move and restart", async () => { diff --git a/packages/harness/src/shared/paths.ts b/packages/harness/src/shared/paths.ts new file mode 100644 index 00000000..3933429b --- /dev/null +++ b/packages/harness/src/shared/paths.ts @@ -0,0 +1,139 @@ +/** + * Path helpers for the ABSOLUTE paths the server hands out. + * + * Shared, not browser-side: `@shared/project-roots` derives project roots with + * them and both the SPA and the server run that derivation, so a second copy + * would be two definitions of path equality. Pure string operations only — no + * `node:path`, no DOM — which is what lets one file serve both hosts. + * + * The server builds them with `path.join`, so they arrive in the host's native + * shape — backslash-separated on Windows. The SPA cannot ask `node:path` which + * host that was; it infers the separator from the string itself, which works + * because a Windows absolute path always contains at least one `\` (`C:\…`) + * and a POSIX one never does. + * + * Joins preserve the input's native separator (what gets POSTed back must + * match what the server sent), but every COMPARISON normalizes both + * separators first: paths that were joined in the browser before this module + * existed shipped in mixed form (`C:\Users\x\projects/newsletter-autopilot`), + * and those still have to compare equal to their native spellings. + */ + +/** The separator `p` itself uses. `\` anywhere marks a Windows path — POSIX + * filenames may legally contain `\`, but never in the absolute paths the + * server supplies. */ +export function sepOf(p: string): "\\" | "/" { + return p.includes("\\") ? "\\" : "/"; +} + +/** `` in the root's native separator, with no doubled + * separator when the root carries a trailing one. */ +export function joinPath(root: string, name: string): string { + const trimmedRoot = root.trim().replace(/[\\/]+$/, ""); + return `${trimmedRoot}${sepOf(root)}${name.trim()}`; +} + +/** Last non-empty segment under either separator, or the input when it has + * none (a relative name is its own basename). */ +export function basenameOf(p: string): string { + return p.split(/[\\/]/).filter(Boolean).pop() ?? p; +} + +/** + * Parent of an absolute path, or null at a filesystem root (`/`, `C:\`, bare + * `C:`) and for separator-free relative strings. Mirrors `path.dirname` + * without pulling node:path into the browser bundle. + * + * Needed because GET /api/fs/list reports one level DOWN: a path can only + * learn whether it is itself an agent project by asking its parent. + */ +export function parentOf(input: string): string | null { + const trimmed = input.replace(/[\\/]+$/, ""); + if (trimmed === "" || /^[A-Za-z]:$/.test(trimmed)) return null; + const lastSep = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); + if (lastSep < 0) return null; + const cut = trimmed.slice(0, lastSep); + // First-level paths keep their root spelled out — `/Users` → `/`, + // `C:\Users` → `C:\` — so the result is always itself a listable path. + if (/^[A-Za-z]:$/.test(cut)) return cut + trimmed[lastSep]; + return cut || "/"; +} + +/** `/a/b/` → `/a/b` under either separator, so a user's trailing slash never + * breaks a path comparison. Bare roots (`/`, `C:\`) pass through unchanged — + * stripping them would leave something that isn't a path. */ +export function stripTrailingSep(p: string): string { + const trimmed = p.replace(/[\\/]+$/, ""); + if (trimmed === p) return p; + if (trimmed === "") return p[0]; + if (/^[A-Za-z]:$/.test(trimmed)) return trimmed + p[trimmed.length]; + return trimmed; +} + +/** + * Comparison-only form for absolute paths shared by the browser and server. + * Windows drive and UNC paths use their platform's case-insensitive identity; + * POSIX paths deliberately retain case. The caller's spelling is never used + * for display or persisted output. + */ +export function pathComparisonKey(input: string): string { + const normalized = stripTrailingSep(input.replace(/\\/g, "/")); + const windows = + /^[A-Za-z]:\//.test(normalized) || + /^\/\/[^/]+\/[^/]+(?:\/|$)/.test(normalized); + return windows ? normalized.toLowerCase() : normalized; +} + +/** Segment depth, independent of path spelling length and separator style. */ +export function pathSegmentDepth(input: string): number { + return pathComparisonKey(input).split("/").filter(Boolean).length; +} + +/** Whether `child` IS `parent` or sits beneath it — never a mere string + * prefix, so `/a/scratch-2` is not within `/a/scratch`. Separator-insensitive + * on both sides, so a mixed-form path still matches its native spelling. */ +/** + * Whether two paths name the same directory, ignoring separator form and a + * trailing separator. + * + * Needed because the client and the server no longer agree byte-for-byte: the + * server `path.resolve()`s every cwd it stores (server/cwd-normalize.ts) while + * the SPA holds whatever the user typed or a recentDirs entry recorded — so a + * `C:/…`-typed path, or one with a trailing slash, fails a raw `===` against + * the very session it just created (empty tab strip, unhighlighted rail row). + */ +export function samePath(a: string, b: string): boolean { + return pathComparisonKey(a) === pathComparisonKey(b); +} + +export function isWithinDir(parent: string, child: string): boolean { + const p = pathComparisonKey(parent); + const c = pathComparisonKey(child); + if (c === p) return true; + // A filesystem root keeps its trailing separator (stripTrailingSep's + // contract), so appending another would test "C://…" and never match — + // every session under a root-level workspace looked like an orphan. + return p.endsWith("/") ? c.startsWith(p) : c.startsWith(`${p}/`); +} + +/** Whether typed input is trying to be an absolute path (`/…`, `~…`, or a + * Windows drive like `C:\…` / `C:/…`) rather than a search query. */ +export function looksAbsolutePath(input: string): boolean { + return ( + input.startsWith("/") || + input.startsWith("~") || + /^[A-Za-z]:[\\/]/.test(input) + ); +} + +/** "/Users/…/onboarding-flow" — middle-truncates a long path so a chip row + * never hard-clips a chip mid-glyph; the full path stays in the tooltip. */ +export function middleTruncatePath(path: string): string { + const sep = sepOf(path); + const segments = path.split(/[\\/]/).filter(Boolean); + if (segments.length <= 2) return path; + // POSIX first segments lost their leading `/` to the split; a drive letter + // (`C:`) never had one. + const prefix = sep === "\\" ? "" : sep; + return `${prefix}${segments[0]}${sep}…${sep}${segments[segments.length - 1]}`; +} diff --git a/packages/harness/src/shared/project-roots.test.ts b/packages/harness/src/shared/project-roots.test.ts new file mode 100644 index 00000000..f76740df --- /dev/null +++ b/packages/harness/src/shared/project-roots.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it } from "vitest"; + +import { + preferredProjectRoot, + projectRoots, + projectSessionRoot, + projectToOpen, + resolveProjectRootForPath, + type ProjectRootSources, +} from "./project-roots.js"; + +function sources( + overrides: Partial = {}, +): ProjectRootSources { + return { + recentDirs: [], + sessions: [], + pendingCwds: [], + agentPaths: [], + sort: "recent", + ...overrides, + }; +} + +describe("shared project-root resolution", () => { + it("chooses the canonical outermost multi-root binding deterministically", () => { + expect( + preferredProjectRoot([ + "/workspace/project/packages/zeta", + "/workspace/project", + "/workspace/project/packages/alpha", + ]), + ).toBe("/workspace/project"); + expect( + preferredProjectRoot(["/workspace/project-b", "/workspace/project-a"]), + ).toBe("/workspace/project-a"); + expect( + preferredProjectRoot([ + "D:\\x\\y", + "C:\\a-very-long-project-directory-name", + ]), + ).toBe("C:\\a-very-long-project-directory-name"); + }); + + it("maps a descendant project session to its nearest durable root", () => { + expect( + projectSessionRoot( + { cwd: "/workspace/project/packages/app", projectId: "project-1" }, + [ + { projectId: "project-1", cwd: "/workspace/project" }, + { projectId: "project-1", cwd: "/workspace/project/packages" }, + { projectId: "project-2", cwd: "/workspace/project/packages/app" }, + ], + ), + ).toBe("/workspace/project/packages"); + }); + + it("maps Windows sessions to the most-specific binding across case variants", () => { + expect( + projectSessionRoot( + { + cwd: "c:/users/alice/project/PACKAGES/app/src", + projectId: "project-1", + }, + [ + { projectId: "project-1", cwd: "C:\\Users\\Alice\\Project" }, + { + projectId: "project-1", + cwd: "C:\\Users\\Alice\\Project\\packages", + }, + { + projectId: "project-2", + cwd: "C:\\Users\\Alice\\Project\\packages\\app", + }, + { projectId: "project-1", cwd: "D:\\Users\\Alice\\Project" }, + ], + ), + ).toBe("C:\\Users\\Alice\\Project\\packages"); + }); + + it("resolves same-project aliases deterministically and fails closed across projects", () => { + const aliases = [ + { projectId: "project-1", cwd: "c:/work/project" }, + { projectId: "project-1", cwd: "C:\\WORK\\PROJECT" }, + ]; + expect(resolveProjectRootForPath("C:/work/project/src", aliases)?.cwd).toBe( + "C:\\WORK\\PROJECT", + ); + expect( + resolveProjectRootForPath("C:/work/project/src", [...aliases].reverse()) + ?.cwd, + ).toBe("C:\\WORK\\PROJECT"); + expect( + resolveProjectRootForPath("C:/work/project/src", [ + aliases[0]!, + { projectId: "project-2", cwd: "C:\\work\\project" }, + ]), + ).toBeNull(); + }); + + it("keeps an evicted durable root instead of promoting its session cwd", () => { + const root = projectSessionRoot( + { cwd: "/workspace/project/packages/app", projectId: "project-1" }, + [{ projectId: "project-1", cwd: "/workspace/project" }], + ); + + expect( + projectRoots( + sources({ + sessions: [ + { + cwd: root!, + createdAt: "2026-01-01T00:00:00.000Z", + status: "running", + }, + ], + pinnedRoots: ["/workspace/project"], + agentPaths: ["/workspace/project/packages/app/agent"], + }), + ), + ).toEqual(["/workspace/project"]); + }); + + it("deduplicates equivalent separator forms and keeps the first trusted spelling", () => { + const pending = "C:\\work\\property-ops\\"; + + expect( + projectRoots( + sources({ + pendingCwds: [pending], + recentDirs: ["C:/work/property-ops"], + sessions: [ + { + cwd: "C:\\work\\property-ops", + createdAt: "2026-01-01T00:00:00.000Z", + status: "running", + }, + ], + }), + ), + ).toEqual([pending]); + }); + + it("deduplicates case-equivalent Windows roots and keeps the first trusted spelling", () => { + const pending = "C:\\Users\\Alice\\Project"; + + expect( + projectRoots( + sources({ + pendingCwds: [pending], + recentDirs: ["c:/users/alice/project/"], + sessions: [ + { + cwd: "C:/USERS/ALICE/PROJECT", + createdAt: "2026-01-01T00:00:00.000Z", + status: "running", + }, + ], + }), + ), + ).toEqual([pending]); + }); + + it("uses a lexical path tie-break when session recency is identical", () => { + expect( + projectRoots( + sources({ + sessions: [ + { + cwd: "/workspace/zeta", + createdAt: "2026-01-01T00:00:00.000Z", + status: "exited", + }, + { + cwd: "/workspace/alpha", + createdAt: "2026-01-01T00:00:00.000Z", + status: "exited", + }, + ], + agentPaths: [ + "/workspace/zeta/zeta-agent", + "/workspace/alpha/alpha-agent", + ], + }), + ), + ).toEqual(["/workspace/alpha", "/workspace/zeta"]); + }); + + it("recognizes an agent root across separator forms before promoting it", () => { + expect( + projectToOpen( + "C:/work/property-ops/tenant-screening", + sources({ + recentDirs: ["C:\\work\\property-ops\\tenant-screening"], + agentPaths: ["C:\\work\\property-ops\\tenant-screening"], + }), + ), + ).toBe("C:/work/property-ops"); + }); + + it("preserves a durable project root when later discovery marks it as an agent", () => { + expect( + projectRoots( + sources({ + recentDirs: ["/workspace/property-ops"], + pinnedRoots: ["/workspace/property-ops"], + agentPaths: ["/workspace/property-ops"], + }), + ), + ).toEqual(["/workspace/property-ops"]); + }); + + it("does not resurrect a durable nested root from an exited session alone", () => { + expect( + projectRoots( + sources({ + recentDirs: ["/workspace"], + pinnedRoots: ["/workspace/removed-project"], + sessions: [ + { + cwd: "/workspace/removed-project", + createdAt: "2026-01-01T00:00:00.000Z", + status: "exited", + }, + ], + agentPaths: ["/workspace/removed-project/agent"], + }), + ), + ).toEqual(["/workspace"]); + }); +}); diff --git a/packages/harness/src/shared/project-roots.ts b/packages/harness/src/shared/project-roots.ts new file mode 100644 index 00000000..8e93a7f7 --- /dev/null +++ b/packages/harness/src/shared/project-roots.ts @@ -0,0 +1,443 @@ +/** + * Canonical project-root derivation shared by Studio's browser and server. + * + * UI grouping, trusted project scope, and session capability composition must + * resolve the same roots from the same inputs. This module is deliberately + * pure and host-neutral: callers supply settings, sessions, pending creation + * roots, and registered agent paths; no filesystem or browser API is used. + */ +import type { SessionStatus } from "./types.js"; +import { + basenameOf, + isWithinDir, + pathComparisonKey, + pathSegmentDepth, + parentOf, +} from "./paths.js"; + +/** + * Row order within a container. "name" is A-Z; "recent" is + * newest-activity-first. It reaches this module because project ORDER is part + * of the derivation's output, and the rail renders that order verbatim. + */ +export type RailSort = "recent" | "name"; + +const isUnder = (childPath: string, root: string): boolean => + isWithinDir(root, childPath); + +/** Comparison form: forward slashes, no trailing separator. Never rendered and + * never POSTed — what the server sent keeps its native spelling. */ +const canonical = (p: string): string => pathComparisonKey(p); + +const lexicalCompare = (left: string, right: string): number => + left === right ? 0 : left < right ? -1 : 1; + +/** Everything the rail knows about which folders are projects. */ +export interface ProjectRootSources { + /** Upstream's workspace list — most-recently-used project directories, + * newest first, already deduped and pruned of dead paths at every boot. */ + recentDirs: readonly string[]; + /** Session cwds widen the candidate set for folders `recentDirs` has not yet + * recorded, and carry the recency signal for them. `status` separates the + * two very different claims a cwd can make: see rule 2. */ + sessions: readonly { + cwd: string; + createdAt: string; + status?: SessionStatus; + }[]; + /** Folders whose agent is mid-creation: known before any session or agent + * exists under them. */ + pendingCwds: readonly string[]; + /** Existing durable Studio roots. A root already carrying project identity + * must not be reclassified when later discovery learns that the directory is + * itself an agent; that would strand persisted sessions in a new project. */ + pinnedRoots?: readonly string[]; + /** + * Every registered agent's OWN directory. + * + * Required, not optional. The rule below cannot be stated without it, and a + * caller that forgets it would silently get the old accumulating behaviour + * back with every test still green. + */ + agentPaths: readonly string[]; + sort: RailSort; +} + +export interface DurableProjectRoot { + projectId: string; + cwd: string; +} + +/** + * Resolve the most-specific containing durable root with one deterministic + * browser/server rule. Equal-specificity claims by different projects fail + * closed; multiple bindings owned by one durable project remain valid. + */ +export function resolveProjectRootForPath( + targetPath: string, + roots: readonly T[], +): T | null { + const matches = roots.filter((root) => isWithinDir(root.cwd, targetPath)); + if (matches.length === 0) return null; + const depth = Math.max(...matches.map((root) => pathSegmentDepth(root.cwd))); + const nearest = matches.filter( + (root) => pathSegmentDepth(root.cwd) === depth, + ); + if (new Set(nearest.map((root) => root.projectId)).size !== 1) return null; + return [...nearest].sort( + (left, right) => + lexicalCompare(canonical(left.cwd), canonical(right.cwd)) || + lexicalCompare(left.cwd, right.cwd), + )[0]!; +} + +/** + * Choose one deterministic launch root for a multi-root project. + * + * The outermost active binding wins so a project-wide session starts with the + * broadest trusted context. Canonical lexical order breaks equal-depth ties; + * callers retain the winning root's original host spelling. + */ +export function preferredProjectRoot(roots: readonly string[]): string | null { + return ( + [...roots] + .filter((root) => canonical(root) !== "") + .sort((left, right) => { + const canonicalLeft = canonical(left); + const canonicalRight = canonical(right); + return ( + pathSegmentDepth(left) - pathSegmentDepth(right) || + lexicalCompare(canonicalLeft, canonicalRight) || + lexicalCompare(left, right) + ); + })[0] ?? null + ); +} + +/** + * Resolve a neutral project session back to its trusted durable root. + * + * Session cwd may be any descendant used for ordinary coding work. Both the + * server scope catalog and the browser rail must contribute the durable root, + * rather than independently promoting that descendant into a second project. + */ +export function projectSessionRoot( + session: { cwd: string; projectId: string }, + roots: readonly DurableProjectRoot[], +): string | null { + return ( + resolveProjectRootForPath( + session.cwd, + roots.filter((root) => root.projectId === session.projectId), + )?.cwd ?? null + ); +} + +/** + * THE FOLDER THAT HOLDS AN AGENT, or null when nothing better than the agent's + * own directory exists. + * + * ONE ANSWER, because there are two callers and they must not disagree. The + * rail's derivation asks it to decide which row to draw; `openProject` asks it + * to decide what the picker actually opens when you point it at an agent. + * + * `projects` must be the list `projectRoots` produces. The guard below is only + * as good as the definition of "project" it is handed, and a caller that builds + * its own will decline hops the rail would have made, which restores the silent + * no-op this exists to remove. `openProject` therefore passes + * `projectRoots(...)` verbatim rather than assembling anything. + * + * Null means REFUSE, and refusing is safe: the agent's own folder stays the + * root and renders as a project with that agent inside, which is what opening + * an agent's folder honestly means. + * + * Two reasons to refuse: + * + * 1. **A filesystem root.** `paths.parentOf` answers `/` (and `C:\`) rather + * than null there, deliberately, so that every result stays a listable + * path. Taken literally it turns an agent at `/solo` into a project called + * `/` holding the entire disk, and the swallow guard below cannot catch it + * because at that point there is no other project to swallow yet. + * 2. **It would contain another project.** Without this, the clean demo + * fixture, whose roots are agent folders sitting beside an ordinary project + * under one home directory, promoted them all to `/Users/demo` and produced + * a single project holding every other project, with every agent inside it + * rendered twice. That is the duplicate-agent rendering this rule exists to + * remove, re-created by the repair. + * + * KNOWN LIMIT, stated rather than papered over: a directory holding nothing but + * agent folders and no other project DOES become the project. That is right + * everywhere except a home directory, and a home directory in practice always + * holds another project, which is what makes the guard fire. A depth floor was + * considered and rejected, because every threshold that saves `/Users/demo` + * also breaks a legitimate two-segment root. + */ +/** + * WHAT OPENING A FOLDER ACTUALLY OPENS. + * + * You cannot open a single agent as a project, so pointing the picker at an + * agent's own folder opens the folder that holds it. Without this the press is + * a silent no-op: `projectRoots` declines to draw a row for an agent-rooted + * entry, so the picker says "This is an agent project", the user presses Open, + * and nothing changes. + * + * THE ELIGIBLE PROJECTS ARE `projectRoots`' OWN OUTPUT, not a list assembled + * here to resemble it. That is the whole design of this function, and it is the + * only version of it that has held: the guard inside `holdingProjectFor` asks + * "would this promotion swallow a project", and the answer is only as good as + * the definition of "project" it is handed. Four separate attempts to + * reconstruct that definition locally were each wrong in a different way, and + * every one of them failed in the same direction, by counting something the + * rail does not keep and so refusing a hop the rail would have made, which puts + * the silent no-op back. + * + * `projectRoots` is the one place that decides what a project is: chosen + * folders, folders with a live session, and session-only folders that hold an + * agent no other root already shows, with agent directories excluded and + * promotions guarded. Calling it costs one derivation on a user gesture and + * removes the entire class of drift, because there is no second definition left + * to disagree with. + */ +export function projectToOpen( + requested: string, + sources: ProjectRootSources, +): string { + const isAgentDir = sources.agentPaths.some( + (path) => canonical(path) === canonical(requested), + ); + if (!isAgentDir) return requested; + return ( + holdingProjectFor(requested, { + agentPaths: sources.agentPaths, + projects: projectRoots(sources), + }) ?? requested + ); +} + +export function holdingProjectFor( + agentDir: string, + { + agentPaths, + projects, + }: { agentPaths: readonly string[]; projects: readonly string[] }, +): string | null { + const agentDirs = new Set(agentPaths.map(canonical)); + let parent = parentOf(agentDir); + // An agent nested inside another agent walks up until it clears them all. + while (parent && agentDirs.has(canonical(parent))) parent = parentOf(parent); + if (parent === null || parentOf(parent) === null) return null; + const swallowsAProject = projects.some( + (held) => isUnder(held, parent!) && canonical(held) !== canonical(parent!), + ); + return swallowsAProject ? null : parent; +} + +/** + * THE ORDERED LIST OF PROJECT ROOTS. + * + * One sentence governs this whole function: + * + * A PROJECT IS A DIRECTORY YOU CHOSE THAT HOLDS AGENTS. + * + * Both clauses keep session working directories from accumulating as + * duplicate project rows. + * + * RULE 1, "you chose": an agent's OWN directory is not a project. The project + * is the directory that HOLDS agents; the agent is the thing inside it. A root + * that is itself a registered agent is a category error, and it is the single + * cause of both symptoms a real install shows. Its dependency graph has exactly + * one node, because nothing else is inside it. And it renders the agent TWICE + * whenever some other open project also contains it, once correctly nested and + * once again at top level under a different label, because `buildProjectTree` + * deliberately files an agent under EVERY root that contains it. An agent-rooted + * entry whose agent another project already shows is dropped, and one nothing shows + * is replaced by its nearest non-agent ancestor. + * + * This is not a new rule. `project-membership.agentNeedsOwnProject` has + * enforced it on every NEW registration since the accumulation was diagnosed: + * "an agent an open project already contains needs nothing remembered". It was + * simply never applied to the entries already in the list, so the guard stopped + * the bleeding and left the wound. Applying one rule in one direction only is + * why SAP-2927 looked complete while the rail still looked broken. + * + * RULE 2, "that holds agents": a folder known ONLY because a session ran there + * earns a row only if it holds an agent no other project already shows, OR a + * session is LIVE in it. "A session ran here once and exited" and "something is + * running here right now" are different claims, and collapsing them cost a real + * case immediately: a bare scaffold session, a live session in a folder with no + * agent yet, is exactly how you start an agent in an empty folder, and dropping + * its row makes a running session unreachable from the rail. + * `recentDirs` is chosen and capped at 8; session cwds are neither, which is + * why the second list has to earn its rows and the first does not. Two failures + * collapse into that one clause. A visited folder with no agent is not a + * project, while an empty project you OPENED keeps its row, because opening a + * folder in order to build the first agent in it is the whole point of that + * row. And a visited folder INSIDE a project you already opened is not a second + * context: `~/polsia` and `~/polsia/services/workers` are two useful views of + * one agent when you opened both, and the same agent printed twice when the + * inner row is merely where a session happened to start. + * + * NOTHING IS DELETED. Both rules are derivational: `recentDirs` on disk is + * untouched and any folder is one "Add a project" away from coming back. That + * is what makes this safe to apply to an install nobody audited, and why it + * needs no migration, no first-run flow and no undo. The design's original "no + * migration, every entry becomes a project" rule is kept in spirit and dropped + * in letter: nothing a user had disappears, but residue of a fixed bug stops + * being rendered as a choice they made. + */ +export function projectRoots({ + recentDirs, + sessions, + pendingCwds, + pinnedRoots = [], + agentPaths, + sort, +}: ProjectRootSources): string[] { + // Newest activity per directory, for folders `recentDirs` has not heard of. + const newestByCwd = new Map(); + for (const session of sessions) { + const key = canonical(session.cwd); + const prev = newestByCwd.get(key); + if (!prev || session.createdAt > prev) + newestByCwd.set(key, session.createdAt); + } + + // First spelling wins: recentDirs and a session cwd can name one directory + // in two forms (the server `path.resolve`s what it stores, the SPA holds + // what the user typed), and two rows for one folder is unreadable. + const seen = new Set(); + const candidates: string[] = []; + for (const dir of [ + ...pendingCwds, + ...recentDirs, + ...sessions.map((s) => s.cwd), + ]) { + const key = canonical(dir); + if (key === "" || seen.has(key)) continue; + seen.add(key); + candidates.push(dir); + } + + const agentDirs = new Set(agentPaths.map(canonical)); + const pinned = new Set(pinnedRoots.map(canonical)); + const isAgentDir = (dir: string): boolean => agentDirs.has(canonical(dir)); + const isPinned = (dir: string): boolean => pinned.has(canonical(dir)); + // A folder mid-creation is as deliberate an act as opening one, and its agent + // does not exist yet, so it can never be an agent directory either. A folder + // with a LIVE session counts too: you are working in it right now, which is a + // stronger claim than any list of remembered paths. + // `status !== "exited"` is the SAME reading `bareSessionAt` uses, so the row + // the rail keeps and the session it offers cannot disagree. An absent status + // reads as not live: the only callers that omit it name a folder's recency, + // and a missing field must never silently keep a row. + const liveCwds = sessions + .filter((session) => session.status != null && session.status !== "exited") + .map((session) => session.cwd); + const chosen = new Set( + [...pendingCwds, ...recentDirs, ...liveCwds].map(canonical), + ); + const wasChosen = (dir: string): boolean => chosen.has(canonical(dir)); + const agentsUnder = (root: string): string[] => + agentPaths.filter( + (path) => isUnder(path, root) && canonical(path) !== canonical(root), + ); + + /** What each surviving root was DERIVED FROM, so a promoted row inherits the + * recency of the entry that produced it rather than sorting as an unknown. */ + const from = new Map(); + const kept: string[] = []; + const holds = (root: string): boolean => + kept.some((held) => canonical(held) === canonical(root)); + + // The folders the user CHOSE, unconditionally and in order. `recentDirs` is a + // list of deliberate acts; second-guessing it is how a rail starts hiding a + // project somebody opened on purpose. + for (const dir of candidates) { + if ( + // A durable binding preserves the identity of a root that is otherwise + // still live/selected; it is not itself a navigation choice. Keeping an + // exited session cwd solely because an old workspace scope pinned it + // resurrects projects the user explicitly removed. + !wasChosen(dir) || + (isAgentDir(dir) && !isPinned(dir)) + ) + continue; + kept.push(dir); + from.set(canonical(dir), dir); + } + + /* RULE 2, over the session-only folders, SHALLOWEST FIRST. + The order is load-bearing, not tidiness. Taken in candidate order an inner + folder is reached before the outer one that would have explained it, and so + keeps a row it does not need: a captured install kept + `harness-e2e/projects/research-micro-site-` as its own project and + then added `harness-e2e` above it, printing both of its agents twice. + Shallowest first means the outermost folder that explains an agent wins and + every folder below it is measured against a list that already holds it. */ + const sessionOnly = candidates + .filter((dir) => !wasChosen(dir) && !isAgentDir(dir)) + .sort( + (a, b) => + canonical(a).split("/").length - canonical(b).split("/").length || + a.localeCompare(b), + ); + for (const dir of sessionOnly) { + const under = agentsUnder(dir); + if (under.length === 0) continue; + if (under.every((path) => kept.some((root) => isUnder(path, root)))) + continue; + kept.push(dir); + from.set(canonical(dir), dir); + } + + // RULE 1, over the agent-rooted entries, in candidate order so the result is + // deterministic. `kept` grows as promotions land, so a later entry can be + // absorbed by an earlier one's promotion. + for (const dir of candidates) { + if (!isAgentDir(dir) || isPinned(dir)) continue; + if ( + kept.some( + (root) => isUnder(dir, root) && canonical(root) !== canonical(dir), + ) + ) + continue; + const root = holdingProjectFor(dir, { agentPaths, projects: kept }) ?? dir; + if (holds(root)) continue; + kept.push(root); + from.set(canonical(root), dir); + } + + const pendingRank = new Map( + pendingCwds.map((cwd, index) => [canonical(cwd), index]), + ); + const recentRank = new Map( + recentDirs.map((dir, index) => [canonical(dir), index]), + ); + /** Rank and recency are asked of the ENTRY a row came from, so a promoted + * parent sorts where the agent that produced it sorted. */ + const source = (root: string): string => from.get(canonical(root)) ?? root; + + const byRecency = (a: string, b: string): number => { + const ia = recentRank.get(canonical(source(a))) ?? -1; + const ib = recentRank.get(canonical(source(b))) ?? -1; + if (ia >= 0 && ib >= 0) return ia - ib; + if (ia >= 0 || ib >= 0) return ia >= 0 ? -1 : 1; + return (newestByCwd.get(canonical(source(b))) ?? "").localeCompare( + newestByCwd.get(canonical(source(a))) ?? "", + ); + }; + + return kept.sort((a, b) => { + // A folder mid-creation outranks everything, on either sort — the user's + // attention is on it. Among several pending folders, newest first. + const ra = pendingRank.get(canonical(source(a))); + const rb = pendingRank.get(canonical(source(b))); + if (ra !== undefined || rb !== undefined) { + if (ra !== undefined && rb !== undefined) return ra - rb; + return ra !== undefined ? -1 : 1; + } + if (sort === "name") + return basenameOf(a).localeCompare(basenameOf(b)) || a.localeCompare(b); + return byRecency(a, b) || a.localeCompare(b); + }); +} diff --git a/packages/harness/web/src/lib/paths.test.ts b/packages/harness/web/src/lib/paths.test.ts index c5459ce8..1b6e4ab0 100644 --- a/packages/harness/web/src/lib/paths.test.ts +++ b/packages/harness/web/src/lib/paths.test.ts @@ -6,6 +6,7 @@ import { looksAbsolutePath, middleTruncatePath, parentOf, + samePath, sepOf, stripTrailingSep, } from "./paths"; @@ -25,7 +26,9 @@ describe("sepOf", () => { describe("joinPath", () => { it("joins POSIX with /", () => { - expect(joinPath("/Users/demo", "price-watch")).toBe("/Users/demo/price-watch"); + expect(joinPath("/Users/demo", "price-watch")).toBe( + "/Users/demo/price-watch", + ); }); it("joins Windows with \\ — the headline fix, no mixed-separator output", () => { @@ -113,11 +116,59 @@ describe("isWithinDir", () => { expect(isWithinDir("C:\\a\\b", "C:\\a\\other")).toBe(false); }); - it("matches a mixed-separator child against its native parent — the shipped bug", () => { - expect(isWithinDir("C:\\Users\\x\\projects", "C:\\Users\\x\\projects/newsletter-autopilot")).toBe( - true, + it("case-folds Windows drive and UNC paths without weakening segment boundaries", () => { + expect( + samePath("C:\\Users\\Alice\\Project", "c:/users/alice/project/"), + ).toBe(true); + expect( + isWithinDir("C:\\Users\\Alice\\Project", "c:/USERS/alice/PROJECT/src"), + ).toBe(true); + expect( + isWithinDir("C:\\Users\\Alice\\Project", "c:/users/alice/project-two"), + ).toBe(false); + expect( + isWithinDir("C:\\Users\\Alice\\Project", "D:/users/alice/project/src"), + ).toBe(false); + + expect( + samePath( + "\\\\BuildServer\\AgentShare\\Project", + "//buildserver/agentshare/project/", + ), + ).toBe(true); + expect( + isWithinDir( + "\\\\BuildServer\\AgentShare\\Project", + "//BUILDSERVER/AGENTSHARE/project/packages/app", + ), + ).toBe(true); + expect( + isWithinDir( + "\\\\BuildServer\\AgentShare\\Project", + "//buildserver/other-share/project", + ), + ).toBe(false); + }); + + it("keeps POSIX path comparisons case-sensitive", () => { + expect(samePath("/Users/Alice/Project", "/users/alice/project")).toBe( + false, ); - expect(isWithinDir("C:\\Users\\x\\projects/app", "C:\\Users\\x\\projects\\app")).toBe(true); + expect( + isWithinDir("/Users/Alice/Project", "/users/alice/project/src"), + ).toBe(false); + }); + + it("matches a mixed-separator child against its native parent — the shipped bug", () => { + expect( + isWithinDir( + "C:\\Users\\x\\projects", + "C:\\Users\\x\\projects/newsletter-autopilot", + ), + ).toBe(true); + expect( + isWithinDir("C:\\Users\\x\\projects/app", "C:\\Users\\x\\projects\\app"), + ).toBe(true); }); it("ignores trailing separators on either side", () => { @@ -144,7 +195,9 @@ describe("looksAbsolutePath", () => { describe("middleTruncatePath", () => { it("middle-truncates a long POSIX path", () => { - expect(middleTruncatePath("/Users/demo/work/onboarding-flow")).toBe("/Users/…/onboarding-flow"); + expect(middleTruncatePath("/Users/demo/work/onboarding-flow")).toBe( + "/Users/…/onboarding-flow", + ); }); it("middle-truncates a Windows path in its own separator", () => { diff --git a/packages/harness/web/src/lib/paths.ts b/packages/harness/web/src/lib/paths.ts index 89380524..7d0dc4d3 100644 --- a/packages/harness/web/src/lib/paths.ts +++ b/packages/harness/web/src/lib/paths.ts @@ -1,111 +1,7 @@ /** - * Browser-side path helpers for the ABSOLUTE paths the server hands us. + * Browser compatibility export for the host-neutral path helpers. * - * The server builds them with `path.join`, so they arrive in the host's native - * shape — backslash-separated on Windows. The SPA cannot ask `node:path` which - * host that was; it infers the separator from the string itself, which works - * because a Windows absolute path always contains at least one `\` (`C:\…`) - * and a POSIX one never does. - * - * Joins preserve the input's native separator (what gets POSTed back must - * match what the server sent), but every COMPARISON normalizes both - * separators first: paths that were joined in the browser before this module - * existed shipped in mixed form (`C:\Users\x\projects/newsletter-autopilot`), - * and those still have to compare equal to their native spellings. - */ - -/** The separator `p` itself uses. `\` anywhere marks a Windows path — POSIX - * filenames may legally contain `\`, but never in the absolute paths the - * server supplies. */ -export function sepOf(p: string): "\\" | "/" { - return p.includes("\\") ? "\\" : "/"; -} - -/** `` in the root's native separator, with no doubled - * separator when the root carries a trailing one. */ -export function joinPath(root: string, name: string): string { - const trimmedRoot = root.trim().replace(/[\\/]+$/, ""); - return `${trimmedRoot}${sepOf(root)}${name.trim()}`; -} - -/** Last non-empty segment under either separator, or the input when it has - * none (a relative name is its own basename). */ -export function basenameOf(p: string): string { - return p.split(/[\\/]/).filter(Boolean).pop() ?? p; -} - -/** - * Parent of an absolute path, or null at a filesystem root (`/`, `C:\`, bare - * `C:`) and for separator-free relative strings. Mirrors `path.dirname` - * without pulling node:path into the browser bundle. - * - * Needed because GET /api/fs/list reports one level DOWN: a path can only - * learn whether it is itself an agent project by asking its parent. - */ -export function parentOf(input: string): string | null { - const trimmed = input.replace(/[\\/]+$/, ""); - if (trimmed === "" || /^[A-Za-z]:$/.test(trimmed)) return null; - const lastSep = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); - if (lastSep < 0) return null; - const cut = trimmed.slice(0, lastSep); - // First-level paths keep their root spelled out — `/Users` → `/`, - // `C:\Users` → `C:\` — so the result is always itself a listable path. - if (/^[A-Za-z]:$/.test(cut)) return cut + trimmed[lastSep]; - return cut || "/"; -} - -/** `/a/b/` → `/a/b` under either separator, so a user's trailing slash never - * breaks a path comparison. Bare roots (`/`, `C:\`) pass through unchanged — - * stripping them would leave something that isn't a path. */ -export function stripTrailingSep(p: string): string { - const trimmed = p.replace(/[\\/]+$/, ""); - if (trimmed === p) return p; - if (trimmed === "") return p[0]; - if (/^[A-Za-z]:$/.test(trimmed)) return trimmed + p[trimmed.length]; - return trimmed; -} - -/** Whether `child` IS `parent` or sits beneath it — never a mere string - * prefix, so `/a/scratch-2` is not within `/a/scratch`. Separator-insensitive - * on both sides, so a mixed-form path still matches its native spelling. */ -/** - * Whether two paths name the same directory, ignoring separator form and a - * trailing separator. - * - * Needed because the client and the server no longer agree byte-for-byte: the - * server `path.resolve()`s every cwd it stores (server/cwd-normalize.ts) while - * the SPA holds whatever the user typed or a recentDirs entry recorded — so a - * `C:/…`-typed path, or one with a trailing slash, fails a raw `===` against - * the very session it just created (empty tab strip, unhighlighted rail row). + * Existing web imports stay stable while the browser and server consume the + * same implementation from src/shared. */ -export function samePath(a: string, b: string): boolean { - return stripTrailingSep(a.replace(/\\/g, "/")) === stripTrailingSep(b.replace(/\\/g, "/")); -} - -export function isWithinDir(parent: string, child: string): boolean { - const p = stripTrailingSep(parent.replace(/\\/g, "/")); - const c = stripTrailingSep(child.replace(/\\/g, "/")); - if (c === p) return true; - // A filesystem root keeps its trailing separator (stripTrailingSep's - // contract), so appending another would test "C://…" and never match — - // every session under a root-level workspace looked like an orphan. - return p.endsWith("/") ? c.startsWith(p) : c.startsWith(`${p}/`); -} - -/** Whether typed input is trying to be an absolute path (`/…`, `~…`, or a - * Windows drive like `C:\…` / `C:/…`) rather than a search query. */ -export function looksAbsolutePath(input: string): boolean { - return input.startsWith("/") || input.startsWith("~") || /^[A-Za-z]:[\\/]/.test(input); -} - -/** "/Users/…/onboarding-flow" — middle-truncates a long path so a chip row - * never hard-clips a chip mid-glyph; the full path stays in the tooltip. */ -export function middleTruncatePath(path: string): string { - const sep = sepOf(path); - const segments = path.split(/[\\/]/).filter(Boolean); - if (segments.length <= 2) return path; - // POSIX first segments lost their leading `/` to the split; a drive letter - // (`C:`) never had one. - const prefix = sep === "\\" ? "" : sep; - return `${prefix}${segments[0]}${sep}…${sep}${segments[segments.length - 1]}`; -} +export * from "../../../src/shared/paths.js"; diff --git a/packages/harness/web/src/lib/project-tree.ts b/packages/harness/web/src/lib/project-tree.ts index 427bb9c5..3172ecdf 100644 --- a/packages/harness/web/src/lib/project-tree.ts +++ b/packages/harness/web/src/lib/project-tree.ts @@ -1,14 +1,35 @@ -import type { SessionStatus, WorkflowInfo } from "@shared/types"; +import type { WorkflowInfo } from "@shared/types"; +import type { RailSort } from "../../../src/shared/project-roots.js"; import { displayAgentName } from "./agent-name"; import { basenameOf, isWithinDir, joinPath, + pathSegmentDepth, parentOf, + samePath, stripTrailingSep, } from "./paths"; +/** + * WHICH FOLDERS ARE PROJECTS now lives in `src/shared/project-roots`, because the + * SERVER has to reach the same answer: it issues one durable Studio project + * per workspace scope, and a scope list built from a second definition left + * every promoted root without a project (see that module's header). Re-exported + * here so this file stays the rail's one import for project shape. + */ +export type { + ProjectRootSources, + RailSort, +} from "../../../src/shared/project-roots.js"; +export { + holdingProjectFor, + projectRoots, + projectSessionRoot, + projectToOpen, +} from "../../../src/shared/project-roots.js"; + /** * The rail's filing axes. * @@ -33,14 +54,10 @@ import { */ export type RailAxis = "project" | "group"; -/** - * Row order within a container. "name" is A–Z; "recent" is - * newest-activity-first, but ONLY for the project rows (they carry session - * recency) — `WorkflowInfo` has no timestamp, so agent ROWS are always - * path-stable regardless of this setting. "recent" therefore changes project - * order, not row order. - */ -export type RailSort = "recent" | "name"; +/* `RailSort` is defined in `src/shared/project-roots` and re-exported above: it + orders the PROJECT rows, which that module produces. `WorkflowInfo` has no + timestamp, so agent rows stay path-stable whatever this is set to — "recent" + changes project order, not row order. */ /** * One agent row. `prefix` is the unbranched directory chain compacted ONTO @@ -147,7 +164,7 @@ const canonical = (p: string): string => function segmentsBetween(root: string, target: string): string[] { const r = canonical(root); const t = canonical(target); - if (t === r) return []; + if (samePath(root, target)) return []; const rest = r.endsWith("/") ? t.slice(r.length) : t.slice(r.length + 1); return rest.split("/").filter(Boolean); } @@ -511,334 +528,6 @@ export function unrootedAgents( .sort(agentOrder(sort)); } -/** Everything the rail knows about which folders are projects. */ -export interface ProjectRootSources { - /** Upstream's workspace list — most-recently-used project directories, - * newest first, already deduped and pruned of dead paths at every boot. */ - recentDirs: readonly string[]; - /** Session cwds widen the candidate set for folders `recentDirs` has not yet - * recorded, and carry the recency signal for them. `status` separates the - * two very different claims a cwd can make: see rule 2. */ - sessions: readonly { - cwd: string; - createdAt: string; - status?: SessionStatus; - }[]; - /** Folders whose agent is mid-creation: known before any session or agent - * exists under them. */ - pendingCwds: readonly string[]; - /** - * Every registered agent's OWN directory. - * - * Required, not optional. The rule below cannot be stated without it, and a - * caller that forgets it would silently get the old accumulating behaviour - * back with every test still green. - */ - agentPaths: readonly string[]; - sort: RailSort; -} - -/** - * THE FOLDER THAT HOLDS AN AGENT, or null when nothing better than the agent's - * own directory exists. - * - * ONE ANSWER, because there are two callers and they must not disagree. The - * rail's derivation asks it to decide which row to draw; `openProject` asks it - * to decide what the picker actually opens when you point it at an agent. - * - * `projects` must be the list `projectRoots` produces. The guard below is only - * as good as the definition of "project" it is handed, and a caller that builds - * its own will decline hops the rail would have made, which restores the silent - * no-op this exists to remove. `openProject` therefore passes - * `projectRoots(...)` verbatim rather than assembling anything. - * - * Null means REFUSE, and refusing is safe: the agent's own folder stays the - * root and renders as a project with that agent inside, which is what opening - * an agent's folder honestly means. - * - * Two reasons to refuse: - * - * 1. **A filesystem root.** `paths.parentOf` answers `/` (and `C:\`) rather - * than null there, deliberately, so that every result stays a listable - * path. Taken literally it turns an agent at `/solo` into a project called - * `/` holding the entire disk, and the swallow guard below cannot catch it - * because at that point there is no other project to swallow yet. - * 2. **It would contain another project.** Without this, the clean demo - * fixture, whose roots are agent folders sitting beside an ordinary project - * under one home directory, promoted them all to `/Users/demo` and produced - * a single project holding every other project, with every agent inside it - * rendered twice. That is the duplicate-agent rendering this rule exists to - * remove, re-created by the repair. - * - * KNOWN LIMIT, stated rather than papered over: a directory holding nothing but - * agent folders and no other project DOES become the project. That is right - * everywhere except a home directory, and a home directory in practice always - * holds another project, which is what makes the guard fire. A depth floor was - * considered and rejected, because every threshold that saves `/Users/demo` - * also breaks a legitimate two-segment root. - */ -/** - * WHAT OPENING A FOLDER ACTUALLY OPENS. - * - * You cannot open a single agent as a project, so pointing the picker at an - * agent's own folder opens the folder that holds it. Without this the press is - * a silent no-op: `projectRoots` declines to draw a row for an agent-rooted - * entry, so the picker says "This is an agent project", the user presses Open, - * and nothing changes. - * - * THE ELIGIBLE PROJECTS ARE `projectRoots`' OWN OUTPUT, not a list assembled - * here to resemble it. That is the whole design of this function, and it is the - * only version of it that has held: the guard inside `holdingProjectFor` asks - * "would this promotion swallow a project", and the answer is only as good as - * the definition of "project" it is handed. Four separate attempts to - * reconstruct that definition locally were each wrong in a different way, and - * every one of them failed in the same direction, by counting something the - * rail does not keep and so refusing a hop the rail would have made, which puts - * the silent no-op back. - * - * `projectRoots` is the one place that decides what a project is: chosen - * folders, folders with a live session, and session-only folders that hold an - * agent no other root already shows, with agent directories excluded and - * promotions guarded. Calling it costs one derivation on a user gesture and - * removes the entire class of drift, because there is no second definition left - * to disagree with. - */ -export function projectToOpen( - requested: string, - sources: ProjectRootSources, -): string { - const isAgentDir = sources.agentPaths.some( - (path) => canonical(path) === canonical(requested), - ); - if (!isAgentDir) return requested; - return ( - holdingProjectFor(requested, { - agentPaths: sources.agentPaths, - projects: projectRoots(sources), - }) ?? requested - ); -} - -export function holdingProjectFor( - agentDir: string, - { - agentPaths, - projects, - }: { agentPaths: readonly string[]; projects: readonly string[] }, -): string | null { - const agentDirs = new Set(agentPaths.map(canonical)); - let parent = parentOf(agentDir); - // An agent nested inside another agent walks up until it clears them all. - while (parent && agentDirs.has(canonical(parent))) parent = parentOf(parent); - if (parent === null || parentOf(parent) === null) return null; - const swallowsAProject = projects.some( - (held) => isUnder(held, parent!) && canonical(held) !== canonical(parent!), - ); - return swallowsAProject ? null : parent; -} - -/** - * THE ORDERED LIST OF PROJECT ROOTS. - * - * One sentence governs this whole function: - * - * A PROJECT IS A DIRECTORY YOU CHOSE THAT HOLDS AGENTS. - * - * Two clauses, and dropping either one is what filled a real rail. Measured - * against a captured `~/.sapiom/harness` (`org-dogfood.json` in the design - * prototype: 75 agents, 8 recentDirs, 41 distinct session cwds), the sources - * below offer 41 candidate roots and this function returns 8. - * - * RULE 1, "you chose": an agent's OWN directory is not a project. The project - * is the directory that HOLDS agents; the agent is the thing inside it. A root - * that is itself a registered agent is a category error, and it is the single - * cause of both symptoms a real install shows. Its dependency graph has exactly - * one node, because nothing else is inside it. And it renders the agent TWICE - * whenever some other open project also contains it, once correctly nested and - * once again at top level under a different label, because `buildProjectTree` - * deliberately files an agent under EVERY root that contains it. Three agents - * were on screen twice this way on one real machine. So an agent-rooted entry - * whose agent another project already shows is dropped, and one nothing shows - * is replaced by its nearest non-agent ancestor. - * - * This is not a new rule. `project-membership.agentNeedsOwnProject` has - * enforced it on every NEW registration since the accumulation was diagnosed: - * "an agent an open project already contains needs nothing remembered". It was - * simply never applied to the entries already in the list, so the guard stopped - * the bleeding and left the wound. Applying one rule in one direction only is - * why SAP-2927 looked complete while the rail still looked broken. - * - * RULE 2, "that holds agents": a folder known ONLY because a session ran there - * earns a row only if it holds an agent no other project already shows, OR a - * session is LIVE in it. "A session ran here once and exited" and "something is - * running here right now" are different claims, and collapsing them cost a real - * case immediately: a bare scaffold session, a live session in a folder with no - * agent yet, is exactly how you start an agent in an empty folder, and dropping - * its row makes a running session unreachable from the rail. - * `recentDirs` is chosen and capped at 8; session cwds are neither, which is - * why the second list has to earn its rows and the first does not. Two failures - * collapse into that one clause. A visited folder with no agent is not a - * project, while an empty project you OPENED keeps its row, because opening a - * folder in order to build the first agent in it is the whole point of that - * row. And a visited folder INSIDE a project you already opened is not a second - * context: `~/polsia` and `~/polsia/services/workers` are two useful views of - * one agent when you opened both, and the same agent printed twice when the - * inner row is merely where a session happened to start. - * - * NOTHING IS DELETED. Both rules are derivational: `recentDirs` on disk is - * untouched and any folder is one "Add a project" away from coming back. That - * is what makes this safe to apply to an install nobody audited, and why it - * needs no migration, no first-run flow and no undo. The design's original "no - * migration, every entry becomes a project" rule is kept in spirit and dropped - * in letter: nothing a user had disappears, but residue of a fixed bug stops - * being rendered as a choice they made. - */ -export function projectRoots({ - recentDirs, - sessions, - pendingCwds, - agentPaths, - sort, -}: ProjectRootSources): string[] { - // Newest activity per directory, for folders `recentDirs` has not heard of. - const newestByCwd = new Map(); - for (const session of sessions) { - const key = canonical(session.cwd); - const prev = newestByCwd.get(key); - if (!prev || session.createdAt > prev) - newestByCwd.set(key, session.createdAt); - } - - // First spelling wins: recentDirs and a session cwd can name one directory - // in two forms (the server `path.resolve`s what it stores, the SPA holds - // what the user typed), and two rows for one folder is unreadable. - const seen = new Set(); - const candidates: string[] = []; - for (const dir of [ - ...pendingCwds, - ...recentDirs, - ...sessions.map((s) => s.cwd), - ]) { - const key = canonical(dir); - if (key === "" || seen.has(key)) continue; - seen.add(key); - candidates.push(dir); - } - - const agentDirs = new Set(agentPaths.map(canonical)); - const isAgentDir = (dir: string): boolean => agentDirs.has(canonical(dir)); - // A folder mid-creation is as deliberate an act as opening one, and its agent - // does not exist yet, so it can never be an agent directory either. A folder - // with a LIVE session counts too: you are working in it right now, which is a - // stronger claim than any list of remembered paths. - // `status !== "exited"` is the SAME reading `bareSessionAt` uses, so the row - // the rail keeps and the session it offers cannot disagree. An absent status - // reads as not live: the only callers that omit it name a folder's recency, - // and a missing field must never silently keep a row. - const liveCwds = sessions - .filter((session) => session.status != null && session.status !== "exited") - .map((session) => session.cwd); - const chosen = new Set( - [...pendingCwds, ...recentDirs, ...liveCwds].map(canonical), - ); - const wasChosen = (dir: string): boolean => chosen.has(canonical(dir)); - const agentsUnder = (root: string): string[] => - agentPaths.filter( - (path) => isUnder(path, root) && canonical(path) !== canonical(root), - ); - - /** What each surviving root was DERIVED FROM, so a promoted row inherits the - * recency of the entry that produced it rather than sorting as an unknown. */ - const from = new Map(); - const kept: string[] = []; - const holds = (root: string): boolean => - kept.some((held) => canonical(held) === canonical(root)); - - // The folders the user CHOSE, unconditionally and in order. `recentDirs` is a - // list of deliberate acts; second-guessing it is how a rail starts hiding a - // project somebody opened on purpose. - for (const dir of candidates) { - if (!wasChosen(dir) || isAgentDir(dir)) continue; - kept.push(dir); - from.set(canonical(dir), dir); - } - - /* RULE 2, over the session-only folders, SHALLOWEST FIRST. - The order is load-bearing, not tidiness. Taken in candidate order an inner - folder is reached before the outer one that would have explained it, and so - keeps a row it does not need: a captured install kept - `harness-e2e/projects/research-micro-site-` as its own project and - then added `harness-e2e` above it, printing both of its agents twice. - Shallowest first means the outermost folder that explains an agent wins and - every folder below it is measured against a list that already holds it. */ - const sessionOnly = candidates - .filter((dir) => !wasChosen(dir) && !isAgentDir(dir)) - .sort( - (a, b) => - canonical(a).split("/").length - canonical(b).split("/").length || - a.localeCompare(b), - ); - for (const dir of sessionOnly) { - const under = agentsUnder(dir); - if (under.length === 0) continue; - if (under.every((path) => kept.some((root) => isUnder(path, root)))) - continue; - kept.push(dir); - from.set(canonical(dir), dir); - } - - // RULE 1, over the agent-rooted entries, in candidate order so the result is - // deterministic. `kept` grows as promotions land, so a later entry can be - // absorbed by an earlier one's promotion. - for (const dir of candidates) { - if (!isAgentDir(dir)) continue; - if ( - kept.some( - (root) => isUnder(dir, root) && canonical(root) !== canonical(dir), - ) - ) - continue; - const root = holdingProjectFor(dir, { agentPaths, projects: kept }) ?? dir; - if (holds(root)) continue; - kept.push(root); - from.set(canonical(root), dir); - } - - const pendingRank = new Map( - pendingCwds.map((cwd, index) => [canonical(cwd), index]), - ); - const recentRank = new Map( - recentDirs.map((dir, index) => [canonical(dir), index]), - ); - /** Rank and recency are asked of the ENTRY a row came from, so a promoted - * parent sorts where the agent that produced it sorted. */ - const source = (root: string): string => from.get(canonical(root)) ?? root; - - const byRecency = (a: string, b: string): number => { - const ia = recentRank.get(canonical(source(a))) ?? -1; - const ib = recentRank.get(canonical(source(b))) ?? -1; - if (ia >= 0 && ib >= 0) return ia - ib; - if (ia >= 0 || ib >= 0) return ia >= 0 ? -1 : 1; - return (newestByCwd.get(canonical(source(b))) ?? "").localeCompare( - newestByCwd.get(canonical(source(a))) ?? "", - ); - }; - - return kept.sort((a, b) => { - // A folder mid-creation outranks everything, on either sort — the user's - // attention is on it. Among several pending folders, newest first. - const ra = pendingRank.get(canonical(source(a))); - const rb = pendingRank.get(canonical(source(b))); - if (ra !== undefined || rb !== undefined) { - if (ra !== undefined && rb !== undefined) return ra - rb; - return ra !== undefined ? -1 : 1; - } - if (sort === "name") - return basenameOf(a).localeCompare(basenameOf(b)) || a.localeCompare(b); - return byRecency(a, b) || a.localeCompare(b); - }); -} - /** * Project labels. * @@ -870,9 +559,8 @@ function projectLabeller(roots: readonly string[]): (root: string) => string { const parentOf = (root: string): string | null => { let best: string | null = null; for (const other of roots) { - if (canonical(other) === canonical(root) || !isUnder(root, other)) - continue; - if (best === null || canonical(other).length > canonical(best).length) + if (samePath(other, root) || !isUnder(root, other)) continue; + if (best === null || pathSegmentDepth(other) > pathSegmentDepth(best)) best = other; } return best; @@ -898,7 +586,7 @@ function projectLabeller(roots: readonly string[]): (root: string) => string { // The SAME grow-leftward rule the unrooted rows use — see `growLeftward`. // `min` is 2 because reaching here already proved one segment collides. const others = roots - .filter((other) => canonical(other) !== canonical(root)) + .filter((other) => !samePath(other, root)) .map(segmentsOf); const grown = growLeftward(segments, others, 2); // Exhausted without ever becoming unique (two roots spelled the same in