Files
AgentCompany/src/main/api-server/routes-files.ts
T
2026-04-05 20:14:48 -04:00

372 lines
16 KiB
TypeScript

import { z } from "zod";
import { requireParam, type AgentApiServer } from "./server-core";
type DocumentRow = {
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;
};
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;
};
const createDocumentSchema = z.object({
type: z.string().optional(),
title: z.string().optional(),
content: z.string().optional(),
status: z.string().optional(),
reviewerAgentId: z.string().optional(),
projectId: z.string().optional(),
goalId: z.string().optional(),
tagsJson: z.string().optional(),
});
const updateDocumentSchema = z.object({
type: z.string().optional(),
title: z.string().optional(),
content: z.string().optional(),
status: z.string().optional(),
reviewerAgentId: z.union([z.string(), z.null()]).optional(),
tagsJson: z.string().optional(),
changeSummary: z.string().optional(),
baseRevisionId: z.string().optional(),
});
const createWorkProductSchema = z.object({
type: z.string(),
provider: z.string(),
title: z.string(),
projectId: z.string().optional(),
externalId: z.string().optional(),
url: z.string().optional(),
status: z.string().optional(),
reviewState: z.string().optional(),
isPrimary: z.boolean().optional(),
summary: z.string().optional(),
metadata: z.unknown().optional(),
});
const updateWorkProductSchema = z.object({
type: z.string().optional(),
provider: z.string().optional(),
externalId: z.string().optional(),
title: z.string().optional(),
url: z.string().optional(),
status: z.string().optional(),
reviewState: z.string().optional(),
isPrimary: z.boolean().optional(),
summary: z.string().optional(),
metadataJson: z.string().optional(),
}).strict();
const linkDocumentSchema = z.object({
documentId: z.string(),
});
export function registerFileRoutes(server: AgentApiServer) {
const db = server.db;
const callbacks = server.callbacks;
const onDomainChanged = server.onDomainChanged;
const onWakeAgent = server.onWakeAgent;
server.addRoute("POST", "/api/companies/:companyId/documents", (_req, res, params, token, body) => {
if (params.companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const parsed = createDocumentSchema.safeParse(body);
if (!parsed.success) { server.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() }); return; }
const data = parsed.data;
const reviewerAgentId = data.reviewerAgentId ?? null;
const projectId = data.projectId ?? null;
const goalId = data.goalId ?? null;
if (reviewerAgentId && !db.belongsToCompany("agents", reviewerAgentId, token.companyId)) {
server.json(res, 400, { error: "Reviewer agent does not belong to the current company" });
return;
}
if (projectId && !db.belongsToCompany("projects", projectId, token.companyId)) {
server.json(res, 400, { error: "Project does not belong to the current company" });
return;
}
if (goalId && !db.belongsToCompany("goals", goalId, token.companyId)) {
server.json(res, 400, { error: "Goal does not belong to the current company" });
return;
}
const docType = data.type ?? "knowledge_article";
const docTitle = data.title ?? "Untitled";
const id = db.saveDocument({
companyId: token.companyId,
type: docType,
title: docTitle,
content: data.content ?? "",
authorAgentId: token.agentId,
reviewerAgentId,
projectId,
goalId,
status: data.status ?? "draft",
tagsJson: data.tagsJson ?? "[]",
sourceRunId: token.runId ?? null,
});
onDomainChanged();
// Derive taskId from the agent's current run so thread integration
// and checkpoint engine can bind the document to its originating task.
let taskId: string | null = null;
if (token.runId) {
try {
const run = db.getRun(token.runId);
if (run.companyId === token.companyId) {
taskId = run.taskId;
}
} catch { /* run may have finished or been deleted */ }
}
// Fire document creation automation rules
callbacks.onDocumentCreated(token.companyId, token.agentId, projectId, id, docTitle, docType, taskId);
// Wake document reviewer if specified
if (reviewerAgentId) {
onWakeAgent(reviewerAgentId, token.companyId, "assignment");
}
// Deliverable review gate: check at document creation time (most reliable).
// In ai_cofounder mode, checkpoint-engine handles document reviews via the
// onDocumentCreated callback, so skip the direct approval here to avoid duplicates.
const IMPORTANT_DOC_TYPES = new Set(["prd","technical_spec","design_doc","project_brief","proposal","budget_proposal","architecture_decision","contract","sop"]);
if (IMPORTANT_DOC_TYPES.has(docType)) {
try {
const companySettings = db.getCompanyApprovalSettings(token.companyId);
const company = companySettings ? { reviewDeliverables: companySettings.reviewDeliverables } : null;
const isCofounderMode = db.getCompanyExecutionMode(token.companyId) === "ai_cofounder";
if (company?.reviewDeliverables && !isCofounderMode) {
if (!db.hasPendingDeliverableReviews(token.companyId)) {
db.requestApproval({
companyId: token.companyId,
relatedTaskId: taskId,
requestedByAgentId: token.agentId,
type: "deliverable_review",
payloadSummary: `Review deliverable: ${docTitle}`.slice(0, 280),
impactSummary: `New ${docType.replaceAll("_", " ")} created. Awaiting your review before workflow continues.`.slice(0, 400),
payloadJson: JSON.stringify({ documentIds: [id], documents: [{ id, type: docType, title: docTitle }] }),
});
try {
const agent = db.getAgent(token.agentId);
if (agent.heartbeatEnabled) {
db.setHeartbeat(token.agentId, false, agent.heartbeatIntervalSec || 120);
}
} catch { /* best effort */ }
onDomainChanged();
}
}
} catch { /* deliverable review gate is best-effort */ }
}
server.json(res, 201, { id });
});
server.addRoute("GET", "/api/companies/:companyId/documents", (_req, res, params, token) => {
if (params.companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const docs = db.queryAll<DocumentRow>(
"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",
token.companyId,
).map((d) => ({ ...d, latestRevisionId: d.latestRevisionId ?? null }));
server.json(res, 200, docs);
});
server.addRoute("PATCH", "/api/companies/:companyId/documents/:documentId", (_req, res, params, token, body) => {
if (params.companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const documentId = params.documentId;
if (!documentId) {
server.json(res, 400, { error: "Missing documentId" });
return;
}
const docRows = db.queryAll<DocumentLookupRow>(
"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",
documentId, token.companyId,
);
const doc = docRows[0] ?? null;
if (!doc) {
server.json(res, 404, { error: "Document not found in this company" });
return;
}
const parsed = updateDocumentSchema.safeParse(body);
if (!parsed.success) { server.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() }); return; }
const data = parsed.data;
const VALID_STATUSES = new Set(["draft", "in_review", "approved", "archived", "superseded"]);
const reviewerAgentId = data.reviewerAgentId !== undefined
? (data.reviewerAgentId ?? null)
: doc.reviewerAgentId;
if (data.reviewerAgentId !== undefined && reviewerAgentId && !db.belongsToCompany("agents", reviewerAgentId, token.companyId)) {
server.json(res, 400, { error: "Reviewer agent does not belong to the current company" });
return;
}
// Conflict detection: if baseRevisionId provided, verify it matches current
if (data.baseRevisionId !== undefined) {
const currentRevId = db.getDocumentLatestRevisionId(documentId);
if (currentRevId && data.baseRevisionId !== currentRevId) {
server.json(res, 409, { error: "Document was updated by someone else", currentRevisionId: currentRevId });
return;
}
}
if (data.status !== undefined) {
if (!VALID_STATUSES.has(data.status)) {
server.json(res, 400, { error: `Invalid status "${data.status}". Must be one of: ${[...VALID_STATUSES].join(", ")}` });
return;
}
}
// Content update → create revision for audit trail
let updatedVersion = doc.version;
if (data.content !== undefined) {
const revision = db.createDocumentRevision({
documentId,
companyId: token.companyId,
body: data.content,
changeSummary: data.changeSummary ?? null,
createdByAgentId: token.agentId ?? null,
});
updatedVersion = revision.revisionNumber;
}
// Field updates (title, reviewer, tags, etc.)
if (data.status !== undefined) {
db.updateDocumentStatus(documentId, token.companyId, data.status);
}
db.saveDocument({
id: documentId,
companyId: token.companyId,
type: data.type ?? doc.type,
title: data.title ?? doc.title,
content: data.content ?? doc.content,
authorAgentId: doc.authorAgentId,
reviewerAgentId,
projectId: doc.projectId,
goalId: doc.goalId,
version: updatedVersion,
status: data.status ?? doc.status,
tagsJson: data.tagsJson ?? doc.tagsJson,
sourceRunId: data.content !== undefined ? (token.runId ?? null) : null,
});
onDomainChanged();
server.json(res, 200, { updated: true, latestRevisionId: db.getDocumentLatestRevisionId(documentId) });
});
server.addRoute("GET", "/api/companies/:companyId/documents/:documentId/revisions", (_req, res, params, token) => {
if (params.companyId !== token.companyId) { server.json(res, 403, { error: "Company mismatch" }); return; }
const documentId = params.documentId;
if (!documentId) { server.json(res, 400, { error: "Missing documentId" }); return; }
const revisions = db.listDocumentRevisions(documentId, token.companyId);
server.json(res, 200, revisions);
});
// ── Work Products ──
server.addRoute("GET", "/api/issues/:issueId/work-products", (_req, res, params, token) => {
try {
const task = db.getTask(requireParam(params, "issueId"));
if (task.companyId !== token.companyId) { server.json(res, 404, { error: "Task not found" }); return; }
const products = db.listWorkProductsForTask(task.id, task.companyId);
server.json(res, 200, products);
} catch { server.json(res, 404, { error: "Task not found" }); }
});
server.addRoute("POST", "/api/issues/:issueId/work-products", (_req, res, params, token, body) => {
try {
const task = db.getTask(requireParam(params, "issueId"));
if (task.companyId !== token.companyId) { server.json(res, 404, { error: "Task not found" }); return; }
const parsed = createWorkProductSchema.safeParse(body);
if (!parsed.success) { server.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() }); return; }
const data = parsed.data;
const id = db.createWorkProduct({
companyId: task.companyId,
taskId: task.id,
projectId: data.projectId ?? task.projectId,
type: data.type,
provider: data.provider,
externalId: data.externalId ?? null,
title: data.title,
url: data.url ?? null,
status: data.status ?? "active",
reviewState: data.reviewState ?? "none",
isPrimary: data.isPrimary === true,
summary: data.summary ?? null,
metadataJson: data.metadata ? JSON.stringify(data.metadata) : null,
createdByRunId: token.runId ?? null,
});
onDomainChanged();
server.json(res, 201, { id });
} catch { server.json(res, 404, { error: "Task not found" }); }
});
server.addRoute("PATCH", "/api/work-products/:workProductId", (_req, res, params, token, body) => {
const wpId = params.workProductId;
if (!wpId) { server.json(res, 400, { error: "Missing workProductId" }); return; }
const parsed = updateWorkProductSchema.safeParse(body);
if (!parsed.success) { server.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() }); return; }
db.updateWorkProduct(wpId, token.companyId, parsed.data);
onDomainChanged();
server.json(res, 200, { updated: true });
});
server.addRoute("DELETE", "/api/work-products/:workProductId", (_req, res, params, token) => {
const wpId = params.workProductId;
if (!wpId) { server.json(res, 400, { error: "Missing workProductId" }); return; }
db.deleteWorkProduct(wpId, token.companyId);
onDomainChanged();
server.json(res, 200, { deleted: true });
});
// ── Task-Document Links ──
server.addRoute("GET", "/api/issues/:issueId/documents", (_req, res, params, token) => {
try {
const task = db.getTask(requireParam(params, "issueId"));
if (task.companyId !== token.companyId) { server.json(res, 404, { error: "Task not found" }); return; }
const links = db.listDocumentsForTask(task.id, task.companyId);
server.json(res, 200, links);
} catch { server.json(res, 404, { error: "Task not found" }); }
});
server.addRoute("PUT", "/api/issues/:issueId/documents/:key", (_req, res, params, token, body) => {
try {
const task = db.getTask(requireParam(params, "issueId"));
if (task.companyId !== token.companyId) { server.json(res, 404, { error: "Task not found" }); return; }
const key = String(params.key ?? "").trim().toLowerCase();
if (!key || !/^[a-z0-9][a-z0-9_-]*$/.test(key) || key.length > 64) {
server.json(res, 400, { error: "Invalid document key. Must be lowercase alphanumeric with _ or -, 1-64 chars." }); return;
}
const parsed = linkDocumentSchema.safeParse(body);
if (!parsed.success) { server.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() }); return; }
const documentId = parsed.data.documentId;
if (!db.belongsToCompany("documents", documentId, token.companyId)) {
server.json(res, 400, { error: "Document does not belong to this company" }); return;
}
const linkId = db.linkDocumentToTask(task.id, documentId, task.companyId, key);
onDomainChanged();
server.json(res, 200, { id: linkId, key });
} catch { server.json(res, 404, { error: "Task not found" }); }
});
server.addRoute("DELETE", "/api/issues/:issueId/documents/:key", (_req, res, params, token) => {
try {
const task = db.getTask(requireParam(params, "issueId"));
if (task.companyId !== token.companyId) { server.json(res, 404, { error: "Task not found" }); return; }
const key = String(params.key ?? "").trim().toLowerCase();
db.unlinkDocumentFromTask(task.id, key, task.companyId);
onDomainChanged();
server.json(res, 200, { unlinked: true });
} catch { server.json(res, 404, { error: "Task not found" }); }
});
}