diff --git a/src/main/database/documents-work-products.ts b/src/main/database/documents-work-products.ts index 92ec4884..67615b11 100644 --- a/src/main/database/documents-work-products.ts +++ b/src/main/database/documents-work-products.ts @@ -33,6 +33,38 @@ function resolveWorkspaceRoot( return null; } +function listCompanyWorkspaceRoots( + getDb: () => DatabaseSync, + companyId: string, +): string[] { + return (getDb() + .prepare( + "SELECT local_path AS localPath FROM workspaces WHERE company_id = ? AND local_path IS NOT NULL ORDER BY is_primary DESC, created_at ASC", + ) + .all(companyId) as Array<{ localPath: string }>) + .map((row) => row.localPath) + .filter((localPath) => localPath.length > 0); +} + +function orderedUnique(values: Array): string[] { + const seen = new Set(); + const unique: string[] = []; + for (const value of values) { + if (!value || seen.has(value)) continue; + seen.add(value); + unique.push(value); + } + return unique; +} + +function fileExists(targetPath: string): boolean { + try { + return statSync(targetPath).isFile(); + } catch { + return false; + } +} + function joinWorkspaceRelative(root: string, rel: string): string | null { if (isAbsolute(rel)) return null; const joined = resolvePath(root, rel); @@ -155,7 +187,7 @@ export function buildWorkProductMethods( companyId: string, ): | { kind: "url" | "path"; value: string } - | { kind: "error"; reason: "not_found" | "archived" | "no_target" | "workspace_unresolved" | "path_escape"; detail: string } { + | { kind: "error"; reason: "not_found" | "archived" | "no_target" | "workspace_unresolved" | "path_escape" | "path_missing"; detail: string } { const row = getDb().prepare( "SELECT provider, external_id, url, status, task_id, created_by_run_id, last_touched_by_run_id, workspace_id FROM work_products WHERE id = ? AND company_id = ?", ).get(workProductId, companyId) as @@ -169,42 +201,40 @@ export function buildWorkProductMethods( return { kind: "error", reason: "no_target", detail: `provider=${row.provider} external_id=${row.external_id ?? "null"} url=null` }; } - const workspaceRoot = resolveWorkspaceRoot( - getDb, - companyId, - row.workspace_id, - row.task_id, - row.last_touched_by_run_id ?? row.created_by_run_id, - ); - if (workspaceRoot) { + const workspaceRoots = orderedUnique([ + resolveWorkspaceRoot( + getDb, + companyId, + row.workspace_id, + row.task_id, + row.last_touched_by_run_id ?? row.created_by_run_id, + ), + ...listCompanyWorkspaceRoots(getDb, companyId), + ]); + let firstCandidate: string | null = null; + for (const workspaceRoot of workspaceRoots) { const absolute = joinWorkspaceRelative(workspaceRoot, row.external_id); if (!absolute) { return { kind: "error", reason: "path_escape", detail: `external_id=${row.external_id} escapes workspace root ${workspaceRoot}` }; } - return { kind: "path", value: absolute }; - } - - const observation = getDb() - .prepare( - "SELECT local_path as localPath FROM workspaces WHERE company_id = ? AND local_path IS NOT NULL ORDER BY is_primary DESC, created_at ASC LIMIT 1", - ) - .get(companyId) as { localPath: string } | undefined; - if (observation?.localPath) { - const candidate = joinWorkspaceRelative(observation.localPath, row.external_id); - if (candidate) { - try { - if (statSync(candidate).isFile()) { - return { kind: "path", value: candidate }; - } - } catch { - // fall through to error below - } + firstCandidate ??= absolute; + if (fileExists(absolute)) { + return { kind: "path", value: absolute }; } } + + if (firstCandidate) { + return { + kind: "error", + reason: "path_missing", + detail: `No registered company workspace contains ${row.external_id}; first candidate was ${firstCandidate}`, + }; + } + return { kind: "error", reason: "workspace_unresolved", - detail: `workspace_id=${row.workspace_id ?? "null"} task_id=${row.task_id ?? "null"} run_id=${row.last_touched_by_run_id ?? row.created_by_run_id ?? "null"} — none led to a workspace with a local_path, and ${row.external_id} is not present in the company's observation workspace${observation?.localPath ? ` (${observation.localPath})` : " (no observation workspace exists)"}`, + detail: `workspace_id=${row.workspace_id ?? "null"} task_id=${row.task_id ?? "null"} run_id=${row.last_touched_by_run_id ?? row.created_by_run_id ?? "null"} — none led to a workspace with a local_path`, }; }, diff --git a/src/main/ipc-core-read-handlers.ts b/src/main/ipc-core-read-handlers.ts index 346cdd03..1cf583f5 100644 --- a/src/main/ipc-core-read-handlers.ts +++ b/src/main/ipc-core-read-handlers.ts @@ -76,7 +76,12 @@ export function registerCoreReadHandlers({ no_target: "WORK_PRODUCT_NO_TARGET", workspace_unresolved: "WORK_PRODUCT_WORKSPACE_UNRESOLVED", path_escape: "WORK_PRODUCT_PATH_ESCAPE", + path_missing: "WORK_PRODUCT_PATH_MISSING", } as const; + if (target.reason === "path_missing") { + db.core.documents.updateWorkProduct(parsed.data.id, parsed.data.companyId, { status: "archived" }); + publishDomainChanged(); + } return fail(codeByReason[target.reason], target.detail); } if (target.kind === "url") { diff --git a/tests/integration/work-products-lifecycle.spec.ts b/tests/integration/work-products-lifecycle.spec.ts index 6da128e0..531e11fe 100644 --- a/tests/integration/work-products-lifecycle.spec.ts +++ b/tests/integration/work-products-lifecycle.spec.ts @@ -1,5 +1,6 @@ +import { mkdirSync, writeFileSync } from "node:fs"; import { mkdtemp, mkdir, rm, writeFile, unlink } from "node:fs/promises"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, describe, expect, it } from "vitest"; import { AppDatabase } from "../../src/main/database"; @@ -107,6 +108,9 @@ async function createHarness(): Promise { } function registerArtifact(harness: Harness, relativePath: string): string { + const absPath = join(harness.workspacePath, relativePath); + mkdirSync(dirname(absPath), { recursive: true }); + writeFileSync(absPath, "tracked artifact", "utf8"); return harness.db.core.documents.createWorkProduct({ companyId: harness.companyId, taskId: harness.taskId, @@ -179,6 +183,56 @@ describe("work_products archived visibility", () => { harness.db.close(); } }); + + it("resolves workspace-prefixed filesystem rows through another company workspace when the bound root misses", async () => { + const harness = await createHarness(); + try { + const parentPath = await createTempDir("agentcompany-parent-workspace-"); + const projectPath = join(parentPath, "booking-app"); + await mkdir(projectPath, { recursive: true }); + await writeFile(join(projectPath, "AGENTS.md"), "instructions", "utf8"); + harness.db.core.workspaces.saveWorkspace({ + companyId: harness.companyId, + name: "Parent", + localPath: parentPath, + repoUrl: "", + repoRef: "", + isPrimary: false, + }); + + const id = harness.db.core.documents.createWorkProduct({ + companyId: harness.companyId, + taskId: harness.taskId, + workspaceId: harness.workspaceId, + type: "file", + provider: "filesystem", + externalId: "booking-app/AGENTS.md", + title: "AGENTS.md", + url: null, + createdByRunId: harness.runId, + }); + + expect( + harness.db.core.documents.resolveWorkProductTarget(id, harness.companyId), + ).toEqual({ kind: "path", value: join(projectPath, "AGENTS.md") }); + } finally { + harness.db.close(); + } + }); + + it("reports missing filesystem rows when no company workspace contains the file", async () => { + const harness = await createHarness(); + try { + const id = registerArtifact(harness, "docs/missing.md"); + await unlink(join(harness.workspacePath, "docs/missing.md")); + + expect( + harness.db.core.documents.resolveWorkProductTarget(id, harness.companyId), + ).toMatchObject({ kind: "error", reason: "path_missing" }); + } finally { + harness.db.close(); + } + }); }); describe("work_products bulk retirement", () => {