372 lines
16 KiB
TypeScript
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" }); }
|
|
});
|
|
}
|