310 lines
13 KiB
TypeScript
310 lines
13 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import type { DatabaseSync } from "node:sqlite";
|
|
import type {
|
|
InboxItem,
|
|
ProfileSnapshot,
|
|
} from "@shared/types";
|
|
import {
|
|
type ReadAll,
|
|
type Transaction,
|
|
nowIso,
|
|
} from "./helpers";
|
|
|
|
export function buildCompanyCrudMethods(
|
|
getDb: () => DatabaseSync,
|
|
readAll: ReadAll,
|
|
transaction: Transaction,
|
|
deps: {
|
|
getSetting: (key: string) => string | null;
|
|
setSetting: (key: string, value: string) => void;
|
|
listSnapshot: (options: { companyId?: string | null }) => ProfileSnapshot;
|
|
addComment: (input: { companyId: string; taskId: string; authorAgentId: string | null; authorName: string; body: string }) => void;
|
|
},
|
|
) {
|
|
return {
|
|
saveCompany(input: {
|
|
id?: string | null;
|
|
name: string;
|
|
description: string;
|
|
status: string;
|
|
autoApproveHires?: boolean;
|
|
reviewDeliverables?: boolean;
|
|
executionMode?: string;
|
|
}): string {
|
|
const id = input.id && input.id.length > 0 ? input.id : randomUUID();
|
|
const ts = nowIso();
|
|
const autoApprove = input.autoApproveHires ? 1 : 0;
|
|
const reviewDeliverables = input.reviewDeliverables ? 1 : 0;
|
|
const executionMode = input.executionMode ?? "ai_ceo";
|
|
const existing = getDb()
|
|
.prepare("select id from companies where id = ?")
|
|
.get(id) as { id: string } | undefined;
|
|
if (existing) {
|
|
getDb()
|
|
.prepare(
|
|
"update companies set name = ?, description = ?, status = ?, auto_approve_hires = ?, review_deliverables = ?, execution_mode = ?, updated_at = ? where id = ?",
|
|
)
|
|
.run(input.name, input.description, input.status, autoApprove, reviewDeliverables, executionMode, ts, id);
|
|
} else {
|
|
getDb()
|
|
.prepare(
|
|
"insert into companies (id, name, description, status, auto_approve_hires, review_deliverables, execution_mode, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
)
|
|
.run(id, input.name, input.description, input.status, autoApprove, reviewDeliverables, executionMode, ts, ts);
|
|
if (!deps.getSetting("current_company_id")) {
|
|
deps.setSetting("current_company_id", id);
|
|
}
|
|
}
|
|
return id;
|
|
},
|
|
|
|
deleteCompany(companyId: string): void {
|
|
transaction(() => {
|
|
try {
|
|
getDb()
|
|
.prepare(
|
|
"delete from agent_message_reads where message_id in (select id from agent_messages where company_id = ?)",
|
|
)
|
|
.run(companyId);
|
|
} catch (err) {
|
|
if (!(err instanceof Error) || !err.message.includes("no such table")) throw err;
|
|
}
|
|
// New table cleanup (budget_incidents before budget_policies due to FK)
|
|
getDb().prepare("delete from budget_incidents where company_id = ?").run(companyId);
|
|
getDb().prepare("delete from budget_policies where company_id = ?").run(companyId);
|
|
// agent_skill_assignments references company_skills -- delete before skills
|
|
try {
|
|
getDb()
|
|
.prepare(
|
|
"delete from agent_skill_assignments where skill_id in (select id from company_skills where company_id = ?)",
|
|
)
|
|
.run(companyId);
|
|
} catch (err) {
|
|
if (!(err instanceof Error) || !err.message.includes("no such table")) throw err;
|
|
}
|
|
getDb().prepare("delete from company_skills where company_id = ?").run(companyId);
|
|
getDb().prepare("delete from task_sessions where company_id = ?").run(companyId);
|
|
getDb().prepare("delete from wakeup_requests where company_id = ?").run(companyId);
|
|
// Thread runtime tables (events + participants cascade from threads via FK)
|
|
getDb().prepare("delete from thread_wait_states where company_id = ?").run(companyId);
|
|
getDb().prepare("delete from conversation_threads where company_id = ?").run(companyId);
|
|
getDb().prepare("delete from cost_entries where company_id = ?").run(companyId);
|
|
getDb().prepare("delete from activity where company_id = ?").run(companyId);
|
|
getDb().prepare("delete from task_comments where company_id = ?").run(companyId);
|
|
getDb().prepare("delete from runs where company_id = ?").run(companyId);
|
|
getDb().prepare("delete from approvals where company_id = ?").run(companyId);
|
|
getDb().prepare("delete from tasks where company_id = ?").run(companyId);
|
|
getDb().prepare("delete from projects where company_id = ?").run(companyId);
|
|
getDb().prepare("delete from goals where company_id = ?").run(companyId);
|
|
getDb().prepare("delete from agents where company_id = ?").run(companyId);
|
|
getDb().prepare("delete from secrets where company_id = ?").run(companyId);
|
|
getDb().prepare("delete from workspaces where company_id = ?").run(companyId);
|
|
for (const table of [
|
|
"automation_rules",
|
|
"automation_log",
|
|
"agent_messages",
|
|
"workflow_pipelines",
|
|
"meetings",
|
|
"documents",
|
|
"knowledge_base",
|
|
"sprints",
|
|
"browser_actions",
|
|
"social_accounts",
|
|
]) {
|
|
try {
|
|
getDb().prepare(`delete from ${table} where company_id = ?`).run(companyId);
|
|
} catch (err) {
|
|
// Only tolerate "no such table" — re-throw anything else (disk error, etc.)
|
|
if (!(err instanceof Error) || !err.message.includes("no such table")) throw err;
|
|
}
|
|
}
|
|
getDb().prepare("delete from companies where id = ?").run(companyId);
|
|
if (deps.getSetting("current_company_id") === companyId) {
|
|
const next = getDb()
|
|
.prepare("select id from companies order by created_at asc limit 1")
|
|
.get() as { id: string } | undefined;
|
|
if (next) {
|
|
deps.setSetting("current_company_id", next.id);
|
|
} else {
|
|
deps.setSetting("current_company_id", "");
|
|
}
|
|
}
|
|
});
|
|
},
|
|
|
|
getCompanyStatus(companyId: string): string | null {
|
|
const row = getDb()
|
|
.prepare("select status from companies where id = ?")
|
|
.get(companyId) as { status: string } | undefined;
|
|
return row?.status ?? null;
|
|
},
|
|
|
|
getCompanyBasic(companyId: string): { name: string; description: string } | null {
|
|
const row = getDb()
|
|
.prepare("select name, description from companies where id = ?")
|
|
.get(companyId) as { name: string; description: string } | undefined;
|
|
return row ?? null;
|
|
},
|
|
|
|
listActiveCompanyIds(): Array<{ id: string; name: string }> {
|
|
return readAll<{ id: string; name: string }>(
|
|
"select id, name from companies where status = 'active'",
|
|
);
|
|
},
|
|
|
|
getCompanyExecutionMode(companyId: string): string {
|
|
const row = getDb()
|
|
.prepare("SELECT execution_mode FROM companies WHERE id = ? LIMIT 1")
|
|
.get(companyId) as { execution_mode: string } | undefined;
|
|
return row?.execution_mode ?? "ai_ceo";
|
|
},
|
|
|
|
getCompanyApprovalSettings(companyId: string): { autoApproveHires: boolean; reviewDeliverables: boolean } | null {
|
|
const row = getDb()
|
|
.prepare("SELECT auto_approve_hires, review_deliverables FROM companies WHERE id = ? LIMIT 1")
|
|
.get(companyId) as { auto_approve_hires: number; review_deliverables: number } | undefined;
|
|
if (!row) return null;
|
|
return {
|
|
autoApproveHires: row.auto_approve_hires === 1,
|
|
reviewDeliverables: row.review_deliverables === 1,
|
|
};
|
|
},
|
|
|
|
getInbox(companyId: string): InboxItem[] {
|
|
const items: InboxItem[] = [];
|
|
const snapshot = deps.listSnapshot({ companyId });
|
|
const agents = snapshot.agents;
|
|
const agentMap = new Map(agents.map((a) => [a.id, a]));
|
|
|
|
// Pending approvals
|
|
for (const approval of snapshot.approvals.filter(
|
|
(a) => a.companyId === companyId && a.state === "pending",
|
|
)) {
|
|
const requester = agentMap.get(approval.requestedByAgentId ?? "");
|
|
items.push({
|
|
id: `approval-${approval.id}`,
|
|
type: "pending_approval",
|
|
title: approval.payloadSummary,
|
|
subtitle: `${approval.type.replaceAll("_", " ")} — requested by ${requester?.name ?? "Unknown"}`,
|
|
severity: approval.type === "dangerous_command" ? "critical" : "high",
|
|
entityType: "approval",
|
|
entityId: approval.id,
|
|
agentId: approval.requestedByAgentId,
|
|
agentName: requester?.name ?? null,
|
|
createdAt: approval.createdAt,
|
|
});
|
|
}
|
|
|
|
// Failed runs (last 24h)
|
|
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
|
for (const run of snapshot.runs.filter(
|
|
(r) =>
|
|
r.companyId === companyId &&
|
|
(r.status === "failed" || r.status === "timed_out") &&
|
|
r.createdAt > oneDayAgo,
|
|
)) {
|
|
const agent = agentMap.get(run.agentId);
|
|
items.push({
|
|
id: `run-${run.id}`,
|
|
type: "failed_run",
|
|
title: run.summary || "Run failed",
|
|
subtitle: `${agent?.name ?? "Unknown agent"} — ${run.errorMessage?.slice(0, 100) ?? "Unknown error"}`,
|
|
severity: "high",
|
|
entityType: "run",
|
|
entityId: run.id,
|
|
agentId: run.agentId,
|
|
agentName: agent?.name ?? null,
|
|
createdAt: run.createdAt,
|
|
});
|
|
}
|
|
|
|
// Budget warnings (agents at >80% budget)
|
|
for (const agent of agents.filter(
|
|
(a) => a.budgetMonthlyUsd > 0 && a.spentMonthlyUsd / a.budgetMonthlyUsd > 0.8,
|
|
)) {
|
|
const pct = Math.round((agent.spentMonthlyUsd / agent.budgetMonthlyUsd) * 100);
|
|
items.push({
|
|
id: `budget-${agent.id}`,
|
|
type: "budget_warning",
|
|
title: `${agent.name} at ${pct}% budget`,
|
|
subtitle: `$${agent.spentMonthlyUsd.toFixed(2)} of $${agent.budgetMonthlyUsd.toFixed(2)} monthly limit`,
|
|
severity: pct >= 100 ? "critical" : "high",
|
|
entityType: "agent",
|
|
entityId: agent.id,
|
|
agentId: agent.id,
|
|
agentName: agent.name,
|
|
createdAt: agent.updatedAt,
|
|
});
|
|
}
|
|
|
|
// Blocked tasks
|
|
for (const task of snapshot.tasks.filter(
|
|
(t) => t.companyId === companyId && t.status === "blocked",
|
|
)) {
|
|
const assignee = agentMap.get(task.assigneeAgentId ?? "");
|
|
items.push({
|
|
id: `blocked-${task.id}`,
|
|
type: "blocked_task",
|
|
title: task.title,
|
|
subtitle: `Assigned to ${assignee?.name ?? "Unassigned"} — blocked`,
|
|
severity: task.priority === "critical" ? "critical" : "medium",
|
|
entityType: "task",
|
|
entityId: task.id,
|
|
agentId: task.assigneeAgentId,
|
|
agentName: assignee?.name ?? null,
|
|
createdAt: task.updatedAt,
|
|
});
|
|
}
|
|
|
|
// Tasks needing review
|
|
for (const task of snapshot.tasks.filter(
|
|
(t) => t.companyId === companyId && t.status === "in_review",
|
|
)) {
|
|
const assignee = agentMap.get(task.assigneeAgentId ?? "");
|
|
items.push({
|
|
id: `review-${task.id}`,
|
|
type: "review_needed",
|
|
title: task.title,
|
|
subtitle: `Submitted by ${assignee?.name ?? "Unknown"} — awaiting review`,
|
|
severity: task.priority === "critical" ? "high" : "medium",
|
|
entityType: "task",
|
|
entityId: task.id,
|
|
agentId: task.assigneeAgentId,
|
|
agentName: assignee?.name ?? null,
|
|
createdAt: task.updatedAt,
|
|
});
|
|
}
|
|
|
|
// Unread urgent agent messages (company-wide or incident channels that the Board should see)
|
|
for (const msg of snapshot.agentMessages.filter(
|
|
(m) =>
|
|
m.companyId === companyId &&
|
|
!m.readAt &&
|
|
(m.channel === "company" || m.channel === "incident" || m.priority === "urgent"),
|
|
)) {
|
|
const sender = agentMap.get(msg.fromAgentId);
|
|
items.push({
|
|
id: `message-${msg.id}`,
|
|
type: "agent_message",
|
|
title: msg.subject,
|
|
subtitle: `${sender?.name ?? "Unknown"} — ${msg.channel === "incident" ? "INCIDENT" : msg.channel} ${msg.priority === "urgent" ? "(urgent)" : ""}`,
|
|
severity: msg.priority === "urgent" || msg.channel === "incident" ? "critical" : "medium",
|
|
entityType: "message",
|
|
entityId: msg.id,
|
|
agentId: msg.fromAgentId,
|
|
agentName: sender?.name ?? null,
|
|
createdAt: msg.createdAt,
|
|
});
|
|
}
|
|
|
|
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
items.sort(
|
|
(a, b) =>
|
|
severityOrder[a.severity] - severityOrder[b.severity] ||
|
|
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
|
);
|
|
return items;
|
|
},
|
|
};
|
|
}
|