fix(projector): stop writing null workspace_id on work_products

Two sites in upsertFilesystemArtifact leaked null workspace_ids
into the work_products table and produced the orphan rows the read
path now has to heal:

1. Create path unconditionally forwarded ctx.workspaceId, which was
   null whenever a run was created without a workspace binding
   (historically cognitive runs, or runs that lost their link through
   the observationPath regression fixed in 1f2f977).

2. Update path passed {workspaceId: ctx.workspaceId} as part of the
   patch. updateWorkProduct treats any key present in patch as "write
   this value", so a run touching a previously-well-linked row with
   ctx.workspaceId=null would clobber the existing workspace_id back
   to null — actively destroying data.

Resolve both at the projector layer:

  - Add workspacesMod.findWorkspaceIdByLocalPath(companyId, localPath)
    as a reverse-lookup. workspacePath is the argument the projector
    is already walking; it came from some workspace row's local_path,
    so finding that row by path is deterministic.
  - Compute resolvedWorkspaceId = ctx.workspaceId ?? reverseLookup(...)
    once, and use it for both the identity lookup
    (findWorkProductByExternalId) and the subsequent write.
  - On the update path, only include workspaceId in the patch when
    resolvedWorkspaceId is non-null. Null now means "no info — leave
    the existing column alone" instead of "clobber to null".

Together with the read-path heal in cfb8ba5 this closes the loop:
new files never enter the table with a null workspace_id if we can
possibly resolve it, old files keep their links, and truly unlinkable
rows still fall back to the observation workspace on open.

Typecheck and build:verify clean.
This commit is contained in:
ZacharyZhang-NY committed 2026-04-21 14:52:38 -04:00
1 parent cfb8ba5dc1
commit 9fba8dd6c3
2 files changed
+35 -5

No files matched your search

+17 -5
View File
@@ -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<typeof db.documentsMod.updateWorkProduct>[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,