Pin work_products identity to (company, workspace, path)

The natural key used to be (companyId, provider, externalId), so two
workspaces with the same workspace-relative path collided onto one
row. Provenance was also frozen at the first write: `created_by_run_id`
never refreshed, so a run that edited a pre-existing file never
qualified as a deliverable and the DocumentCenter "open" link could
still jump into the first workspace after a repo move.

Migration v45 adds `workspace_id` + `last_touched_by_run_id`, backfills
`workspace_id` via the task/run join, seeds `last_touched_by_run_id =
created_by_run_id`, and installs a partial unique index on
`(company_id, workspace_id, provider, external_id)` scoped to rows
that have a workspace. The artifact projector now records
`workspace_id` on insert and refreshes `last_touched_by_run_id` on
every upsert. The deliverable signal query reads either provenance
column so a run that only edited a pre-existing file still counts.
`resolveWorkProductTarget` prefers the stored `workspace_id` and falls
back to the task/run join for legacy NULL rows.
This commit is contained in:
ZacharyZhang-NY committed 2026-04-19 15:43:48 -04:00
1 parent d1483dcd5d
commit d9b35d73ab
12 files changed
+179 -25

No files matched your search

+13 -2
View File
@@ -69,6 +69,7 @@ export type ArtifactProjectorRunContext = {
agentId: string;
companyId: string;
taskId: string | null;
workspaceId: string | null;
workspacePath: string | null;
};
@@ -284,14 +285,24 @@ function upsertFilesystemArtifact(
const title = lastSlash >= 0 ? relativePath.slice(lastSlash + 1) : relativePath;
const provider = "filesystem";
const existing = db.findWorkProductByExternalId(ctx.companyId, provider, relativePath);
// 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.findWorkProductByExternalId(ctx.companyId, provider, relativePath, ctx.workspaceId);
if (existing) {
db.updateWorkProduct(existing.id, ctx.companyId, { title });
db.updateWorkProduct(existing.id, ctx.companyId, {
title,
lastTouchedByRunId: ctx.runId,
workspaceId: ctx.workspaceId,
});
return { kind: "updated", workProductId: existing.id, relativePath };
}
const workProductId = db.createWorkProduct({
companyId: ctx.companyId,
taskId: ctx.taskId,
workspaceId: ctx.workspaceId,
type: "file",
provider,
externalId: relativePath,
+2
View File
@@ -56,6 +56,7 @@ export interface ActiveRunContext {
connectorId: string;
modelId: string;
taskSessionId: string;
workspaceId: string | null;
workspacePath: string | null;
state: ActiveRunLifecycleState;
}
@@ -176,6 +177,7 @@ export function handleAfterToolCall(
agentId: ctx.agentId,
companyId: ctx.companyId,
taskId: ctx.taskId,
workspaceId: ctx.workspaceId,
workspacePath: ctx.workspacePath,
};
try {
+3
View File
@@ -114,6 +114,7 @@ export class RuntimeBridge {
agentId: ctx.agentId,
companyId: ctx.companyId,
taskId: ctx.taskId,
workspaceId: ctx.workspaceId,
workspacePath: ctx.workspacePath,
});
const inserted = outcomes.filter((o) => o.kind === "inserted").length;
@@ -460,6 +461,7 @@ export class RuntimeBridge {
companyId: string;
taskId: string | null;
modelId: string;
workspaceId: string | null;
workspacePath: string | null;
};
this.registerActiveRun({
@@ -470,6 +472,7 @@ export class RuntimeBridge {
connectorId: payload.connectorId,
modelId: payload.modelId,
taskSessionId: payload.taskSessionId,
workspaceId: payload.workspaceId,
workspacePath: payload.workspacePath,
state: "preparing",
});
+1
View File
@@ -231,6 +231,7 @@ export function registerFileRoutes(server: AgentApiServer) {
companyId: task.companyId,
taskId: task.id,
projectId: data.projectId ?? task.projectId,
workspaceId: task.workspaceId ?? null,
type: data.type,
provider: data.provider,
externalId: data.externalId ?? null,
+2 -2
View File
@@ -94,10 +94,10 @@ export class AppDatabase extends AppDatabaseBase {
createDocumentRevision(input: { documentId: string; companyId: string; body: string; changeSummary?: string | null; createdByAgentId?: string | null; createdByUserId?: string | null }) { return this.documentsMod.createDocumentRevision(input); }
getDocumentLatestRevisionId(documentId: string) { return this.documentsMod.getDocumentLatestRevisionId(documentId); }
listDocumentRevisions(documentId: string, companyId: string) { return this.documentsMod.listDocumentRevisions(documentId, companyId); }
createWorkProduct(input: { companyId: string; taskId: string | null; projectId?: string | null; type: string; provider: string; externalId?: string | null; title: string; url?: string | null; status?: string; reviewState?: string; isPrimary?: boolean; summary?: string | null; metadataJson?: string | null; createdByRunId?: string | null }) { return this.documentsMod.createWorkProduct(input); }
createWorkProduct(input: { companyId: string; taskId: string | null; projectId?: string | null; workspaceId?: string | null; type: string; provider: string; externalId?: string | null; title: string; url?: string | null; status?: string; reviewState?: string; isPrimary?: boolean; summary?: string | null; metadataJson?: string | null; createdByRunId?: string | null }) { return this.documentsMod.createWorkProduct(input); }
updateWorkProduct(id: string, companyId: string, patch: Parameters<ReturnType<typeof import("./database/documents").buildDocumentMethods>["updateWorkProduct"]>[2]) { return this.documentsMod.updateWorkProduct(id, companyId, patch); }
listWorkProductsForTask(taskId: string, companyId: string) { return this.documentsMod.listWorkProductsForTask(taskId, companyId); }
findWorkProductByExternalId(companyId: string, provider: string, externalId: string) { return this.documentsMod.findWorkProductByExternalId(companyId, provider, externalId); }
findWorkProductByExternalId(companyId: string, provider: string, externalId: string, workspaceId: string | null) { return this.documentsMod.findWorkProductByExternalId(companyId, provider, externalId, workspaceId); }
resolveWorkProductTarget(workProductId: string, companyId: string) { return this.documentsMod.resolveWorkProductTarget(workProductId, companyId); }
deleteWorkProduct(id: string, companyId: string) { return this.documentsMod.deleteWorkProduct(id, companyId); }
linkDocumentToTask(taskId: string, documentId: string, companyId: string, key: string) { return this.documentsMod.linkDocumentToTask(taskId, documentId, companyId, key); }
+56 -18
View File
@@ -8,19 +8,26 @@ import { buildConfigRevisionMethods } from "./config-revisions";
function resolveWorkspaceRoot(
getDb: () => DatabaseSync,
companyId: string,
workspaceId: string | null,
taskId: string | null,
createdByRunId: string | null,
runId: string | null,
): string | null {
if (workspaceId) {
const row = getDb().prepare(
"SELECT local_path AS localPath FROM workspaces WHERE id = ? AND company_id = ?",
).get(workspaceId, companyId) as { localPath: string } | undefined;
if (row?.localPath) return row.localPath;
}
if (taskId) {
const row = getDb().prepare(
"SELECT w.local_path AS localPath FROM tasks t JOIN workspaces w ON w.id = t.workspace_id WHERE t.id = ? AND t.company_id = ?",
).get(taskId, companyId) as { localPath: string } | undefined;
if (row?.localPath) return row.localPath;
}
if (createdByRunId) {
if (runId) {
const row = getDb().prepare(
"SELECT w.local_path AS localPath FROM runs r JOIN workspaces w ON w.id = r.workspace_id WHERE r.id = ? AND r.company_id = ?",
).get(createdByRunId, companyId) as { localPath: string } | undefined;
).get(runId, companyId) as { localPath: string } | undefined;
if (row?.localPath) return row.localPath;
}
return null;
@@ -222,6 +229,7 @@ export function buildDocumentMethods(
createWorkProduct(input: {
companyId: string; taskId: string | null; projectId?: string | null;
workspaceId?: string | null;
type: string; provider: string; externalId?: string | null;
title: string; url?: string | null; status?: string; reviewState?: string;
isPrimary?: boolean; summary?: string | null; metadataJson?: string | null;
@@ -235,35 +243,47 @@ export function buildDocumentMethods(
).run(ts, input.companyId, input.taskId, input.type);
}
getDb().prepare(
`INSERT INTO work_products (id, company_id, project_id, task_id, type, provider, external_id, title, url, status, review_state, is_primary, summary, metadata_json, created_by_run_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
`INSERT INTO work_products (id, company_id, project_id, task_id, workspace_id, type, provider, external_id, title, url, status, review_state, is_primary, summary, metadata_json, created_by_run_id, last_touched_by_run_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
id, input.companyId, input.projectId ?? null, input.taskId ?? null,
input.workspaceId ?? null,
input.type, input.provider, input.externalId ?? null,
input.title, input.url ?? null, input.status ?? "active",
input.reviewState ?? "none", input.isPrimary ? 1 : 0,
input.summary ?? null, input.metadataJson ?? null,
input.createdByRunId ?? null, ts, ts,
input.createdByRunId ?? null, input.createdByRunId ?? null, ts, ts,
);
return id;
},
/**
* Look up a work product by its natural identity — the tuple
* (companyId, provider, externalId) uniquely names a registered
* artifact. Returns null when not found. Used by the artifact
* projector to make tool-output registration idempotent: the first
* write inserts, subsequent writes (same path, same provider) go
* through `updateWorkProduct` instead of spamming duplicates.
* Look up a work product by its natural identity. The v45 schema
* pinned identity to `(companyId, workspaceId, provider, externalId)`
* so two workspaces with the same relative path no longer collide.
* When `workspaceId` is null we fall back to the legacy
* `(companyId, provider, externalId)` scope so historical rows that
* pre-date the workspace backfill can still be found and updated.
*/
findWorkProductByExternalId(
companyId: string,
provider: string,
externalId: string,
workspaceId: string | null,
): { id: string; taskId: string | null } | null {
if (workspaceId) {
const row = getDb()
.prepare(
"SELECT id, task_id as taskId FROM work_products WHERE company_id = ? AND workspace_id = ? AND provider = ? AND external_id = ? LIMIT 1",
)
.get(companyId, workspaceId, provider, externalId) as
| { id: string; taskId: string | null }
| undefined;
return row ?? null;
}
const row = getDb()
.prepare(
"SELECT id, task_id as taskId FROM work_products WHERE company_id = ? AND provider = ? AND external_id = ? LIMIT 1",
"SELECT id, task_id as taskId FROM work_products WHERE company_id = ? AND workspace_id IS NULL AND provider = ? AND external_id = ? LIMIT 1",
)
.get(companyId, provider, externalId) as
| { id: string; taskId: string | null }
@@ -278,6 +298,7 @@ export function buildDocumentMethods(
type?: string;
provider?: string;
externalId?: string | null;
workspaceId?: string | null;
title?: string;
url?: string | null;
status?: string;
@@ -285,6 +306,7 @@ export function buildDocumentMethods(
isPrimary?: boolean;
summary?: string | null;
metadataJson?: string | null;
lastTouchedByRunId?: string | null;
},
): number {
const ts = nowIso();
@@ -294,6 +316,7 @@ export function buildDocumentMethods(
["type", "type", (v) => (v as string) ?? null],
["provider", "provider", (v) => (v as string) ?? null],
["externalId", "external_id", (v) => (v as string | null) ?? null],
["workspaceId", "workspace_id", (v) => (v as string | null) ?? null],
["title", "title", (v) => (v as string) ?? null],
["url", "url", (v) => (v as string | null) ?? null],
["status", "status", (v) => (v as string) ?? null],
@@ -301,6 +324,7 @@ export function buildDocumentMethods(
["isPrimary", "is_primary", (v) => (v ? 1 : 0)],
["summary", "summary", (v) => (v as string | null) ?? null],
["metadataJson", "metadata_json", (v) => (v as string | null) ?? null],
["lastTouchedByRunId", "last_touched_by_run_id", (v) => (v as string | null) ?? null],
];
for (const [key, col, coerce] of map) {
if (key in patch) {
@@ -329,9 +353,9 @@ export function buildDocumentMethods(
*/
resolveWorkProductTarget(workProductId: string, companyId: string): { kind: "url" | "path"; value: string } | null {
const row = getDb().prepare(
"SELECT provider, external_id, url, task_id, created_by_run_id FROM work_products WHERE id = ? AND company_id = ?",
"SELECT provider, external_id, url, 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
| { provider: string; external_id: string | null; url: string | null; task_id: string | null; created_by_run_id: string | null }
| { provider: string; external_id: string | null; url: string | null; task_id: string | null; created_by_run_id: string | null; last_touched_by_run_id: string | null; workspace_id: string | null }
| undefined;
if (!row) return null;
@@ -340,7 +364,13 @@ export function buildDocumentMethods(
}
if (row.provider !== "filesystem" || !row.external_id) return null;
const workspaceRoot = resolveWorkspaceRoot(getDb, companyId, row.task_id, row.created_by_run_id);
const workspaceRoot = resolveWorkspaceRoot(
getDb,
companyId,
row.workspace_id,
row.task_id,
row.last_touched_by_run_id ?? row.created_by_run_id,
);
if (!workspaceRoot) return null;
const absolute = joinWorkspaceRelative(workspaceRoot, row.external_id);
@@ -351,19 +381,23 @@ export function buildDocumentMethods(
listWorkProductsForTask(taskId: string, companyId: string) {
return readAll<{
id: string; company_id: string; project_id: string | null; task_id: string;
workspace_id: string | null;
type: string; provider: string; external_id: string | null; title: string;
url: string | null; status: string; review_state: string; is_primary: number;
summary: string | null; metadata_json: string | null; created_by_run_id: string | null;
summary: string | null; metadata_json: string | null;
created_by_run_id: string | null; last_touched_by_run_id: string | null;
created_at: string; updated_at: string;
}>(
"SELECT * FROM work_products WHERE task_id = ? AND company_id = ? ORDER BY is_primary DESC, updated_at DESC",
taskId, companyId,
).map((r) => ({
id: r.id, companyId: r.company_id, projectId: r.project_id, taskId: r.task_id,
workspaceId: r.workspace_id,
type: r.type, provider: r.provider, externalId: r.external_id, title: r.title,
url: r.url, status: r.status, reviewState: r.review_state,
isPrimary: Boolean(r.is_primary), summary: r.summary,
metadataJson: r.metadata_json, createdByRunId: r.created_by_run_id,
lastTouchedByRunId: r.last_touched_by_run_id,
createdAt: r.created_at, updatedAt: r.updated_at,
}));
},
@@ -379,19 +413,23 @@ export function buildDocumentMethods(
listWorkProductsForCompany(companyId: string, limit = 500) {
return readAll<{
id: string; company_id: string; project_id: string | null; task_id: string | null;
workspace_id: string | null;
type: string; provider: string; external_id: string | null; title: string;
url: string | null; status: string; review_state: string; is_primary: number;
summary: string | null; metadata_json: string | null; created_by_run_id: string | null;
summary: string | null; metadata_json: string | null;
created_by_run_id: string | null; last_touched_by_run_id: string | null;
created_at: string; updated_at: string;
}>(
"SELECT * FROM work_products WHERE company_id = ? ORDER BY updated_at DESC LIMIT ?",
companyId, limit,
).map((r) => ({
id: r.id, companyId: r.company_id, projectId: r.project_id, taskId: r.task_id,
workspaceId: r.workspace_id,
type: r.type, provider: r.provider, externalId: r.external_id, title: r.title,
url: r.url, status: r.status, reviewState: r.review_state,
isPrimary: Boolean(r.is_primary), summary: r.summary,
metadataJson: r.metadata_json, createdByRunId: r.created_by_run_id,
lastTouchedByRunId: r.last_touched_by_run_id,
createdAt: r.created_at, updatedAt: r.updated_at,
}));
},
+6 -2
View File
@@ -217,9 +217,13 @@ export function buildKnowledgeMethods(
)
: [];
// Pre-v45 provenance was frozen at creation; post-v45 every upsert
// refreshes `last_touched_by_run_id`. Querying by either side keeps
// the deliverable signal correct for a run that edited a pre-existing
// work product as well as a run that created a new one.
const workProducts = readAll<{ id: string }>(
"select id from work_products where company_id = ? and created_by_run_id = ? limit 1",
companyId, runId,
"select id from work_products where company_id = ? and (last_touched_by_run_id = ? or created_by_run_id = ?) limit 1",
companyId, runId, runId,
);
const recentComments = readAll<{ body: string }>(
+88
View File
@@ -0,0 +1,88 @@
import log from "electron-log/main.js";
import type { Migration, MigrationDeps } from "./types";
import { columnExists, tableExists } from "./types";
export function createMigrationsV45V45(deps: MigrationDeps): Migration[] {
const { db } = deps;
return [
{
// Promote work_products identity from `(company_id, provider, external_id)`
// to `(company_id, workspace_id, provider, external_id)` and replace
// the write-once `created_by_run_id` provenance with an append-only
// `last_touched_by_run_id` so the deliverable judgment, the thread
// projection, and the DocumentCenter open link all stay correct as
// a file is rewritten across multiple runs.
//
// Pre-v45 behaviour silently collapsed two workspaces' files at the
// same relative path into one row, and locked provenance at the
// first run that touched the path — later edits were invisible to
// `runProducedDeliverable`, so the task that produced the edit
// could stay pinned in-progress.
//
// Backfill strategy: derive `workspace_id` per row via `task_id ->
// tasks.workspace_id` first, then via `created_by_run_id ->
// runs.workspace_id`. Rows with neither binding (historically the
// exception, not the rule) remain NULL so nothing is dropped; the
// new uniqueness constraint is scoped to rows with a workspace so
// legacy NULL rows stay live without triggering conflicts.
// `last_touched_by_run_id` is seeded to `created_by_run_id` so the
// deliverable signal reads the same provenance pre- and post-
// migration until the next write arrives.
version: 45,
name: "work_products_workspace_identity",
fn: () => {
if (!tableExists(db, "work_products")) {
log.info("[migration v45] work_products table missing; skipping");
return;
}
if (!columnExists(db, "work_products", "workspace_id")) {
db.exec("ALTER TABLE work_products ADD COLUMN workspace_id TEXT");
}
if (!columnExists(db, "work_products", "last_touched_by_run_id")) {
db.exec("ALTER TABLE work_products ADD COLUMN last_touched_by_run_id TEXT");
}
db.exec(`
UPDATE work_products
SET workspace_id = (
SELECT tasks.workspace_id FROM tasks
WHERE tasks.id = work_products.task_id
)
WHERE workspace_id IS NULL
AND task_id IS NOT NULL
`);
db.exec(`
UPDATE work_products
SET workspace_id = (
SELECT runs.workspace_id FROM runs
WHERE runs.id = work_products.created_by_run_id
)
WHERE workspace_id IS NULL
AND created_by_run_id IS NOT NULL
`);
db.exec(`
UPDATE work_products
SET last_touched_by_run_id = created_by_run_id
WHERE last_touched_by_run_id IS NULL
AND created_by_run_id IS NOT NULL
`);
db.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_work_products_identity
ON work_products(company_id, workspace_id, provider, external_id)
WHERE workspace_id IS NOT NULL AND external_id IS NOT NULL
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_work_products_last_touched
ON work_products(company_id, last_touched_by_run_id)
`);
log.info("[migration v45] work_products identity + provenance upgraded");
},
},
];
}
+2
View File
@@ -9,6 +9,7 @@ import { createMigrationsV41V41 } from "./migrations/v41-v41";
import { createMigrationsV42V42 } from "./migrations/v42-v42";
import { createMigrationsV43V43 } from "./migrations/v43-v43";
import { createMigrationsV44V44 } from "./migrations/v44-v44";
import { createMigrationsV45V45 } from "./migrations/v45-v45";
import {
columnExists,
nowIso,
@@ -193,6 +194,7 @@ export function runMigrations(
...createMigrationsV42V42(deps),
...createMigrationsV43V43(deps),
...createMigrationsV44V44(deps),
...createMigrationsV45V45(deps),
];
for (const migration of migrations) {
+3 -1
View File
@@ -130,14 +130,16 @@ export function buildSnapshotMethods(
tagsJson: r.tags_json, latestRevisionId: r.latest_revision_id ?? null, createdAt: r.created_at, updatedAt: r.updated_at,
})),
workProducts: readAll<SqliteRow>(
`select id, company_id, project_id, task_id, type, provider, external_id, title, url, status, review_state, is_primary, summary, metadata_json, created_by_run_id, created_at, updated_at from work_products${companyWhere} order by updated_at desc limit 500`,
`select id, company_id, project_id, task_id, workspace_id, type, provider, external_id, title, url, status, review_state, is_primary, summary, metadata_json, created_by_run_id, last_touched_by_run_id, created_at, updated_at from work_products${companyWhere} order by updated_at desc limit 500`,
...companyArgs,
).map((r) => ({
id: r.id, companyId: r.company_id, projectId: r.project_id, taskId: r.task_id,
workspaceId: r.workspace_id ?? null,
type: r.type, provider: r.provider, externalId: r.external_id, title: r.title,
url: r.url, status: r.status, reviewState: r.review_state,
isPrimary: Boolean(r.is_primary), summary: r.summary,
metadataJson: r.metadata_json, createdByRunId: r.created_by_run_id,
lastTouchedByRunId: r.last_touched_by_run_id ?? null,
createdAt: r.created_at, updatedAt: r.updated_at,
})),
knowledgeBase: readAll<SqliteRow>(
+1
View File
@@ -120,6 +120,7 @@ export class WorkerRunner {
companyId: job.companyId,
taskId: job.taskId,
modelId: job.model,
workspaceId: job.workspaceId ?? null,
workspacePath: job.workspacePath ?? null,
});
if (!prepareResponse.ok) {
+2
View File
@@ -102,6 +102,7 @@ export interface WorkProductRecord {
companyId: string;
projectId: string | null;
taskId: string | null;
workspaceId: string | null;
type: string;
provider: string;
externalId: string | null;
@@ -113,6 +114,7 @@ export interface WorkProductRecord {
summary: string | null;
metadataJson: string | null;
createdByRunId: string | null;
lastTouchedByRunId: string | null;
createdAt: string;
updatedAt: string;
}