diff --git a/src/main/agent-runtime/artifact-projector.ts b/src/main/agent-runtime/artifact-projector.ts index 232879f7..32f8f1d7 100644 --- a/src/main/agent-runtime/artifact-projector.ts +++ b/src/main/agent-runtime/artifact-projector.ts @@ -608,29 +608,41 @@ function upsertFilesystemArtifact( const title = lastSlash >= 0 ? relativePath.slice(lastSlash + 1) : relativePath; const provider = "filesystem"; + // Reverse-resolve workspaceId from workspacePath when ctx didn't + // carry it. The run may have been created before workspace_id was + // tracked end-to-end, but the projector only ever walks a workspace + // whose local_path is known — that path uniquely identifies the + // workspace row, so it is safe to persist the foreign key instead + // of writing another orphan. + const resolvedWorkspaceId = + ctx.workspaceId ?? db.workspacesMod.findWorkspaceIdByLocalPath(ctx.companyId, workspacePath); // Identity is scoped to (company, workspace, provider, externalId) so two // workspaces with the same relative path do not collapse onto one row. // Every touch refreshes `last_touched_by_run_id` so the deliverable // signal, the DocumentCenter preview, and the run-provenance readers // all see the latest run that wrote the file. - const existing = db.documentsMod.findWorkProductByExternalId(ctx.companyId, provider, relativePath, ctx.workspaceId); + const existing = db.documentsMod.findWorkProductByExternalId(ctx.companyId, provider, relativePath, resolvedWorkspaceId); if (existing) { // `status: "active"` flips a previously-archived row (earlier run // retired the path, this run recreated it) back into the visible // DocumentCenter set. `lastTouchedByRunId` keeps the provenance // timeline pointed at the run that just wrote the bytes. - db.documentsMod.updateWorkProduct(existing.id, ctx.companyId, { + // Only include workspaceId in the patch when we actually have one — + // otherwise the UPDATE would clobber a previously-resolved + // workspace_id back to null (the original source of orphan rows). + const patch: Parameters[2] = { title, status: "active", lastTouchedByRunId: ctx.runId, - workspaceId: ctx.workspaceId, - }); + }; + if (resolvedWorkspaceId) patch.workspaceId = resolvedWorkspaceId; + db.documentsMod.updateWorkProduct(existing.id, ctx.companyId, patch); return { kind: "updated", workProductId: existing.id, relativePath }; } const workProductId = db.documentsMod.createWorkProduct({ companyId: ctx.companyId, taskId: ctx.taskId, - workspaceId: ctx.workspaceId, + workspaceId: resolvedWorkspaceId, type: "file", provider, externalId: relativePath, diff --git a/src/main/database/workspaces.ts b/src/main/database/workspaces.ts index 72c60cce..d106ead9 100644 --- a/src/main/database/workspaces.ts +++ b/src/main/database/workspaces.ts @@ -51,6 +51,24 @@ export function buildWorkspaceMethods( return row ? { ...row, isPrimary: toBoolean(row.isPrimary) } : null; }, + /** + * Reverse-lookup a workspace's id by its local_path within a + * company. Used by the artifact projector to heal a run context + * whose workspaceId was never bound but whose workspacePath is + * still known — we look up the row that owns that path so the + * resulting work_product carries a real foreign key instead of + * another null that downstream readers would have to heal again. + * Returns null when the path does not match any workspace (the + * caller proceeds without a workspace_id, which is then handled + * defensively by updateWorkProduct). + */ + findWorkspaceIdByLocalPath(companyId: string, localPath: string): string | null { + const row = getDb() + .prepare("select id from workspaces where company_id = ? and local_path = ? limit 1") + .get(companyId, localPath) as { id: string } | undefined; + return row?.id ?? null; + }, + getProjectPrimaryWorkspace(projectId: string | null | undefined, companyId: string): WorkspaceRecord | null { if (!projectId) return null; const row = getDb()