Files
AgentCompany/src/main/database/workspaces.ts
T
ZacharyZhang-NY 9fba8dd6c3 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.
2026-04-21 14:52:38 -04:00

209 lines
9.2 KiB
TypeScript

import type { DatabaseSync } from "node:sqlite";
import type { RunStatus, WorkspaceRecord } from "@shared/types";
import type { ReadAll, Transaction } from "./helpers";
import { ensureId, normalizeOptionalId, nowIso, toBoolean } from "./helpers";
export function buildWorkspaceMethods(
getDb: () => DatabaseSync,
readAll: ReadAll,
transaction: Transaction,
) {
return {
saveWorkspace(input: {
id?: string | null;
companyId: string;
name: string;
localPath: string;
repoUrl: string;
repoRef: string;
projectId?: string | null;
isPrimary: boolean;
}) {
const id = ensureId(input.id ?? null);
const ts = nowIso();
if (input.isPrimary) {
getDb().prepare("update workspaces set is_primary = 0 where company_id = ?").run(input.companyId);
}
const existing = getDb().prepare("select id from workspaces where id = ? and company_id = ?").get(id, input.companyId) as { id: string } | undefined;
if (existing) {
getDb()
.prepare(
"update workspaces set name = ?, local_path = ?, repo_url = ?, repo_ref = ?, project_id = ?, is_primary = ?, updated_at = ? where id = ? and company_id = ?",
)
.run(input.name, input.localPath, input.repoUrl, input.repoRef, normalizeOptionalId(input.projectId), input.isPrimary ? 1 : 0, ts, id, input.companyId);
} else {
getDb()
.prepare(
"insert into workspaces (id, company_id, name, local_path, repo_url, repo_ref, project_id, state, is_primary, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, 'ready', ?, ?, ?)",
)
.run(id, input.companyId, input.name, input.localPath, input.repoUrl, input.repoRef, normalizeOptionalId(input.projectId), input.isPrimary ? 1 : 0, ts, ts);
}
return id;
},
getWorkspace(id: string | null | undefined): WorkspaceRecord | null {
if (!id) return null;
const row = getDb()
.prepare(
"select id, company_id as companyId, name, local_path as localPath, repo_url as repoUrl, repo_ref as repoRef, project_id as projectId, state, is_primary as isPrimary, created_at as createdAt, updated_at as updatedAt from workspaces where id = ?",
)
.get(id) as WorkspaceRecord | undefined;
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()
.prepare(
"select id, company_id as companyId, name, local_path as localPath, repo_url as repoUrl, repo_ref as repoRef, project_id as projectId, state, is_primary as isPrimary, created_at as createdAt, updated_at as updatedAt from workspaces where company_id = ? and project_id = ? order by is_primary desc, created_at asc limit 1",
)
.get(companyId, projectId) as WorkspaceRecord | undefined;
return row ? { ...row, isPrimary: toBoolean(row.isPrimary) } : null;
},
/**
* Pick a single workspace per company for *observation only* (no
* lock claim). Used by the artifact projector to know where on
* disk a `lockMode='none'` cognitive run might write files. The
* tie-break order is `is_primary` first, then earliest `created_at`
* — i.e. the workspace the operator marked as canonical wins, and
* if no one is marked, the oldest row wins (which historically is
* the company's default scratch directory).
*
* Returns `null` when the company has no workspace at all; the
* caller treats that as "agent has no observation surface", which
* is correct — there is genuinely nowhere to look for files.
*/
getCompanyObservationWorkspace(companyId: string): WorkspaceRecord | null {
const row = getDb()
.prepare(
"select id, company_id as companyId, name, local_path as localPath, repo_url as repoUrl, repo_ref as repoRef, project_id as projectId, state, is_primary as isPrimary, created_at as createdAt, updated_at as updatedAt from workspaces where company_id = ? order by is_primary desc, created_at asc limit 1",
)
.get(companyId) as WorkspaceRecord | undefined;
return row ? { ...row, isPrimary: toBoolean(row.isPrimary) } : null;
},
listWorkspaceLocalPaths(companyId: string): string[] {
const rows = getDb()
.prepare("select local_path as localPath from workspaces where company_id = ? and local_path is not null")
.all(companyId) as Array<{ localPath: string }>;
return rows.map((r) => r.localPath).filter(Boolean);
},
deleteWorkspace(id: string, companyId: string) {
transaction(() => {
getDb().prepare("update agents set workspace_id = null where workspace_id = ? and company_id = ?").run(id, companyId);
getDb().prepare("update tasks set workspace_id = null where workspace_id = ? and company_id = ?").run(id, companyId);
getDb().prepare("delete from workspaces where id = ? and company_id = ?").run(id, companyId);
});
},
acquireWorkspaceLock(workspaceId: string, runId: string): boolean {
const ts = nowIso();
const result = getDb()
.prepare(
`update workspaces
set lock_holder_run_id = ?, lock_acquired_at = ?, state = 'busy', updated_at = ?
where id = ?
and (lock_holder_run_id is null or lock_holder_run_id = ?)`,
)
.run(runId, ts, ts, workspaceId, runId);
return result.changes > 0;
},
claimWorkspaceLock(workspaceId: string, runId: string): boolean {
const holder = getDb()
.prepare("select lock_holder_run_id as lockHolderRunId from workspaces where id = ?")
.get(workspaceId) as { lockHolderRunId: string | null } | undefined;
if (!holder) return false;
if (!holder.lockHolderRunId || holder.lockHolderRunId === runId) {
const ts = nowIso();
const result = getDb()
.prepare(
`update workspaces
set lock_holder_run_id = ?, lock_acquired_at = ?, state = 'busy', updated_at = ?
where id = ?
and (lock_holder_run_id is null or lock_holder_run_id = ?)`,
)
.run(runId, ts, ts, workspaceId, runId);
return result.changes > 0;
}
const blockingRun = getDb()
.prepare("select status from runs where id = ?")
.get(holder.lockHolderRunId) as { status: RunStatus } | undefined;
if (blockingRun && (blockingRun.status === "queued" || blockingRun.status === "running")) {
return false;
}
const ts = nowIso();
const result = getDb()
.prepare(
`update workspaces
set lock_holder_run_id = ?, lock_acquired_at = ?, state = 'busy', updated_at = ?
where id = ? and lock_holder_run_id = ?`,
)
.run(runId, ts, ts, workspaceId, holder.lockHolderRunId);
return result.changes > 0;
},
releaseWorkspaceLock(workspaceId: string, runId: string) {
getDb()
.prepare(
`update workspaces
set lock_holder_run_id = null, lock_acquired_at = null, state = 'ready', updated_at = ?
where id = ? and lock_holder_run_id = ?`,
)
.run(nowIso(), workspaceId, runId);
},
releaseAllWorkspaceLocks() {
getDb()
.prepare(
"update workspaces set lock_holder_run_id = null, lock_acquired_at = null, state = 'ready', updated_at = ? where lock_holder_run_id is not null",
)
.run(nowIso());
},
releaseStaleWorkspaceLocks(): number {
const result = getDb()
.prepare(
`update workspaces
set lock_holder_run_id = null, lock_acquired_at = null, state = 'ready', updated_at = ?
where lock_holder_run_id is not null
and not exists (
select 1
from runs
where runs.id = workspaces.lock_holder_run_id
and runs.status in ('queued', 'running')
)`,
)
.run(nowIso());
return Number(result.changes);
},
isWorkspaceLocked(workspaceId: string): boolean {
const row = getDb()
.prepare("select lock_holder_run_id from workspaces where id = ?")
.get(workspaceId) as { lock_holder_run_id: string | null } | undefined;
return Boolean(row?.lock_holder_run_id);
},
};
}