Files
AgentCompany/src/main/database/documents.ts
T
ZacharyZhang-NY 2df921ca88 refactor(agents): share leadership role detector, use word-boundary match
P2-23: 'lead' in system-prompt.ts matched 'cleaner' / 'leaderboard' via
substring .includes(). Hoist LEADERSHIP_ROLE_KEYWORDS into a shared
agent-role-helpers.ts module; tokenize input on non-alphanum to require
whole-word match.

P3-54: Same keyword list was duplicated in automation-skills with drift
(missing 'ceo', different members). Both sites now call through the
shared helper.
2026-04-16 22:57:35 -04:00

427 lines
20 KiB
TypeScript

import { randomUUID } from "node:crypto";
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
import type { AgentRecord } from "@shared/types";
import type { ReadAll, SqliteRow, Transaction } from "./helpers";
import { ensureId, nowIso, parseJson } from "./helpers";
/**
* Document row shape returned by listDocuments — used by
* GET /api/companies/:companyId/documents. Mirrors the historical inline
* SQL projection so HTTP response bodies stay byte-stable for clients.
*/
export type DocumentListRow = {
id: string;
companyId: string;
type: string;
title: string;
content: string;
authorAgentId: string;
reviewerAgentId: string | null;
projectId: string | null;
goalId: string | null;
parentDocId: string | null;
version: number;
status: string;
tagsJson: string;
latestRevisionId: string | null;
createdAt: string;
updatedAt: string;
};
/**
* Document lookup row used by the PATCH document handler — narrower
* projection without parent_doc_id / latest_revision_id / timestamps.
*/
export type DocumentLookupRow = {
id: string;
companyId: string;
type: string;
title: string;
content: string;
authorAgentId: string;
reviewerAgentId: string | null;
projectId: string | null;
goalId: string | null;
version: number;
status: string;
tagsJson: string;
};
export function buildDocumentMethods(
getDb: () => DatabaseSync,
readAll: ReadAll,
transaction: Transaction,
) {
return {
saveDocument(input: {
id?: string | null; companyId: string; type: string; title: string; content?: string;
authorAgentId?: string | null; reviewerAgentId?: string | null; projectId?: string | null;
goalId?: string | null; parentDocId?: string | null; version?: number; status?: string; tagsJson?: string;
sourceRunId?: string | null;
}) {
const id = ensureId(input.id ?? null);
const ts = nowIso();
const existing = getDb().prepare("select id from documents where id = ?").get(id) as { id: string } | undefined;
if (existing) {
getDb().prepare(
"update documents set type = ?, title = ?, content = ?, author_agent_id = ?, reviewer_agent_id = ?, project_id = ?, goal_id = ?, parent_doc_id = ?, version = ?, status = ?, tags_json = ?, source_run_id = coalesce(?, source_run_id), updated_at = ? where id = ?",
).run(input.type, input.title, input.content ?? "", input.authorAgentId ?? null, input.reviewerAgentId ?? null, input.projectId ?? null, input.goalId ?? null, input.parentDocId ?? null, input.version ?? 1, input.status ?? "draft", input.tagsJson ?? "[]", input.sourceRunId ?? null, ts, id);
} else {
getDb().prepare(
"insert into documents (id, company_id, type, title, content, author_agent_id, reviewer_agent_id, project_id, goal_id, parent_doc_id, version, status, tags_json, source_run_id, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
).run(id, input.companyId, input.type, input.title, input.content ?? "", input.authorAgentId ?? null, input.reviewerAgentId ?? null, input.projectId ?? null, input.goalId ?? null, input.parentDocId ?? null, input.version ?? 1, input.status ?? "draft", input.tagsJson ?? "[]", input.sourceRunId ?? null, ts, ts);
}
return id;
},
/**
* Returns all documents for a company, ordered by updated_at desc.
* Used by GET /api/companies/:companyId/documents.
*/
listDocuments(companyId: string): DocumentListRow[] {
return readAll<DocumentListRow>(
"select id, company_id as companyId, type, title, content, author_agent_id as authorAgentId, reviewer_agent_id as reviewerAgentId, project_id as projectId, goal_id as goalId, parent_doc_id as parentDocId, version, status, tags_json as tagsJson, latest_revision_id as latestRevisionId, created_at as createdAt, updated_at as updatedAt from documents where company_id = ? order by updated_at desc",
companyId,
).map((d) => ({ ...d, latestRevisionId: d.latestRevisionId ?? null }));
},
/**
* Returns the lookup row needed for PATCH /api/companies/:companyId/documents/:documentId.
* Returns null when the document is missing or belongs to another company.
*/
getDocumentForUpdate(
documentId: string,
companyId: string,
): DocumentLookupRow | null {
const row = getDb()
.prepare(
"select id, company_id as companyId, type, title, content, author_agent_id as authorAgentId, reviewer_agent_id as reviewerAgentId, project_id as projectId, goal_id as goalId, version, status, tags_json as tagsJson from documents where id = ? and company_id = ? limit 1",
)
.get(documentId, companyId) as DocumentLookupRow | undefined;
return row ?? null;
},
updateDocumentStatus(id: string, companyId: string, status: string): void {
getDb().prepare(
"UPDATE documents SET status = ?, updated_at = ? WHERE id = ? AND company_id = ?",
).run(status, nowIso(), id, companyId);
},
deleteDocument(id: string, companyId: string) {
getDb().prepare("delete from documents where id = ? and company_id = ?").run(id, companyId);
},
createDocumentRevision(input: {
documentId: string; companyId: string; body: string;
changeSummary?: string | null; createdByAgentId?: string | null; createdByUserId?: string | null;
}): { id: string; revisionNumber: number } {
const id = randomUUID();
const ts = nowIso();
const current = getDb().prepare(
"SELECT version FROM documents WHERE id = ? AND company_id = ?",
).get(input.documentId, input.companyId) as { version: number } | undefined;
if (!current) throw new Error(`Document ${input.documentId} not found`);
const revisionNumber = current.version + 1;
getDb().prepare(
"INSERT INTO document_revisions (id, company_id, document_id, revision_number, body, change_summary, created_by_agent_id, created_by_user_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
).run(id, input.companyId, input.documentId, revisionNumber, input.body, input.changeSummary ?? null, input.createdByAgentId ?? null, input.createdByUserId ?? null, ts);
getDb().prepare(
"UPDATE documents SET version = ?, latest_revision_id = ?, updated_at = ? WHERE id = ?",
).run(revisionNumber, id, ts, input.documentId);
return { id, revisionNumber };
},
getDocumentLatestRevisionId(documentId: string): string | null {
const row = getDb().prepare(
"SELECT latest_revision_id FROM documents WHERE id = ?",
).get(documentId) as { latest_revision_id: string | null } | undefined;
return row?.latest_revision_id ?? null;
},
/**
* Returns up to `limit` documents of the given types for a company that
* are either authored by `authorAgentId` or were created at/after the
* task's createdAt. Used by the deliverable-review gate when a task is
* advanced to "done".
*/
listImportantDocsForReview(input: {
companyId: string;
types: readonly string[];
authorAgentId: string | null;
taskId: string;
limit?: number;
}): Array<{ id: string; type: string; title: string }> {
const limit = input.limit ?? 10;
const placeholders = input.types.map(() => "?").join(", ");
return readAll<{ id: string; type: string; title: string }>(
`select id, type, title from documents where company_id = ? and type in (${placeholders})
and (author_agent_id = ? or created_at >= (select created_at from tasks where id = ?))
order by created_at desc limit ?`,
input.companyId,
...input.types,
input.authorAgentId ?? "",
input.taskId,
limit,
);
},
listDocumentRevisions(documentId: string, companyId: string) {
return readAll<{
id: string; company_id: string; document_id: string; revision_number: number;
body: string; change_summary: string | null; created_by_agent_id: string | null;
created_by_user_id: string | null; created_at: string;
}>(
"SELECT id, company_id, document_id, revision_number, body, change_summary, created_by_agent_id, created_by_user_id, created_at FROM document_revisions WHERE document_id = ? AND company_id = ? ORDER BY revision_number ASC",
documentId, companyId,
).map((r) => ({
id: r.id, companyId: r.company_id, documentId: r.document_id,
revisionNumber: r.revision_number, body: r.body,
changeSummary: r.change_summary, createdByAgentId: r.created_by_agent_id,
createdByUserId: r.created_by_user_id, createdAt: r.created_at,
}));
},
// ── Work Products ──
createWorkProduct(input: {
companyId: string; taskId: string; 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;
}): string {
const id = randomUUID();
const ts = nowIso();
if (input.isPrimary) {
getDb().prepare(
"UPDATE work_products SET is_primary = 0, updated_at = ? WHERE company_id = ? AND task_id = ? AND type = ?",
).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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
id, input.companyId, input.projectId ?? null, input.taskId,
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,
);
return id;
},
updateWorkProduct(
id: string,
companyId: string,
patch: {
type?: string;
provider?: string;
externalId?: string | null;
title?: string;
url?: string | null;
status?: string;
reviewState?: string;
isPrimary?: boolean;
summary?: string | null;
metadataJson?: string | null;
},
): number {
const ts = nowIso();
const sets: string[] = ["updated_at = ?"];
const values: SQLInputValue[] = [ts];
const map: Array<[keyof typeof patch, string, (v: unknown) => SQLInputValue]> = [
["type", "type", (v) => (v as string) ?? null],
["provider", "provider", (v) => (v as string) ?? null],
["externalId", "external_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],
["reviewState", "review_state", (v) => (v as string) ?? null],
["isPrimary", "is_primary", (v) => (v ? 1 : 0)],
["summary", "summary", (v) => (v as string | null) ?? null],
["metadataJson", "metadata_json", (v) => (v as string | null) ?? null],
];
for (const [key, col, coerce] of map) {
if (key in patch) {
sets.push(`${col} = ?`);
values.push(coerce(patch[key]));
}
}
values.push(id, companyId);
const result = getDb()
.prepare(`UPDATE work_products SET ${sets.join(", ")} WHERE id = ? AND company_id = ?`)
.run(...values);
return Number(result.changes ?? 0);
},
listWorkProductsForTask(taskId: string, companyId: string) {
return readAll<{
id: string; company_id: string; project_id: string | null; task_id: string;
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;
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,
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,
createdAt: r.created_at, updatedAt: r.updated_at,
}));
},
deleteWorkProduct(id: string, companyId: string): number {
const result = getDb()
.prepare("DELETE FROM work_products WHERE id = ? AND company_id = ?")
.run(id, companyId);
return Number(result.changes ?? 0);
},
// ── Task-Document Links ──
linkDocumentToTask(taskId: string, documentId: string, companyId: string, key: string): string {
const id = randomUUID();
const ts = nowIso();
getDb().prepare(
"INSERT OR REPLACE INTO task_documents (id, company_id, task_id, document_id, key, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
).run(id, companyId, taskId, documentId, key.toLowerCase(), ts, ts);
return id;
},
unlinkDocumentFromTask(taskId: string, key: string, companyId: string): void {
getDb().prepare(
"DELETE FROM task_documents WHERE task_id = ? AND key = ? AND company_id = ?",
).run(taskId, key.toLowerCase(), companyId);
},
listDocumentsForTask(taskId: string, companyId: string) {
return readAll<{
id: string; company_id: string; task_id: string; document_id: string;
key: string; created_at: string; updated_at: string;
}>(
"SELECT * FROM task_documents WHERE task_id = ? AND company_id = ? ORDER BY key ASC",
taskId, companyId,
).map((r) => ({
id: r.id, companyId: r.company_id, taskId: r.task_id,
documentId: r.document_id, key: r.key,
createdAt: r.created_at, updatedAt: r.updated_at,
}));
},
// ── Config Revisions ──
recordConfigRevision(input: {
companyId: string; agentId: string; changedKeys: string[];
configBefore: Record<string, unknown>; configAfter: Record<string, unknown>;
source?: string; rolledBackFromRevisionId?: string | null;
createdByAgentId?: string | null; createdByUserId?: string | null;
}): string {
const id = randomUUID();
const ts = nowIso();
const lastRev = getDb().prepare(
"SELECT MAX(revision_number) as maxRev FROM agent_config_revisions WHERE agent_id = ?"
).get(input.agentId) as { maxRev: number | null } | undefined;
const revisionNumber = (lastRev?.maxRev ?? 0) + 1;
getDb().prepare(
"INSERT INTO agent_config_revisions (id, company_id, agent_id, revision_number, changed_keys, config_before, config_after, source, rolled_back_from_revision_id, created_by_agent_id, created_by_user_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
).run(
id, input.companyId, input.agentId, revisionNumber,
JSON.stringify(input.changedKeys), JSON.stringify(input.configBefore),
JSON.stringify(input.configAfter), input.source ?? "patch",
input.rolledBackFromRevisionId ?? null, input.createdByAgentId ?? null,
input.createdByUserId ?? null, ts
);
return id;
},
listConfigRevisions(agentId: string, companyId: string): Array<{
id: string; agentId: string; revisionNumber: number; changedKeys: string[];
source: string; rolledBackFromRevisionId: string | null;
createdByAgentId: string | null; createdByUserId: string | null; createdAt: string;
}> {
return readAll<SqliteRow>(
"SELECT id, agent_id, revision_number, changed_keys, source, rolled_back_from_revision_id, created_by_agent_id, created_by_user_id, created_at FROM agent_config_revisions WHERE agent_id = ? AND company_id = ? ORDER BY revision_number DESC",
agentId, companyId
).map((row) => ({
id: row.id, agentId: row.agent_id, revisionNumber: row.revision_number,
changedKeys: parseJson<string[]>(row.changed_keys, []),
source: row.source, rolledBackFromRevisionId: row.rolled_back_from_revision_id ?? null,
createdByAgentId: row.created_by_agent_id ?? null,
createdByUserId: row.created_by_user_id ?? null, createdAt: row.created_at,
}));
},
getConfigRevision(revisionId: string, companyId: string): {
id: string; agentId: string; revisionNumber: number; changedKeys: string[];
configBefore: Record<string, unknown>; configAfter: Record<string, unknown>;
source: string; rolledBackFromRevisionId: string | null;
createdByAgentId: string | null; createdByUserId: string | null; createdAt: string;
} {
const row = getDb().prepare(
"SELECT * FROM agent_config_revisions WHERE id = ? AND company_id = ?"
).get(revisionId, companyId) as {
id: string; agent_id: string; revision_number: number; changed_keys: string;
config_before: string; config_after: string; source: string;
rolled_back_from_revision_id: string | null;
created_by_agent_id: string | null; created_by_user_id: string | null; created_at: string;
} | undefined;
if (!row) throw new Error(`Config revision ${revisionId} not found.`);
return {
id: row.id, agentId: row.agent_id, revisionNumber: row.revision_number,
changedKeys: parseJson<string[]>(row.changed_keys, []),
configBefore: parseJson<Record<string, unknown>>(row.config_before, {}),
configAfter: parseJson<Record<string, unknown>>(row.config_after, {}),
source: row.source, rolledBackFromRevisionId: row.rolled_back_from_revision_id ?? null,
createdByAgentId: row.created_by_agent_id ?? null,
createdByUserId: row.created_by_user_id ?? null, createdAt: row.created_at,
};
},
rollbackConfigRevision(agentId: string, revisionId: string, companyId: string, selfDeps: {
getConfigRevision: (revisionId: string, companyId: string) => {
id: string; agentId: string; revisionNumber: number; changedKeys: string[];
configBefore: Record<string, unknown>; configAfter: Record<string, unknown>;
source: string; rolledBackFromRevisionId: string | null;
createdByAgentId: string | null; createdByUserId: string | null; createdAt: string;
};
getAgent: (id: string) => AgentRecord;
recordConfigRevision: (input: {
companyId: string; agentId: string; changedKeys: string[];
configBefore: Record<string, unknown>; configAfter: Record<string, unknown>;
source?: string; rolledBackFromRevisionId?: string | null;
createdByAgentId?: string | null; createdByUserId?: string | null;
}) => string;
}): string {
return transaction(() => {
const revision = selfDeps.getConfigRevision(revisionId, companyId);
if (revision.agentId !== agentId) throw new Error("Revision does not belong to this agent.");
const before = revision.configBefore as Record<string, string | number | null>;
const ts = nowIso();
getDb().prepare(
"UPDATE agents SET name = ?, role = ?, title = ?, department = ?, capabilities = ?, connector_id = ?, model = ?, budget_monthly_usd = ?, metadata_json = ?, updated_at = ? WHERE id = ? AND company_id = ?"
).run(
before.name ?? "", before.role ?? "", before.title ?? "", before.department ?? null,
before.capabilities ?? "", before.connectorId ?? before.connector_id ?? "",
before.model ?? null, before.budgetMonthlyUsd ?? before.budget_monthly_usd ?? 0,
before.metadataJson ?? before.metadata_json ?? "{}", ts, agentId, companyId
);
const agent = selfDeps.getAgent(agentId);
const currentConfig = { name: agent.name, role: agent.role, title: agent.title, department: agent.department, capabilities: agent.capabilities, connectorId: agent.connectorId, model: agent.model, budgetMonthlyUsd: agent.budgetMonthlyUsd, metadataJson: agent.metadataJson };
return selfDeps.recordConfigRevision({
companyId, agentId,
changedKeys: revision.changedKeys,
configBefore: currentConfig,
configAfter: before,
source: "rollback",
rolledBackFromRevisionId: revisionId,
});
});
},
};
}