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; agentId: string;
companyId: string; companyId: string;
taskId: string | null; taskId: string | null;
workspaceId: string | null;
workspacePath: string | null; workspacePath: string | null;
}; };
@@ -284,14 +285,24 @@ function upsertFilesystemArtifact(
const title = lastSlash >= 0 ? relativePath.slice(lastSlash + 1) : relativePath; const title = lastSlash >= 0 ? relativePath.slice(lastSlash + 1) : relativePath;
const provider = "filesystem"; 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) { 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 }; return { kind: "updated", workProductId: existing.id, relativePath };
} }
const workProductId = db.createWorkProduct({ const workProductId = db.createWorkProduct({
companyId: ctx.companyId, companyId: ctx.companyId,
taskId: ctx.taskId, taskId: ctx.taskId,
workspaceId: ctx.workspaceId,
type: "file", type: "file",
provider, provider,
externalId: relativePath, externalId: relativePath,
+2
View File
@@ -56,6 +56,7 @@ export interface ActiveRunContext {
connectorId: string; connectorId: string;
modelId: string; modelId: string;
taskSessionId: string; taskSessionId: string;
workspaceId: string | null;
workspacePath: string | null; workspacePath: string | null;
state: ActiveRunLifecycleState; state: ActiveRunLifecycleState;
} }
@@ -176,6 +177,7 @@ export function handleAfterToolCall(
agentId: ctx.agentId, agentId: ctx.agentId,
companyId: ctx.companyId, companyId: ctx.companyId,
taskId: ctx.taskId, taskId: ctx.taskId,
workspaceId: ctx.workspaceId,
workspacePath: ctx.workspacePath, workspacePath: ctx.workspacePath,
}; };
try { try {
+3
View File
@@ -114,6 +114,7 @@ export class RuntimeBridge {
agentId: ctx.agentId, agentId: ctx.agentId,
companyId: ctx.companyId, companyId: ctx.companyId,
taskId: ctx.taskId, taskId: ctx.taskId,
workspaceId: ctx.workspaceId,
workspacePath: ctx.workspacePath, workspacePath: ctx.workspacePath,
}); });
const inserted = outcomes.filter((o) => o.kind === "inserted").length; const inserted = outcomes.filter((o) => o.kind === "inserted").length;
@@ -460,6 +461,7 @@ export class RuntimeBridge {
companyId: string; companyId: string;
taskId: string | null; taskId: string | null;
modelId: string; modelId: string;
workspaceId: string | null;
workspacePath: string | null; workspacePath: string | null;
}; };
this.registerActiveRun({ this.registerActiveRun({
@@ -470,6 +472,7 @@ export class RuntimeBridge {
connectorId: payload.connectorId, connectorId: payload.connectorId,
modelId: payload.modelId, modelId: payload.modelId,
taskSessionId: payload.taskSessionId, taskSessionId: payload.taskSessionId,
workspaceId: payload.workspaceId,
workspacePath: payload.workspacePath, workspacePath: payload.workspacePath,
state: "preparing", state: "preparing",
}); });
+1
View File
@@ -231,6 +231,7 @@ export function registerFileRoutes(server: AgentApiServer) {
companyId: task.companyId, companyId: task.companyId,
taskId: task.id, taskId: task.id,
projectId: data.projectId ?? task.projectId, projectId: data.projectId ?? task.projectId,
workspaceId: task.workspaceId ?? null,
type: data.type, type: data.type,
provider: data.provider, provider: data.provider,
externalId: data.externalId ?? null, 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); } 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); } getDocumentLatestRevisionId(documentId: string) { return this.documentsMod.getDocumentLatestRevisionId(documentId); }
listDocumentRevisions(documentId: string, companyId: string) { return this.documentsMod.listDocumentRevisions(documentId, companyId); } 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); } 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); } 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); } resolveWorkProductTarget(workProductId: string, companyId: string) { return this.documentsMod.resolveWorkProductTarget(workProductId, companyId); }
deleteWorkProduct(id: string, companyId: string) { return this.documentsMod.deleteWorkProduct(id, 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); } 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( function resolveWorkspaceRoot(
getDb: () => DatabaseSync, getDb: () => DatabaseSync,
companyId: string, companyId: string,
workspaceId: string | null,
taskId: string | null, taskId: string | null,
createdByRunId: string | null, runId: string | null,
): 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) { if (taskId) {
const row = getDb().prepare( 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 = ?", "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; ).get(taskId, companyId) as { localPath: string } | undefined;
if (row?.localPath) return row.localPath; if (row?.localPath) return row.localPath;
} }
if (createdByRunId) { if (runId) {
const row = getDb().prepare( 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 = ?", "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; if (row?.localPath) return row.localPath;
} }
return null; return null;
@@ -222,6 +229,7 @@ export function buildDocumentMethods(
createWorkProduct(input: { createWorkProduct(input: {
companyId: string; taskId: string | null; projectId?: string | null; companyId: string; taskId: string | null; projectId?: string | null;
workspaceId?: string | null;
type: string; provider: string; externalId?: string | null; type: string; provider: string; externalId?: string | null;
title: string; url?: string | null; status?: string; reviewState?: string; title: string; url?: string | null; status?: string; reviewState?: string;
isPrimary?: boolean; summary?: string | null; metadataJson?: string | null; isPrimary?: boolean; summary?: string | null; metadataJson?: string | null;
@@ -235,35 +243,47 @@ export function buildDocumentMethods(
).run(ts, input.companyId, input.taskId, input.type); ).run(ts, input.companyId, input.taskId, input.type);
} }
getDb().prepare( 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) `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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run( ).run(
id, input.companyId, input.projectId ?? null, input.taskId ?? null, id, input.companyId, input.projectId ?? null, input.taskId ?? null,
input.workspaceId ?? null,
input.type, input.provider, input.externalId ?? null, input.type, input.provider, input.externalId ?? null,
input.title, input.url ?? null, input.status ?? "active", input.title, input.url ?? null, input.status ?? "active",
input.reviewState ?? "none", input.isPrimary ? 1 : 0, input.reviewState ?? "none", input.isPrimary ? 1 : 0,
input.summary ?? null, input.metadataJson ?? null, input.summary ?? null, input.metadataJson ?? null,
input.createdByRunId ?? null, ts, ts, input.createdByRunId ?? null, input.createdByRunId ?? null, ts, ts,
); );
return id; return id;
}, },
/** /**
* Look up a work product by its natural identity — the tuple * Look up a work product by its natural identity. The v45 schema
* (companyId, provider, externalId) uniquely names a registered * pinned identity to `(companyId, workspaceId, provider, externalId)`
* artifact. Returns null when not found. Used by the artifact * so two workspaces with the same relative path no longer collide.
* projector to make tool-output registration idempotent: the first * When `workspaceId` is null we fall back to the legacy
* write inserts, subsequent writes (same path, same provider) go * `(companyId, provider, externalId)` scope so historical rows that
* through `updateWorkProduct` instead of spamming duplicates. * pre-date the workspace backfill can still be found and updated.
*/ */
findWorkProductByExternalId( findWorkProductByExternalId(
companyId: string, companyId: string,
provider: string, provider: string,
externalId: string, externalId: string,
workspaceId: string | null,
): { id: string; taskId: string | null } | 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() const row = getDb()
.prepare( .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 .get(companyId, provider, externalId) as
| { id: string; taskId: string | null } | { id: string; taskId: string | null }
@@ -278,6 +298,7 @@ export function buildDocumentMethods(
type?: string; type?: string;
provider?: string; provider?: string;
externalId?: string | null; externalId?: string | null;
workspaceId?: string | null;
title?: string; title?: string;
url?: string | null; url?: string | null;
status?: string; status?: string;
@@ -285,6 +306,7 @@ export function buildDocumentMethods(
isPrimary?: boolean; isPrimary?: boolean;
summary?: string | null; summary?: string | null;
metadataJson?: string | null; metadataJson?: string | null;
lastTouchedByRunId?: string | null;
}, },
): number { ): number {
const ts = nowIso(); const ts = nowIso();
@@ -294,6 +316,7 @@ export function buildDocumentMethods(
["type", "type", (v) => (v as string) ?? null], ["type", "type", (v) => (v as string) ?? null],
["provider", "provider", (v) => (v as string) ?? null], ["provider", "provider", (v) => (v as string) ?? null],
["externalId", "external_id", (v) => (v as string | null) ?? 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], ["title", "title", (v) => (v as string) ?? null],
["url", "url", (v) => (v as string | null) ?? null], ["url", "url", (v) => (v as string | null) ?? null],
["status", "status", (v) => (v as string) ?? null], ["status", "status", (v) => (v as string) ?? null],
@@ -301,6 +324,7 @@ export function buildDocumentMethods(
["isPrimary", "is_primary", (v) => (v ? 1 : 0)], ["isPrimary", "is_primary", (v) => (v ? 1 : 0)],
["summary", "summary", (v) => (v as string | null) ?? null], ["summary", "summary", (v) => (v as string | null) ?? null],
["metadataJson", "metadata_json", (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) { for (const [key, col, coerce] of map) {
if (key in patch) { if (key in patch) {
@@ -329,9 +353,9 @@ export function buildDocumentMethods(
*/ */
resolveWorkProductTarget(workProductId: string, companyId: string): { kind: "url" | "path"; value: string } | null { resolveWorkProductTarget(workProductId: string, companyId: string): { kind: "url" | "path"; value: string } | null {
const row = getDb().prepare( 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 ).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; | undefined;
if (!row) return null; if (!row) return null;
@@ -340,7 +364,13 @@ export function buildDocumentMethods(
} }
if (row.provider !== "filesystem" || !row.external_id) return null; 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; if (!workspaceRoot) return null;
const absolute = joinWorkspaceRelative(workspaceRoot, row.external_id); const absolute = joinWorkspaceRelative(workspaceRoot, row.external_id);
@@ -351,19 +381,23 @@ export function buildDocumentMethods(
listWorkProductsForTask(taskId: string, companyId: string) { listWorkProductsForTask(taskId: string, companyId: string) {
return readAll<{ return readAll<{
id: string; company_id: string; project_id: string | null; task_id: string; 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; type: string; provider: string; external_id: string | null; title: string;
url: string | null; status: string; review_state: string; is_primary: number; 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; created_at: string; updated_at: string;
}>( }>(
"SELECT * FROM work_products WHERE task_id = ? AND company_id = ? ORDER BY is_primary DESC, updated_at DESC", "SELECT * FROM work_products WHERE task_id = ? AND company_id = ? ORDER BY is_primary DESC, updated_at DESC",
taskId, companyId, taskId, companyId,
).map((r) => ({ ).map((r) => ({
id: r.id, companyId: r.company_id, projectId: r.project_id, taskId: r.task_id, 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, type: r.type, provider: r.provider, externalId: r.external_id, title: r.title,
url: r.url, status: r.status, reviewState: r.review_state, url: r.url, status: r.status, reviewState: r.review_state,
isPrimary: Boolean(r.is_primary), summary: r.summary, isPrimary: Boolean(r.is_primary), summary: r.summary,
metadataJson: r.metadata_json, createdByRunId: r.created_by_run_id, 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, createdAt: r.created_at, updatedAt: r.updated_at,
})); }));
}, },
@@ -379,19 +413,23 @@ export function buildDocumentMethods(
listWorkProductsForCompany(companyId: string, limit = 500) { listWorkProductsForCompany(companyId: string, limit = 500) {
return readAll<{ return readAll<{
id: string; company_id: string; project_id: string | null; task_id: string | null; 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; type: string; provider: string; external_id: string | null; title: string;
url: string | null; status: string; review_state: string; is_primary: number; 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; created_at: string; updated_at: string;
}>( }>(
"SELECT * FROM work_products WHERE company_id = ? ORDER BY updated_at DESC LIMIT ?", "SELECT * FROM work_products WHERE company_id = ? ORDER BY updated_at DESC LIMIT ?",
companyId, limit, companyId, limit,
).map((r) => ({ ).map((r) => ({
id: r.id, companyId: r.company_id, projectId: r.project_id, taskId: r.task_id, 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, type: r.type, provider: r.provider, externalId: r.external_id, title: r.title,
url: r.url, status: r.status, reviewState: r.review_state, url: r.url, status: r.status, reviewState: r.review_state,
isPrimary: Boolean(r.is_primary), summary: r.summary, isPrimary: Boolean(r.is_primary), summary: r.summary,
metadataJson: r.metadata_json, createdByRunId: r.created_by_run_id, 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, 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 }>( const workProducts = readAll<{ id: string }>(
"select id from work_products where company_id = ? and created_by_run_id = ? limit 1", "select id from work_products where company_id = ? and (last_touched_by_run_id = ? or created_by_run_id = ?) limit 1",
companyId, runId, companyId, runId, runId,
); );
const recentComments = readAll<{ body: string }>( 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 { createMigrationsV42V42 } from "./migrations/v42-v42";
import { createMigrationsV43V43 } from "./migrations/v43-v43"; import { createMigrationsV43V43 } from "./migrations/v43-v43";
import { createMigrationsV44V44 } from "./migrations/v44-v44"; import { createMigrationsV44V44 } from "./migrations/v44-v44";
import { createMigrationsV45V45 } from "./migrations/v45-v45";
import { import {
columnExists, columnExists,
nowIso, nowIso,
@@ -193,6 +194,7 @@ export function runMigrations(
...createMigrationsV42V42(deps), ...createMigrationsV42V42(deps),
...createMigrationsV43V43(deps), ...createMigrationsV43V43(deps),
...createMigrationsV44V44(deps), ...createMigrationsV44V44(deps),
...createMigrationsV45V45(deps),
]; ];
for (const migration of migrations) { 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, tagsJson: r.tags_json, latestRevisionId: r.latest_revision_id ?? null, createdAt: r.created_at, updatedAt: r.updated_at,
})), })),
workProducts: readAll<SqliteRow>( 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, ...companyArgs,
).map((r) => ({ ).map((r) => ({
id: r.id, companyId: r.company_id, projectId: r.project_id, taskId: r.task_id, 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, type: r.type, provider: r.provider, externalId: r.external_id, title: r.title,
url: r.url, status: r.status, reviewState: r.review_state, url: r.url, status: r.status, reviewState: r.review_state,
isPrimary: Boolean(r.is_primary), summary: r.summary, isPrimary: Boolean(r.is_primary), summary: r.summary,
metadataJson: r.metadata_json, createdByRunId: r.created_by_run_id, 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, createdAt: r.created_at, updatedAt: r.updated_at,
})), })),
knowledgeBase: readAll<SqliteRow>( knowledgeBase: readAll<SqliteRow>(
+1
View File
@@ -120,6 +120,7 @@ export class WorkerRunner {
companyId: job.companyId, companyId: job.companyId,
taskId: job.taskId, taskId: job.taskId,
modelId: job.model, modelId: job.model,
workspaceId: job.workspaceId ?? null,
workspacePath: job.workspacePath ?? null, workspacePath: job.workspacePath ?? null,
}); });
if (!prepareResponse.ok) { if (!prepareResponse.ok) {
+2
View File
@@ -102,6 +102,7 @@ export interface WorkProductRecord {
companyId: string; companyId: string;
projectId: string | null; projectId: string | null;
taskId: string | null; taskId: string | null;
workspaceId: string | null;
type: string; type: string;
provider: string; provider: string;
externalId: string | null; externalId: string | null;
@@ -113,6 +114,7 @@ export interface WorkProductRecord {
summary: string | null; summary: string | null;
metadataJson: string | null; metadataJson: string | null;
createdByRunId: string | null; createdByRunId: string | null;
lastTouchedByRunId: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }