Promote AgentCompany-New to the repository root and retire the old app

The legacy application - its src, tests, landing site, promo assets, docs,
release tooling, CI workflows, and root configs - leaves the tree; it stays
reachable in history. Everything under AgentCompany-New moves up unchanged:
same package name, so the production userData/database path is untouched,
and e2e/build scripts already resolved paths relative to the project. The
developer cred.txt stays gitignored at its new location. Full verify:ci
passes from the new root.
This commit is contained in:
ZacharyZhang-NY committed 2026-07-07 00:04:13 -04:00
1 parent 2350f744e3
commit 07d0b4587d
1904 files changed
+1133 -282075

No files matched your search

-119
View File
@@ -1,119 +0,0 @@
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { FatalError } from "./errors/index.js";
let signingKey: Buffer | null = null;
const TOKEN_TTL_MS = 48 * 60 * 60 * 1000; // 48 hours
const SIGNING_KEY_BYTES = 32;
// Once-per-process warning when a malformed token shape is seen. Helps
// diagnose schema breaks after upgrades.
let loggedMalformedTokenWarning = false;
interface TokenValidationLogContext {
runId?: string | null;
companyId?: string | null;
requestId?: string | null;
}
function safeAuthLogField(value: string | null | undefined): string {
return typeof value === "string" && /^[A-Za-z0-9._:-]{1,160}$/.test(value) ? value : "unknown";
}
function warnMalformedTokenOnce(reason: string, context: TokenValidationLogContext | undefined) {
if (loggedMalformedTokenWarning) return;
loggedMalformedTokenWarning = true;
console.warn(
"[agent-auth] token shape invalid: " +
`reason=${safeAuthLogField(reason)} ` +
`runId=${safeAuthLogField(context?.runId)} ` +
`companyId=${safeAuthLogField(context?.companyId)} ` +
`requestId=${safeAuthLogField(context?.requestId)}`,
);
}
interface TokenPayload {
agentId: string;
companyId: string;
runId: string;
iat: number;
exp: number;
}
function sign(payload: TokenPayload): string {
const key = getSigningKey();
const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
const signature = createHmac("sha256", key)
.update(`${header}.${body}`)
.digest("base64url");
return `${header}.${body}.${signature}`;
}
function verify(token: string, context?: TokenValidationLogContext): TokenPayload | null {
const key = getSigningKey();
const parts = token.split(".");
if (parts.length !== 3) return null;
const [header, body, signature] = parts as [string, string, string];
const expectedSignature = createHmac("sha256", key)
.update(`${header}.${body}`)
.digest("base64url");
const sigBuffer = Buffer.from(signature, "base64url");
const expectedBuffer = Buffer.from(expectedSignature, "base64url");
if (sigBuffer.length !== expectedBuffer.length || !timingSafeEqual(sigBuffer, expectedBuffer)) {
return null;
}
try {
const payload = JSON.parse(Buffer.from(body, "base64url").toString()) as TokenPayload;
if (Date.now() > payload.exp) return null;
return payload;
} catch {
warnMalformedTokenOnce("payload_json_parse_failed", context);
return null;
}
}
function getSigningKey() {
if (!signingKey) {
throw new Error("Agent token signing key was used before initialization.");
}
return signingKey;
}
export function initializeAgentTokenSigningKey(profileDir: string) {
mkdirSync(profileDir, { recursive: true });
const keyPath = join(profileDir, "agent-api-signing.key");
let key: Buffer;
try {
key = readFileSync(keyPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
key = randomBytes(SIGNING_KEY_BYTES);
writeFileSync(keyPath, key, { mode: 0o600, flag: "wx" });
}
if (key.length !== SIGNING_KEY_BYTES) {
throw new FatalError({
code: "agent_auth.signing_key.invalid",
message: `Agent token signing key must be ${SIGNING_KEY_BYTES} bytes.`,
context: { keyPath, actualBytes: key.length },
});
}
chmodSync(keyPath, 0o600);
signingKey = key;
}
export function createAgentToken(agentId: string, companyId: string, runId: string): string {
const now = Date.now();
return sign({ agentId, companyId, runId, iat: now, exp: now + TOKEN_TTL_MS });
}
export function validateAgentToken(token: string, context?: TokenValidationLogContext): TokenPayload | null {
return verify(token, context);
}
export type { TokenPayload, TokenValidationLogContext };
-321
View File
@@ -1,321 +0,0 @@
import type { AppDatabase } from "./database";
import type { TaskRecord } from "@shared/types";
function safeGetAgent(db: AppDatabase, agentId: string | null | undefined) {
if (!agentId) {
return null;
}
try {
return db.core.entity.getAgent(agentId);
} catch (error) {
if (error instanceof Error && error.message.startsWith("Agent not found:")) {
return null;
}
throw error;
}
}
const MANAGEMENT_KEYWORDS = [
"chief",
"cto",
"cmo",
"coo",
"cfo",
"vp",
"vice president",
"head",
"director",
"manager",
"lead",
];
/** Regex union for MANAGEMENT_KEYWORDS with `\b`-anchored matching so short
* tokens like "vp" or "cmo" do not false-match inside "vpc" or "economical". */
const MANAGEMENT_KEYWORD_PATTERN = new RegExp(
`\\b(?:${MANAGEMENT_KEYWORDS.map((k) => k.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\b`,
"i",
);
export function hasManagementAuthority(agent: { role: string; title: string; department: string | null }) {
if (agent.department === "hr") {
return true;
}
const text = `${agent.role} ${agent.title}`;
return MANAGEMENT_KEYWORD_PATTERN.test(text);
}
function hasOrganizationPermission(db: AppDatabase, agentId: string) {
return db.core.agents.hasAgentPermission(agentId, "organization_manage");
}
function managesAgent(db: AppDatabase, managerId: string, targetAgentId: string): boolean {
if (managerId === targetAgentId) {
return true;
}
return db.core.agents.getChainOfCommand(targetAgentId).some((agent) => agent.id === managerId);
}
export function canControlAgent(db: AppDatabase, companyId: string, actorAgentId: string, targetAgentId: string): boolean {
const actor = safeGetAgent(db, actorAgentId);
const target = safeGetAgent(db, targetAgentId);
if (!actor || !target) {
return false;
}
if (actor.companyId !== companyId || target.companyId !== companyId) {
return false;
}
if (actor.id === target.id) {
return true;
}
if (canManageOrganization(db, companyId, actorAgentId)) {
return true;
}
return managesAgent(db, actorAgentId, targetAgentId);
}
export function canManageOrganization(db: AppDatabase, companyId: string, agentId: string): boolean {
const agent = safeGetAgent(db, agentId);
if (!agent || agent.companyId !== companyId) {
return false;
}
if (!agent.reportsTo) {
return true;
}
if (hasOrganizationPermission(db, agentId)) {
return true;
}
return agent.department === "hr";
}
export function canRequestHire(db: AppDatabase, companyId: string, agentId: string): boolean {
const agent = safeGetAgent(db, agentId);
if (!agent || agent.companyId !== companyId) {
return false;
}
if (canManageOrganization(db, companyId, agentId)) {
return true;
}
if (db.core.agents.getDirectReports(agentId, companyId).length > 0) {
return true;
}
return hasManagementAuthority(agent);
}
const CEO_ROLE_PATTERN = /^(chief executive officer|ceo)$/i;
const CEO_TITLE_PATTERN = /^ceo$/i;
/**
* True only when the agent is a top-level CEO — CEO-equivalent role/title
* AND no internal manager (`reportsTo === null`, i.e. reports to the Board).
*
* Intentionally orthogonal to {@link canRequestHire}: hiring authority spans
* managers, directors, VPs, HR, etc., but the `approve_ceo_strategy` flow is
* the CEO's alone. Using `reportsTo === null` by itself is too loose (a VP
* with no manager would slip through) and using a keyword list by itself is
* too loose (a "VP" matches the management keywords). Requiring both pins
* the predicate to a genuine CEO even when someone inserts a virtual
* superior above an executive.
*/
export function canFileCeoStrategy(db: AppDatabase, companyId: string, agentId: string): boolean {
const agent = safeGetAgent(db, agentId);
if (!agent || agent.companyId !== companyId) {
return false;
}
if (agent.reportsTo !== null) {
return false;
}
return CEO_ROLE_PATTERN.test(agent.role.trim()) || CEO_TITLE_PATTERN.test(agent.title.trim());
}
function getDepartmentLeadCandidateId(
db: AppDatabase,
companyId: string,
department: string,
excludeAgentId: string | null,
): string | null {
const candidates = db.queryAll<{
id: string;
role: string;
title: string;
department: string | null;
createdAt: string;
}>(
`select id, role, title, department, created_at as createdAt
from agents
where company_id = ?
and department = ?
and status not in ('terminated', 'pending_approval')`,
companyId,
department,
);
const ranked = candidates
.filter((candidate) => candidate.id !== excludeAgentId)
.map((candidate) => ({
id: candidate.id,
hasReports: db.core.agents.getDirectReports(candidate.id, companyId).length > 0,
leadershipScore: hasManagementAuthority(candidate) ? 1 : 0,
chainDepth: db.core.agents.getChainOfCommand(candidate.id).length,
createdAt: candidate.createdAt,
}))
.filter((candidate) => candidate.hasReports || candidate.leadershipScore > 0)
.sort((left, right) => {
if (left.hasReports !== right.hasReports) return left.hasReports ? -1 : 1;
if (left.leadershipScore !== right.leadershipScore) return right.leadershipScore - left.leadershipScore;
if (left.chainDepth !== right.chainDepth) return left.chainDepth - right.chainDepth;
return left.createdAt.localeCompare(right.createdAt);
});
return ranked[0]?.id ?? null;
}
export function resolveHireReportsTo(
db: AppDatabase,
companyId: string,
requestedByAgentId: string | null | undefined,
explicitReportsTo: string | null | undefined,
department: string | null | undefined,
): string | null {
if (explicitReportsTo !== undefined) {
return explicitReportsTo;
}
const requester = requestedByAgentId ? safeGetAgent(db, requestedByAgentId) : null;
if (requester && requester.companyId !== companyId) {
return null;
}
if (!department) {
return requester?.id ?? db.core.agents.getCeoForCompany(companyId)?.id ?? null;
}
if (requester && (requester.department === department || !requester.reportsTo)) {
return requester.id;
}
const departmentLeadId = getDepartmentLeadCandidateId(db, companyId, department, requester?.id ?? null);
if (departmentLeadId) {
return departmentLeadId;
}
if (requester) {
return requester.id;
}
return db.core.agents.getCeoForCompany(companyId)?.id ?? null;
}
function normalizeComparableText(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[\s_\-()[\]{}]+/g, " ")
.replace(/\s+/g, " ");
}
function isAllowedRoleAcronym(value: string): boolean {
return /^[A-Z]{2,5}$/.test(value.trim());
}
export function getHireNameValidationError(input: {
name: string;
role: string;
title: string;
}): string | null {
const normalizedName = normalizeComparableText(input.name);
if (!normalizedName) {
return "Hire requests require a real display name.";
}
const normalizedRole = normalizeComparableText(input.role);
const normalizedTitle = normalizeComparableText(input.title);
if (normalizedName === normalizedRole && !isAllowedRoleAcronym(input.name)) {
return "Hire name must be a real display name, not just the role.";
}
if (
normalizedTitle.length > 0 &&
normalizedName === normalizedTitle &&
!isAllowedRoleAcronym(input.name)
) {
return "Hire name must be a real display name, not just the title.";
}
return null;
}
export function getHireRoutingValidationError(
db: AppDatabase,
companyId: string,
requestedByAgentId: string | null | undefined,
reportsTo: string | null | undefined,
department: string | null | undefined,
): string | null {
const requester = requestedByAgentId ? safeGetAgent(db, requestedByAgentId) : null;
const manager = reportsTo ? safeGetAgent(db, reportsTo) : null;
const requestedDepartment = department ?? null;
if (requester && requester.companyId !== companyId) {
return "Requesting agent does not belong to the current company.";
}
if (!requestedDepartment) {
return null;
}
if (reportsTo != null && !manager) {
return "Selected manager not found.";
}
if (manager && manager.companyId !== companyId) {
return "Selected manager does not belong to the current company.";
}
if (
manager &&
manager.reportsTo != null &&
manager.department !== requestedDepartment
) {
return "Selected manager must belong to the same department as the new hire, unless they are a top-level executive.";
}
return null;
}
export function canAssignTask(db: AppDatabase, companyId: string, actorAgentId: string, targetAgentId: string | null): boolean {
if (!targetAgentId || targetAgentId === actorAgentId) {
return true;
}
if (!db.core.settings.belongsToCompany("agents", targetAgentId, companyId)) {
return false;
}
if (canManageOrganization(db, companyId, actorAgentId)) {
return true;
}
return managesAgent(db, actorAgentId, targetAgentId);
}
export function canUpdateTask(db: AppDatabase, companyId: string, actorAgentId: string, task: TaskRecord): boolean {
if (task.companyId !== companyId) {
return false;
}
if (task.assigneeAgentId === actorAgentId) {
return true;
}
if (!task.assigneeAgentId) {
return canManageOrganization(db, companyId, actorAgentId);
}
if (canManageOrganization(db, companyId, actorAgentId)) {
return true;
}
return managesAgent(db, actorAgentId, task.assigneeAgentId);
}
export function canManageGoal(db: AppDatabase, companyId: string, actorAgentId: string, ownerAgentId: string | null): boolean {
if (canManageOrganization(db, companyId, actorAgentId)) {
return true;
}
return Boolean(ownerAgentId && ownerAgentId === actorAgentId);
}
export function canManageProject(db: AppDatabase, companyId: string, actorAgentId: string, leadAgentId: string | null): boolean {
if (canManageOrganization(db, companyId, actorAgentId)) {
return true;
}
return Boolean(leadAgentId && leadAgentId === actorAgentId);
}
-229
View File
@@ -1,229 +0,0 @@
/**
* Role-classification helpers used across prompt construction and skill
* auto-assignment. Historical keyword match remains here for backfill and
* UI-free fallback; typed `agent.roleLevel` is the source of truth once the
* column is populated.
*
* Matching uses word-boundary tokenization instead of substring search,
* so "lead" does not match "cleaner" or "leaderboard".
*/
import type { AgentRoleLevel, TaskDomain, TaskTier } from "@shared/types";
import { isLeadershipLevel } from "@shared/types";
export const LEADERSHIP_ROLE_KEYWORDS = [
"chief",
"cto",
"cmo",
"coo",
"cfo",
"ceo",
"vp",
"vice",
"president",
"head",
"director",
"manager",
"lead",
] as const;
function tokenize(text: string): string[] {
// Unicode-safe tokenization. Matches runs of letters/numbers across
// scripts so a Chinese title like "高级架构师" yields ["高级架构师"]
// rather than being stripped. English tokens still tokenize on word
// boundaries as before.
return text.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
}
/**
* Check whether a keyword appears in the role/title text. For Latin
* keywords we use whole-token equality (so "lead" does not match
* "leaderboard"). For CJK keywords we fall back to substring containment
* because CJK text has no explicit word boundaries — "架构师" is a
* legitimate substring of "高级架构师" and should match.
*/
function matchesKeyword(loweredText: string, tokens: Set<string>, keyword: string): boolean {
if (/[\u4e00-\u9fff]/.test(keyword)) {
return loweredText.includes(keyword);
}
return tokens.has(keyword);
}
export function roleMatchesLeadershipKeywords(role: string, title?: string | null): boolean {
const tokens = new Set(tokenize(`${role} ${title ?? ""}`));
return LEADERSHIP_ROLE_KEYWORDS.some((keyword) => tokens.has(keyword));
}
/**
* One-shot inference from free-text role/title strings. Used when creating or
* backfilling an agent so `roleLevel` can be stored once and re-read cheaply.
* The most specific token wins; order here is most-senior → least.
*/
export function inferRoleLevel(role: string, title?: string | null): AgentRoleLevel {
const tokens = new Set(tokenize(`${role} ${title ?? ""}`));
if (tokens.has("ceo") || tokens.has("cto") || tokens.has("cmo") || tokens.has("coo") || tokens.has("cfo") || tokens.has("chief")) {
return "c_suite";
}
if (tokens.has("vp") || tokens.has("vice") || tokens.has("president")) return "vp";
if (tokens.has("director") || tokens.has("head")) return "director";
if (tokens.has("manager")) return "manager";
if (tokens.has("lead")) return "lead";
return "ic";
}
/** Canonical leadership check: typed enum wins, fallback to keyword match. */
export function agentIsLeader(agent: { roleLevel?: AgentRoleLevel | null; role: string; title: string | null }): boolean {
if (agent.roleLevel) return isLeadershipLevel(agent.roleLevel);
return roleMatchesLeadershipKeywords(agent.role, agent.title);
}
export interface TaskProfile {
domain: TaskDomain;
requiredTier: TaskTier;
}
/**
* Ordered keyword → domain table. First match wins; order matters because
* role strings can contain multiple cues ("research engineer" is primarily
* a researcher, not a coder). English keywords match on whole-token
* equality; CJK keywords (Chinese here) match on substring containment.
* The list is intentionally narrow — anything unmatched falls through to
* the role-level heuristic below.
*/
const DOMAIN_KEYWORDS: Array<{ keyword: string; domain: TaskDomain }> = [
// ── Reasoning-heavy ──
// English
{ keyword: "architect", domain: "reasoning" },
{ keyword: "reviewer", domain: "reasoning" },
{ keyword: "auditor", domain: "reasoning" },
{ keyword: "analyst", domain: "reasoning" },
{ keyword: "researcher", domain: "reasoning" },
{ keyword: "scientist", domain: "reasoning" },
{ keyword: "mathematician", domain: "reasoning" },
{ keyword: "economist", domain: "reasoning" },
{ keyword: "strategist", domain: "reasoning" },
{ keyword: "cto", domain: "reasoning" },
{ keyword: "cfo", domain: "reasoning" },
// Chinese (CJK keywords match via substring, so "高级架构师" hits 架构师)
{ keyword: "架构师", domain: "reasoning" },
{ keyword: "审查员", domain: "reasoning" },
{ keyword: "审计", domain: "reasoning" },
{ keyword: "分析师", domain: "reasoning" },
{ keyword: "研究员", domain: "reasoning" },
{ keyword: "科学家", domain: "reasoning" },
{ keyword: "数学家", domain: "reasoning" },
{ keyword: "经济学家", domain: "reasoning" },
{ keyword: "策略师", domain: "reasoning" },
{ keyword: "首席技术", domain: "reasoning" },
{ keyword: "首席财务", domain: "reasoning" },
// ── Coding-heavy ──
{ keyword: "engineer", domain: "coding" },
{ keyword: "developer", domain: "coding" },
{ keyword: "coder", domain: "coding" },
{ keyword: "programmer", domain: "coding" },
{ keyword: "devops", domain: "coding" },
{ keyword: "sre", domain: "coding" },
{ keyword: "工程师", domain: "coding" },
{ keyword: "开发", domain: "coding" },
{ keyword: "程序员", domain: "coding" },
{ keyword: "研发", domain: "coding" },
{ keyword: "运维", domain: "coding" },
// ── Writing-heavy: copy / marketing / content / design / PM / HR ──
{ keyword: "writer", domain: "writing" },
{ keyword: "copywriter", domain: "writing" },
{ keyword: "marketer", domain: "writing" },
{ keyword: "marketing", domain: "writing" },
{ keyword: "content", domain: "writing" },
{ keyword: "editor", domain: "writing" },
{ keyword: "pm", domain: "writing" },
{ keyword: "designer", domain: "writing" },
{ keyword: "hr", domain: "writing" },
{ keyword: "撰稿", domain: "writing" },
{ keyword: "文案", domain: "writing" },
{ keyword: "市场", domain: "writing" },
{ keyword: "营销", domain: "writing" },
{ keyword: "内容", domain: "writing" },
{ keyword: "编辑", domain: "writing" },
{ keyword: "设计师", domain: "writing" },
{ keyword: "产品经理", domain: "writing" },
{ keyword: "运营", domain: "writing" },
{ keyword: "人力资源", domain: "writing" },
{ keyword: "人事", domain: "writing" },
];
/**
* Keyword cues that hard-pin a role to the trivial tier regardless of the
* role-level enum. A data-entry clerk whose `roleLevel` is `ic` runs on a
* trivial-tier model even if the free-text title is long; an "intern
* director" (yes, these exist) gets reviewed by their title before their
* level. Mixed English + Chinese cues.
*/
const TRIVIAL_KEYWORDS = [
"intern",
"assistant",
"clerk",
"entry",
"junior",
"associate",
"实习",
"助理",
"初级",
"见习",
"文员",
] as const;
/**
* Map a role string + optional title to (domain, requiredTier). Both the
* domain and the tier are conservative defaults — the picker will still
* fall back to the CEO model if no enabled candidate clears the bar, so
* under-classifying here is always safe.
*
* The CEO / c_suite cases are handled by the caller (the dispatcher has
* access to `agent.roleLevel` and knows to pin CEO to the user-chosen
* model). This function treats every input as a subordinate.
*/
export function inferTaskProfile(
role: string,
title: string | null | undefined,
roleLevel?: AgentRoleLevel | null,
): TaskProfile {
const loweredText = `${role} ${title ?? ""}`.toLowerCase();
const tokens = new Set(tokenize(loweredText));
// 1. Domain by keyword — first match wins.
let domain: TaskDomain = "writing";
for (const entry of DOMAIN_KEYWORDS) {
if (matchesKeyword(loweredText, tokens, entry.keyword)) {
domain = entry.domain;
break;
}
}
// 2. Tier is pinned trivial by explicit cues; otherwise rides role level.
const trivialOverride = TRIVIAL_KEYWORDS.some((kw) => matchesKeyword(loweredText, tokens, kw));
let tier: TaskTier;
if (trivialOverride) {
tier = "trivial";
} else {
switch (roleLevel) {
case "c_suite":
case "vp":
case "director":
tier = "hard";
break;
case "manager":
case "lead":
tier = "standard";
break;
case "ic":
case null:
case undefined:
default:
tier = "standard";
break;
}
}
return { domain, requiredTier: tier };
}
@@ -1,127 +0,0 @@
/**
* Pure projection helpers for pi AgentEvent → human-readable console log.
*
* Extracted from runtime-bridge.ts to keep the bridge under the 400-line
* cap. These functions never touch DB, IPC, or filesystem — they are
* deterministic text transforms and must stay that way. New event
* types are added here; the bridge pipes the result into its run-log
* stream.
*
* The `@mariozechner/pi-agent-core` `AgentEvent` union is the
* canonical shape we project.
*/
export type AgentEventLogChunk = { stream: "stdout" | "stderr"; chunk: string };
/**
* Extract plain text from a pi AgentMessage's content array. User
* messages carry `content: [{ type: "text", text: "..." }]`; other
* content types (image, tool_result, thinking) are filtered out.
*/
export function extractMessageText(message: { content?: unknown }): string {
const content = message.content;
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
const parts: string[] = [];
for (const block of content) {
if (block && typeof block === "object" && (block as { type?: string }).type === "text") {
const text = (block as { text?: string }).text;
if (typeof text === "string") parts.push(text);
}
}
return parts.join("");
}
/**
* Compact JSON preview used in tool-execution log lines. Falls back to a
* sentinel when the value cannot be serialised (circular refs etc.).
*/
export function summariseJson(value: unknown, maxLength: number): string {
if (value === undefined || value === null) return "";
try {
const json = typeof value === "string" ? value : JSON.stringify(value);
if (json.length <= maxLength) return json;
return `${json.slice(0, maxLength - 3)}...`;
} catch {
return "<unserialisable>";
}
}
/**
* Best-effort textual rendering of a pi message's content for thread
* rows. Handles text, thinking, and toolCall blocks; returns an empty
* string for purely structural payloads so the thread view does not
* show noise.
*/
export function extractTextBody(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
const parts: string[] = [];
for (const block of content) {
if (!block || typeof block !== "object") continue;
const obj = block as { type?: string; text?: string; thinking?: string; name?: string };
if (obj.type === "text" && typeof obj.text === "string") parts.push(obj.text);
else if (obj.type === "thinking" && typeof obj.thinking === "string") parts.push(`[thinking] ${obj.thinking}`);
else if (obj.type === "toolCall" && typeof obj.name === "string") parts.push(`[tool:${obj.name}]`);
}
return parts.join("\n").trim();
}
/**
* Project a pi AgentEvent to a single console log chunk. Returns null
* for events that should not appear in the log (e.g., streaming
* deltas whose content is already rendered upstream).
*/
export function projectAgentEventToLogChunk(
event: { type: string } & Record<string, unknown>,
): AgentEventLogChunk | null {
switch (event.type) {
case "agent_start":
return { stream: "stdout", chunk: "\n[agent:start]\n" };
case "turn_start":
return { stream: "stdout", chunk: "\n" };
case "turn_end":
return { stream: "stdout", chunk: "\n" };
case "message_start": {
const message = event.message as { role?: string; content?: unknown } | undefined;
if (message?.role === "user") {
const body = extractMessageText(message);
if (!body) return null;
return { stream: "stdout", chunk: `[user] ${body}\n` };
}
return null;
}
case "message_update": {
const inner = event.assistantMessageEvent as
| ({ type?: string; delta?: string; content?: string } & Record<string, unknown>)
| undefined;
if (!inner) return null;
if (inner.type === "text_delta" && typeof inner.delta === "string" && inner.delta.length > 0) {
return { stream: "stdout", chunk: inner.delta };
}
if (inner.type === "thinking_delta" && typeof inner.delta === "string" && inner.delta.length > 0) {
return { stream: "stdout", chunk: inner.delta };
}
return null;
}
case "message_end": {
return { stream: "stdout", chunk: "\n" };
}
case "tool_execution_start": {
const toolName = typeof event.toolName === "string" ? event.toolName : "tool";
const argsPreview = summariseJson(event.args, 200);
return { stream: "stdout", chunk: `\n[tool:${toolName}] ${argsPreview}\n` };
}
case "tool_execution_end": {
const toolName = typeof event.toolName === "string" ? event.toolName : "tool";
const isError = event.isError === true;
const resultPreview = summariseJson(event.result, 300);
const tag = isError ? "tool:error" : "tool:done";
return { stream: isError ? "stderr" : "stdout", chunk: `[${tag}:${toolName}] ${resultPreview}\n` };
}
case "agent_end":
return { stream: "stdout", chunk: "\n[agent:end]\n" };
default:
return null;
}
}
-128
View File
@@ -1,128 +0,0 @@
/**
* Aggregated factory for the pi-agent-core runtime hooks
* installed by `pi-agent-factory.createPiAgent`.
*
* Before this module the factory wired `beforeToolCall` and
* `transformContext` as inline closures, each re-establishing
* its own RPC envelope shape and its own error policy. A new hook (or
* a signature change in pi) meant touching the factory in multiple places,
* and the "what does each hook do on failure?" matrix lived only in
* reader memory.
*
* `buildAgentHooks` collapses the wiring into a single object whose
* shape mirrors the subset of `AgentOptions` that we populate. The
* factory spreads the result into `new Agent({...})`, so the agent
* contract is still the pi-defined one. Tool policy stays routed
* through `WorkerRpcClient`; context compaction runs inside the worker
* so the full transcript never crosses the worker-main boundary.
*
* The signatures below are pinned to pi via `pi-hook-contract`: any
* drift in `AgentOptions.beforeToolCall` / `transformContext` breaks
* the build on the contract file, so the
* aggregation cannot silently drift from upstream.
*/
import type {
AgentMessage,
AgentOptions,
BeforeToolCallResult,
} from "@mariozechner/pi-agent-core";
import type {
BeforeToolCallRpcPayload,
BeforeToolCallRpcResult,
} from "./hook-rpc";
import type { WorkerRpcClient } from "../worker/entry/rpc";
import {
compactMessagesIfNeeded,
type CompactionTelemetry,
} from "./context-compactor";
export interface ContextCompactionTelemetry extends CompactionTelemetry {
runId: string;
emittedAt: string;
}
/**
* Inputs the aggregator needs to produce a hook bundle. `rpc` is the
* worker-side RPC client that ships policy hook invocations back to
* main. `runId` is the routing key main uses to look up the per-run
* `ActiveRunContext` in `RuntimeBridge`.
*/
export interface AgentHooksDeps {
runId: string;
rpc: WorkerRpcClient;
onContextCompaction?: (telemetry: ContextCompactionTelemetry) => void;
}
/**
* Subset of `AgentOptions` the aggregator populates. Exposed as an
* explicit interface so the factory call site keeps a stable shape
* even when pi adds further option slots — anything not in here stays
* the factory's own responsibility.
*/
export interface AgentHookBundle {
beforeToolCall: NonNullable<AgentOptions["beforeToolCall"]>;
transformContext: NonNullable<AgentOptions["transformContext"]>;
}
/**
* Build the pi-agent-core hooks as a single bundle.
*
* Each hook's behavior matches the pre-aggregation inline closures in
* `pi-agent-factory`:
*
* - `beforeToolCall`: fail-closed RPC. A thrown error travels up into
* pi's loop and surfaces as a tool-error result — destructive tools
* must never silently bypass main's approval gate.
* - `transformContext`: worker-local compaction. The transcript stays
* inside the worker; main receives telemetry only.
*/
export function buildAgentHooks(deps: AgentHooksDeps): AgentHookBundle {
const { runId, rpc, onContextCompaction } = deps;
const beforeToolCall: AgentHookBundle["beforeToolCall"] = async (
ctx,
): Promise<BeforeToolCallResult | undefined> => {
const payload: BeforeToolCallRpcPayload = {
toolName: ctx.toolCall.name,
toolCallId: ctx.toolCall.id,
args: ctx.args,
};
const response = await rpc.invoke(runId, "beforeToolCall", payload);
if (!response.ok) {
// RPC failure must fail closed for destructive tools: main is
// the source of truth for approval state, and silently running
// without it would bypass the gate. The loop converts the thrown
// error into a tool-error result and surfaces it to the agent.
throw new Error(`beforeToolCall RPC failed: ${response.error}`);
}
const result = response.result as BeforeToolCallRpcResult;
if (!result.block) return undefined;
return { block: true, reason: result.reason ?? undefined };
};
const transformContext: AgentHookBundle["transformContext"] = async (
messages,
): Promise<AgentMessage[]> => {
try {
const outcome = compactMessagesIfNeeded(messages);
if (outcome.changed && outcome.messages) {
if (outcome.telemetry) {
onContextCompaction?.({
...outcome.telemetry,
runId,
emittedAt: new Date().toISOString(),
});
}
return outcome.messages;
}
return messages;
} catch (error) {
throw new Error(
`transformContext compaction failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
};
return { beforeToolCall, transformContext };
}
@@ -1,100 +0,0 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
const AGENT_MESSAGE_SCHEMA_VERSION = 1;
const ALLOWED_AGENT_MESSAGE_ROLES = new Set(["user", "assistant", "toolResult"]);
interface PersistedAgentMessageEnvelope {
schemaVersion: 1;
kind: "agent_message";
message: AgentMessage;
persistedAt: string;
}
interface SessionEntryPayloadRow {
id: string;
payloadJson: string;
}
function formatMessageError(prefix: string, message: string): Error {
return new Error(`${prefix}: ${message}`);
}
function assertRecord(value: unknown, prefix: string): asserts value is Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw formatMessageError(prefix, "expected an object");
}
}
function normalizeAgentMessageForSession(
input: AgentMessage,
prefix = "Invalid AgentMessage",
options: { requireTimestamp: boolean } = { requireTimestamp: true },
): AgentMessage {
assertRecord(input, prefix);
const role = input.role;
if (typeof role !== "string" || !ALLOWED_AGENT_MESSAGE_ROLES.has(role)) {
throw formatMessageError(prefix, `invalid role ${typeof role === "string" ? JSON.stringify(role) : "<missing>"}`);
}
const content = (input as { content?: unknown }).content;
if (typeof content !== "string" && !Array.isArray(content)) {
throw formatMessageError(prefix, "content must be a string or an array");
}
const timestamp = (input as { timestamp?: unknown }).timestamp;
if (typeof timestamp !== "number" || !Number.isFinite(timestamp)) {
if (options.requireTimestamp) {
throw formatMessageError(prefix, "timestamp must be a finite number");
}
return {
...input,
timestamp: Date.now(),
} as AgentMessage;
}
return input;
}
export function validateNewAgentMessageForSession(input: AgentMessage, prefix = "Invalid AgentMessage"): AgentMessage {
return normalizeAgentMessageForSession(input, prefix, { requireTimestamp: true });
}
function normalizeLegacyAgentMessageForSession(input: AgentMessage, prefix = "Invalid legacy AgentMessage"): AgentMessage {
return normalizeAgentMessageForSession(input, prefix, { requireTimestamp: false });
}
export function encodeAgentMessageForSession(message: AgentMessage, persistedAt = new Date().toISOString()): string {
const normalized = validateNewAgentMessageForSession(message);
const envelope: PersistedAgentMessageEnvelope = {
schemaVersion: AGENT_MESSAGE_SCHEMA_VERSION,
kind: "agent_message",
message: normalized,
persistedAt,
};
return JSON.stringify(envelope);
}
export function decodeAgentMessageFromSessionEntry(row: SessionEntryPayloadRow): AgentMessage {
let parsed: unknown;
try {
parsed = JSON.parse(row.payloadJson);
} catch (error) {
throw new Error(
`Invalid session entry ${row.id} payload JSON: ${error instanceof Error ? error.message : String(error)}`,
);
}
assertRecord(parsed, `Invalid session entry ${row.id}`);
if (parsed.schemaVersion === AGENT_MESSAGE_SCHEMA_VERSION || parsed.kind === "agent_message") {
if (parsed.schemaVersion !== AGENT_MESSAGE_SCHEMA_VERSION || parsed.kind !== "agent_message") {
throw new Error(`Invalid session entry ${row.id}: unsupported AgentMessage envelope`);
}
return validateNewAgentMessageForSession(
parsed.message as AgentMessage,
`Invalid session entry ${row.id} AgentMessage`,
);
}
return normalizeLegacyAgentMessageForSession(parsed as unknown as AgentMessage, `Invalid legacy session entry ${row.id}`);
}
export function getAgentMessageRole(message: AgentMessage): string {
return validateNewAgentMessageForSession(message).role as string;
}
@@ -1,97 +0,0 @@
import { relative, resolve } from "node:path";
import { readdirSync, statSync } from "node:fs";
import { isGitRepo } from "./artifact-git";
import { logArtifactFsError, type Logger } from "./artifact-logging";
import type { WorkspaceBaseline } from "./artifact-types";
const MAX_BASELINE_ENTRIES = 20_000;
export const BASELINE_SKIP_DIR_NAMES: ReadonlySet<string> = new Set([
".git",
"node_modules",
".next",
"dist",
"build",
"out",
".cache",
".pnpm-store",
]);
/**
* Capture a run-start mtime baseline only for non-git workspaces.
* Git workspaces use `git status` as the authoritative diff source.
*/
export function captureBaselineIfRequired(
workspacePath: string | null,
logger?: Logger,
): WorkspaceBaseline | null {
if (!workspacePath) return null;
if (isGitRepo(workspacePath)) return null;
try {
if (!statSync(workspacePath).isDirectory()) return null;
} catch (error) {
logArtifactFsError(
logger,
error,
"artifact.fs.workspace_stat_failed",
"Failed to stat workspace before artifact baseline capture.",
{ workspacePath },
);
return null;
}
const mtimeByRelPath = new Map<string, number>();
const stack: string[] = [workspacePath];
let truncated = false;
while (stack.length > 0) {
if (mtimeByRelPath.size >= MAX_BASELINE_ENTRIES) {
truncated = true;
break;
}
const dir = stack.pop();
if (!dir) break;
let entries: { name: string; isDirectory: () => boolean; isSymbolicLink: () => boolean }[];
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch (error) {
logArtifactFsError(
logger,
error,
"artifact.fs.baseline_readdir_failed",
"Failed to read workspace directory during artifact baseline capture.",
{ workspacePath, dir },
);
continue;
}
for (const entry of entries) {
const abs = resolve(dir, entry.name);
if (entry.isSymbolicLink()) continue;
if (entry.isDirectory()) {
if (BASELINE_SKIP_DIR_NAMES.has(entry.name)) continue;
stack.push(abs);
continue;
}
try {
const stat = statSync(abs);
if (!stat.isFile()) continue;
const rel = relative(workspacePath, abs);
if (rel.length === 0) continue;
mtimeByRelPath.set(rel, stat.mtimeMs);
} catch (error) {
logArtifactFsError(
logger,
error,
"artifact.fs.baseline_stat_failed",
"Failed to stat workspace file during artifact baseline capture.",
{ workspacePath, absPath: abs },
);
continue;
}
if (mtimeByRelPath.size >= MAX_BASELINE_ENTRIES) {
truncated = true;
break;
}
}
}
return { mtimeByRelPath, capturedAt: Date.now(), truncated };
}
@@ -1,139 +0,0 @@
import { execFileSync } from "node:child_process";
import { readdirSync, statSync } from "node:fs";
import { relative, resolve } from "node:path";
import { BASELINE_SKIP_DIR_NAMES } from "./artifact-baseline";
import { isGitRepo } from "./artifact-git";
import { logArtifactFsError } from "./artifact-logging";
import type { ArtifactProjectorRunContext, ChangeSet, GitChangeSet, WorkspaceBaseline } from "./artifact-types";
export function collectChangedPaths(ctx: ArtifactProjectorRunContext): ChangeSet {
if (!ctx.workspacePath) {
return { source: "none", changed: [], deleted: [], skipReason: "no_workspace" };
}
if (isGitRepo(ctx.workspacePath)) {
try {
const gitSet = listChangedRelPaths(ctx.workspacePath);
return { source: "git", changed: gitSet.changed, deleted: gitSet.deleted };
} catch (error) {
logArtifactFsError(
ctx.logger,
error,
"artifact.fs.git_status_failed",
"Failed to read git status during artifact projection.",
{ workspacePath: ctx.workspacePath, runId: ctx.runId },
);
return { source: "none", changed: [], deleted: [], skipReason: "git_status_failed" };
}
}
if (ctx.workspaceBaseline) {
const diff = diffAgainstBaseline(ctx.workspacePath, ctx.workspaceBaseline, ctx.logger);
return {
source: "baseline",
changed: diff.changed,
deleted: diff.deleted,
coverageTruncated: ctx.workspaceBaseline.truncated,
};
}
return { source: "none", changed: [], deleted: [], skipReason: "no_baseline" };
}
function diffAgainstBaseline(
workspacePath: string,
baseline: WorkspaceBaseline,
logger: ArtifactProjectorRunContext["logger"],
): GitChangeSet {
const changed: string[] = [];
const seenRels = new Set<string>();
const stack: string[] = [workspacePath];
while (stack.length > 0) {
const dir = stack.pop();
if (!dir) break;
let entries: { name: string; isDirectory: () => boolean; isSymbolicLink: () => boolean }[];
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch (error) {
logArtifactFsError(
logger,
error,
"artifact.fs.diff_readdir_failed",
"Failed to read workspace directory during artifact diff.",
{ workspacePath, dir },
);
continue;
}
for (const entry of entries) {
if (entry.isSymbolicLink()) continue;
const abs = resolve(dir, entry.name);
if (entry.isDirectory()) {
if (BASELINE_SKIP_DIR_NAMES.has(entry.name)) continue;
stack.push(abs);
continue;
}
let mtimeMs: number;
try {
const stat = statSync(abs);
if (!stat.isFile()) continue;
mtimeMs = stat.mtimeMs;
} catch (error) {
logArtifactFsError(
logger,
error,
"artifact.fs.diff_stat_failed",
"Failed to stat workspace file during artifact diff.",
{ workspacePath, absPath: abs },
);
continue;
}
const rel = relative(workspacePath, abs);
if (rel.length === 0) continue;
seenRels.add(rel);
const previous = baseline.mtimeByRelPath.get(rel);
if (previous === undefined) {
if (!baseline.truncated) changed.push(rel);
continue;
}
if (mtimeMs > previous) changed.push(rel);
}
}
const deleted: string[] = [];
for (const rel of baseline.mtimeByRelPath.keys()) {
if (!seenRels.has(rel)) deleted.push(rel);
}
return { changed, deleted };
}
function listChangedRelPaths(workspacePath: string): GitChangeSet {
const raw = execFileSync("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], {
cwd: workspacePath,
timeout: 5_000,
encoding: "utf8",
maxBuffer: 1024 * 1024,
});
if (!raw) return { changed: [], deleted: [] };
const entries = raw.split("\0").filter(Boolean);
const changed: string[] = [];
const deleted: string[] = [];
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (entry.length < 3) continue;
const indexStatus = entry[0];
const worktreeStatus = entry[1];
const rest = entry.slice(3);
if (indexStatus === "!" && worktreeStatus === "!") continue;
const isRename = indexStatus === "R" || worktreeStatus === "R";
if (representsDeletion(indexStatus, worktreeStatus)) {
deleted.push(rest);
if (isRename) i += 1;
continue;
}
changed.push(rest);
if (isRename) i += 1;
}
return { changed, deleted };
}
function representsDeletion(indexStatus: string, worktreeStatus: string): boolean {
if (worktreeStatus === "D") return true;
if (indexStatus === "D" && worktreeStatus === " ") return true;
return false;
}
-14
View File
@@ -1,14 +0,0 @@
import { spawnSync } from "node:child_process";
/**
* Ask git whether `workspacePath` sits inside a working tree. This
* covers a plain clone, linked worktree, and submodule.
*/
export function isGitRepo(workspacePath: string): boolean {
const result = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], {
cwd: workspacePath,
timeout: 2_000,
stdio: ["ignore", "ignore", "ignore"],
});
return result.status === 0;
}
@@ -1,25 +0,0 @@
import { logAppError, type Logger } from "../errors/index.js";
const DEFAULT_ARTIFACT_LOGGER: Logger = console;
export type { Logger };
export function logArtifactFsError(
logger: Logger | undefined,
error: unknown,
code: string,
message: string,
context: Record<string, unknown>,
): void {
logAppError(
logger ?? DEFAULT_ARTIFACT_LOGGER,
error,
{
kind: "recoverable",
code,
message,
context,
},
"warn",
);
}
@@ -1,52 +0,0 @@
import { isAbsolute, relative } from "node:path";
/** Paths we never register because they are tool output or build/cache state. */
const IGNORED_PATH_SEGMENTS: readonly string[] = [
"/.git/",
"/node_modules/",
"/.next/",
"/dist/",
"/build/",
"/out/",
"/.cache/",
"/.pnpm-store/",
"/.DS_Store",
];
const IGNORED_EXACT_NAMES: ReadonlySet<string> = new Set([
".DS_Store",
"package-lock.json",
"pnpm-lock.yaml",
"yarn.lock",
".gitignore",
]);
const IGNORED_EXTENSIONS: ReadonlySet<string> = new Set([
".lock",
".log",
".map",
".min.js",
".min.css",
]);
/** Returns true when the path is inside the workspace and passes the artifact filter. */
export function isRegistrablePath(absPath: string, workspacePath: string): boolean {
const rel = relative(workspacePath, absPath);
if (rel.startsWith("..") || isAbsolute(rel)) return false;
const probePath = `/${rel}/`;
if (IGNORED_PATH_SEGMENTS.some((seg) => probePath.includes(seg))) return false;
const lastSlash = rel.lastIndexOf("/");
const basename = lastSlash >= 0 ? rel.slice(lastSlash + 1) : rel;
if (IGNORED_EXACT_NAMES.has(basename)) return false;
const dot = basename.lastIndexOf(".");
if (dot > 0) {
const ext = basename.slice(dot).toLowerCase();
if (IGNORED_EXTENSIONS.has(ext)) return false;
const prevDot = basename.lastIndexOf(".", dot - 1);
if (prevDot > 0) {
const compound = basename.slice(prevDot).toLowerCase();
if (IGNORED_EXTENSIONS.has(compound)) return false;
}
}
return true;
}
@@ -1,97 +0,0 @@
import type { AppDatabase } from "@main/database";
import type { ProjectionOutcome } from "./artifact-types";
export type ArtifactProjectionPhase = "completion_reconcile";
export interface ArtifactProjectionEventInput {
companyId: string;
runId: string;
agentId: string;
phase: ArtifactProjectionPhase;
workspaceId: string | null;
workspaceMode: "exclusive" | "shared" | "none";
outcomes: ProjectionOutcome[];
}
export interface ArtifactProjectionFailureInput {
companyId: string;
runId: string;
agentId: string;
phase: ArtifactProjectionPhase;
workspaceId: string | null;
workspaceMode: "exclusive" | "shared" | "none";
errorMessage: string;
}
interface ProjectionCounts {
inserted: number;
updated: number;
retired: number;
skipped: number;
}
function countOutcomes(outcomes: ProjectionOutcome[]): ProjectionCounts {
const counts: ProjectionCounts = {
inserted: 0,
updated: 0,
retired: 0,
skipped: 0,
};
for (const outcome of outcomes) {
counts[outcome.kind] += 1;
}
return counts;
}
function summarizeOutcomes(phase: ArtifactProjectionPhase, counts: ProjectionCounts): string {
return [
`Artifact projection ${phase} acknowledged`,
`${counts.inserted} inserted`,
`${counts.updated} updated`,
`${counts.retired} retired`,
`${counts.skipped} skipped`,
].join("; ");
}
export function appendArtifactProjectionEvent(
db: AppDatabase,
input: ArtifactProjectionEventInput,
): ProjectionCounts {
const counts = countOutcomes(input.outcomes);
db.core.runs.appendRunEvent({
companyId: input.companyId,
runId: input.runId,
agentId: input.agentId,
eventType: "artifact_projection",
level: "info",
message: summarizeOutcomes(input.phase, counts),
payload: {
phase: input.phase,
workspaceId: input.workspaceId,
workspaceMode: input.workspaceMode,
counts,
outcomes: input.outcomes,
},
});
return counts;
}
export function appendArtifactProjectionFailureEvent(
db: AppDatabase,
input: ArtifactProjectionFailureInput,
): void {
db.core.runs.appendRunEvent({
companyId: input.companyId,
runId: input.runId,
agentId: input.agentId,
eventType: "artifact_projection",
level: "error",
message: `Artifact projection ${input.phase} failed: ${input.errorMessage}`,
payload: {
phase: input.phase,
workspaceId: input.workspaceId,
workspaceMode: input.workspaceMode,
errorMessage: input.errorMessage,
},
});
}
@@ -1,63 +0,0 @@
import type { AppDatabase } from "@main/database";
import { collectChangedPaths } from "./artifact-change-detector";
import {
projectChangedPaths,
retireDeletedArtifacts,
} from "./artifact-work-products";
import type {
ArtifactProjectorRunContext,
ChangeSet,
ProjectionOutcome,
} from "./artifact-types";
export { captureBaselineIfRequired } from "./artifact-baseline";
export type {
ArtifactProjectorRunContext,
ProjectionOutcome,
WorkspaceBaseline,
} from "./artifact-types";
/**
* End-of-run filesystem artifact protocol.
*
* Completion reconciliation projects changed and deleted workspace
* paths into `work_products`, so the desktop UI can surface what the
* agent produced without relying on the agent to POST documents.
*
* Projection is idempotent through the natural identity
* `(companyId, workspaceId, provider="filesystem", externalId=<workspace-relative-path>)`.
* Git workspaces use `git status`; non-git workspaces use a run-start
* mtime baseline captured by `captureBaselineIfRequired`.
*/
/**
* End-of-run reconciliation sweep. This is the single artifact
* projection boundary for files produced or removed during a run.
*/
export function reconcileWorkspaceArtifacts(
db: AppDatabase,
ctx: ArtifactProjectorRunContext,
): ProjectionOutcome[] {
return projectCollectedChanges(db, ctx, collectChangedPaths(ctx));
}
function projectCollectedChanges(
db: AppDatabase,
ctx: ArtifactProjectorRunContext,
change: ChangeSet,
): ProjectionOutcome[] {
if (change.source === "none") {
return [{ kind: "skipped", reason: change.skipReason ?? "no_changes" }];
}
const outcomes: ProjectionOutcome[] = [];
if (change.changed.length === 0 && change.deleted.length === 0) {
outcomes.push({ kind: "skipped", reason: "no_changes" });
} else {
outcomes.push(...projectChangedPaths(db, ctx, change.changed));
outcomes.push(...retireDeletedArtifacts(db, ctx, change.deleted));
}
if (change.coverageTruncated) {
outcomes.push({ kind: "skipped", reason: "baseline_truncated" });
}
return outcomes;
}
-55
View File
@@ -1,55 +0,0 @@
import type { Logger } from "./artifact-logging";
export type ArtifactProjectorRunContext = {
runId: string;
agentId: string;
companyId: string;
taskId: string | null;
workspaceId: string | null;
workspacePath: string | null;
workspaceMode: "exclusive" | "shared" | "none";
logger?: Logger;
/**
* mtime snapshot captured at run start for workspaces that are NOT
* a git repo. Diff-against-baseline is how completion reconciliation
* finds files the agent wrote without a `.git` to fall back on.
*/
workspaceBaseline: WorkspaceBaseline | null;
};
export interface WorkspaceBaseline {
/** Relative path -> mtimeMs at capture. */
mtimeByRelPath: Map<string, number>;
/** Unix ms at which the baseline was taken. */
capturedAt: number;
/** True when the walker stopped early because the cap was hit; the baseline is still usable for captured paths. */
truncated: boolean;
}
export type ProjectionOutcome =
| { kind: "skipped"; reason: string }
| { kind: "inserted"; workProductId: string; relativePath: string }
| { kind: "updated"; workProductId: string; relativePath: string }
| { kind: "retired"; workProductId: string; relativePath: string };
export interface GitChangeSet {
changed: string[];
deleted: string[];
}
export type ChangeSource = "git" | "baseline" | "none";
export interface ChangeSet {
source: ChangeSource;
/** Paths, relative to the workspace root, that currently exist on disk with a new or advanced mtime. */
changed: string[];
/** Paths, relative to the workspace root, that the detector reports as removed during the run. */
deleted: string[];
/** Reason the source chose to skip, meaningful when source is "none". */
skipReason?: string;
/**
* True when the baseline walker stopped before sampling the whole
* tree. Callers surface it as a `baseline_truncated` skip outcome.
*/
coverageTruncated?: boolean;
}
@@ -1,113 +0,0 @@
import { statSync } from "node:fs";
import { relative, resolve } from "node:path";
import type { AppDatabase } from "@main/database";
import { logArtifactFsError } from "./artifact-logging";
import { isRegistrablePath } from "./artifact-path-filter";
import type { ArtifactProjectorRunContext, ProjectionOutcome } from "./artifact-types";
export function projectChangedPaths(
db: AppDatabase,
ctx: ArtifactProjectorRunContext,
changed: string[],
): ProjectionOutcome[] {
const workspacePath = ctx.workspacePath;
if (!workspacePath) return [{ kind: "skipped", reason: "no_workspace" }];
const outcomes: ProjectionOutcome[] = [];
for (const rel of changed) {
const absPath = resolve(workspacePath, rel);
if (!isRegistrablePath(absPath, workspacePath)) {
outcomes.push({ kind: "skipped", reason: "path_ignored" });
continue;
}
outcomes.push(upsertFilesystemArtifact(db, ctx, workspacePath, absPath));
}
return outcomes;
}
export function retireDeletedArtifacts(
db: AppDatabase,
ctx: ArtifactProjectorRunContext,
deleted: string[],
): ProjectionOutcome[] {
if (deleted.length === 0) return [];
const resolvedWorkspaceId =
ctx.workspaceId ??
(ctx.workspacePath
? db.core.workspaces.findWorkspaceIdByLocalPath(ctx.companyId, ctx.workspacePath)
: null);
const archived = db.core.documents.archiveWorkProductsByExternalPaths({
companyId: ctx.companyId,
workspaceId: resolvedWorkspaceId,
provider: "filesystem",
paths: deleted,
lastTouchedByRunId: ctx.runId,
});
return archived.map(({ id, externalId }) => ({
kind: "retired",
workProductId: id,
relativePath: externalId,
}));
}
function upsertFilesystemArtifact(
db: AppDatabase,
ctx: ArtifactProjectorRunContext,
workspacePath: string,
absPath: string,
): ProjectionOutcome {
const relativePath = relative(workspacePath, absPath);
let stat: ReturnType<typeof statSync>;
try {
stat = statSync(absPath);
} catch (error) {
logArtifactFsError(
ctx.logger,
error,
"artifact.fs.path_stat_failed",
"Failed to stat filesystem artifact before projection.",
{
workspacePath,
absPath,
runId: ctx.runId,
},
);
return { kind: "skipped", reason: "path_missing" };
}
if (!stat.isFile()) {
return { kind: "skipped", reason: "not_a_regular_file" };
}
const lastSlash = relativePath.lastIndexOf("/");
const title = lastSlash >= 0 ? relativePath.slice(lastSlash + 1) : relativePath;
const provider = "filesystem";
const resolvedWorkspaceId =
ctx.workspaceId ?? db.core.workspaces.findWorkspaceIdByLocalPath(ctx.companyId, workspacePath);
const existing = db.core.documents.findWorkProductByExternalId(
ctx.companyId,
provider,
relativePath,
resolvedWorkspaceId,
);
if (existing) {
const patch: Parameters<typeof db.core.documents.updateWorkProduct>[2] = {
title,
status: "active",
lastTouchedByRunId: ctx.runId,
};
if (resolvedWorkspaceId) patch.workspaceId = resolvedWorkspaceId;
db.core.documents.updateWorkProduct(existing.id, ctx.companyId, patch);
return { kind: "updated", workProductId: existing.id, relativePath };
}
const workProductId = db.core.documents.createWorkProduct({
companyId: ctx.companyId,
taskId: ctx.taskId,
workspaceId: resolvedWorkspaceId,
type: "file",
provider,
externalId: relativePath,
title,
url: null,
createdByRunId: ctx.runId,
});
return { kind: "inserted", workProductId, relativePath };
}
-328
View File
@@ -1,328 +0,0 @@
/**
* Deterministic pi context compactor.
*
* pi's `transformContext` hook runs before each LLM call and returns
* the message list that will actually be sent. A long-running agent
* can accumulate hundreds of messages — if we forward every one on
* every call we pay per-token for the whole history, and large models
* throttle when the context approaches their window.
*
* This compactor keeps the hook pure and provider-free: no LLM
* round-trip, no mutation, no IO. It counts transcript text with a
* local tiktoken-compatible encoder, keeps recent traffic verbatim, and
* collapses older messages into a single "context summary" user turn
* that preserves the assistant's narrative plus tool-call headlines.
*
* Triggering only when the estimate clears the threshold means short
* runs pay nothing; long runs see roughly constant per-call cost once
* they cross the threshold.
*/
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { getEncoding } from "js-tiktoken";
/** Token-budget threshold that triggers compaction. */
const COMPACTION_TRIGGER_TOKENS = 120_000;
/**
* Number of recent messages preserved verbatim. The tail must include
* the current turn plus enough context for the agent to answer without
* losing mid-stream state (tool-call pairs, user intent).
*/
const RECENT_MESSAGE_TAIL = 20;
const CONTEXT_ENCODING = getEncoding("cl100k_base");
const CHAT_MESSAGE_OVERHEAD_TOKENS = 4;
/** Per-assistant-message summary cap to keep the compacted turn readable. */
const MAX_SUMMARY_CHARS_PER_MESSAGE = 160;
const MAX_TOOL_RESULT_LINES = 12;
const MAX_TOOL_RESULT_FACTS = 10;
const MAX_FACT_VALUE_CHARS = 120;
interface MessageLike {
role?: string;
content?: unknown;
timestamp?: number;
toolCallId?: string;
details?: unknown;
}
export interface CompactionOutcome {
changed: boolean;
messages: AgentMessage[] | null;
telemetry: CompactionTelemetry | null;
}
export interface CompactionTelemetry {
estimatedTokens: number;
originalMessageCount: number;
compactedMessageCount: number;
compactedOlderMessageCount: number;
retainedRecentMessageCount: number;
}
/**
* Returns a compacted message list when the incoming transcript
* exceeds the budget, or `{ changed: false }` when no compaction is
* needed. The transformContext hook forwards the result verbatim.
*
* Cutoff must land on a user-turn boundary so we never orphan a
* `toolResult` from its originating assistant `toolCall`. pi-ai's
* message shape pairs assistant-with-toolCall blocks against standalone
* `{ role: "toolResult", toolCallId }` messages — cutting mid-pair
* would leave the provider with a dangling id and crash the next call.
* When no safe boundary exists within reach, we decline to compact and
* let the upstream token budget handle it.
*/
export function compactMessagesIfNeeded(messages: AgentMessage[]): CompactionOutcome {
const estimatedTokens = estimateTokens(messages);
if (estimatedTokens < COMPACTION_TRIGGER_TOKENS) {
return { changed: false, messages: null, telemetry: null };
}
if (messages.length <= RECENT_MESSAGE_TAIL + 1) {
return { changed: false, messages: null, telemetry: null };
}
const desiredCutoff = messages.length - RECENT_MESSAGE_TAIL;
const cutoff = findSafeCutoff(messages, desiredCutoff);
if (cutoff === null) {
return { changed: false, messages: null, telemetry: null };
}
const older = messages.slice(0, cutoff);
const recent = messages.slice(cutoff);
const summaryText = buildSummaryText(older);
const summary: AgentMessage = {
role: "user",
content: [{ type: "text", text: summaryText }],
timestamp: Date.now(),
} as AgentMessage;
const compactedMessages = [summary, ...recent];
return {
changed: true,
messages: compactedMessages,
telemetry: {
estimatedTokens,
originalMessageCount: messages.length,
compactedMessageCount: compactedMessages.length,
compactedOlderMessageCount: older.length,
retainedRecentMessageCount: recent.length,
},
};
}
/**
* Walk back from `desiredCutoff` until we find an index where the
* message at that position starts a clean user turn. A user turn is
* identifiable by `role === "user"`; anything else (`assistant`,
* `toolResult`, or custom app message) can reference or be referenced
* by neighbouring messages and must stay grouped.
*
* Returns `null` when no safe boundary exists in [1, desiredCutoff] —
* e.g. an entirely tool-chained transcript. Returning null signals "do
* not compact"; callers must pass the transcript through unchanged.
*/
function findSafeCutoff(messages: AgentMessage[], desiredCutoff: number): number | null {
for (let i = desiredCutoff; i >= 1; i--) {
const raw = messages[i] as MessageLike | undefined;
if (raw && raw.role === "user") {
return i;
}
}
return null;
}
function estimateTokens(messages: AgentMessage[]): number {
let total = 0;
for (const message of messages) {
total += estimateMessageTokens(message);
}
return total;
}
function estimateMessageTokens(message: AgentMessage): number {
const raw = extractTextForEstimate(message as MessageLike);
if (raw.length === 0) return CHAT_MESSAGE_OVERHEAD_TOKENS;
return CONTEXT_ENCODING.encode(raw).length + CHAT_MESSAGE_OVERHEAD_TOKENS;
}
function extractTextForEstimate(message: MessageLike): string {
const content = message.content;
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
const parts: string[] = [];
for (const block of content) {
if (!block || typeof block !== "object") continue;
const obj = block as { type?: string; text?: string; thinking?: string; name?: string };
if (typeof obj.text === "string") parts.push(obj.text);
else if (typeof obj.thinking === "string") parts.push(obj.thinking);
else if (obj.type === "toolCall" && typeof obj.name === "string") parts.push(`tool:${obj.name}`);
}
return parts.join(" ");
}
function buildSummaryText(older: AgentMessage[]): string {
const lines: string[] = [
"[Context summary — older turns compacted to stay within the token budget]",
"",
];
const firstUserText = findFirstUserPrompt(older);
if (firstUserText) {
lines.push(`Original task: ${truncate(firstUserText, 200)}`);
lines.push("");
}
const assistantPoints = collectAssistantHighlights(older);
if (assistantPoints.length > 0) {
lines.push("Key turns:");
for (const point of assistantPoints) {
lines.push(`- ${point}`);
}
}
const toolResultFacts = collectToolResultFacts(older);
if (toolResultFacts.length > 0) {
lines.push("");
lines.push("Tool results:");
for (const fact of toolResultFacts) {
lines.push(`- ${fact}`);
}
}
lines.push("");
lines.push(`(${older.length} earlier message${older.length === 1 ? "" : "s"} compacted.)`);
return lines.join("\n");
}
function findFirstUserPrompt(messages: AgentMessage[]): string | null {
for (const message of messages) {
const raw = message as MessageLike;
if (raw.role !== "user") continue;
const text = extractTextForEstimate(raw).trim();
if (text.length > 0) return text;
}
return null;
}
function collectAssistantHighlights(messages: AgentMessage[]): string[] {
const highlights: string[] = [];
for (const message of messages) {
const raw = message as MessageLike;
if (raw.role !== "assistant") continue;
const text = extractTextForEstimate(raw).trim();
if (text.length === 0) continue;
highlights.push(truncate(firstSentence(text), MAX_SUMMARY_CHARS_PER_MESSAGE));
if (highlights.length >= 12) break;
}
return highlights;
}
function collectToolResultFacts(messages: AgentMessage[]): string[] {
const toolNamesById = collectToolNamesById(messages);
const facts: string[] = [];
for (const message of messages) {
const raw = message as MessageLike;
if (raw.role !== "toolResult") continue;
const toolCallId = raw.toolCallId ?? "unknown";
const toolName = toolNamesById.get(toolCallId) ?? "tool";
const resultFacts = collectFactsFromToolResult(raw);
const factText =
resultFacts.length > 0
? resultFacts.slice(0, MAX_TOOL_RESULT_FACTS).join("; ")
: `result=${truncate(firstSentence(extractTextForEstimate(raw)), MAX_FACT_VALUE_CHARS)}`;
facts.push(`${toolName} ${toolCallId}: ${factText}`);
if (facts.length >= MAX_TOOL_RESULT_LINES) break;
}
return facts;
}
function collectToolNamesById(messages: AgentMessage[]): Map<string, string> {
const names = new Map<string, string>();
for (const message of messages) {
const raw = message as MessageLike;
if (raw.role !== "assistant" || !Array.isArray(raw.content)) continue;
for (const block of raw.content) {
if (!block || typeof block !== "object") continue;
const obj = block as { type?: string; id?: string; name?: string };
if (obj.type === "toolCall" && typeof obj.id === "string" && typeof obj.name === "string") {
names.set(obj.id, obj.name);
}
}
}
return names;
}
function collectFactsFromToolResult(message: MessageLike): string[] {
const facts = new Map<string, string>();
if (message.toolCallId) facts.set("toolCallId", message.toolCallId);
collectStructuredFacts(message.details, facts);
const text = extractTextForEstimate(message).trim();
collectStructuredFacts(parseJsonObject(text), facts);
collectTextFacts(text, facts);
return [...facts.entries()].map(([key, value]) => `${key}=${value}`);
}
function parseJsonObject(text: string): unknown {
if (!text.startsWith("{") && !text.startsWith("[")) return null;
try {
return JSON.parse(text) as unknown;
} catch {
return null;
}
}
function collectStructuredFacts(value: unknown, facts: Map<string, string>, path: string[] = []): void {
if (facts.size >= MAX_TOOL_RESULT_FACTS) return;
if (value === null || value === undefined) return;
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
const key = path.at(-1);
if (key && shouldPreserveFactKey(key)) {
facts.set(key, truncate(String(value), MAX_FACT_VALUE_CHARS));
}
return;
}
if (Array.isArray(value)) {
for (const item of value.slice(0, 8)) {
collectStructuredFacts(item, facts, path);
}
return;
}
if (typeof value !== "object") return;
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
collectStructuredFacts(nested, facts, [...path, key]);
}
}
function collectTextFacts(text: string, facts: Map<string, string>): void {
const pattern = /\b(taskId|approvalId|messageId|status|state|decision|error)\s*[:=]\s*("[^"]+"|'[^']+'|[^,;\s}]+)/g;
for (const match of text.matchAll(pattern)) {
const key = match[1];
const rawValue = match[2];
if (!key || !rawValue || facts.has(key)) continue;
facts.set(key, truncate(rawValue.replace(/^["']|["']$/g, ""), MAX_FACT_VALUE_CHARS));
if (facts.size >= MAX_TOOL_RESULT_FACTS) break;
}
}
function shouldPreserveFactKey(key: string): boolean {
const lower = key.toLowerCase();
return (
lower === "id" ||
lower.endsWith("id") ||
lower.endsWith("_id") ||
lower === "status" ||
lower === "state" ||
lower === "error" ||
lower === "decision" ||
lower === "ok"
);
}
function firstSentence(text: string): string {
const collapsed = text.replace(/\s+/g, " ").trim();
const sentenceEnd = collapsed.search(/[.?!](\s|$)/);
if (sentenceEnd > 0) return collapsed.slice(0, sentenceEnd + 1);
return collapsed;
}
function truncate(text: string, maxLen: number): string {
if (text.length <= maxLen) return text;
return text.slice(0, maxLen - 1).trimEnd() + "…";
}
-68
View File
@@ -1,68 +0,0 @@
import { calculateCost, type Usage } from "@mariozechner/pi-ai";
import type { AppDatabase } from "@main/database";
import { getConnectorDefinition } from "@main/connectors";
import { resolveModel, resolveModelCostFallback } from "@main/model-resolver";
export interface CostWriterDeps {
db: AppDatabase;
}
interface ObserveInput {
runId: string;
companyId: string;
agentId: string;
taskId: string | null;
connectorId: string;
modelId: string;
usage: Usage;
}
// Local OpenAI-compatible servers run on the user's own machine, so the
// LLM call has no third-party billing line. Record a zero cost entry
// without invoking pi-ai's calculator (which would require the model's
// baseUrl from the credential — unnecessary work for a flat-zero cost).
const ZERO_COST_PROVIDERS: ReadonlySet<string> = new Set(["ollama", "lm-studio"]);
export class CostWriter {
constructor(private readonly deps: CostWriterDeps) {}
observeAssistantMessage(input: ObserveInput): void {
const definition = getConnectorDefinition(input.connectorId);
if (!definition.provider) {
throw new Error(`[cost-writer] connector ${input.connectorId} has no provider mapping`);
}
if (ZERO_COST_PROVIDERS.has(definition.provider)) {
this.deps.db.core.runs.recordCostEntry({
runId: input.runId,
companyId: input.companyId,
agentId: input.agentId,
taskId: input.taskId,
connectorId: input.connectorId,
amountUsd: 0,
unattributed: false,
});
return;
}
const model = resolveModel(definition.provider, input.modelId);
if (!model) {
throw new Error(`[cost-writer] model ${input.modelId} not found for provider ${definition.provider}`);
}
const patchedModel = resolveModelCostFallback(model, definition.provider);
const cost = calculateCost(patchedModel, input.usage);
if (!Number.isFinite(cost.total)) {
throw new Error(
`[cost-writer] cost calculation returned non-finite total for ${input.connectorId}/${input.modelId}`,
);
}
this.deps.db.core.runs.recordCostEntry({
runId: input.runId,
companyId: input.companyId,
agentId: input.agentId,
taskId: input.taskId,
connectorId: input.connectorId,
amountUsd: cost.total,
unattributed: false,
});
}
}
-117
View File
@@ -1,117 +0,0 @@
/**
* Main-side implementation of pi's tool-execution approval hook.
* Invoked by `RuntimeBridge.handleRpc` when the worker proxies
* `beforeToolCall`.
*
* The handler is deliberately isolated to the operational-approval
* gate: it blocks destructive tools while an approval that gates
* EXECUTION is pending.
*
* Policy lives here; `RuntimeBridge` stays a pure IPC router.
*/
import type { AppDatabase } from "@main/database";
import type {
BeforeToolCallRpcPayload,
BeforeToolCallRpcResult,
} from "./hook-rpc";
/**
* Lifecycle phase of an active pi run as seen by main.
*
* - `preparing`: `prepareRun` RPC has landed; main knows the run is real,
* but the worker has not yet constructed the Agent instance. The
* in-process `activeAgents` map is empty, so any `steer` attempt would
* be silently dropped.
* - `agent_active`: the worker has both constructed the Agent and
* subscribed to its event stream. Steer-run messages are safe to
* dispatch — `agent.steer()` will enqueue onto the live steering queue
* and pi will drain it at the next turn boundary.
* - `closing`: the Agent finished (or aborted) and the worker has
* unsubscribed and removed the instance from `activeAgents`. The
* `run-finished` child message may still be in flight; treat steer
* messages as rejected so inbox delivery falls back to the next run.
*/
export type ActiveRunLifecycleState = "preparing" | "agent_active" | "closing";
export interface ActiveRunContext {
runId: string;
agentId: string;
companyId: string;
taskId: string | null;
connectorId: string;
modelId: string;
taskSessionId: string;
workspaceId: string | null;
workspacePath: string | null;
workspaceMode: "exclusive" | "shared" | "none";
workspaceBaseline: import("./artifact-projector").WorkspaceBaseline | null;
state: ActiveRunLifecycleState;
}
export interface HookHandlerDeps {
db: AppDatabase;
logger: {
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
}
/**
* Tools that can mutate the workspace or execute host-level effects.
* Every other tool (read/grep/glob) is always permitted — gating them
* would only slow observation.
*/
const DESTRUCTIVE_TOOLS: ReadonlySet<string> = new Set(["bash", "write", "edit"]);
export function isDestructiveTool(toolName: string): boolean {
return DESTRUCTIVE_TOOLS.has(toolName);
}
/**
* Approval gate for destructive tools. Runs at every
* `beforeToolCall`, so approvals created mid-run take effect on the
* next tool call with no cross-process cache to invalidate.
*
* Heartbeat-level wait-state pausing is the responsibility of the
* scheduler (`checkpoint-engine.shouldPauseHeartbeat`); mirroring it
* here would freeze every outbound message and every coordination
* reply while an unrelated thread was still waiting on the user —
* the exact failure mode that made Co-Founder agents silent.
*/
export function handleBeforeToolCall(
deps: HookHandlerDeps,
ctx: ActiveRunContext,
payload: BeforeToolCallRpcPayload,
): BeforeToolCallRpcResult {
if (!isDestructiveTool(payload.toolName)) {
return { block: false, reason: null };
}
return checkOperationalApprovalBlock(deps, ctx, payload.toolName);
}
function checkOperationalApprovalBlock(
deps: HookHandlerDeps,
ctx: ActiveRunContext,
toolName: string,
): BeforeToolCallRpcResult {
try {
const blocking = deps.db.core.approvals.getBlockingApproval(ctx.companyId, ctx.agentId, ctx.taskId);
if (!blocking) return { block: false, reason: null };
return {
block: true,
reason: `Approval ${blocking.id} (${blocking.type}, state=${blocking.state}) is outstanding: ${blocking.payloadSummary}. Wait for the decision before retrying ${toolName}.`,
};
} catch (error) {
// Fail closed: an unreadable approval table is a safety incident,
// not a reason to run destructive code.
deps.logger.error(
`[hook-handlers] approval lookup failed for agent ${ctx.agentId}: ${error instanceof Error ? error.message : String(error)}`,
);
return {
block: true,
reason: `Approval lookup failed — ${toolName} is held until the approval subsystem recovers.`,
};
}
}
-27
View File
@@ -1,27 +0,0 @@
/**
* Hook RPC contracts — the parent↔worker half of pi-agent-core's
* `beforeToolCall` hook.
*
* The Agent instance lives in the utility process. Approval policy
* lives in main. The hook is therefore proxied: the worker invokes an
* RPC that main answers from the same `RuntimeBridge` that already owns
* agent-event and `persistMessage` handling. Context compaction runs in
* the worker process and reports telemetry through a worker message.
*
* Payloads intentionally exclude pi's `AgentContext` (systemPrompt,
* messages, tools) because main policy only needs the tool call. This
* keeps the hook IPC well under the structured-clone budget even for
* large transcripts.
*/
// ─── beforeToolCall ───────────────────────────────────────────────────────
export interface BeforeToolCallRpcPayload {
toolName: string;
toolCallId: string;
args: unknown;
}
export interface BeforeToolCallRpcResult {
block: boolean;
reason: string | null;
}
@@ -1,62 +0,0 @@
import type { AgentMessageRecord } from "@shared/types";
export interface InboundCorrelationEntry {
fromAgentId: string;
senderKind: AgentMessageRecord["senderKind"];
messageId: string;
/**
* Stable pi-message timestamp that the projected user turn carries.
* Reverse-indexed so `message_end` can resolve back to `messageId`.
*/
piTimestamp: number;
}
interface InboundCorrelationRegistry {
byMessageId: Map<string, InboundCorrelationEntry>;
byPiTimestamp: Map<number, string>;
lastAllocatedTimestamp: number;
}
export class InboundCorrelationRegistryStore {
private readonly registries = new Map<string, InboundCorrelationRegistry>();
register(runId: string, record: AgentMessageRecord, baselineTimestamp: number): number {
const registry = this.getOrCreate(runId);
const allocated = Math.max(baselineTimestamp, registry.lastAllocatedTimestamp + 1);
registry.lastAllocatedTimestamp = allocated;
const entry: InboundCorrelationEntry = {
fromAgentId: record.fromAgentId,
senderKind: record.senderKind,
messageId: record.id,
piTimestamp: allocated,
};
registry.byMessageId.set(record.id, entry);
registry.byPiTimestamp.set(allocated, record.id);
return allocated;
}
lookup(runId: string, timestamp: number | undefined): InboundCorrelationEntry | null {
if (typeof timestamp !== "number" || !Number.isFinite(timestamp)) return null;
const registry = this.registries.get(runId);
if (!registry) return null;
const messageId = registry.byPiTimestamp.get(timestamp);
if (!messageId) return null;
return registry.byMessageId.get(messageId) ?? null;
}
deleteRun(runId: string): void {
this.registries.delete(runId);
}
private getOrCreate(runId: string): InboundCorrelationRegistry {
const existing = this.registries.get(runId);
if (existing) return existing;
const registry: InboundCorrelationRegistry = {
byMessageId: new Map(),
byPiTimestamp: new Map(),
lastAllocatedTimestamp: 0,
};
this.registries.set(runId, registry);
return registry;
}
}
-111
View File
@@ -1,111 +0,0 @@
/**
* Inbox projection — converts a stored AgentMessageRecord into a pi
* AgentMessage (user role) so inter-agent communication enters the
* recipient's transcript as natural conversation rather than a prompt
* block re-rendered every turn.
*
* The projection is the single source of truth for how an inbound message
* looks inside a recipient's context: `[Message from {sender}{· channel}]`
* header, optional subject line, then the body. Content is normalized
* (control characters stripped, oversized bodies truncated) via the same
* helpers used by the legacy prompt-builder boundary.
*/
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { AgentMessageRecord } from "@shared/types";
const MAX_MESSAGE_BODY_LENGTH = 4000;
const MAX_SUBJECT_LENGTH = 200;
export interface AgentDirectory {
getAgentName(agentId: string): string;
}
/**
* Strip control characters (except newlines/tabs) and collapse excessive
* whitespace runs. Mirrors `messaging/normalize.ts#cleanText` to keep the
* transcript resilient against garbage from upstream senders.
*/
function cleanText(text: string): string {
// eslint-disable-next-line no-control-regex
return text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "")
.replace(/[ \t]{4,}/g, " ")
.trim();
}
function truncate(text: string, maxLen: number): string {
if (text.length <= maxLen) return text;
return text.slice(0, maxLen - 1) + "…";
}
function formatChannelLabel(record: AgentMessageRecord): string | null {
if (record.channel === "direct") return null;
if (record.channelTargetId) return `${record.channel}:${record.channelTargetId}`;
return record.channel;
}
function resolveSenderLabel(
record: AgentMessageRecord,
directory: AgentDirectory,
): string {
switch (record.senderKind) {
case "human":
// The board-reader virtual account is the only non-agent human sender
// surfaced in practice; render it as "Board" so agents know where
// strategic directives originate.
if (record.fromAgentId === "__board__") return "Board";
return "User";
case "system":
return "System";
case "agent":
default:
return directory.getAgentName(record.fromAgentId);
}
}
/**
* Projects a stored `AgentMessageRecord` into a pi `AgentMessage`
* (role: "user") suitable for appending to the recipient's session
* transcript.
*
* `timestampOverride`, when supplied, replaces the wall-clock timestamp
* derived from `record.createdAt`. Callers use this to allocate a
* monotonically unique value per run — two inbound messages landing in
* the same millisecond would otherwise share the same key, and the
* `RuntimeBridge` correlation index would lose one author's identity
* on the `message_end` callback. The actual value still lives in pi's
* `timestamp` number so ordering semantics remain identical.
*/
export function projectInboxMessageToPi(
record: AgentMessageRecord,
directory: AgentDirectory,
options?: { timestampOverride?: number },
): AgentMessage {
const senderLabel = resolveSenderLabel(record, directory);
const channelLabel = formatChannelLabel(record);
const replyContext = record.channel === "direct"
? `reply-to: channel=direct, toAgentId=${record.fromAgentId}`
: `reply-to: channel=${record.channel}, channelTargetId=${record.channelTargetId ?? ""}`;
const header = channelLabel
? `[Message from ${senderLabel} · ${channelLabel} | ${replyContext}]`
: `[Message from ${senderLabel} | ${replyContext}]`;
const subject = record.subject ? truncate(cleanText(record.subject), MAX_SUBJECT_LENGTH) : "";
const body = truncate(cleanText(record.body), MAX_MESSAGE_BODY_LENGTH);
const parts: string[] = [header];
if (subject.length > 0) parts.push(subject);
if (body.length > 0) parts.push(body);
const text = parts.join("\n\n");
let timestamp = options?.timestampOverride;
if (typeof timestamp !== "number" || !Number.isFinite(timestamp)) {
const parsed = Date.parse(record.createdAt);
timestamp = Number.isFinite(parsed) ? parsed : Date.now();
}
return {
role: "user",
content: [{ type: "text", text }],
timestamp,
} as AgentMessage;
}
-4
View File
@@ -1,4 +0,0 @@
export { SqliteSessionStore } from "./sqlite-session-store";
export { CostWriter } from "./cost-writer";
export { RuntimeBridge } from "./runtime-bridge";
export { RuntimeMessageInjector } from "./runtime-message-injector";
@@ -1,190 +0,0 @@
/**
* Main-side handler for the `file_approval` internal tool (C4 part 2/5).
*
* Mirrors the control flow of `POST /api/companies/:id/approvals` in
* `api-server/routes-approvals.ts`: validate → enforce the
* `approve_ceo_strategy` ordering gate → request approval → auto-
* approve or notify. Both the tool path and the HTTP path funnel
* through the same `db.core.approvals.requestApproval` + `shouldAutoApprove`
* plumbing, so the board sees identical rows regardless of entry point.
*
* No fallbacks: malformed input throws UserInputError, DB failures
* throw UpstreamError. The worker tool wrapper surfaces the classified
* message as a pi tool-error so the model can correct its call.
*/
import type { AppDatabase } from "@main/database";
import { approvalInputSchema, mergeApprovalAttachments } from "@shared/contracts";
import { resolveApprovalDecision } from "@main/approval-policy";
import { shouldAutoApprove } from "@main/execution-policy";
import { UserInputError, UpstreamError, classifyUnknownError } from "@main/errors";
import type { InvokeInternalToolRpcResult } from "@main/worker/runtime/internal-tools/internal-tool-rpc";
export interface FileApprovalHandlerDeps {
db: AppDatabase;
onApprovalCreated: (approvalId: string, companyId: string, taskId: string | null, approvalType: string, summary: string) => void;
onApprovalResolved: (approvalId: string, companyId: string, decision: string) => void;
onDomainChanged: () => void;
logger: {
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
}
export interface FileApprovalHandlerContext {
companyId: string;
agentId: string;
}
export function handleFileApprovalToolCall(
deps: FileApprovalHandlerDeps,
ctx: FileApprovalHandlerContext,
rawArgs: unknown,
): InvokeInternalToolRpcResult {
const { db } = deps;
const { companyId, agentId: requesterId } = ctx;
const argObject = typeof rawArgs === "object" && rawArgs !== null
? (rawArgs as Record<string, unknown>)
: {};
const parsed = approvalInputSchema.safeParse({
companyId,
...argObject,
requestedByAgentId: requesterId,
});
if (!parsed.success) {
throw new UserInputError({
code: "file_approval.input.invalid",
message: `Invalid file_approval payload: ${parsed.error.issues
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join("; ")}`,
context: { companyId, requesterId },
});
}
const input = parsed.data;
// Mirror the HTTP route's `approve_ceo_strategy` ordering gate: the
// strategy approval cannot exist before any hire_agent approvals do,
// and cannot be re-filed once it exists. The tool path would
// otherwise re-open the exact loop the HTTP gate was written to
// close.
if (input.type === "approve_ceo_strategy") {
const companyHireApprovals = db.core.approvals.listCompanyApprovalsByType(companyId, "hire_agent");
if (companyHireApprovals.length === 0) {
throw new UserInputError({
code: "file_approval.strategy.before_hires",
message:
"approve_ceo_strategy requires at least one hire_agent approval to exist first. File the intended hires, then the strategy.",
});
}
const existingStrategy = db.core.approvals
.listCompanyApprovalsByType(companyId, "approve_ceo_strategy")
.find((a) => a.state === "pending" || a.state === "approved");
if (existingStrategy) {
throw new UserInputError({
code: "file_approval.strategy.already_filed",
message: `approve_ceo_strategy already exists for this company (id ${existingStrategy.id}); do not submit another.`,
});
}
}
// Fold schema-level documentIds / workProductIds into payloadJson so the
// desktop inspector has a single source of truth. payloadJson remains the
// escape hatch for type-specific structured fields. Malformed payloadJson
// (non-JSON or non-object) is a producer bug — surface it to the agent as
// UserInputError so the tool call can be corrected, not silently stashed.
let mergedPayloadJson: string | null;
try {
mergedPayloadJson = mergeApprovalAttachments(
input.payloadJson,
input.documentIds,
input.workProductIds,
);
} catch (error) {
throw new UserInputError({
code: "file_approval.payload_json.invalid",
message: error instanceof Error ? error.message : "payloadJson must be a JSON object or null.",
});
}
let approvalId: string;
try {
approvalId = db.core.approvals.requestApproval({
companyId,
type: input.type,
relatedTaskId: input.relatedTaskId ?? null,
requestedByAgentId: requesterId,
relatedAgentId: input.relatedAgentId ?? null,
payloadSummary: input.payloadSummary,
impactSummary: input.impactSummary,
payloadJson: mergedPayloadJson,
});
} catch (error) {
throw classifyUnknownError(error, {
kind: "upstream",
code: "file_approval.create.failed",
message: error instanceof Error ? error.message : "Approval could not be created.",
context: { companyId, requesterId, type: input.type },
});
}
try {
db.core.activity.addActivity({
companyId,
actor: requesterId,
action: "approval.created",
entityType: "approval",
entityId: approvalId,
detail: input.payloadSummary,
});
} catch (error) {
throw new UpstreamError({
code: "file_approval.activity.failed",
message: "Failed to record activity for approval.",
cause: error,
});
}
deps.onApprovalCreated(
approvalId,
companyId,
input.relatedTaskId ?? null,
input.type,
input.payloadSummary ?? "",
);
let autoApproved = false;
if (shouldAutoApprove(db, companyId, input.type)) {
resolveApprovalDecision({
db,
handleApprovalResolved: deps.onApprovalResolved,
companyId,
approvalId,
state: "approved",
decisionNote: "Auto-approved by company policy",
detail: "Auto-approved by company policy",
});
autoApproved = true;
deps.logger.info(
`[file-approval-handler] Auto-approved approval ${approvalId}: ${input.payloadSummary}`,
);
}
deps.onDomainChanged();
const summary = autoApproved
? `Filed ${input.type} approval ${approvalId}. Auto-approved by company policy.`
: `Filed ${input.type} approval ${approvalId}. Pending board decision.`;
return {
content: [{ type: "text", text: summary }],
details: {
approvalId,
autoApproved,
type: input.type,
},
isError: false,
};
}
@@ -1,305 +0,0 @@
/**
* Main-side handler for the `hire_agent` internal tool (C4 part 1/5).
*
* Contract: this handler accepts the exact same field shape the HTTP
* `POST /api/companies/:id/agents` route accepts (see
* `api-server/routes-agents.ts`) and funnels through the same
* `db.createHireRequest` primitive + auto-approval policy so both paths
* produce identical approval rows. The HTTP path is kept live for
* legacy clients (see C4 part 5/5 deprecation marker); this is the
* tool-first path the CEO prompt now advertises.
*
* Error policy:
* - Input validation fails → throw `UserInputError`. The worker's tool
* wrapper converts `{ ok: false, error }` into a pi tool-error
* result, so the CEO sees the validation message on its next turn
* and can correct the call.
* - DB write / approval layer fails → throw `UpstreamError`. Same
* surface behaviour, different classification for telemetry.
*
* No fallbacks, no silent catches: a malformed hire is a bug worth
* surfacing to the model, not worth papering over.
*/
import type { AppDatabase } from "@main/database";
import {
canManageOrganization,
canRequestHire,
getHireNameValidationError,
getHireRoutingValidationError,
resolveHireReportsTo,
} from "@main/agent-permissions";
import { resolveApprovalDecision } from "@main/approval-policy";
import { shouldAutoApprove } from "@main/execution-policy";
import { getConnectorExecutionReadinessIssue } from "@shared/connector-policy";
import { hireRequestSchema } from "@shared/contracts";
import { UserInputError, UpstreamError, classifyUnknownError } from "@main/errors";
import type { InvokeInternalToolRpcResult } from "@main/worker/runtime/internal-tools/internal-tool-rpc";
export interface HireAgentHandlerDeps {
db: AppDatabase;
onApprovalResolved: (approvalId: string, companyId: string, decision: string) => void;
onDomainChanged: () => void;
logger: {
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
}
export interface HireAgentHandlerContext {
companyId: string;
agentId: string;
}
/**
* Handle a `hire_agent` tool invocation from the worker RPC.
*
* Mirrors the control flow of the HTTP handler (validate → resolve
* reportsTo → name/routing checks → connector readiness → dedup →
* create → auto-approve) and returns the `{ agentId, approvalId,
* autoApproved }` shape wrapped as a pi tool-result.
*/
export function handleHireAgentToolCall(
deps: HireAgentHandlerDeps,
ctx: HireAgentHandlerContext,
rawArgs: unknown,
): InvokeInternalToolRpcResult {
const { db } = deps;
const { companyId, agentId: requesterId } = ctx;
if (!canRequestHire(db, companyId, requesterId)) {
throw new UserInputError({
code: "hire_agent.permission.denied",
message: "Current agent is not allowed to request hires.",
context: { companyId, requesterId },
});
}
const argObject = typeof rawArgs === "object" && rawArgs !== null
? (rawArgs as Record<string, unknown>)
: {};
const reportsToProvided = Object.prototype.hasOwnProperty.call(argObject, "reportsTo");
const parsed = hireRequestSchema.safeParse({
companyId,
requestedByAgentId: requesterId,
...argObject,
});
if (!parsed.success) {
throw new UserInputError({
code: "hire_agent.input.invalid",
message: `Invalid hire_agent payload: ${parsed.error.issues
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join("; ")}`,
context: { companyId, requesterId },
});
}
const input = parsed.data;
const resolvedReportsTo = resolveHireReportsTo(
db,
companyId,
requesterId,
reportsToProvided ? input.reportsTo : undefined,
input.department ?? null,
);
const nameError = getHireNameValidationError({
name: input.name,
role: input.role,
title: input.title,
});
if (nameError) {
throw new UserInputError({
code: "hire_agent.name.invalid",
message: nameError,
context: { name: input.name, role: input.role },
});
}
const routingError = getHireRoutingValidationError(
db,
companyId,
requesterId,
resolvedReportsTo,
input.department ?? null,
);
if (routingError) {
throw new UserInputError({
code: "hire_agent.routing.invalid",
message: routingError,
context: { resolvedReportsTo, department: input.department ?? null },
});
}
if (
resolvedReportsTo &&
resolvedReportsTo !== requesterId &&
!canManageOrganization(db, companyId, requesterId) &&
!db.core.agents.getChainOfCommand(resolvedReportsTo).some((agent) => agent.id === requesterId)
) {
throw new UserInputError({
code: "hire_agent.routing.not_in_chain",
message:
"Requested hire must report to you or a manager in your reporting chain.",
context: { resolvedReportsTo },
});
}
const connectorRecord = (() => {
try {
return db.core.connectors.getConnector(input.connectorId);
} catch (error) {
throw new UserInputError({
code: "hire_agent.connector.missing",
message: `Connector ${input.connectorId} does not exist.`,
cause: error,
});
}
})();
const connectorIssue = getConnectorExecutionReadinessIssue(connectorRecord);
if (connectorIssue) {
throw new UserInputError({
code: "hire_agent.connector.not_ready",
message: connectorIssue,
context: { connectorId: input.connectorId },
});
}
const existingAgents = db.core.agents.listExistingAgentsForHireCheck(companyId);
const duplicateByName = existingAgents.find(
(a) => a.name.toLowerCase() === input.name.toLowerCase(),
);
if (duplicateByName) {
throw new UserInputError({
code: "hire_agent.duplicate.existing",
message: `Agent "${input.name}" already exists in this company (id: ${duplicateByName.id}, role: ${duplicateByName.role}). Use the existing agent instead of creating a duplicate.`,
});
}
const pendingHires = db.core.approvals.listPendingHireApprovalPayloads(companyId);
for (const pending of pendingHires) {
try {
const payload = pending.payloadJson ? JSON.parse(pending.payloadJson) as Record<string, unknown> : {};
if (typeof payload.name === "string" && payload.name.toLowerCase() === input.name.toLowerCase()) {
throw new UserInputError({
code: "hire_agent.duplicate.pending",
message: `A hire request for "${input.name}" is already pending approval.`,
});
}
} catch (error) {
if (error instanceof UserInputError) throw error;
deps.logger.warn(
`[hire-agent-handler] invalid pending-hire payloadJson for company ${companyId}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
const managerLabel = resolvedReportsTo
? (() => {
try {
return db.core.entity.getAgent(resolvedReportsTo).name;
} catch {
return resolvedReportsTo;
}
})()
: "the company board";
let result: { agentId: string; approvalId: string };
try {
result = db.createHireRequest({
companyId,
requestedByAgentId: input.requestedByAgentId ?? requesterId,
name: input.name,
role: input.role,
title: input.title,
department: input.department ?? null,
reportsTo: resolvedReportsTo,
connectorId: input.connectorId,
workspaceId: input.workspaceId ?? null,
model: input.model ?? null,
metadataJson: input.metadataJson ?? "{}",
capabilities: input.capabilities,
budgetMonthlyUsd: input.budgetMonthlyUsd,
payloadSummary: `Hire ${input.name} as ${input.role}`,
impactSummary: input.capabilities || `${input.name} will report to ${managerLabel}.`,
payloadJson: JSON.stringify({
name: input.name,
role: input.role,
title: input.title,
requestedByAgentId: input.requestedByAgentId ?? requesterId,
requestedConfigurationSnapshot: {
connectorId: input.connectorId,
workspaceId: input.workspaceId ?? null,
model: input.model ?? null,
metadataJson: input.metadataJson ?? "{}",
capabilities: input.capabilities,
budgetMonthlyUsd: input.budgetMonthlyUsd,
reportsTo: resolvedReportsTo,
department: input.department ?? null,
title: input.title,
},
}),
});
} catch (error) {
throw classifyUnknownError(error, {
kind: "upstream",
code: "hire_agent.create.failed",
message: error instanceof Error ? error.message : "Hire could not be created.",
context: { companyId, requesterId },
});
}
try {
db.core.activity.addActivity({
companyId,
actor: requesterId,
action: "agent.hire_requested",
entityType: "agent",
entityId: result.agentId,
detail: input.name,
});
} catch (error) {
throw new UpstreamError({
code: "hire_agent.activity.failed",
message: "Failed to record activity for hire request.",
cause: error,
});
}
let autoApproved = false;
if (shouldAutoApprove(db, companyId, "hire_agent")) {
resolveApprovalDecision({
db,
handleApprovalResolved: deps.onApprovalResolved,
companyId,
approvalId: result.approvalId,
state: "approved",
decisionNote: "Auto-approved by company policy",
detail: `Auto-approved hire: ${input.name}`,
});
autoApproved = true;
deps.logger.info(
`[hire-agent-handler] Auto-approved hire of ${input.name} via agent tool.`,
);
}
deps.onDomainChanged();
const summary = autoApproved
? `Hired ${input.name} (${input.role}). Approval ${result.approvalId} was auto-approved by company policy; the new agent id is ${result.agentId}.`
: `Filed hire_agent approval ${result.approvalId} for ${input.name} (${input.role}). Pending agent id is ${result.agentId}; awaiting board decision.`;
return {
content: [{ type: "text", text: summary }],
details: {
agentId: result.agentId,
approvalId: result.approvalId,
autoApproved,
name: input.name,
role: input.role,
},
isError: false,
};
}
@@ -1,64 +0,0 @@
/**
* Dispatch router for main-side internal-tool handlers (C4 part 3/5).
*
* The worker RPC method `invokeInternalTool` lands here; this module
* picks the right handler by `toolName` and delegates. Adding a new
* internal tool means: (a) new worker-side tool definition under
* `src/main/worker/runtime/internal-tools/`, (b) new handler under
* `src/main/agent-runtime/internal-tools/`, (c) one new `case` in the
* switch below. No third file to update, no registry lookup — the
* compile-time `never`-exhaustiveness on the default case catches a
* missing branch at build time.
*
* The router never catches: every handler throws a classified
* `AppError` on failure and the caller (`RuntimeBridge`) translates the
* throw into the `{ ok: false, error }` RPC envelope. Silent swallow
* would collapse tool-error plus tool-success onto the same success
* wire shape, which is the exact failure mode the tool-error
* convention exists to prevent.
*/
import { HIRE_AGENT_TOOL_NAME } from "@main/worker/runtime/internal-tools/hire-agent-tool";
import { FILE_APPROVAL_TOOL_NAME } from "@main/worker/runtime/internal-tools/file-approval-tool";
import type {
InvokeInternalToolRpcPayload,
InvokeInternalToolRpcResult,
} from "@main/worker/runtime/internal-tools/internal-tool-rpc";
import {
handleHireAgentToolCall,
type HireAgentHandlerDeps,
} from "./hire-agent-handler";
import {
handleFileApprovalToolCall,
type FileApprovalHandlerDeps,
} from "./file-approval-handler";
import { UserInputError } from "@main/errors";
export interface InternalToolRouterDeps {
hireAgent: HireAgentHandlerDeps;
fileApproval: FileApprovalHandlerDeps;
}
export interface InternalToolRouterContext {
companyId: string;
agentId: string;
}
export function routeInternalToolCall(
deps: InternalToolRouterDeps,
ctx: InternalToolRouterContext,
payload: InvokeInternalToolRpcPayload,
): InvokeInternalToolRpcResult {
switch (payload.toolName) {
case HIRE_AGENT_TOOL_NAME:
return handleHireAgentToolCall(deps.hireAgent, ctx, payload.args);
case FILE_APPROVAL_TOOL_NAME:
return handleFileApprovalToolCall(deps.fileApproval, ctx, payload.args);
default:
throw new UserInputError({
code: "internal_tool.unknown",
message: `Unknown internal tool: ${payload.toolName}`,
context: { toolName: payload.toolName },
});
}
}
@@ -1,79 +0,0 @@
import type { AppDatabase } from "@main/database";
import type { ActiveRunContext } from "./hook-handlers";
import { reconcileWorkspaceArtifacts } from "./artifact-projector";
import {
appendArtifactProjectionEvent,
appendArtifactProjectionFailureEvent,
} from "./artifact-projection-events";
export type RunResourceReleaseResult =
| { ok: true }
| { ok: false; errorMessage: string };
export interface RuntimeArtifactReconcilerDeps {
db: AppDatabase;
logger: {
info: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
}
export function reconcileArtifactsForActiveRun(
deps: RuntimeArtifactReconcilerDeps,
ctx: ActiveRunContext,
): RunResourceReleaseResult {
try {
const outcomes = deps.db.withTransaction(() => {
const projectionOutcomes = reconcileWorkspaceArtifacts(deps.db, {
runId: ctx.runId,
agentId: ctx.agentId,
companyId: ctx.companyId,
taskId: ctx.taskId,
workspaceId: ctx.workspaceId,
workspacePath: ctx.workspacePath,
workspaceMode: ctx.workspaceMode,
logger: deps.logger,
workspaceBaseline: ctx.workspaceBaseline,
});
appendArtifactProjectionEvent(deps.db, {
companyId: ctx.companyId,
runId: ctx.runId,
agentId: ctx.agentId,
phase: "completion_reconcile",
workspaceId: ctx.workspaceId,
workspaceMode: ctx.workspaceMode,
outcomes: projectionOutcomes,
});
return projectionOutcomes;
});
const inserted = outcomes.filter((o) => o.kind === "inserted").length;
const retired = outcomes.filter((o) => o.kind === "retired").length;
if (inserted > 0 || retired > 0) {
deps.logger.info(
`[runtime-bridge] reconciliation for run ${ctx.runId}: ${inserted} registered, ${retired} retired`,
);
}
return { ok: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
try {
appendArtifactProjectionFailureEvent(deps.db, {
companyId: ctx.companyId,
runId: ctx.runId,
agentId: ctx.agentId,
phase: "completion_reconcile",
workspaceId: ctx.workspaceId,
workspaceMode: ctx.workspaceMode,
errorMessage,
});
} catch (ackError) {
deps.logger.error(
`[runtime-bridge] artifact reconciliation failure ack failed for run ${ctx.runId}: ${ackError instanceof Error ? ackError.message : String(ackError)}`,
);
}
deps.logger.error(
`[runtime-bridge] artifact reconciliation failed for run ${ctx.runId}: ${errorMessage}`,
);
return { ok: false, errorMessage };
}
}
-506
View File
@@ -1,506 +0,0 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { AppDatabase } from "@main/database";
import type { AgentMessageRecord, DesktopEvent } from "@shared/types";
import { SqliteSessionStore } from "./sqlite-session-store";
import { CostWriter } from "./cost-writer";
import type { CredentialResolver } from "@main/credentials";
import { getConnectorDefinition } from "@main/connectors";
import { projectInboxMessageToPi } from "./inbox-projection";
import { projectAgentEventToLogChunk } from "./agent-event-projection";
import { hasAttributableAssistantUsage } from "../run-usage-status";
import { captureBaselineIfRequired } from "./artifact-projector";
import {
reconcileArtifactsForActiveRun,
type RunResourceReleaseResult,
} from "./runtime-artifact-reconciler";
import {
handleBeforeToolCall,
type ActiveRunContext,
type ActiveRunLifecycleState,
} from "./hook-handlers";
import type { BeforeToolCallRpcPayload } from "./hook-rpc";
import { routeInternalToolCall } from "./internal-tools/router";
import type { InvokeInternalToolRpcPayload } from "@main/worker/runtime/internal-tools/internal-tool-rpc";
import { InboundCorrelationRegistryStore } from "./inbound-correlation-registry";
import { RuntimeThreadProjector } from "./runtime-thread-projector";
/**
* Side-effect callbacks the internal-tool handlers invoke after a
* successful DB write. These come from the same wire-api-server fabric
* the HTTP routes funnel through, so thread integration / automation /
* renderer domain-change notifications stay identical across both
* entry points. Supplied lazily via `setInternalToolCallbacks` because
* the graph builds `RuntimeBridge` before the orchestration/thread-
* integration layer exists.
*/
export interface InternalToolCallbacks {
onApprovalCreated: (
approvalId: string,
companyId: string,
taskId: string | null,
approvalType: string,
summary: string,
) => void;
onApprovalResolved: (
approvalId: string,
companyId: string,
decision: string,
) => void;
onDomainChanged: () => void;
}
export interface RuntimeBridgeDeps {
db: AppDatabase;
emitEvent: (event: DesktopEvent) => void;
logger: {
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
credentialResolver: CredentialResolver;
}
// Derived from the zod schema in `worker/entry/types.ts` so there is
// exactly one source of truth for the RPC method name list. The C4→C5
// hotfix (9d9f003) showed what happens when this union and the zod
// enum drift — worker emits a new method, handler accepts it, and the
// runtime gate silently rejects the envelope. `z.infer` plus a
// compile-time `satisfies` check below makes that category of drift
// a TypeScript error rather than a production ZodError.
import type { WorkerRpcMethod } from "@main/worker/entry/types";
type RpcMethod = WorkerRpcMethod;
/**
* Single owner of pi runtime side-effects in main.
*
* - Forwards `agent-event` from the worker to the renderer (push channel).
* - Persists each `message_end` message via `SqliteSessionStore`.
* - Calls `CostWriter` for assistant messages with usage.
* - Proxies pi's `beforeToolCall` hook into the approval-gate policy handler.
* - Reconciles filesystem artifacts during run resource release.
*
* Policy itself lives in `hook-handlers.ts`; this class is a pure IPC
* router plus an `ActiveRunContext` cache.
*/
export class RuntimeBridge {
private readonly costWriter: CostWriter;
private readonly threadProjector: RuntimeThreadProjector;
private readonly activeRuns = new Map<string, ActiveRunContext>();
private readonly sessionStores = new Map<string, SqliteSessionStore>();
private internalToolCallbacks: InternalToolCallbacks | null = null;
private readonly inboundCorrelations = new InboundCorrelationRegistryStore();
constructor(private readonly deps: RuntimeBridgeDeps) {
this.costWriter = new CostWriter({ db: deps.db });
this.threadProjector = new RuntimeThreadProjector({
db: deps.db,
logger: deps.logger,
resolveAgentName: (agentId) => this.resolveAgentName(agentId),
lookupInboundCorrelation: (runId, timestamp) =>
this.inboundCorrelations.lookup(runId, timestamp),
});
}
/**
* Late-bind the side-effect callbacks used by the internal-tool
* router. The graph must call this exactly once before any tool
* invocation fires; missing callbacks at RPC time throw rather than
* silently skipping the notification, so a wiring regression surfaces
* on the first tool call instead of as a stale-UI ghost.
*/
setInternalToolCallbacks(callbacks: InternalToolCallbacks): void {
if (this.internalToolCallbacks) {
throw new Error("RuntimeBridge.setInternalToolCallbacks called twice.");
}
this.internalToolCallbacks = callbacks;
}
private registerActiveRun(context: ActiveRunContext): void {
this.activeRuns.set(context.runId, context);
this.getOrCreateStore(context.taskSessionId);
}
private evictActiveRun(runId: string): ActiveRunContext | null {
const ctx = this.activeRuns.get(runId) ?? null;
this.activeRuns.delete(runId);
this.inboundCorrelations.deleteRun(runId);
if (ctx) this.sessionStores.delete(ctx.taskSessionId);
return ctx;
}
private assertRunContextCurrent(
ctx: ActiveRunContext,
operation: string,
): { ok: true } | { ok: false; error: string } {
const runState = this.deps.db.core.runs.getRunAuthState(ctx.runId);
if (!runState) {
this.evictActiveRun(ctx.runId);
const error = `${operation}: stale run ${ctx.runId} no longer exists.`;
this.deps.logger.warn(`[runtime-bridge] ${error}`);
return { ok: false, error };
}
if (runState.companyId !== ctx.companyId || runState.agentId !== ctx.agentId) {
this.evictActiveRun(ctx.runId);
const error = `${operation}: stale run ${ctx.runId} context does not match the persisted run.`;
this.deps.logger.warn(`[runtime-bridge] ${error}`);
return { ok: false, error };
}
return { ok: true };
}
/**
* Evicts the per-run `activeRuns` context, the per-session store cache,
* and the inbound-correlation log. Callers must invoke after a
* `run-finished` worker message so the maps stay bounded by concurrent-
* run count rather than process lifetime.
*
* Before eviction, sweeps the workspace to register filesystem
* artifacts produced during the run. This completion boundary keeps
* the DocumentCenter consistent with disk through one durable
* reconciliation acknowledgement.
*/
releaseRunResources(runId: string): RunResourceReleaseResult {
const ctx = this.activeRuns.get(runId);
let result: RunResourceReleaseResult = { ok: true };
try {
if (ctx) {
const current = this.assertRunContextCurrent(ctx, "releaseRunResources");
if (!current.ok) return { ok: true };
result = this.reconcileArtifactsForRun(ctx);
}
return result;
} finally {
this.evictActiveRun(runId);
}
}
private reconcileArtifactsForRun(ctx: ActiveRunContext): RunResourceReleaseResult {
return reconcileArtifactsForActiveRun(this.deps, ctx);
}
/**
* Build the pi projection for an inbound message and register the
* correlation under `record.id`. Returns the projected user turn so
* callers (the prepareRun inbox loop, the runtime message injector)
* append / steer the same object that the bridge has indexed.
*
* A monotonically increasing `piTimestamp` is stamped onto the
* returned message so two inbound rows that share the same
* wall-clock millisecond still resolve to distinct correlation
* entries when `message_end` fires.
*/
projectInboundMessage(runId: string, record: AgentMessageRecord): AgentMessage {
const base = Date.parse(record.createdAt);
const baseline = Number.isFinite(base) ? base : Date.now();
const allocated = this.inboundCorrelations.register(runId, record, baseline);
return projectInboxMessageToPi(record, {
getAgentName: (id) => this.resolveAgentName(id),
}, { timestampOverride: allocated });
}
/** Returns the active run context for a given agent, if any. Used by
* main-side services (inbox dispatcher) to decide whether a new inbox
* row should route to `agent.steer()` instead of waiting for the next
* `prepareRun` cycle. When `requireSteerReady` is set, only runs whose
* worker-side Agent is actually subscribed (`agent_active`) are
* returned — this avoids steering into `preparing` or `closing` runs
* where the steer would be silently dropped. */
findActiveRunForAgent(
agentId: string,
options?: { requireSteerReady?: boolean },
): ActiveRunContext | null {
for (const ctx of this.activeRuns.values()) {
if (ctx.agentId !== agentId) continue;
if (options?.requireSteerReady && ctx.state !== "agent_active") continue;
return ctx;
}
return null;
}
/**
* Transition an active run between lifecycle phases as the worker
* publishes `run-agent-state`. Unknown run ids are ignored: the worker
* may publish state for a run that main has already evicted when a
* `run-finished` races ahead of the trailing state message.
*/
setRunAgentState(runId: string, state: ActiveRunLifecycleState): void {
const ctx = this.activeRuns.get(runId);
if (!ctx) return;
ctx.state = state;
}
private getOrCreateStore(taskSessionId: string): SqliteSessionStore {
const existing = this.sessionStores.get(taskSessionId);
if (existing) return existing;
const store = new SqliteSessionStore(this.deps.db, taskSessionId);
this.sessionStores.set(taskSessionId, store);
return store;
}
/**
* Resolve a display name for an agent id used in inbox header lines.
* Falls back to the id when the lookup fails so the transcript remains
* unambiguous instead of silently rendering "undefined".
*/
private resolveAgentName(agentId: string): string {
try {
const name = this.deps.db.core.agents.findAgentName(agentId);
if (name && name.length > 0) return name;
} catch (error) {
this.deps.logger.warn(
`[runtime-bridge] agent name lookup failed for ${agentId}: ${error instanceof Error ? error.message : String(error)}`,
);
}
return agentId;
}
/** Receives an `agent-event` envelope from the worker; emits to renderer. */
handleAgentEvent(envelope: {
runId: string;
agentId: string;
companyId: string;
taskId: string | null;
emittedAt: string;
event: { type: string } & Record<string, unknown>;
}): void {
this.deps.emitEvent({
type: "agent-event",
runId: envelope.runId,
agentId: envelope.agentId,
companyId: envelope.companyId,
taskId: envelope.taskId,
emittedAt: envelope.emittedAt,
event: envelope.event,
});
const logChunk = projectAgentEventToLogChunk(envelope.event);
if (logChunk) {
this.deps.emitEvent({
type: "run-log",
runId: envelope.runId,
stream: logChunk.stream,
chunk: logChunk.chunk,
});
}
if (envelope.event.type === "message_end") {
const ctx = this.activeRuns.get(envelope.runId);
if (ctx) {
const current = this.assertRunContextCurrent(ctx, "agent-event");
if (!current.ok) return;
}
const messageRaw = envelope.event.message as
| {
role?: string;
stopReason?: string;
usage?: { input?: unknown; output?: unknown };
content?: unknown;
toolCallId?: string;
timestamp?: number;
}
| undefined;
if (ctx && hasAttributableAssistantUsage(messageRaw)) {
try {
this.costWriter.observeAssistantMessage({
runId: ctx.runId,
companyId: ctx.companyId,
agentId: ctx.agentId,
taskId: ctx.taskId,
connectorId: ctx.connectorId,
modelId: ctx.modelId,
usage: messageRaw.usage as never,
});
} catch (error) {
this.deps.logger.error(
`[runtime-bridge] cost recording failed for run ${ctx.runId}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
this.threadProjector.recordThreadEvent(envelope, messageRaw);
}
}
/** Handles RPC requests issued by the worker (`rpc-request` envelope). */
async handleRpc(request: {
rpcId: string;
method: RpcMethod;
runId: string;
payload: unknown;
}): Promise<{ ok: true; result: unknown } | { ok: false; error: string }> {
try {
switch (request.method) {
case "persistMessage": {
const payload = request.payload as { taskSessionId: string; message: AgentMessage };
this.getOrCreateStore(payload.taskSessionId).appendMessage(payload.message);
return { ok: true, result: undefined };
}
case "prepareRun":
return await this.handlePrepareRunRpc(request.runId, request.payload);
case "beforeToolCall":
return this.handleBeforeToolCallRpc(request.runId, request.payload as BeforeToolCallRpcPayload);
case "invokeInternalTool":
return this.handleInvokeInternalToolRpc(request.runId, request.payload as InvokeInternalToolRpcPayload);
default: {
// Exhaustiveness guard. If `WorkerRpcMethod` widens in
// `worker/entry/types.ts` without a corresponding case here,
// the assignment to `never` is a TypeScript error — which is
// exactly what we want so the C4→C5 drift category cannot
// silently recur at runtime.
const _exhaustive: never = request.method;
return { ok: false, error: `unknown RPC method: ${String(_exhaustive)}` };
}
}
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
}
}
private handleInvokeInternalToolRpc(
runId: string,
payload: InvokeInternalToolRpcPayload,
): { ok: true; result: unknown } | { ok: false; error: string } {
const ctx = this.activeRuns.get(runId);
if (!ctx) {
return { ok: false, error: `invokeInternalTool: no active run ${runId}` };
}
const current = this.assertRunContextCurrent(ctx, "invokeInternalTool");
if (!current.ok) return { ok: false, error: current.error };
const callbacks = this.internalToolCallbacks;
if (!callbacks) {
return {
ok: false,
error: "invokeInternalTool: internal-tool callbacks not wired — service graph init regression.",
};
}
const result = routeInternalToolCall(
{
hireAgent: {
db: this.deps.db,
onApprovalResolved: callbacks.onApprovalResolved,
onDomainChanged: callbacks.onDomainChanged,
logger: this.deps.logger,
},
fileApproval: {
db: this.deps.db,
onApprovalCreated: callbacks.onApprovalCreated,
onApprovalResolved: callbacks.onApprovalResolved,
onDomainChanged: callbacks.onDomainChanged,
logger: this.deps.logger,
},
},
{ companyId: ctx.companyId, agentId: ctx.agentId },
payload,
);
return { ok: true, result };
}
private async handlePrepareRunRpc(
runId: string,
payloadRaw: unknown,
): Promise<{ ok: true; result: unknown } | { ok: false; error: string }> {
const payload = payloadRaw as {
connectorId: string;
taskSessionId: string;
agentId: string;
companyId: string;
taskId: string | null;
modelId: string;
workspaceId: string | null;
workspacePath: string | null;
workspaceMode: "exclusive" | "shared" | "none";
};
const runState = this.deps.db.core.runs.getRunAuthState(runId);
if (!runState) {
return { ok: false, error: `prepareRun: stale run ${runId} no longer exists.` };
}
if (runState.companyId !== payload.companyId || runState.agentId !== payload.agentId) {
return { ok: false, error: `prepareRun: run ${runId} context does not match the persisted run.` };
}
const baseline = captureBaselineIfRequired(payload.workspacePath, this.deps.logger);
this.registerActiveRun({
runId,
agentId: payload.agentId,
companyId: payload.companyId,
taskId: payload.taskId,
connectorId: payload.connectorId,
modelId: payload.modelId,
taskSessionId: payload.taskSessionId,
workspaceId: payload.workspaceId,
workspacePath: payload.workspacePath,
workspaceMode: payload.workspaceMode,
workspaceBaseline: baseline,
state: "preparing",
});
const definition = getConnectorDefinition(payload.connectorId);
const resolved = await this.deps.credentialResolver.resolve(payload.connectorId);
// Project any pending inbox messages into the session transcript
// before loading priorMessages — the recipient must observe them
// as plain user turns in its context.
//
// Replay safety lives entirely on the `agent_message_projections`
// ledger. "We put the message into the agent's transcript" and
// "the agent has acknowledged the message" are two different
// facts, tracked in two different tables: projections are append-
// only on dispatch, while reads are inserted into
// `agent_message_reads` only when the agent POSTs
// /messages/{id}/read. Conflating the two (as an earlier build
// that wrote to `agent_messages.read_at` at projection time did)
// made every projected message immediately invisible to
// `?unreadOnly=true`, so agents posted status comments instead of
// replies. The explicit read endpoint is the sole signal.
const store = this.getOrCreateStore(payload.taskSessionId);
const inboxRecords = this.deps.db.core.messages.listAgentInboxUnread(payload.companyId, payload.agentId);
if (inboxRecords.length > 0) {
this.deps.db.withTransaction(() => {
for (const record of inboxRecords) {
const claimed = this.deps.db.projection.inbox.claimProjection(record.id, payload.taskSessionId);
if (!claimed) continue;
// `projectInboundMessage` allocates a monotonic pi-timestamp
// and indexes the correlation under `record.id`. Two rows
// that share the same wall-clock millisecond still resolve
// to distinct authors at `message_end`.
const projected = this.projectInboundMessage(runId, record);
store.appendMessage(projected);
this.threadProjector.recordInboundMessageAsThreadEvent(
{
runId,
agentId: payload.agentId,
companyId: payload.companyId,
taskId: payload.taskId,
},
record,
);
}
});
}
const priorMessages = store.loadMessages();
return {
ok: true,
result: {
provider: definition.provider,
apiKey: resolved.apiKey,
extraEnv: resolved.extraEnv ?? {},
baseUrl: resolved.baseUrl ?? null,
priorMessages,
},
};
}
private handleBeforeToolCallRpc(
runId: string,
payload: BeforeToolCallRpcPayload,
): { ok: true; result: unknown } | { ok: false; error: string } {
const ctx = this.activeRuns.get(runId);
if (!ctx) {
return { ok: false, error: `beforeToolCall: no active run ${runId}` };
}
const current = this.assertRunContextCurrent(ctx, "beforeToolCall");
if (!current.ok) return { ok: false, error: current.error };
const result = handleBeforeToolCall(this.deps, ctx, payload);
return { ok: true, result };
}
}
@@ -1,173 +0,0 @@
/**
* Runtime message injector — the inward half of the Slack-like messaging
* system. When an agent has an active pi run, incoming direct, channel,
* or mention messages must reach the agent during the loop rather than
* on the next heartbeat. Pre-injector, the only delivery path was the
* batched `prepareRun` projection, so any message that arrived while the
* recipient was running sat dark until the next wake.
*
* Delivery is a two-step handshake:
*
* main → worker steer-run { correlationId, runId, message }
* worker → main steer-ack { correlationId, accepted }
*
* The worker's `runner.steer()` is the single authority for whether the
* message reached the live Agent. Main holds the inbox `delivered_at`
* write until the ACK lands: a positive ACK commits the write; a rejected
* or timed-out ACK leaves the row unread and re-triggers the recipient's
* wake, so the next `prepareRun` picks the message up via the regular
* inbox projection. Either way the message is observed exactly once.
*
* The injector short-circuits when the active run is not in
* `agent_active` — `preparing` and `closing` windows cannot accept a
* steer — and the caller falls back to the wake + inbox flow directly.
*/
import { randomUUID } from "node:crypto";
import type { AppDatabase } from "@main/database";
import type { AgentMessageRecord } from "@shared/types";
import type { ParentMessage } from "@main/worker/runtime/helpers";
import type { RuntimeBridge } from "./runtime-bridge";
export interface RuntimeMessageInjectorDeps {
db: AppDatabase;
runtimeBridge: RuntimeBridge;
postToWorker: (message: ParentMessage) => void;
/**
* Lazy wake hook. The injector is instantiated before orchestration so
* the wake function has to be resolved per-call; returning `null` means
* orchestration is not yet online, in which case the caller will have
* handled the wake path already.
*/
getWakeAgent: () => ((agentId: string, companyId: string, trigger: string) => string | null) | null;
logger: {
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
};
steerAckTimeoutMs?: number;
}
interface PendingSteer {
recipientAgentId: string;
companyId: string;
runId: string;
message: AgentMessageRecord;
timer: ReturnType<typeof setTimeout>;
}
const DEFAULT_STEER_ACK_TIMEOUT_MS = 5_000;
export class RuntimeMessageInjector {
private readonly pending = new Map<string, PendingSteer>();
private readonly ackTimeoutMs: number;
constructor(private readonly deps: RuntimeMessageInjectorDeps) {
this.ackTimeoutMs = deps.steerAckTimeoutMs ?? DEFAULT_STEER_ACK_TIMEOUT_MS;
}
/**
* Attempts to deliver a persisted inbox message into the recipient's
* live pi run. Returns true when the steer was dispatched to the worker
* with a pending ACK — callers must skip the synchronous wake fallback
* for this recipient because the injector now owns the message's fate:
* it will either mark the row delivered on a positive ACK or trigger a
* wake on a negative/timed-out ACK.
*
* Returns false when no steer was dispatched (no active run, cross-
* company mismatch, or the run is not yet `agent_active`). Callers then
* fall through to the legacy inbox + wake flow immediately.
*/
tryInject(recipientAgentId: string, companyId: string, message: AgentMessageRecord): boolean {
const ctx = this.deps.runtimeBridge.findActiveRunForAgent(recipientAgentId, {
requireSteerReady: true,
});
if (!ctx) return false;
if (ctx.companyId !== companyId) {
this.deps.logger.warn(
`[injector] refusing cross-company steer: agent ${recipientAgentId} active in ${ctx.companyId}, message from ${companyId}`,
);
return false;
}
// Delegate projection + correlation registration to the bridge so
// the worker steers the exact same pi-message object that the
// bridge has indexed under `record.id`. `projectInboundMessage`
// allocates a monotonic `piTimestamp` which is what `message_end`
// surfaces back on the wire — two inbound rows that share the
// same wall-clock millisecond are no longer indistinguishable.
const projected = this.deps.runtimeBridge.projectInboundMessage(ctx.runId, message);
const correlationId = randomUUID();
try {
this.deps.postToWorker({
type: "steer-run",
runId: ctx.runId,
correlationId,
message: projected,
});
} catch (error) {
this.deps.logger.warn(
`[injector] postToWorker steer-run failed for run ${ctx.runId}: ${error instanceof Error ? error.message : String(error)}`,
);
return false;
}
const timer = setTimeout(() => {
this.resolvePending(correlationId, false, "timeout");
}, this.ackTimeoutMs);
if (typeof timer.unref === "function") timer.unref();
this.pending.set(correlationId, {
recipientAgentId,
companyId,
runId: ctx.runId,
message,
timer,
});
return true;
}
/**
* Routed from `worker-message-handler` on every `steer-ack`. Confirms
* the message reached the live Agent (the bridge already registered
* the inbound correlation when it built the projection). On rejection
* the recipient is woken so the next `prepareRun` picks the message
* up through the regular inbox projection.
*
* The read receipt is intentionally NOT written here: "the worker
* queued this message onto the agent" is not "the agent replied".
* The agent inserts into `agent_message_reads` itself by POSTing
* /messages/{id}/read once it has actually processed the row.
*/
handleSteerAck(correlationId: string, accepted: boolean): void {
this.resolvePending(correlationId, accepted, "worker_ack");
}
private resolvePending(
correlationId: string,
accepted: boolean,
source: "worker_ack" | "timeout",
): void {
const entry = this.pending.get(correlationId);
if (!entry) return;
clearTimeout(entry.timer);
this.pending.delete(correlationId);
if (accepted) return;
this.deps.logger.warn(
`[injector] steer rejected (${source}) for message ${entry.message.id} to agent ${entry.recipientAgentId}; falling back to wake + inbox projection`,
);
const wake = this.deps.getWakeAgent();
if (!wake) return;
try {
wake(entry.recipientAgentId, entry.companyId, "message");
} catch (error) {
this.deps.logger.warn(
`[injector] wake fallback failed for agent ${entry.recipientAgentId}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
@@ -1,172 +0,0 @@
import type { AppDatabase } from "@main/database";
import type { AgentMessageRecord } from "@shared/types";
import {
ensureAgentCommunicationThread,
ensureTaskThread,
} from "@main/thread-integration/thread-helpers";
import { extractMessageText } from "./agent-event-projection";
import type { InboundCorrelationEntry } from "./inbound-correlation-registry";
type RuntimeThreadEnvelope = {
runId: string;
agentId: string;
companyId: string;
taskId: string | null;
};
type RuntimeThreadMessage = {
role?: string;
content?: unknown;
toolCallId?: string;
timestamp?: number;
};
interface RuntimeThreadProjectorDeps {
db: AppDatabase;
logger: {
warn: (...args: unknown[]) => void;
};
resolveAgentName: (agentId: string) => string;
lookupInboundCorrelation: (
runId: string,
timestamp: number | undefined,
) => InboundCorrelationEntry | null;
}
export class RuntimeThreadProjector {
constructor(private readonly deps: RuntimeThreadProjectorDeps) {}
/**
* Project the pi `message_end` event into a `conversation_events` row
* so renderer thread views stay live.
*/
recordThreadEvent(
envelope: RuntimeThreadEnvelope,
message: RuntimeThreadMessage | undefined,
): void {
if (!message?.role) return;
if (message.role !== "assistant" && message.role !== "user") return;
try {
const body = extractMessageText(message);
const actor = this.resolveThreadEventActor(envelope, message);
if (!actor) return;
if (body.length === 0) return;
const thread = this.resolveThreadForRun(envelope);
if (!thread) return;
this.deps.db.core.threads.addThreadEvent({
threadId: thread.id,
actorId: actor.actorId,
actorType: actor.actorType,
eventType: "message",
body: body.length > 4000 ? `${body.slice(0, 3997)}...` : body,
metadataJson: JSON.stringify({
runId: envelope.runId,
role: message.role,
sourceMessageId: actor.sourceMessageId ?? undefined,
}),
});
} catch (error) {
this.deps.logger.warn(
`[runtime-bridge] failed to project agent-event for run ${envelope.runId}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
/**
* Persist an inbound peer message projected as a prior turn during
* `prepareRun`; priorMessages do not emit `message_end`.
*/
recordInboundMessageAsThreadEvent(
envelope: RuntimeThreadEnvelope,
record: AgentMessageRecord,
): void {
const thread = this.resolveThreadForRun(envelope);
if (!thread) return;
try {
const body = record.body.length > 4000 ? `${record.body.slice(0, 3997)}...` : record.body;
const actorType: "agent" | "user" | "system" =
record.senderKind === "human"
? "user"
: record.senderKind === "system"
? "system"
: "agent";
this.deps.db.core.threads.addThreadEvent({
threadId: thread.id,
actorId: record.fromAgentId,
actorType,
eventType: "message",
body,
metadataJson: JSON.stringify({
runId: envelope.runId,
role: "user",
sourceMessageId: record.id,
projection: "prepare_run",
}),
});
} catch (error) {
this.deps.logger.warn(
`[runtime-bridge] inbound thread-event write failed for message ${record.id}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
private resolveThreadForRun(envelope: RuntimeThreadEnvelope) {
if (envelope.taskId) {
try {
const task = this.deps.db.core.entity.getTask(envelope.taskId);
return ensureTaskThread(this.deps.db, envelope.taskId, envelope.companyId, task.title);
} catch (error) {
this.deps.logger.warn(
`[runtime-bridge] getTask failed for ${envelope.taskId}: ${error instanceof Error ? error.message : String(error)}`,
);
return null;
}
}
try {
const agentLabel = this.deps.resolveAgentName(envelope.agentId);
return ensureAgentCommunicationThread(
this.deps.db,
envelope.agentId,
envelope.companyId,
agentLabel,
);
} catch (error) {
this.deps.logger.warn(
`[runtime-bridge] ensureAgentCommunicationThread failed for ${envelope.agentId}: ${error instanceof Error ? error.message : String(error)}`,
);
return null;
}
}
private resolveThreadEventActor(
envelope: { runId: string; agentId: string },
message: { role?: string; timestamp?: number },
): { actorId: string; actorType: "agent" | "user" | "system"; sourceMessageId: string | null } | null {
if (message.role === "user") {
const correlation = this.deps.lookupInboundCorrelation(envelope.runId, message.timestamp);
if (correlation) {
const actorType: "agent" | "user" | "system" =
correlation.senderKind === "human"
? "user"
: correlation.senderKind === "system"
? "system"
: "agent";
return {
actorId: correlation.fromAgentId,
actorType,
sourceMessageId: correlation.messageId,
};
}
return null;
}
if (message.role === "assistant") {
return { actorId: envelope.agentId, actorType: "agent", sourceMessageId: null };
}
return null;
}
}
@@ -1,62 +0,0 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { AppDatabase } from "@main/database";
import { compactMessagesIfNeeded } from "./context-compactor";
import {
decodeAgentMessageFromSessionEntry,
encodeAgentMessageForSession,
getAgentMessageRole,
validateNewAgentMessageForSession,
} from "./agent-message-schema";
/**
* SQLite-backed pi `AgentMessage[]` store.
*
* One store instance per task_session. `appendMessage` runs exactly
* once per pi `message_end` event; `loadMessages` reads the live
* transcript back at `prepareRun`. When the persisted transcript
* crosses the compaction threshold, `loadMessages` writes a durable
* summary row and supersedes the older rows inside one DB transaction,
* leaving the recent tail as the active transcript.
*/
export class SqliteSessionStore {
constructor(
private readonly db: AppDatabase,
public readonly taskSessionId: string,
) {}
loadMessages(): AgentMessage[] {
const rows = this.db.projection.sessionEntries.listActive(this.taskSessionId);
const messages: AgentMessage[] = [];
for (const row of rows) {
if (row.kind === "message") {
messages.push(decodeAgentMessageFromSessionEntry(row));
}
}
const outcome = compactMessagesIfNeeded(messages);
if (!outcome.changed || !outcome.messages || !outcome.telemetry) {
return messages;
}
const summary = outcome.messages[0];
if (!summary) {
return messages;
}
const olderRows = rows.slice(0, outcome.telemetry.compactedOlderMessageCount);
this.db.projection.sessionEntries.compactWithSummary({
taskSessionId: this.taskSessionId,
supersedeEntryIds: olderRows.map((row) => row.id),
summaryRole: getAgentMessageRole(summary),
summaryPayloadJson: encodeAgentMessageForSession(summary),
});
return outcome.messages;
}
appendMessage(message: AgentMessage): void {
const normalized = validateNewAgentMessageForSession(message);
this.db.projection.sessionEntries.append({
taskSessionId: this.taskSessionId,
kind: "message",
role: getAgentMessageRole(normalized),
payloadJson: encodeAgentMessageForSession(normalized),
});
}
}
-4
View File
@@ -1,4 +0,0 @@
// Re-export from the split module for backward compatibility.
// All route logic now lives in src/main/api-server/
export { AgentApiServer, createApiServer } from "./api-server/index";
export type { ApiServerEventCallbacks } from "./api-server/index";
@@ -1,36 +0,0 @@
/**
* In-process counter for deprecated HTTP routes (C4-followup #3).
*
* The counters let us assert "legacy HTTP hire traffic has hit zero"
* before physically deleting the deprecated handlers. Keys are the
* route label the handler passes in (e.g. `POST /api/companies/:companyId/agents`).
*
* Scope is intentionally narrow: module-level Map, synchronous mutation,
* no IO, no fallback. An empty label is a programmer error — we throw
* so it surfaces in tests instead of silently recording under "".
*
* The counter is not exposed over HTTP yet (scope creep). Internal
* code reads via `readDeprecationCounters()`.
*/
const deprecationCallCounters = new Map<string, number>();
export function incrementDeprecationCall(routeLabel: string): void {
if (typeof routeLabel !== "string" || routeLabel.length === 0) {
throw new Error("incrementDeprecationCall requires a non-empty routeLabel");
}
const previous = deprecationCallCounters.get(routeLabel) ?? 0;
deprecationCallCounters.set(routeLabel, previous + 1);
}
export function readDeprecationCounters(): Record<string, number> {
const snapshot: Record<string, number> = {};
for (const [label, count] of deprecationCallCounters) {
snapshot[label] = count;
}
return snapshot;
}
export function resetDeprecationCounters(): void {
deprecationCallCounters.clear();
}
-40
View File
@@ -1,40 +0,0 @@
import { AgentApiServer, type ApiServerEventCallbacks } from "./server-core";
import { registerAgentRoutes } from "./routes-agents";
import { registerAgentControlRoutes } from "./routes-agent-control";
import { registerAgentStandupRoute } from "./routes-agents-standup";
import { registerTaskRoutes } from "./routes-tasks";
import { registerWorkGraphRoutes } from "./routes-work-graph";
import { registerMessageRoutes } from "./routes-messages";
import { registerApprovalRoutes } from "./routes-approvals";
import { registerFileRoutes } from "./routes-files";
import { registerMiscRoutes } from "./routes-misc";
import { registerMeetingRoutes } from "./routes-meetings";
import { registerRunRoutes } from "./routes-runs";
import type { AppDatabase } from "../database";
export type { ApiServerEventCallbacks };
export { AgentApiServer };
/**
* Factory that creates and returns a fully-wired AgentApiServer with all
* domain route modules registered.
*/
export function createApiServer(
db: AppDatabase,
callbacks: ApiServerEventCallbacks,
allowedOrigins?: Iterable<string>,
): AgentApiServer {
const server = new AgentApiServer(db, callbacks, allowedOrigins);
registerAgentRoutes(server);
registerAgentControlRoutes(server);
registerAgentStandupRoute(server);
registerTaskRoutes(server);
registerWorkGraphRoutes(server);
registerMessageRoutes(server);
registerApprovalRoutes(server);
registerFileRoutes(server);
registerMiscRoutes(server);
registerMeetingRoutes(server);
registerRunRoutes(server);
return server;
}
-169
View File
@@ -1,169 +0,0 @@
import log from "electron-log/main.js";
import { z } from "zod";
import { canControlAgent } from "../agent-permissions";
import { requireParam, type AgentApiServer } from "./server-core";
export function registerAgentControlRoutes(server: AgentApiServer) {
const db = server.db;
const onDomainChanged = server.onDomainChanged;
const onWakeAgent = server.onWakeAgent;
server.addRoute("POST", "/api/agents/:agentId/wake", (_req, res, params, token, body) => {
const targetAgentId = requireParam(params, "agentId");
const targetAgent = (() => {
try {
return db.core.entity.getAgent(targetAgentId);
} catch (err) {
log.warn(`[api/agent-control] getAgent failed for ${targetAgentId}:`, err instanceof Error ? err.message : String(err));
return null;
}
})();
if (!targetAgent || targetAgent.companyId !== token.companyId) {
server.json(res, 404, { error: "Agent not found" });
return;
}
if (!canControlAgent(db, token.companyId, token.agentId, targetAgentId)) {
server.json(res, 403, { error: "Current agent is not allowed to wake this agent" });
return;
}
const wakeSchema = z.object({
reason: z.string().min(1).max(200).default("peer_request"),
});
const parsed = wakeSchema.safeParse(body);
const reason = parsed.success ? parsed.data.reason : "peer_request";
const alreadyActive = db.core.runs.hasActiveRun({ companyId: token.companyId, agentId: targetAgentId });
const runId = onWakeAgent(targetAgentId, token.companyId, reason);
if (runId) {
server.json(res, 200, { ok: true, runId, message: `Wake started for agent ${targetAgentId}` });
return;
}
if (alreadyActive) {
server.json(res, 202, {
ok: true,
queued: true,
message: `Agent ${targetAgentId} is already running. Wake will be retried after the active run completes.`,
});
return;
}
server.json(res, 409, { ok: false, error: "Agent could not be woken right now" });
});
server.addRoute("PATCH", "/api/agents/:agentId/heartbeat", (_req, res, params, token) => {
const targetAgentId = requireParam(params, "agentId");
let targetAgent;
try {
targetAgent = db.core.entity.getAgent(targetAgentId);
} catch (err) {
log.warn(`[api/agent-control] getAgent failed for heartbeat ${targetAgentId}:`, err instanceof Error ? err.message : String(err));
targetAgent = null;
}
if (!targetAgent || targetAgent.companyId !== token.companyId) {
server.json(res, 404, { error: "Agent not found or not in the same company" });
return;
}
if (!canControlAgent(db, token.companyId, token.agentId, targetAgentId)) {
server.json(res, 403, { error: "Current agent is not allowed to trigger heartbeat for this agent" });
return;
}
const alreadyActive = db.core.runs.hasActiveRun({ companyId: token.companyId, agentId: targetAgentId });
const runId = onWakeAgent(targetAgentId, token.companyId, "peer_request");
if (runId) {
server.json(res, 200, { ok: true, runId, message: `Heartbeat triggered for agent ${targetAgentId}` });
return;
}
if (alreadyActive) {
server.json(res, 202, {
ok: true,
queued: true,
message: `Agent ${targetAgentId} is already running. Heartbeat will retry after the current run.`,
});
return;
}
server.json(res, 409, { ok: false, error: "Heartbeat could not be started for this agent" });
});
server.addRoute("GET", "/api/agents/:agentId/skills", (_req, res, params, token) => {
const agentId = requireParam(params, "agentId");
let agent;
try {
agent = db.core.entity.getAgent(agentId);
} catch (error) {
log.warn(`[api/agent-skills:GET] getAgent(${agentId}) failed: ${error instanceof Error ? error.message : String(error)}`);
agent = null;
}
if (!agent || agent.companyId !== token.companyId) {
server.json(res, 404, { error: "Agent not found in your company" });
return;
}
const skillIds = db.core.skills.listAgentSkillAssignments(token.companyId, agentId);
const skills = skillIds
.map((sid) => db.core.skills.getCompanySkill(token.companyId, sid))
.filter((s): s is NonNullable<typeof s> => s != null);
server.json(res, 200, skills.map((s) => ({
id: s.id,
name: s.name,
slug: s.slug,
description: s.description,
sourceType: s.sourceType,
trustLevel: s.trustLevel,
})));
});
server.addRoute("POST", "/api/agents/:agentId/skills", (_req, res, params, token, body) => {
const agentId = requireParam(params, "agentId");
const parsed = z.object({ skillId: z.string().uuid() }).safeParse(body);
if (!parsed.success) {
server.json(res, 400, { error: "Invalid body: skillId (uuid) required" });
return;
}
let agent;
try {
agent = db.core.entity.getAgent(agentId);
} catch (error) {
log.warn(`[api/agent-skills:POST] getAgent(${agentId}) failed: ${error instanceof Error ? error.message : String(error)}`);
agent = null;
}
if (!agent || agent.companyId !== token.companyId) {
server.json(res, 404, { error: "Agent not found in your company" });
return;
}
const skill = db.core.skills.getCompanySkill(token.companyId, parsed.data.skillId);
if (!skill) {
server.json(res, 404, { error: "Skill not found in your company" });
return;
}
if (!db.core.skills.assignSkillToAgent(token.companyId, agentId, parsed.data.skillId)) {
server.json(res, 404, { error: "Skill assignment targets not found in your company" });
return;
}
onDomainChanged();
server.json(res, 200, { ok: true, message: `Skill "${skill.name}" assigned to agent ${agent.name}` });
});
server.addRoute("DELETE", "/api/agents/:agentId/skills/:skillId", (_req, res, params, token) => {
const agentId = requireParam(params, "agentId");
const skillId = requireParam(params, "skillId");
let agent;
try {
agent = db.core.entity.getAgent(agentId);
} catch (error) {
log.warn(`[api/agent-skills:DELETE] getAgent(${agentId}) failed: ${error instanceof Error ? error.message : String(error)}`);
agent = null;
}
if (!agent || agent.companyId !== token.companyId) {
server.json(res, 404, { error: "Agent not found in your company" });
return;
}
const skill = db.core.skills.getCompanySkill(token.companyId, skillId);
if (!skill) {
server.json(res, 404, { error: "Skill not found in your company" });
return;
}
if (!db.core.skills.removeSkillFromAgent(token.companyId, agentId, skillId)) {
server.json(res, 404, { error: "Skill assignment not found in your company" });
return;
}
onDomainChanged();
server.json(res, 200, { ok: true, message: `Skill removed from agent ${agent.name}` });
});
}
@@ -1,181 +0,0 @@
import type { AgentApiServer } from "./server-core";
export function registerAgentStandupRoute(server: AgentApiServer): void {
const db = server.db;
server.addRoute("GET", "/api/agents/me/standup", (_req, res, _params, token) => {
const agent = db.core.entity.getAgent(token.agentId);
if (!agent || agent.companyId !== token.companyId) {
server.json(res, 404, { error: "Agent not found" });
return;
}
const directReports = db.core.agents.listDirectReportsForStandup(token.companyId, token.agentId);
const myTasks = db.core.tasks.listAssigneeTasksForStandup(token.companyId, token.agentId);
const reportTasks = db.core.tasks.listTasksForAssignees(
token.companyId,
directReports.map((r) => r.id),
);
const reportStatusRows = directReports.length > 0
? db.core.agents.listReportStatusesForStandup(token.companyId, token.agentId)
: [];
const tasksAwaitingMyReview = reportTasks.filter((t) => t.status === "in_review");
const blockedReportTasks = reportTasks.filter((t) => t.status === "blocked");
const myBlockedTasks = myTasks.filter((t) => t.status === "blocked");
const myInProgressTasks = myTasks.filter((t) => t.status === "in_progress");
const myTodoTasks = myTasks.filter((t) => t.status === "todo");
const allRelevantTaskIds = new Set<string>([
...myTasks.map((t) => t.id),
...reportTasks.map((t) => t.id),
]);
const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
const recentComments = db.core.activity.listRecentTaskComments([...allRelevantTaskIds], twoHoursAgo, 20);
const myPendingApprovals = db.core.approvals.listAgentPendingApprovals(token.companyId, token.agentId);
const myResolvedApprovals = db.core.approvals.listAgentResolvedApprovals(token.companyId, token.agentId, 5);
// Unread inbox lives in the standup briefing as the agent's
// canonical view of pending communication. Pre-fix the agent had
// to make a second API call to /messages?unreadOnly=true that the
// model frequently skipped, so messages from peers and managers
// sat dark while the agent posted heartbeat status comments.
const unreadInbox = db.core.messages.listAgentInboxUnread(token.companyId, token.agentId);
const senderIds = new Set(unreadInbox.map((m) => m.fromAgentId));
const senderNames = senderIds.size > 0
? new Map(db.core.agents.listAgentNamesByIds(token.companyId, Array.from(senderIds)).map((row) => [row.id, row.name]))
: new Map<string, string>();
// Recent outputs the AGENT itself produced — closes the
// trust-but-verify loop. Without this view the model could
// narrate "I wrote the spec doc" in its assistant text without
// ever calling the persistence API, and we'd never catch it.
// Filter scope: documents the agent authored, work_products on
// any task the agent has worked on (assigned tasks). Taskless
// run outputs are intentionally out of scope here — the smaller
// surface keeps the briefing readable.
const RECENT_OUTPUT_LIMIT = 20;
const myTaskIds = new Set(myTasks.map((t) => t.id));
const recentDocuments = db.core.documents.listDocuments(token.companyId)
.filter((doc) => doc.authorAgentId === token.agentId)
.slice(0, RECENT_OUTPUT_LIMIT);
const recentWorkProducts = db.core.documents.listWorkProductsForCompany(token.companyId)
.filter((wp) => wp.taskId !== null && myTaskIds.has(wp.taskId))
.slice(0, RECENT_OUTPUT_LIMIT);
const reportNameMap = new Map(directReports.map((a) => [a.id, a.name]));
const taskTitleMap = new Map<string, string>([
...myTasks.map<[string, string]>((t) => [t.id, t.title]),
...reportTasks.map<[string, string]>((t) => [t.id, t.title]),
]);
const socialAccounts = db.core.social.listSocialAccounts(token.companyId);
server.json(res, 200, {
agent: { id: agent.id, name: agent.name, role: agent.role, status: agent.status },
summary: {
unreadMessages: unreadInbox.length,
tasksAwaitingReview: tasksAwaitingMyReview.length,
blockedReportTasks: blockedReportTasks.length,
myBlockedTasks: myBlockedTasks.length,
myInProgress: myInProgressTasks.length,
myTodo: myTodoTasks.length,
pendingApprovals: myPendingApprovals.length,
directReports: directReports.length,
},
unreadMessages: unreadInbox.map((m) => ({
id: m.id,
from: senderNames.get(m.fromAgentId) ?? m.fromAgentId,
fromAgentId: m.fromAgentId,
toAgentId: m.toAgentId,
channel: m.channel,
channelTargetId: m.channelTargetId,
subject: m.subject,
bodyPreview: m.body.slice(0, 240),
priority: m.priority,
parentMessageId: m.parentMessageId,
createdAt: m.createdAt,
})),
recentDocuments: recentDocuments.map((d) => ({
id: d.id,
type: d.type,
title: d.title,
status: d.status,
version: d.version,
updatedAt: d.updatedAt,
})),
recentWorkProducts: recentWorkProducts.map((wp) => ({
id: wp.id,
type: wp.type,
provider: wp.provider,
title: wp.title,
externalId: wp.externalId,
url: wp.url,
status: wp.status,
taskId: wp.taskId,
taskTitle: wp.taskId ? (taskTitleMap.get(wp.taskId) ?? "") : "",
updatedAt: wp.updatedAt,
})),
tasksAwaitingReview: tasksAwaitingMyReview.map((t) => ({
id: t.id,
title: t.title,
assignee: reportNameMap.get(t.assigneeAgentId) ?? "Unknown",
priority: t.priority,
})),
blockedTasks: [
...blockedReportTasks.map((t) => ({
id: t.id,
title: t.title,
assignee: reportNameMap.get(t.assigneeAgentId) ?? "Unknown",
priority: t.priority,
})),
...myBlockedTasks.map((t) => ({
id: t.id,
title: t.title,
assignee: agent.name,
priority: t.priority,
})),
],
recentComments: recentComments.map((c) => ({
taskId: c.taskId,
taskTitle: taskTitleMap.get(c.taskId) ?? "",
author: c.authorName,
body: c.body.slice(0, 200),
createdAt: c.createdAt,
})),
pendingApprovals: myPendingApprovals.map((a) => ({
id: a.id,
type: a.type,
summary: a.payloadSummary,
state: a.state,
})),
resolvedApprovals: myResolvedApprovals.map((a) => ({
id: a.id,
type: a.type,
summary: a.payloadSummary,
state: a.state,
decisionNote: a.decisionNote,
})),
reportStatuses: reportStatusRows.map((r) => ({
id: r.id,
name: r.name,
role: r.role,
status: r.status,
tasksInProgress: r.tasksInProgress,
tasksInReview: r.tasksInReview,
tasksBlocked: r.tasksBlocked,
tasksDone: r.tasksDone,
})),
socialAccounts: socialAccounts.map((a) => ({
id: a.id,
platform: a.platform,
accountName: a.accountName,
displayName: a.displayName,
status: a.status,
requireApproval: a.requireApproval,
})),
});
});
}
-314
View File
@@ -1,314 +0,0 @@
import log from "electron-log/main.js";
import { hireRequestSchema } from "@shared/contracts";
import { getConnectorExecutionReadinessIssue } from "@shared/connector-policy";
import {
canManageOrganization,
canRequestHire,
getHireNameValidationError,
getHireRoutingValidationError,
resolveHireReportsTo,
} from "../agent-permissions";
import { resolveApprovalDecision } from "../approval-policy";
import { shouldAutoApprove } from "../execution-policy";
import { incrementDeprecationCall } from "./deprecation-counter";
import { requireParam, type AgentApiServer } from "./server-core";
// C4-followup #3: RFC 8594 deprecation headers for the legacy HTTP hire
// route. Sunset is the 30-day removal gate — if the counter stays zero
// through this date, delete the handler.
const LEGACY_HIRE_ROUTE_LABEL = "POST /api/companies/:companyId/agents";
const LEGACY_HIRE_SUNSET = new Date("2026-05-20T00:00:00Z").toUTCString();
export function registerAgentRoutes(server: AgentApiServer) {
const db = server.db;
const callbacks = server.callbacks;
const onDomainChanged = server.onDomainChanged;
server.addRoute("GET", "/api/agents/me", (_req, res, _params, token) => {
const agent = db.core.entity.getAgent(token.agentId);
const chainOfCommand = db.core.agents.getChainOfCommand(token.agentId);
const directReports = db.core.agents.getDirectReports(token.agentId, token.companyId);
server.json(res, 200, { agent, chainOfCommand, directReports });
});
// GET /api/agents/me/standup lives in routes-agents-standup.ts, which
// uses targeted standup-shaped DB queries. The inline duplicate that
// used to sit here was dead code — the router keeps the first match
// and the standup module is registered separately in api-server/index.ts.
server.addRoute("GET", "/api/agents/me/metrics", (_req, res, _params, token) => {
const companyMetrics = db.core.company.getCompanyMetrics(token.companyId, 30);
const agentMetric = companyMetrics.agentMetrics.find(m => m.agentId === token.agentId);
if (!agentMetric) {
server.json(res, 404, { error: "Metrics not found" });
return;
}
server.json(res, 200, agentMetric);
});
server.addRoute("GET", "/api/agents/:agentId/metrics", (_req, res, params, token) => {
const agentId = requireParam(params, "agentId");
const agent = db.core.entity.getAgent(agentId);
if (!agent || agent.companyId !== token.companyId) {
server.json(res, 404, { error: "Agent not found" });
return;
}
const companyMetrics = db.core.company.getCompanyMetrics(token.companyId, 30);
const agentMetric = companyMetrics.agentMetrics.find((metric) => metric.agentId === agentId);
if (!agentMetric) {
server.json(res, 404, { error: "Metrics not found" });
return;
}
server.json(res, 200, agentMetric);
});
server.addRoute("GET", "/api/agents/:agentId", (_req, res, params, token) => {
let agent;
try {
agent = db.core.entity.getAgent(requireParam(params, "agentId"));
} catch (err) {
log.warn(`[api/agents] getAgent failed:`, err instanceof Error ? err.message : String(err));
server.json(res, 404, { error: "Agent not found" });
return;
}
if (agent.companyId !== token.companyId) {
server.json(res, 404, { error: "Agent not found" });
return;
}
const directReports = db.core.agents.getDirectReports(agent.id, token.companyId);
const reportsTo = agent.reportsTo;
const manager = reportsTo
? (() => {
try {
return db.core.entity.getAgent(reportsTo);
} catch (error) {
log.warn(`[api/agents] manager lookup failed for ${reportsTo}: ${error instanceof Error ? error.message : String(error)}`);
return null;
}
})()
: null;
server.json(res, 200, {
...agent,
manager: manager ? { id: manager.id, name: manager.name, role: manager.role } : null,
directReports: directReports.map(r => ({ id: r.id, name: r.name, role: r.role, status: r.status })),
});
});
server.addRoute("GET", "/api/companies/:companyId/agents", (_req, res, params, token) => {
if (params.companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const agents = db.core.agents.listAgentsForApi(params.companyId);
server.json(res, 200, agents);
});
/**
* @deprecated since C4 (2026-04-20). Agents should file hires via the
* first-class `hire_agent` pi tool (see
* `src/main/worker/runtime/internal-tools/hire-agent-tool.ts`). This
* HTTP route stays live for legacy clients (UI form post, automation
* tests, unmigrated scripts) during the deprecation window and will
* be removed once tool-path eval confirms no regressions. Every
* successful request emits a `log.warn` tagged with `[deprecated]`
* so residual callers are easy to audit.
*/
server.addRoute("POST", "/api/companies/:companyId/agents", (_req, res, params, token, body) => {
// RFC 8594 deprecation signaling — emit on every response from this
// handler regardless of downstream validation outcome.
res.setHeader("Deprecation", "true");
res.setHeader("Sunset", LEGACY_HIRE_SUNSET);
res.setHeader("Link", '</docs/proposals/C4-followup.md>; rel="deprecation"');
incrementDeprecationCall(LEGACY_HIRE_ROUTE_LABEL);
const companyId = requireParam(params, "companyId");
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
if (!canRequestHire(db, companyId, token.agentId)) {
server.json(res, 403, { error: "Current agent is not allowed to request hires" });
return;
}
log.warn(
`[deprecated][C4] POST /api/companies/${companyId}/agents — prefer the hire_agent internal tool (caller agent=${token.agentId}).`,
);
const reportsToProvided =
typeof body === "object" &&
body !== null &&
Object.prototype.hasOwnProperty.call(body, "reportsTo");
const parsed = hireRequestSchema.safeParse({
companyId,
requestedByAgentId: token.agentId,
...(typeof body === "object" && body ? body : {}),
});
if (!parsed.success) {
server.json(res, 400, { error: "Invalid hire payload" });
return;
}
const input = parsed.data;
const resolvedReportsTo = resolveHireReportsTo(
db,
companyId,
token.agentId,
reportsToProvided ? input.reportsTo : undefined,
input.department ?? null,
);
const nameError = getHireNameValidationError({
name: input.name,
role: input.role,
title: input.title,
});
if (nameError) {
server.json(res, 422, { error: nameError });
return;
}
const routingError = getHireRoutingValidationError(
db,
companyId,
token.agentId,
resolvedReportsTo,
input.department ?? null,
);
if (routingError) {
server.json(res, 422, { error: routingError });
return;
}
if (server.companyRefError(res, companyId, [
{ id: input.requestedByAgentId, table: "agents", label: "requesting agent" },
{ id: resolvedReportsTo, table: "agents", label: "manager" },
{ id: input.workspaceId, table: "workspaces", label: "workspace" },
])) {
return;
}
if (
resolvedReportsTo &&
resolvedReportsTo !== token.agentId &&
!canManageOrganization(db, companyId, token.agentId) &&
!db.core.agents.getChainOfCommand(resolvedReportsTo).some((agent) => agent.id === token.agentId)
) {
server.json(res, 403, { error: "Requested hire must report to you or a manager in your reporting chain." });
return;
}
const connectorRecord = (() => {
try {
return db.core.connectors.getConnector(input.connectorId);
} catch (err) {
log.warn(`[api/agents] getConnector failed for ${input.connectorId}:`, err instanceof Error ? err.message : String(err));
return null;
}
})();
if (!connectorRecord) {
server.json(res, 422, { error: "The selected connector does not exist." });
return;
}
const connectorIssue = getConnectorExecutionReadinessIssue(connectorRecord);
if (connectorIssue) {
server.json(res, 422, { error: connectorIssue });
return;
}
// Prevent duplicate agents: reject if a non-terminated agent with the same name already exists
const existingAgents = db.core.agents.listExistingAgentsForHireCheck(companyId);
const duplicateByName = existingAgents.find(
(a) => a.name.toLowerCase() === input.name.toLowerCase(),
);
if (duplicateByName) {
server.json(res, 409, { error: `Agent "${input.name}" already exists in this company (id: ${duplicateByName.id}, role: ${duplicateByName.role}). Use the existing agent instead of creating a duplicate.` });
return;
}
// Also check pending hire approvals with the same name to prevent concurrent duplicate requests
const pendingHires = db.core.approvals.listPendingHireApprovalPayloads(companyId);
for (const pending of pendingHires) {
try {
const payload = pending.payloadJson ? JSON.parse(pending.payloadJson) as Record<string, unknown> : {};
if (typeof payload.name === "string" && payload.name.toLowerCase() === input.name.toLowerCase()) {
server.json(res, 409, { error: `A hire request for "${input.name}" is already pending approval.` });
return;
}
} catch (error) {
log.warn(
`[api/agents] invalid pending-hire payloadJson for company ${companyId}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
const managerLabel = resolvedReportsTo
? (() => {
try {
return db.core.entity.getAgent(resolvedReportsTo).name;
} catch (err) {
log.warn(`[api/agents] getAgent failed for manager ${resolvedReportsTo}:`, err instanceof Error ? err.message : String(err));
return resolvedReportsTo;
}
})()
: "the company board";
let result: { agentId: string; approvalId: string };
try {
result = db.createHireRequest({
companyId,
requestedByAgentId: input.requestedByAgentId ?? token.agentId,
name: input.name,
role: input.role,
title: input.title,
department: input.department ?? null,
reportsTo: resolvedReportsTo,
connectorId: input.connectorId,
workspaceId: input.workspaceId ?? null,
model: input.model ?? null,
metadataJson: input.metadataJson ?? "{}",
capabilities: input.capabilities,
budgetMonthlyUsd: input.budgetMonthlyUsd,
payloadSummary: `Hire ${input.name} as ${input.role}`,
impactSummary: input.capabilities || `${input.name} will report to ${managerLabel}.`,
payloadJson: JSON.stringify({
name: input.name,
role: input.role,
title: input.title,
requestedByAgentId: input.requestedByAgentId ?? token.agentId,
requestedConfigurationSnapshot: {
connectorId: input.connectorId,
workspaceId: input.workspaceId ?? null,
model: input.model ?? null,
metadataJson: input.metadataJson ?? "{}",
capabilities: input.capabilities,
budgetMonthlyUsd: input.budgetMonthlyUsd,
reportsTo: resolvedReportsTo,
department: input.department ?? null,
title: input.title,
},
}),
});
} catch (error) {
server.json(res, 422, { error: error instanceof Error ? error.message : "Hire could not be created" });
return;
}
db.core.activity.addActivity({
companyId,
actor: token.agentId,
action: "agent.hire_requested",
entityType: "agent",
entityId: result.agentId,
detail: input.name,
});
let autoApproved = false;
if (shouldAutoApprove(db, companyId, "hire_agent")) {
resolveApprovalDecision({
db,
handleApprovalResolved: callbacks.onApprovalResolved,
companyId,
approvalId: result.approvalId,
state: "approved",
decisionNote: "Auto-approved by company policy",
detail: `Auto-approved hire: ${input.name}`,
});
autoApproved = true;
log.info(`[auto-approve] Auto-approved hire of ${input.name} via agent API`);
}
onDomainChanged();
server.json(res, 201, { ...result, autoApproved });
});
}
-286
View File
@@ -1,286 +0,0 @@
import { z } from "zod";
import log from "electron-log/main.js";
import { approvalInputSchema, mergeApprovalAttachments } from "@shared/contracts";
import { resolveApprovalDecision } from "../approval-policy";
import { shouldAutoApprove } from "../execution-policy";
import { incrementDeprecationCall } from "./deprecation-counter";
import { requireParam, type AgentApiServer } from "./server-core";
// C4-followup #3: RFC 8594 deprecation headers for the legacy
// approve_ceo_strategy HTTP path. Only this approval type is
// deprecated — other types still use this route as first-class.
const LEGACY_APPROVE_CEO_STRATEGY_LABEL =
"POST /api/companies/:companyId/approvals type=approve_ceo_strategy";
const LEGACY_APPROVE_CEO_STRATEGY_SUNSET = new Date("2026-05-20T00:00:00Z").toUTCString();
const approvalListQuerySchema = z.object({
requestedByAgentId: z.string().uuid().optional(),
relatedTaskId: z.string().uuid().optional(),
status: z.string().optional(),
});
export function registerApprovalRoutes(server: AgentApiServer) {
const db = server.db;
const callbacks = server.callbacks;
const onDomainChanged = server.onDomainChanged;
const onWakeAgent = server.onWakeAgent;
/**
* @deprecated since C4 (2026-04-20) for the `approve_ceo_strategy`
* flow. CEOs should file that approval via the `file_approval` pi
* tool (see `src/main/worker/runtime/internal-tools/file-approval-
* tool.ts`). Other approval types still use this HTTP route. Once
* every approval type has a tool surface, this route is removed
* outright; until then, the deprecated warn is scoped to
* `approve_ceo_strategy` so signal stays clean.
*/
server.addRoute("POST", "/api/companies/:companyId/approvals", (_req, res, params, token, body) => {
const companyId = requireParam(params, "companyId");
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const parsed = approvalInputSchema.safeParse({
companyId,
...(typeof body === "object" && body ? body : {}),
requestedByAgentId: token.agentId,
});
if (!parsed.success) {
server.json(res, 400, { error: "Invalid approval payload" });
return;
}
const input = parsed.data;
if (input.type === "approve_ceo_strategy") {
res.setHeader("Deprecation", "true");
res.setHeader("Sunset", LEGACY_APPROVE_CEO_STRATEGY_SUNSET);
res.setHeader("Link", '</docs/proposals/C4-followup.md>; rel="deprecation"');
incrementDeprecationCall(LEGACY_APPROVE_CEO_STRATEGY_LABEL);
log.warn(
`[deprecated][C4] POST /api/companies/${companyId}/approvals type=approve_ceo_strategy — prefer the file_approval internal tool (caller agent=${token.agentId}).`,
);
}
if (server.companyRefError(res, companyId, [
{ id: input.relatedTaskId, table: "tasks", label: "related task" },
{ id: token.agentId, table: "agents", label: "requesting agent" },
{ id: input.relatedAgentId, table: "agents", label: "related agent" },
])) {
return;
}
// Team-building strategy approvals must follow their intended hires.
// Accepting an approve_ceo_strategy before any hire_agent requests
// exist was observed to re-enter the empty team-building loop (see
// comment in ceo-modes/team-building.en.md and §5.2 of 问题.md).
// Reject the request at the API boundary so the model cannot
// circumvent the ordering by resubmitting.
if (input.type === "approve_ceo_strategy") {
const companyHireApprovals = db.core.approvals.listCompanyApprovalsByType(companyId, "hire_agent");
if (companyHireApprovals.length === 0) {
server.json(res, 409, {
error: "approve_ceo_strategy requires at least one hire_agent approval to exist first. Submit the intended hires, then file the strategy.",
code: "STRATEGY_BEFORE_HIRES",
});
return;
}
const existingStrategy = db.core.approvals.listCompanyApprovalsByType(companyId, "approve_ceo_strategy")
.find((a) => a.state === "pending" || a.state === "approved");
if (existingStrategy) {
server.json(res, 409, {
error: "approve_ceo_strategy already exists for this company; do not submit another.",
code: "STRATEGY_ALREADY_FILED",
existingApprovalId: existingStrategy.id,
});
return;
}
}
// Fold the schema-level documentIds / workProductIds into payloadJson so
// the ApprovalInspector and extractApprovalDocumentReferences can read a
// single source of truth. Legacy callers that stuffed documentIds inside
// payloadJson keep working unchanged. Malformed payloadJson (non-JSON
// or non-object) surfaces as 400 at the API boundary — no silent
// rawPayload stashing, because that hid producer bugs from the operator.
let mergedPayloadJson: string | null;
try {
mergedPayloadJson = mergeApprovalAttachments(
input.payloadJson,
input.documentIds,
input.workProductIds,
);
} catch (error) {
server.json(res, 400, {
error: error instanceof Error ? error.message : "Invalid approval payloadJson",
code: "INVALID_PAYLOAD_JSON",
});
return;
}
let id: string;
try {
id = db.core.approvals.requestApproval({
companyId,
type: input.type,
relatedTaskId: input.relatedTaskId ?? null,
requestedByAgentId: token.agentId,
relatedAgentId: input.relatedAgentId ?? null,
payloadSummary: input.payloadSummary,
impactSummary: input.impactSummary,
payloadJson: mergedPayloadJson,
});
} catch (error) {
server.json(res, 422, { error: error instanceof Error ? error.message : "Approval could not be created" });
return;
}
db.core.activity.addActivity({
companyId,
actor: token.agentId,
action: "approval.created",
entityType: "approval",
entityId: id,
detail: input.payloadSummary,
});
// Notify thread integration about the new approval
callbacks.onApprovalCreated(id, companyId, input.relatedTaskId ?? null, input.type, input.payloadSummary ?? "");
if (shouldAutoApprove(db, companyId, input.type)) {
resolveApprovalDecision({
db,
handleApprovalResolved: callbacks.onApprovalResolved,
companyId,
approvalId: id,
state: "approved",
decisionNote: "Auto-approved by company policy",
detail: "Auto-approved by company policy",
});
log.info(`[auto-approve] Auto-approved approval ${id}: ${input.payloadSummary}`);
} else {
callbacks.notify({
title: "Approval Required",
body: input.payloadSummary ?? `${input.type} approval pending`,
urgency: "critical",
navigation: { section: "approvals", entityId: id },
batchKey: "pending_approval",
});
}
onDomainChanged();
server.json(res, 201, { id });
});
server.addRoute("GET", "/api/companies/:companyId/approvals", (req, res, params, token) => {
const companyId = requireParam(params, "companyId");
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const url = new URL(req.url ?? "/", `http://127.0.0.1:${server.port}`);
const parsedQuery = approvalListQuerySchema.safeParse({
requestedByAgentId: url.searchParams.get("requestedByAgentId") ?? undefined,
relatedTaskId: url.searchParams.get("relatedTaskId") ?? undefined,
status: url.searchParams.get("status") ?? undefined,
});
if (!parsedQuery.success) {
server.json(res, 400, { error: "Invalid approval query" });
return;
}
const statuses = parsedQuery.data.status?.split(",").filter(Boolean) ?? null;
const approvals = db.core.approvals.listApprovalsForApi(companyId, {
requestedByAgentId: parsedQuery.data.requestedByAgentId,
relatedTaskId: parsedQuery.data.relatedTaskId,
statuses,
});
server.json(res, 200, approvals);
});
server.addRoute("GET", "/api/approvals/:approvalId", (_req, res, params, token) => {
const approval = db.core.approvals.findApprovalById(requireParam(params, "approvalId"));
if (!approval || approval.companyId !== token.companyId) {
server.json(res, 404, { error: "Approval not found" });
return;
}
server.json(res, 200, approval);
});
server.addRoute("PATCH", "/api/approvals/:approvalId", (_req, res) => {
server.json(res, 403, { error: "Approvals must be decided from the desktop board, not from an agent run token" });
});
server.addRoute("GET", "/api/approvals/:approvalId/comments", (_req, res, params, token) => {
const approvalId = requireParam(params, "approvalId");
const approval = db.core.approvals.findApprovalById(approvalId);
if (!approval) { server.json(res, 404, { error: "Approval not found" }); return; }
if (approval.companyId !== token.companyId) { server.json(res, 403, { error: "Company mismatch" }); return; }
const comments = db.core.approvals.listApprovalComments(approvalId, approval.companyId);
server.json(res, 200, comments);
});
server.addRoute("POST", "/api/approvals/:approvalId/comments", (_req, res, params, token, body) => {
const approval = db.core.approvals.findApprovalById(requireParam(params, "approvalId"));
if (!approval) { server.json(res, 404, { error: "Approval not found" }); return; }
if (approval.companyId !== token.companyId) { server.json(res, 403, { error: "Company mismatch" }); return; }
const data = body as Record<string, unknown>;
const commentBody = typeof data.body === "string" ? data.body.trim() : "";
if (!commentBody) { server.json(res, 400, { error: "Comment body is required" }); return; }
const commentId = db.core.approvals.addApprovalComment({
companyId: approval.companyId,
approvalId: approval.id,
authorAgentId: token.agentId ?? null,
body: commentBody,
});
// Wake mentioned agents in comment
const mentionedIds = db.core.agents.findMentionedAgentIds(approval.companyId, commentBody);
for (const mentionedId of mentionedIds) {
if (mentionedId !== token.agentId) {
onWakeAgent(mentionedId, approval.companyId, "approval_comment_mentioned");
}
}
onDomainChanged();
server.json(res, 201, { id: commentId });
});
server.addRoute("POST", "/api/approvals/:approvalId/resubmit", (_req, res, params, token) => {
const approval = db.core.approvals.findApprovalById(requireParam(params, "approvalId"));
if (!approval) { server.json(res, 404, { error: "Approval not found" }); return; }
if (approval.companyId !== token.companyId) { server.json(res, 403, { error: "Company mismatch" }); return; }
if (approval.state !== "revision_requested") {
server.json(res, 422, { error: "Only revision_requested approvals can be resubmitted" });
return;
}
if (token.agentId !== approval.requestedByAgentId) {
server.json(res, 403, { error: "Only the requesting agent can resubmit this approval" });
return;
}
db.core.approvals.resubmitApproval(approval.id, approval.companyId);
onDomainChanged();
server.json(res, 200, { resubmitted: true });
});
server.addRoute("GET", "/api/approvals/:approvalId/tasks", (_req, res, params, token) => {
const approvalId = requireParam(params, "approvalId");
const approval = db.core.approvals.findApprovalById(approvalId);
if (!approval) { server.json(res, 404, { error: "Approval not found" }); return; }
if (approval.companyId !== token.companyId) { server.json(res, 403, { error: "Company mismatch" }); return; }
const taskIds = db.core.approvals.listTasksForApproval(approvalId);
server.json(res, 200, taskIds);
});
server.addRoute("POST", "/api/approvals/:approvalId/tasks", (_req, res, params, token, body) => {
const approvalId = requireParam(params, "approvalId");
const approval = db.core.approvals.findApprovalById(approvalId);
if (!approval) { server.json(res, 404, { error: "Approval not found" }); return; }
if (approval.companyId !== token.companyId) { server.json(res, 403, { error: "Company mismatch" }); return; }
const data = body as Record<string, unknown>;
const taskIds = Array.isArray(data.taskIds) ? data.taskIds.filter((id): id is string => typeof id === "string") : [];
if (taskIds.length === 0) { server.json(res, 400, { error: "taskIds array is required" }); return; }
for (const taskId of taskIds) {
if (!db.core.settings.belongsToCompany("tasks", taskId, token.companyId)) {
server.json(res, 400, { error: `Task ${taskId} does not belong to this company` }); return;
}
}
db.core.approvals.linkManyTasksToApproval(approvalId, taskIds, token.companyId, token.agentId ?? null);
onDomainChanged();
server.json(res, 200, { linked: taskIds.length });
});
}
-402
View File
@@ -1,402 +0,0 @@
import log from "electron-log/main.js";
import { z } from "zod";
import {
canonicalOptionalJsonString,
jsonStringArraySchema,
workProductProviderEnum,
workProductReviewStateEnum,
workProductStatusEnum,
workProductTypeEnum,
} from "@shared/contracts";
import { requireParam, type AgentApiServer } from "./server-core";
import { normalizeExternalHttpUrl } from "../external-url-policy";
import { WorkProductScopeError } from "../database/documents-work-products";
const workProductUrlSchema = z.string().trim().superRefine((value, ctx) => {
try {
normalizeExternalHttpUrl(value);
} catch (error) {
ctx.addIssue({
code: "custom",
message: error instanceof Error ? error.message : String(error),
});
}
}).transform((value) => normalizeExternalHttpUrl(value));
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: canonicalOptionalJsonString(jsonStringArraySchema).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: canonicalOptionalJsonString(jsonStringArraySchema).optional(),
changeSummary: z.string().optional(),
baseRevisionId: z.string().optional(),
});
const createWorkProductSchema = z.object({
type: workProductTypeEnum,
provider: workProductProviderEnum,
title: z.string(),
projectId: z.string().optional(),
externalId: z.string().optional(),
url: workProductUrlSchema.optional(),
status: workProductStatusEnum.optional(),
reviewState: workProductReviewStateEnum.optional(),
isPrimary: z.boolean().optional(),
summary: z.string().optional(),
metadata: z.unknown().optional(),
});
const updateWorkProductSchema = z.object({
type: workProductTypeEnum.optional(),
provider: workProductProviderEnum.optional(),
externalId: z.string().optional(),
title: z.string().optional(),
url: workProductUrlSchema.optional(),
status: workProductStatusEnum.optional(),
reviewState: workProductReviewStateEnum.optional(),
isPrimary: z.boolean().optional(),
summary: z.string().optional(),
metadataJson: z.string().optional(),
}).strict();
const linkDocumentSchema = z.object({
documentId: z.string(),
});
function writeWorkProductCreateError(
server: AgentApiServer,
res: Parameters<AgentApiServer["json"]>[0],
error: unknown,
): boolean {
if (error instanceof WorkProductScopeError) {
server.json(res, 422, { error: error.message });
return true;
}
const message = error instanceof Error ? error.message : String(error);
if (/UNIQUE constraint failed/i.test(message)) {
const conflict = message.includes("work_products.company_id, work_products.task_id, work_products.type")
? "Work product primary selection conflicts with an existing active primary"
: "Work product identity conflicts with an existing artifact";
server.json(res, 409, { error: conflict });
return true;
}
if (/constraint failed/i.test(message)) {
server.json(res, 422, { error: "Work product violates a database constraint" });
return true;
}
return false;
}
export function registerFileRoutes(server: AgentApiServer) {
const db = server.db;
const callbacks = server.callbacks;
const onDomainChanged = server.onDomainChanged;
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.core.settings.belongsToCompany("agents", reviewerAgentId, token.companyId)) {
server.json(res, 400, { error: "Reviewer agent does not belong to the current company" });
return;
}
if (projectId && !db.core.settings.belongsToCompany("projects", projectId, token.companyId)) {
server.json(res, 400, { error: "Project does not belong to the current company" });
return;
}
if (goalId && !db.core.settings.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";
// Single source of truth for document-creation side effects lives in
// `document-lifecycle.ts`. Persistence, thread/checkpoint/automation
// fan-out, reviewer wake, and the deliverable-review gate all fire
// from inside the pipeline so this HTTP route and the desktop IPC
// handler stay byte-for-byte consistent.
const outcome = callbacks.createDocument({
id: null,
companyId: token.companyId,
type: docType,
title: docTitle,
content: data.content ?? "",
authorAgentId: token.agentId,
reviewerAgentId,
projectId,
goalId,
parentDocId: null,
version: 1,
status: data.status ?? "draft",
tagsJson: data.tagsJson ?? "[]",
sourceRunId: token.runId ?? null,
});
server.json(res, 201, { id: outcome.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.core.documents.listDocuments(token.companyId);
server.json(res, 200, docs);
});
server.addRoute("GET", "/api/companies/:companyId/documents/:documentId", (_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 doc = db.core.documents.getDocumentForUpdate(documentId, token.companyId);
if (!doc) {
server.json(res, 404, { error: "Document not found in this company" });
return;
}
server.json(res, 200, doc);
});
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 doc = db.core.documents.getDocumentForUpdate(documentId, token.companyId);
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.core.settings.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.core.documents.getDocumentLatestRevisionId(documentId, token.companyId);
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.core.documents.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.core.documents.updateDocumentStatus(documentId, token.companyId, data.status);
}
db.core.documents.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.core.documents.getDocumentLatestRevisionId(documentId, token.companyId) });
});
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.core.documents.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.core.entity.getTask(requireParam(params, "issueId"));
if (task.companyId !== token.companyId) { server.json(res, 404, { error: "Task not found" }); return; }
const products = db.core.documents.listWorkProductsForTask(task.id, task.companyId);
server.json(res, 200, products);
} catch (error) {
log.warn(`[api/work-products] list failed for issue ${params.issueId}: ${error instanceof Error ? error.message : String(error)}`);
server.json(res, 404, { error: "Task not found" });
}
});
server.addRoute("POST", "/api/issues/:issueId/work-products", (_req, res, params, token, body) => {
let task: ReturnType<typeof db.core.entity.getTask>;
try {
task = db.core.entity.getTask(requireParam(params, "issueId"));
} catch (error) {
log.warn(`[api/work-products] task lookup failed for issue ${params.issueId}: ${error instanceof Error ? error.message : String(error)}`);
server.json(res, 404, { error: "Task not found" });
return;
}
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;
try {
const id = db.core.documents.createWorkProduct({
companyId: task.companyId,
taskId: task.id,
projectId: data.projectId,
workspaceId: task.workspaceId ?? null,
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 (error) {
if (writeWorkProductCreateError(server, res, error)) return;
log.warn(`[api/work-products] create failed for issue ${params.issueId}: ${error instanceof Error ? error.message : String(error)}`);
server.json(res, 500, { error: "Work product could not be created" });
}
});
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; }
try {
const changes = db.core.documents.updateWorkProduct(wpId, token.companyId, parsed.data);
if (changes === 0) { server.json(res, 404, { error: "Work product not found" }); return; }
onDomainChanged();
server.json(res, 200, { updated: true });
} catch (error) {
if (writeWorkProductCreateError(server, res, error)) return;
log.warn(`[api/work-products] update failed for work product ${wpId}: ${error instanceof Error ? error.message : String(error)}`);
server.json(res, 500, { error: "Work product could not be updated" });
}
});
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; }
const changes = db.core.documents.deleteWorkProduct(wpId, token.companyId);
if (changes === 0) { server.json(res, 404, { error: "Work product not found" }); return; }
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.core.entity.getTask(requireParam(params, "issueId"));
if (task.companyId !== token.companyId) { server.json(res, 404, { error: "Task not found" }); return; }
const links = db.core.documents.listDocumentsForTask(task.id, task.companyId);
server.json(res, 200, links);
} catch (error) {
log.warn(`[api/task-documents] list failed for issue ${params.issueId}: ${error instanceof Error ? error.message : String(error)}`);
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.core.entity.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.core.settings.belongsToCompany("documents", documentId, token.companyId)) {
server.json(res, 400, { error: "Document does not belong to this company" }); return;
}
const linkId = db.core.documents.linkDocumentToTask(task.id, documentId, task.companyId, key);
onDomainChanged();
server.json(res, 200, { id: linkId, key });
} catch (error) {
log.warn(`[api/task-documents] link failed for issue ${params.issueId}: ${error instanceof Error ? error.message : String(error)}`);
server.json(res, 404, { error: "Task not found" });
}
});
server.addRoute("DELETE", "/api/issues/:issueId/documents/:key", (_req, res, params, token) => {
try {
const task = db.core.entity.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.core.documents.unlinkDocumentFromTask(task.id, key, task.companyId);
onDomainChanged();
server.json(res, 200, { unlinked: true });
} catch (error) {
log.warn(`[api/task-documents] unlink failed for issue ${params.issueId}: ${error instanceof Error ? error.message : String(error)}`);
server.json(res, 404, { error: "Task not found" });
}
});
}
-158
View File
@@ -1,158 +0,0 @@
import log from "electron-log/main.js";
import { canonicalJsonString, canonicalOptionalJsonString, jsonArraySchema, jsonStringArraySchema, meetingActionItemArraySchema } from "@shared/contracts";
import { z } from "zod";
import { validateTaskCreation } from "../task-quality-gates";
import { requireParam, type AgentApiServer } from "./server-core";
export function registerMeetingRoutes(server: AgentApiServer) {
const db = server.db;
const onDomainChanged = server.onDomainChanged;
const onWakeAgent = server.onWakeAgent;
server.addRoute("POST", "/api/companies/:companyId/meetings", (_req, res, params, token, body) => {
if (params.companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const meetingCreateSchema = z.object({
type: z.string().default("department_sync"),
title: z.string().default("Meeting"),
participantAgentIds: canonicalJsonString(jsonStringArraySchema, []),
scheduledAt: z.string().default(new Date().toISOString()),
durationMinutes: z.number().default(30),
agendaJson: canonicalJsonString(jsonArraySchema, []),
});
const parsed = meetingCreateSchema.safeParse(body);
if (!parsed.success) {
server.json(res, 400, { error: "Invalid meeting payload", details: parsed.error.issues });
return;
}
const input = parsed.data;
try {
const parsedParticipants = JSON.parse(input.participantAgentIds) as string[];
for (const agentId of parsedParticipants) {
if (typeof agentId !== "string" || !db.core.settings.belongsToCompany("agents", agentId, token.companyId)) {
server.json(res, 400, { error: "Meeting participants must belong to the current company" });
return;
}
}
} catch (err) {
log.warn(`[api/meetings] participantAgentIds JSON parse failed:`, err instanceof Error ? err.message : String(err));
server.json(res, 400, { error: "participantAgentIds must be a JSON array of agent ids" });
return;
}
const id = db.core.knowledge.saveMeeting({
companyId: token.companyId,
type: input.type,
title: input.title,
organizerAgentId: token.agentId,
participantAgentIds: input.participantAgentIds,
scheduledAt: input.scheduledAt,
durationMinutes: input.durationMinutes,
agendaJson: input.agendaJson,
status: "scheduled",
});
onDomainChanged();
server.json(res, 201, { id });
});
server.addRoute("GET", "/api/companies/:companyId/meetings", (_req, res, params, token) => {
if (params.companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const meetings = db.core.knowledge.listMeetings(token.companyId);
server.json(res, 200, meetings);
});
server.addRoute("PATCH", "/api/meetings/:meetingId", (_req, res, params, token, body) => {
const meetingId = requireParam(params, "meetingId");
const existing = db.core.knowledge.getMeeting(meetingId, token.companyId);
if (!existing) {
server.json(res, 404, { error: "Meeting not found" });
return;
}
const meetingUpdateSchema = z.object({
type: z.string().optional(),
title: z.string().optional(),
notesJson: canonicalOptionalJsonString(jsonArraySchema).optional(),
decisionsJson: canonicalOptionalJsonString(jsonArraySchema).optional(),
actionItemsJson: canonicalOptionalJsonString(meetingActionItemArraySchema).optional(),
status: z.string().optional(),
scheduledAt: z.string().optional(),
});
const parsed = meetingUpdateSchema.safeParse(body);
if (!parsed.success) {
server.json(res, 400, { error: "Invalid meeting update payload", details: parsed.error.issues });
return;
}
const data = parsed.data;
let actionItems: Array<{ title?: string; assigneeAgentId?: string; description?: string }> = [];
if (data.actionItemsJson) {
try {
actionItems = JSON.parse(data.actionItemsJson) as Array<{ title?: string; assigneeAgentId?: string; description?: string }>;
} catch (err) {
log.warn(`[api/meetings] actionItemsJson parse failed:`, err instanceof Error ? err.message : String(err));
server.json(res, 400, { error: "actionItemsJson must be valid JSON" });
return;
}
for (const item of actionItems) {
if (item.assigneeAgentId && !db.core.settings.belongsToCompany("agents", item.assigneeAgentId, token.companyId)) {
server.json(res, 400, { error: "Meeting action item assignees must belong to the current company" });
return;
}
}
}
db.core.knowledge.saveMeeting({
id: meetingId,
companyId: token.companyId,
type: data.type ?? existing.type,
title: data.title ?? existing.title,
organizerAgentId: existing.organizerAgentId,
participantAgentIds: existing.participantAgentIds,
durationMinutes: existing.durationMinutes,
agendaJson: existing.agendaJson,
notesJson: data.notesJson ?? existing.notesJson ?? "[]",
decisionsJson: data.decisionsJson ?? existing.decisionsJson ?? "[]",
actionItemsJson: data.actionItemsJson ?? existing.actionItemsJson ?? "[]",
status: data.status ?? existing.status,
scheduledAt: data.scheduledAt ?? existing.scheduledAt,
});
onDomainChanged();
// When action items are added, create tasks for them automatically
if (actionItems.length > 0) {
for (const item of actionItems) {
if (item.title && typeof item.title === "string") {
const meetingTaskValidation = validateTaskCreation(
{ title: item.title, description: item.description ?? `Action item from meeting: ${data.title ?? existing.title}` },
);
if (!meetingTaskValidation.valid) {
log.warn(
`[meetings] Skipped invalid action item from meeting ${existing.id}: ${meetingTaskValidation.errors.join("; ")}`,
);
continue;
}
db.core.tasks.saveTask({
companyId: token.companyId,
title: item.title,
description: item.description ?? `Action item from meeting: ${data.title ?? existing.title}`,
assigneeAgentId: item.assigneeAgentId ?? null,
status: item.assigneeAgentId ? "todo" : "backlog",
priority: "medium",
requiresFilesystem: false,
});
if (item.assigneeAgentId) {
onWakeAgent(item.assigneeAgentId, token.companyId, "assignment");
}
}
}
}
server.json(res, 200, { updated: true });
});
}
-179
View File
@@ -1,179 +0,0 @@
import log from "electron-log/main.js";
import { agentMessageInputSchema } from "@shared/contracts";
import { dispatchAgentMessage } from "../messaging/dispatch";
import { requireParam, type AgentApiServer } from "./server-core";
export function registerMessageRoutes(server: AgentApiServer) {
const db = server.db;
const onDomainChanged = server.onDomainChanged;
const onWakeAgent = server.onWakeAgent;
const emitEvent = server.emitEvent;
server.addRoute("POST", "/api/issues/:issueId/comments", (_req, res, params, token, body) => {
const input = body as { body?: string } | null;
if (!input?.body || typeof input.body !== "string") {
server.json(res, 400, { error: "body is required" });
return;
}
if (input.body.length > 4000) {
server.json(res, 400, { error: "Comment body must not exceed 4000 characters" });
return;
}
try {
const task = db.core.entity.getTask(requireParam(params, "issueId"));
if (task.companyId !== token.companyId) {
server.json(res, 404, { error: "Task not found" });
return;
}
const agent = db.core.entity.getAgent(token.agentId);
const comment = db.core.activity.addComment({
companyId: task.companyId,
taskId: task.id,
authorAgentId: token.agentId,
authorName: agent.name,
body: input.body,
});
onDomainChanged();
// Fire comment automation rules
server.callbacks.onCommentPosted(task.id, task.companyId, token.agentId);
const wokenForComment = new Set<string>();
if (task.assigneeAgentId && task.assigneeAgentId !== token.agentId) {
onWakeAgent(task.assigneeAgentId, task.companyId, "comment");
wokenForComment.add(task.assigneeAgentId);
}
const chain = db.core.agents.getChainOfCommand(token.agentId);
if (chain.length > 0 && chain[0].id !== token.agentId && !wokenForComment.has(chain[0].id)) {
onWakeAgent(chain[0].id, task.companyId, "comment");
wokenForComment.add(chain[0].id);
}
// @mention detection: wake any mentioned agents not already woken
const mentionedIds = db.core.agents.findMentionedAgentIds(task.companyId, input.body);
for (const mentionedId of mentionedIds) {
if (mentionedId !== token.agentId && !wokenForComment.has(mentionedId)) {
onWakeAgent(mentionedId, task.companyId, "comment_mentioned");
wokenForComment.add(mentionedId);
}
}
server.json(res, 201, comment);
} catch (err) {
log.warn(`[api/messages] addComment failed:`, err instanceof Error ? err.message : String(err));
server.json(res, 404, { error: "Task not found" });
}
});
server.addRoute("GET", "/api/issues/:issueId/comments", (_req, res, params, token) => {
try {
const task = db.core.entity.getTask(requireParam(params, "issueId"));
if (task.companyId !== token.companyId) {
server.json(res, 404, { error: "Task not found" });
return;
}
} catch (err) {
log.warn(`[api/messages] getTask failed for comments list:`, err instanceof Error ? err.message : String(err));
server.json(res, 404, { error: "Task not found" });
return;
}
const comments = db.core.activity.listComments(requireParam(params, "issueId"));
server.json(res, 200, comments);
});
server.addRoute("POST", "/api/companies/:companyId/messages", (_req, res, params, token, body) => {
const companyId = requireParam(params, "companyId");
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const parsed = agentMessageInputSchema.safeParse({
...(body as Record<string, unknown>),
companyId,
fromAgentId: token.agentId,
});
if (!parsed.success) {
server.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() });
return;
}
const dispatchResult = dispatchAgentMessage(
{
db,
emitEvent,
notify: server.callbacks.notify,
publishDomainChanged: onDomainChanged,
wakeAgentIfPossible: (agentId, cid, trigger) => {
if (!agentId) return null;
return onWakeAgent(agentId, cid, trigger);
},
injectRuntimeMessage: server.callbacks.injectRuntimeMessage,
onHumanMessage: server.callbacks.onHumanMessage,
},
parsed.data,
"message",
);
if (!dispatchResult.ok) {
server.json(res, 400, { error: dispatchResult.error.message, code: dispatchResult.error.code });
return;
}
server.json(res, 201, { id: dispatchResult.data.id });
});
server.addRoute("GET", "/api/companies/:companyId/messages", (req, res, params, token) => {
const companyId = requireParam(params, "companyId");
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const url = new URL(req.url ?? "/", `http://127.0.0.1:${server.port}`);
const channelParam = url.searchParams.get("channel");
const channelTargetId = url.searchParams.get("channelTargetId");
const unreadOnly = url.searchParams.get("unreadOnly") === "true";
const limitStr = url.searchParams.get("limit");
const limit = limitStr ? Math.min(Math.max(parseInt(limitStr, 10) || 50, 1), 200) : 50;
const allowedChannels = new Set(["direct", "department", "company", "project", "incident"]);
if (channelParam && !allowedChannels.has(channelParam)) {
server.json(res, 400, { error: `Invalid channel filter: ${channelParam}` });
return;
}
const messages = db.core.messages.listAgentMessages(companyId, {
agentId: token.agentId,
channel: channelParam ?? undefined,
channelTargetId: channelTargetId && channelTargetId.length > 0 ? channelTargetId : undefined,
unreadOnly,
limit,
});
server.json(res, 200, messages);
});
server.addRoute("POST", "/api/companies/:companyId/messages/:messageId/read", (_req, res, params, token) => {
const companyId = requireParam(params, "companyId");
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const message = db.core.messages.getAgentMessage(requireParam(params, "messageId"), companyId);
if (!message) {
server.json(res, 404, { error: "Message not found" });
return;
}
const visibleToAgent = db.core.messages.listAgentMessages(companyId, {
agentId: token.agentId,
messageId: message.id,
limit: 1,
}).some((entry) => entry.id === message.id);
if (!visibleToAgent) {
server.json(res, 403, { error: "This message is not visible to the current agent" });
return;
}
db.core.messages.markMessageReadForReader(requireParam(params, "messageId"), companyId, token.agentId);
onDomainChanged();
server.json(res, 200, { ok: true });
});
}
-301
View File
@@ -1,301 +0,0 @@
import { z } from "zod";
import { canonicalJsonString, jsonStringArraySchema } from "@shared/contracts";
import { requireParam, type AgentApiServer } from "./server-core";
import { SocialAccountScopeError } from "../database/social";
const socialDraftSchema = z.object({
socialAccountId: z.string().uuid(),
platform: z.string(),
content: z.string().min(1).max(10000),
mediaUrls: z.array(z.string()).default([]),
scheduledAt: z.string().datetime().optional(),
});
function writeSocialScopeError(
server: AgentApiServer,
res: Parameters<AgentApiServer["json"]>[0],
error: unknown,
): boolean {
if (error instanceof SocialAccountScopeError) {
server.json(res, 422, { error: error.message, reference: error.reference });
return true;
}
return false;
}
export function registerMiscRoutes(server: AgentApiServer) {
const db = server.db;
const onDomainChanged = server.onDomainChanged;
server.addRoute("POST", "/api/companies/:companyId/browser/action", async (_req, res, params, token, body) => {
const companyId = requireParam(params, "companyId");
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const actionSchema = z.object({
socialAccountId: z.string().uuid(),
actionType: z.enum(["post", "reply", "like", "follow", "browse_feed", "screenshot", "navigate", "search"]),
payload: z.record(z.string(), z.unknown()).default({}),
});
const parsed = actionSchema.safeParse(body);
if (!parsed.success) {
server.json(res, 400, { error: "Invalid browser action payload", details: parsed.error.issues });
return;
}
const input = parsed.data;
const account = db.core.social.getSocialAccount(input.socialAccountId, companyId);
if (!account) {
server.json(res, 404, { error: "Social account not found" });
return;
}
const postActions = ["post", "reply", "dm"];
if (account.requireApproval && postActions.includes(input.actionType)) {
const approvalId = db.core.approvals.requestApproval({
companyId,
type: "dangerous_command",
relatedTaskId: null,
requestedByAgentId: token.agentId,
payloadSummary: `${input.actionType} on ${account.platform}/@${account.accountName}: ${String(input.payload.content || "").slice(0, 100)}`,
impactSummary: `Will ${input.actionType} on ${account.displayName || account.accountName}'s ${account.platform} account. Content will be publicly visible.`,
});
let actionId: string;
try {
actionId = db.core.social.saveBrowserAction({
companyId,
socialAccountId: input.socialAccountId,
agentId: token.agentId,
actionType: input.actionType,
payloadJson: JSON.stringify(input.payload),
status: "approval_required",
approvalId,
});
} catch (error) {
if (writeSocialScopeError(server, res, error)) return;
throw error;
}
db.core.activity.addActivity({
companyId,
actor: token.agentId,
action: "browser.action_pending_approval",
entityType: "browser_action",
entityId: actionId,
detail: `${input.actionType} on ${account.platform} requires approval`,
});
onDomainChanged();
server.json(res, 202, { id: actionId, status: "approval_required", approvalId, message: "Action requires Board approval before execution" });
return;
}
let actionId: string;
try {
actionId = db.core.social.saveBrowserAction({
companyId,
socialAccountId: input.socialAccountId,
agentId: token.agentId,
actionType: input.actionType,
payloadJson: JSON.stringify(input.payload),
status: "queued",
});
} catch (error) {
if (writeSocialScopeError(server, res, error)) return;
throw error;
}
db.core.activity.addActivity({
companyId,
actor: token.agentId,
action: "browser.action_queued",
entityType: "browser_action",
entityId: actionId,
detail: `${input.actionType} on ${account.platform}/@${account.accountName}`,
});
onDomainChanged();
const onBrowserAction = server.onBrowserAction;
if (onBrowserAction) {
try {
onBrowserAction(actionId, companyId);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
db.core.social.updateBrowserAction(actionId, {
status: "failed",
resultSummary: "Browser action dispatch failed before execution completed.",
errorMessage: message,
finishedAt: new Date().toISOString(),
});
db.core.activity.addActivity({
companyId,
actor: token.agentId,
action: "browser.action_dispatch_failed",
entityType: "browser_action",
entityId: actionId,
detail: message,
});
onDomainChanged();
server.json(res, 500, { id: actionId, status: "failed", error: "Browser action dispatch failed" });
return;
}
}
server.json(res, 201, { id: actionId, status: "queued", message: "Browser action queued for execution" });
});
server.addRoute("GET", "/api/companies/:companyId/social-accounts", (_req, res, params, token) => {
if (params.companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const accounts = db.core.social.listSocialAccounts(requireParam(params, "companyId"));
const sanitized = accounts.map(a => ({
id: a.id,
platform: a.platform,
accountName: a.accountName,
displayName: a.displayName,
profileUrl: a.profileUrl,
status: a.status,
requireApproval: a.requireApproval,
}));
server.json(res, 200, sanitized);
});
server.addRoute("POST", "/api/companies/:companyId/social/draft", async (_req, res, params, token, body) => {
const companyId = requireParam(params, "companyId");
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const parsed = socialDraftSchema.safeParse(body);
if (!parsed.success) {
server.json(res, 400, { error: "Invalid draft", details: parsed.error.issues });
return;
}
const account = db.core.social.getSocialAccount(parsed.data.socialAccountId, companyId);
if (!account) {
server.json(res, 404, { error: "Social account not found" });
return;
}
const contentPreview = parsed.data.content.slice(0, 120) + (parsed.data.content.length > 120 ? "..." : "");
const approvalId = db.core.approvals.requestApproval({
companyId,
requestedByAgentId: token.agentId,
type: "social_post",
payloadSummary: contentPreview,
impactSummary: `Post to ${account.platform} via @${account.accountName}`,
payloadJson: JSON.stringify({
socialAccountId: parsed.data.socialAccountId,
platform: account.platform,
content: parsed.data.content,
mediaUrls: parsed.data.mediaUrls,
scheduledAt: parsed.data.scheduledAt ?? null,
}),
});
onDomainChanged();
server.json(res, 201, { approvalId, status: "pending_approval" });
});
server.addRoute("GET", "/api/companies/:companyId/browser/actions", (req, res, params, token) => {
if (params.companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const url = new URL(req.url ?? "/", `http://127.0.0.1:${server.port}`);
const socialAccountId = url.searchParams.get("socialAccountId") ?? undefined;
const status = url.searchParams.get("status") ?? undefined;
const limit = Number(url.searchParams.get("limit") || 50);
const actions = db.core.social.listBrowserActions(requireParam(params, "companyId"), { socialAccountId, status, limit });
server.json(res, 200, actions);
});
server.addRoute("POST", "/api/companies/:companyId/knowledge", (_req, res, params, token, body) => {
if (params.companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const knowledgeCreateSchema = z.object({
category: z.string().default("lesson_learned"),
topic: z.string().default(""),
content: z.string().default(""),
importance: z.string().default("medium"),
tagsJson: canonicalJsonString(jsonStringArraySchema, []),
referencedEntityType: z.string().nullable().optional(),
referencedEntityId: z.string().nullable().optional(),
});
const parsed = knowledgeCreateSchema.safeParse(body);
if (!parsed.success) {
server.json(res, 400, { error: "Invalid knowledge entry payload", details: parsed.error.issues });
return;
}
const input = parsed.data;
const id = db.core.knowledge.saveKnowledgeEntry({
companyId: token.companyId,
category: input.category,
topic: input.topic,
content: input.content,
authorAgentId: token.agentId,
importance: input.importance,
tagsJson: input.tagsJson,
referencedEntityType: input.referencedEntityType ?? null,
referencedEntityId: input.referencedEntityId ?? null,
sourceRunId: token.runId ?? null,
});
onDomainChanged();
server.json(res, 201, { id });
});
server.addRoute("GET", "/api/companies/:companyId/knowledge", (_req, res, params, token) => {
if (params.companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const entries = db.core.knowledge.listKnowledgeEntries(token.companyId);
server.json(res, 200, entries);
});
// ── Skill REST API endpoints ──────────────────────────────────────
server.addRoute("GET", "/api/companies/:companyId/skills", (_req, res, params, token) => {
const companyId = requireParam(params, "companyId");
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const skills = db.core.skills.listCompanySkills(companyId);
server.json(res, 200, skills.map((s) => ({
id: s.id,
name: s.name,
slug: s.slug,
description: s.description,
sourceType: s.sourceType,
trustLevel: s.trustLevel,
createdAt: s.createdAt,
})));
});
server.addRoute("GET", "/api/skills/:skillId", (_req, res, params, token) => {
const skill = db.core.skills.getCompanySkill(token.companyId, requireParam(params, "skillId"));
if (!skill) {
server.json(res, 404, { error: "Skill not found" });
return;
}
server.json(res, 200, skill);
});
}
-95
View File
@@ -1,95 +0,0 @@
import type { TokenPayload } from "../agent-auth";
import { requireParam, type AgentApiServer } from "./server-core";
const DEFAULT_EVENT_LIMIT = 500;
const MAX_EVENT_LIMIT = 5_000;
const SSE_BATCH_LIMIT = 500;
const EVENT_POLL_INTERVAL_MS = 1_000;
const SSE_HEARTBEAT_MS = 15_000;
function parsePositiveInt(value: string | null, fallback: number) {
const parsed = Number.parseInt(value ?? "", 10);
if (!Number.isFinite(parsed) || parsed < 0) {
return fallback;
}
return parsed;
}
function resolveAuthorizedRun(server: AgentApiServer, token: TokenPayload, runId: string) {
const runState = server.db.core.runs.getRunAuthState(runId);
if (!runState || runState.companyId !== token.companyId || runState.agentId !== token.agentId) {
return null;
}
return runState;
}
function writeSse(res: Parameters<AgentApiServer["json"]>[0], event: string, data: unknown) {
res.write(`event: ${event}\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
}
export function registerRunRoutes(server: AgentApiServer) {
server.addRoute("GET", "/api/runs/:runId/events", (req, res, params, token) => {
const runId = requireParam(params, "runId");
const runState = resolveAuthorizedRun(server, token, runId);
if (!runState) {
server.json(res, 404, { error: "Run not found for the current agent token" });
return;
}
const url = new URL(req.url ?? "/", `http://127.0.0.1:${server.port}`);
const offset = parsePositiveInt(url.searchParams.get("offset"), 0);
const limit = Math.min(parsePositiveInt(url.searchParams.get("limit"), DEFAULT_EVENT_LIMIT), MAX_EVENT_LIMIT);
const events = server.db.core.runs.listRunEvents(runId, token.companyId, { offset, limit });
server.json(res, 200, { events, nextOffset: offset + events.length });
});
server.addRoute("GET", "/api/runs/:runId/events/stream", (req, res, params, token) => {
const runId = requireParam(params, "runId");
const runState = resolveAuthorizedRun(server, token, runId);
if (!runState) {
server.json(res, 404, { error: "Run not found for the current agent token" });
return;
}
const url = new URL(req.url ?? "/", `http://127.0.0.1:${server.port}`);
let cursor = parsePositiveInt(url.searchParams.get("after"), 0);
res.writeHead(200, {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
});
res.flushHeaders();
writeSse(res, "connected", { runId, after: cursor });
const flushPendingEvents = () => {
while (true) {
const events = server.db.core.runs.listRunEventsAfter(runId, token.companyId, cursor, SSE_BATCH_LIMIT);
if (events.length === 0) return;
for (const event of events) {
writeSse(res, "run-event", event);
cursor = event.seq;
}
if (events.length < SSE_BATCH_LIMIT) return;
}
};
flushPendingEvents();
const pollTimer = setInterval(flushPendingEvents, EVENT_POLL_INTERVAL_MS);
const heartbeatTimer = setInterval(() => {
res.write(":keepalive\n\n");
}, SSE_HEARTBEAT_MS);
const cleanup = () => {
clearInterval(pollTimer);
clearInterval(heartbeatTimer);
};
req.on("close", cleanup);
req.on("error", cleanup);
res.on("close", cleanup);
res.on("error", cleanup);
});
}
-384
View File
@@ -1,384 +0,0 @@
import log from "electron-log/main.js";
import { z } from "zod";
import { taskInputSchema } from "@shared/contracts";
import { IMPORTANT_DOC_TYPES } from "@shared/document-categories";
import { canAssignTask, canUpdateTask } from "../agent-permissions";
import { inferTaskType } from "../connector-matching";
import { validateTaskCreation } from "../task-quality-gates";
import { resolveTaskWorkspaceBinding } from "../task-workspace-binding";
import { requireParam, type AgentApiServer } from "./server-core";
const taskUpdateSchema = z.object({
status: z.enum(["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"]).optional(),
comment: z.string().min(1).max(4000).optional(),
title: z.string().min(2).max(160).optional(),
description: z.string().max(4000).optional(),
assigneeAgentId: z.string().uuid().nullable().optional(),
priority: z.enum(["critical", "high", "medium", "low"]).optional(),
requiresFilesystem: z.boolean().optional(),
});
const checkoutSchema = z.object({
agentId: z.string().uuid().optional(),
});
export function registerTaskRoutes(server: AgentApiServer) {
const db = server.db;
const callbacks = server.callbacks;
const onDomainChanged = server.onDomainChanged;
const onWakeAgent = server.onWakeAgent;
server.addRoute("GET", "/api/companies/:companyId/issues", (req, res, params, token) => {
const companyId = requireParam(params, "companyId");
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const url = new URL(req.url ?? "/", `http://127.0.0.1:${server.port}`);
const assigneeAgentId = url.searchParams.get("assigneeAgentId");
const statusFilter = url.searchParams.get("status");
const statuses = statusFilter ? statusFilter.split(",") : ["todo", "in_progress", "blocked"];
if (assigneeAgentId) {
const tasks = db.core.tasks.listAgentTasks(assigneeAgentId, companyId, statuses);
server.json(res, 200, tasks);
} else {
const tasks = db.core.tasks.listTasksByStatuses(companyId, statuses);
server.json(res, 200, tasks);
}
});
server.addRoute("GET", "/api/issues/:issueId", (_req, res, params, token) => {
try {
const task = db.core.entity.getTask(requireParam(params, "issueId"));
if (task.companyId !== token.companyId) {
server.json(res, 404, { error: "Task not found" });
return;
}
server.json(res, 200, task);
} catch (err) {
log.warn(`[api/tasks] getTask failed:`, err instanceof Error ? err.message : String(err));
server.json(res, 404, { error: "Task not found" });
}
});
server.addRoute("POST", "/api/issues/:issueId/checkout", (_req, res, params, token, body) => {
const parsedBody = checkoutSchema.safeParse(body);
if (!parsedBody.success) {
server.json(res, 400, { error: "Invalid checkout payload" });
return;
}
const agentId = parsedBody.data.agentId ?? token.agentId;
if (agentId !== token.agentId) {
server.json(res, 403, { error: "Agents can only checkout work for themselves" });
return;
}
let task;
try {
task = db.core.entity.getTask(requireParam(params, "issueId"));
} catch (err) {
log.warn(`[api/tasks] getTask failed for checkout:`, err instanceof Error ? err.message : String(err));
server.json(res, 404, { error: "Task not found" });
return;
}
if (task.companyId !== token.companyId) {
server.json(res, 404, { error: "Task not found" });
return;
}
if (task.assigneeAgentId && !canUpdateTask(db, token.companyId, token.agentId, task)) {
server.json(res, 403, { error: "Current agent is not allowed to checkout this task" });
return;
}
const previousStatus = task.status;
const success = db.core.tasks.checkoutTask(task.id, token.runId);
if (!success) {
server.json(res, 409, { error: "Task cannot be checked out — already assigned or not in valid status" });
return;
}
onDomainChanged();
// Checkout changes status from todo/backlog to in_progress — use actual previous status
callbacks.onTaskStatusChanged(task.id, task.companyId, previousStatus, "in_progress");
server.json(res, 200, { ok: true });
});
server.addRoute("PATCH", "/api/issues/:issueId", (_req, res, params, token, body) => {
const parsedUpdate = taskUpdateSchema.safeParse(body);
if (!parsedUpdate.success) {
server.json(res, 400, { error: "Invalid task update payload" });
return;
}
const update = parsedUpdate.data;
try {
const task = db.core.entity.getTask(requireParam(params, "issueId"));
if (task.companyId !== token.companyId) {
server.json(res, 404, { error: "Task not found" });
return;
}
if (!canUpdateTask(db, token.companyId, token.agentId, task)) {
server.json(res, 403, { error: "Current agent is not allowed to update this task" });
return;
}
const newAssignee = update.assigneeAgentId !== undefined ? update.assigneeAgentId : task.assigneeAgentId;
if (!canAssignTask(db, token.companyId, token.agentId, newAssignee ?? null)) {
server.json(res, 403, { error: "Current agent is not allowed to assign this task to the selected agent" });
return;
}
// Validate status transition
const effectiveStatus = update.status ?? task.status;
const VALID_STATUSES = new Set(["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"]);
if (update.status && !VALID_STATUSES.has(update.status)) {
server.json(res, 400, { error: `Invalid task status: ${update.status}` });
return;
}
const actorAgent = db.core.entity.getAgent(token.agentId);
let reviewRequest: { reviewerAgentName: string; documents: Array<{ id: string; title: string } & Record<string, unknown>> } | null = null;
if (effectiveStatus === "done" && task.requiresUserReview) {
const docs = db.core.documents.listImportantDocsForReview({
companyId: task.companyId,
types: IMPORTANT_DOC_TYPES,
authorAgentId: task.assigneeAgentId ?? null,
taskId: task.id,
limit: 10,
});
reviewRequest = { reviewerAgentName: actorAgent.name, documents: docs };
}
const nextRequiresFilesystem = update.requiresFilesystem ?? task.requiresFilesystem;
const workspaceBinding = resolveTaskWorkspaceBinding(db, {
companyId: task.companyId,
requiresFilesystem: nextRequiresFilesystem,
explicitWorkspaceId: task.workspaceId,
projectId: task.projectId,
assigneeAgentId: newAssignee,
creatorAgentId: token.agentId,
currentRunId: token.runId ?? null,
});
if (!workspaceBinding.ok) {
server.json(res, 422, { error: workspaceBinding.error });
return;
}
const updateResult = db.core.tasks.updateTaskWithReviewSideEffects({
taskId: task.id,
companyId: task.companyId,
actorAgentId: token.agentId,
actorName: actorAgent.name,
task: {
title: update.title ?? task.title,
description: update.description ?? task.description,
status: effectiveStatus,
priority: update.priority ?? task.priority,
projectId: task.projectId,
goalId: task.goalId,
parentId: task.parentId,
assigneeAgentId: newAssignee,
workspaceId: workspaceBinding.workspaceId,
requiresFilesystem: nextRequiresFilesystem,
},
comment: update.comment,
reviewRequest,
activity: {
action: `task.${update.status ? "status_changed" : "updated"}`,
detail: update.comment ?? `Status -> ${update.status ?? task.status}`,
},
});
onDomainChanged();
// Fire automation rules for status changes
const previousStatus = updateResult.previousStatus;
const newStatus = updateResult.newStatus;
if (update.status && updateResult.statusChanged) {
callbacks.onTaskStatusChanged(task.id, task.companyId, previousStatus, newStatus);
}
if (update.status && ["done", "in_review"].includes(newStatus)) {
if (updateResult.parentId) {
try {
const parentTask = db.core.entity.getTask(updateResult.parentId);
if (parentTask.assigneeAgentId && parentTask.assigneeAgentId !== token.agentId) {
onWakeAgent(parentTask.assigneeAgentId, task.companyId, "subtask_completed");
}
} catch (error) {
log.warn(
`[api/tasks] parent task lookup failed for ${updateResult.parentId}: ${error instanceof Error ? error.message : String(error)}`,
);
}
} else {
// Top-level task submission goes to the assignee's manager.
// This is not a subtask — use a distinct wake reason so the
// reviewer prompt does not misframe the work as "a subtask
// you delegated".
const chain = db.core.agents.getChainOfCommand(token.agentId);
if (chain.length > 0) {
onWakeAgent(chain[0].id, task.companyId, "task_review_requested");
}
}
}
if (update.status && newStatus === "blocked") {
const chain = db.core.agents.getChainOfCommand(token.agentId);
if (chain.length > 0) {
onWakeAgent(chain[0].id, task.companyId, "report_blocked");
}
}
if (update.status && ["in_progress", "todo"].includes(newStatus) &&
updateResult.newAssigneeAgentId && updateResult.newAssigneeAgentId !== token.agentId) {
onWakeAgent(updateResult.newAssigneeAgentId, task.companyId, "assignment");
}
if (update.comment) {
// Fire comment automation rules
callbacks.onCommentPosted(task.id, task.companyId, token.agentId);
const wokenForComment = new Set<string>();
if (task.assigneeAgentId && task.assigneeAgentId !== token.agentId) {
onWakeAgent(task.assigneeAgentId, task.companyId, "comment");
wokenForComment.add(task.assigneeAgentId);
}
const chain = db.core.agents.getChainOfCommand(token.agentId);
if (chain.length > 0 && chain[0].id !== token.agentId && !wokenForComment.has(chain[0].id)) {
onWakeAgent(chain[0].id, task.companyId, "comment");
}
}
// Wake new assignee when task is reassigned
if (updateResult.newAssigneeAgentId && updateResult.newAssigneeAgentId !== updateResult.previousAssigneeAgentId && updateResult.newAssigneeAgentId !== token.agentId) {
onWakeAgent(updateResult.newAssigneeAgentId, task.companyId, "assignment");
}
server.json(res, 200, { ok: true });
} catch (error) {
if (error instanceof Error && error.message.includes("require an assignee")) {
server.json(res, 422, { error: error.message });
return;
}
if (error instanceof Error && error.message.includes("not found")) {
server.json(res, 404, { error: error.message });
return;
}
// Top-level assignee self-review guard: translate to a 422 so the
// agent sees an actionable business-logic rejection rather than a
// generic server failure.
if (error instanceof Error && error.message.includes("cannot enter in_review")) {
server.json(res, 422, {
error: error.message,
code: "NO_REVIEWER_ON_TOP_LEVEL_TASK",
hint: "Either reassign the task to a report, mark it done directly if the work is complete, or set requires_user_review=1 if the Board should review.",
});
return;
}
server.json(res, 500, { error: error instanceof Error ? error.message : "Task update failed" });
}
});
server.addRoute("POST", "/api/companies/:companyId/issues", (_req, res, params, token, body) => {
const companyId = requireParam(params, "companyId");
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const parsed = taskInputSchema.safeParse({
companyId,
...(typeof body === "object" && body ? body : {}),
});
if (!parsed.success) {
server.json(res, 400, { error: "Invalid task payload" });
return;
}
const input = parsed.data;
if (server.companyRefError(res, companyId, [
{ id: input.assigneeAgentId, table: "agents", label: "assignee" },
{ id: input.parentId, table: "tasks", label: "parent task" },
{ id: input.projectId, table: "projects", label: "project" },
{ id: input.goalId, table: "goals", label: "goal" },
{ id: input.workspaceId, table: "workspaces", label: "workspace" },
])) {
return;
}
if (!canAssignTask(db, companyId, token.agentId, input.assigneeAgentId ?? null)) {
server.json(res, 403, { error: "Current agent is not allowed to assign this task to the selected agent" });
return;
}
const taskValidation = validateTaskCreation({ title: input.title, description: input.description ?? "" });
if (!taskValidation.valid) {
server.json(res, 422, { error: taskValidation.errors.join("; ") });
return;
}
let id: string;
try {
const workspaceBinding = resolveTaskWorkspaceBinding(db, {
companyId,
requiresFilesystem: input.requiresFilesystem,
explicitWorkspaceId: input.workspaceId ?? null,
projectId: input.projectId ?? null,
assigneeAgentId: input.assigneeAgentId ?? null,
creatorAgentId: token.agentId,
currentRunId: token.runId ?? null,
});
if (!workspaceBinding.ok) {
server.json(res, 422, { error: workspaceBinding.error });
return;
}
id = db.core.tasks.saveTask({
companyId,
title: input.title,
description: input.description ?? "",
assigneeAgentId: input.assigneeAgentId ?? null,
parentId: input.parentId ?? null,
projectId: input.projectId ?? null,
goalId: input.goalId ?? null,
priority: input.priority,
status: input.status,
workspaceId: workspaceBinding.workspaceId,
requiresFilesystem: input.requiresFilesystem,
});
} catch (error) {
server.json(res, 422, { error: error instanceof Error ? error.message : "Task could not be saved" });
return;
}
// Auto-infer task_type from assignee's department
if (input.assigneeAgentId) {
const assignee = db.core.entity.getAgent(input.assigneeAgentId);
const inferredType = inferTaskType(assignee.department);
db.core.tasks.setTaskType(id, inferredType);
}
db.core.activity.addActivity({
companyId,
actor: token.agentId,
action: "task.created",
entityType: "task",
entityId: id,
detail: input.title,
});
onDomainChanged();
// Fire the full task creation event chain (automation rules, auto-assign, etc.)
callbacks.onTaskCreated(id, companyId);
// Also wake the assignee directly if specified
if (input.assigneeAgentId && input.assigneeAgentId !== token.agentId) {
onWakeAgent(input.assigneeAgentId, companyId, "assignment");
}
// If no assignee specified, auto-assign
if (!input.assigneeAgentId) {
const assignedId = callbacks.onAutoAssignTask(id, companyId);
if (assignedId && assignedId !== token.agentId) {
onWakeAgent(assignedId, companyId, "assignment");
}
}
server.json(res, 201, { id });
});
}
-282
View File
@@ -1,282 +0,0 @@
import log from "electron-log/main.js";
import { z } from "zod";
import { goalInputSchema, projectInputSchema } from "@shared/contracts";
import { canManageGoal, canManageProject } from "../agent-permissions";
import { requireParam, type AgentApiServer } from "./server-core";
export function registerWorkGraphRoutes(server: AgentApiServer) {
const db = server.db;
const onDomainChanged = server.onDomainChanged;
const onWakeAgent = server.onWakeAgent;
server.addRoute("POST", "/api/companies/:companyId/projects", (_req, res, params, token, body) => {
const companyId = requireParam(params, "companyId");
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const parsed = projectInputSchema.safeParse({
companyId,
...(typeof body === "object" && body ? body : {}),
});
if (!parsed.success) {
server.json(res, 400, { error: "Invalid project payload" });
return;
}
const input = parsed.data;
if (server.companyRefError(res, companyId, [
{ id: input.goalId, table: "goals", label: "goal" },
{ id: input.leadAgentId, table: "agents", label: "lead agent" },
])) {
return;
}
if (!canManageProject(db, companyId, token.agentId, input.leadAgentId ?? null)) {
server.json(res, 403, { error: "Current agent is not allowed to create or assign this project" });
return;
}
const id = db.core.entity.saveProject({
companyId,
name: input.name,
description: input.description ?? "",
goalId: input.goalId ?? null,
leadAgentId: input.leadAgentId ?? null,
status: (input.status as "planned") ?? "planned",
targetDate: input.targetDate ?? null,
});
db.core.activity.addActivity({
companyId,
actor: token.agentId,
action: "project.created",
entityType: "project",
entityId: id,
detail: input.name,
});
onDomainChanged();
if (input.leadAgentId && input.leadAgentId !== token.agentId) {
onWakeAgent(input.leadAgentId, companyId, "assignment");
}
server.json(res, 201, { id });
});
server.addRoute("PATCH", "/api/projects/:projectId", (_req, res, params, token, body) => {
const projectId = requireParam(params, "projectId");
const updateSchema = z.object({
name: z.string().min(2).max(120).optional(),
description: z.string().max(2000).optional(),
status: z.enum(["planned", "active", "completed", "cancelled"]).optional(),
leadAgentId: z.string().uuid().optional(),
goalId: z.string().uuid().nullable().optional(),
targetDate: z.string().nullable().optional(),
});
const parsed = updateSchema.safeParse(body);
if (!parsed.success) {
server.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() });
return;
}
const existing = db.core.entity.getProjectForUpdate(projectId, token.companyId);
if (!existing) {
server.json(res, 404, { error: "Project not found" });
return;
}
const nextLeadAgentId = parsed.data.leadAgentId ?? existing.leadAgentId ?? null;
if (!canManageProject(db, token.companyId, token.agentId, nextLeadAgentId)) {
server.json(res, 403, { error: "Current agent is not allowed to update this project" });
return;
}
try {
db.core.entity.saveProject({
id: projectId,
companyId: token.companyId,
name: parsed.data.name ?? existing.name,
description: parsed.data.description ?? existing.description,
status: parsed.data.status ?? existing.status,
leadAgentId: nextLeadAgentId,
goalId: parsed.data.goalId !== undefined ? parsed.data.goalId : (existing.goalId ?? null),
targetDate: parsed.data.targetDate !== undefined ? parsed.data.targetDate : (existing.targetDate ?? null),
});
onDomainChanged();
server.json(res, 200, { ok: true });
} catch (err) {
log.warn(`[api/work-graph] Failed to update project:`, err instanceof Error ? err.message : String(err));
server.json(res, 500, { error: "Failed to update project" });
}
});
server.addRoute("POST", "/api/companies/:companyId/goals", (_req, res, params, token, body) => {
const companyId = requireParam(params, "companyId");
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const parsed = goalInputSchema.safeParse({
companyId,
...(typeof body === "object" && body ? body : {}),
});
if (!parsed.success) {
server.json(res, 400, { error: "Invalid goal payload" });
return;
}
const input = parsed.data;
if (server.companyRefError(res, companyId, [
{ id: input.parentId, table: "goals", label: "parent goal" },
{ id: input.ownerAgentId, table: "agents", label: "owner agent" },
])) {
return;
}
if (!canManageGoal(db, companyId, token.agentId, input.ownerAgentId ?? null)) {
server.json(res, 403, { error: "Current agent is not allowed to create this goal" });
return;
}
const id = db.core.entity.saveGoal(input);
db.core.activity.addActivity({
companyId,
actor: token.agentId,
action: "goal.created",
entityType: "goal",
entityId: id,
detail: input.title,
});
onDomainChanged();
if (input.ownerAgentId && input.ownerAgentId !== token.agentId && input.status === "active") {
onWakeAgent(input.ownerAgentId, companyId, "goal_activated");
}
server.json(res, 201, { id });
});
server.addRoute("PATCH", "/api/goals/:goalId", (_req, res, params, token, body) => {
const goalId = requireParam(params, "goalId");
const updateSchema = z.object({
title: z.string().min(2).max(120).optional(),
description: z.string().max(2000).optional(),
status: z.enum(["planned", "active", "achieved", "cancelled"]).optional(),
ownerAgentId: z.string().uuid().optional(),
});
const parsed = updateSchema.safeParse(body);
if (!parsed.success) {
server.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() });
return;
}
const existing = db.core.entity.getGoalForUpdate(goalId, token.companyId);
if (!existing) {
server.json(res, 404, { error: "Goal not found" });
return;
}
const nextOwnerAgentId = parsed.data.ownerAgentId ?? existing.ownerAgentId ?? null;
if (!canManageGoal(db, token.companyId, token.agentId, nextOwnerAgentId)) {
server.json(res, 403, { error: "Current agent is not allowed to update this goal" });
return;
}
try {
db.core.entity.saveGoal({
id: goalId,
companyId: token.companyId,
title: parsed.data.title ?? existing.title,
description: parsed.data.description ?? existing.description,
status: parsed.data.status ?? existing.status,
ownerAgentId: nextOwnerAgentId,
});
onDomainChanged();
server.json(res, 200, { ok: true });
} catch (err) {
log.warn(`[api/work-graph] Failed to update goal:`, err instanceof Error ? err.message : String(err));
server.json(res, 500, { error: "Failed to update goal" });
}
});
server.addRoute("GET", "/api/companies/:companyId/goals", (_req, res, params, token) => {
if (params.companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const goals = db.core.entity.listGoals(params.companyId);
server.json(res, 200, goals);
});
server.addRoute("GET", "/api/companies/:companyId/projects", (_req, res, params, token) => {
if (params.companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const projects = db.core.entity.listProjects(params.companyId);
server.json(res, 200, projects);
});
server.addRoute("GET", "/api/companies/:companyId/sprints", (_req, res, params, token) => {
const companyId = requireParam(params, "companyId");
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const sprints = db.core.knowledge.listSprints(companyId);
server.json(res, 200, sprints);
});
server.addRoute("POST", "/api/companies/:companyId/sprints", (_req, res, params, token, body) => {
const companyId = requireParam(params, "companyId");
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const data = body as Record<string, unknown>;
try {
const id = db.core.knowledge.saveSprint({
companyId,
name: String(data.name ?? "Sprint"),
goal: String(data.goal ?? ""),
startDate: String(data.startDate ?? new Date().toISOString()),
endDate: String(data.endDate ?? new Date(Date.now() + 14 * 86400000).toISOString()),
status: String(data.status ?? "planning") as "planning" | "active" | "review" | "completed" | "cancelled",
retrospectiveNotes: String(data.retrospectiveNotes ?? ""),
velocityPoints: Number(data.velocityPoints ?? 0),
completedPoints: Number(data.completedPoints ?? 0),
});
onDomainChanged();
server.json(res, 201, { id });
} catch (err) {
log.warn(`[api/work-graph] Invalid sprint data:`, err instanceof Error ? err.message : String(err));
server.json(res, 400, { error: "Invalid sprint data" });
}
});
server.addRoute("GET", "/api/companies/:companyId/dashboard", (_req, res, params, token) => {
const companyId = requireParam(params, "companyId");
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const { agents, tasks, recentActivity } = db.core.company.getDashboardSnapshot(companyId);
const tasksByStatus: Record<string, number> = {};
for (const t of tasks) {
tasksByStatus[t.status] = (tasksByStatus[t.status] ?? 0) + 1;
}
server.json(res, 200, {
totalAgents: agents.length,
activeAgents: agents.filter((a) => a.status === "active" || a.status === "running").length,
totalTasks: tasks.length,
tasksByStatus,
recentActivity: recentActivity.map((a) => ({
id: a.id,
actor: a.actor,
action: a.action,
entityType: a.entityType,
entityId: a.entityId,
detail: a.detail,
createdAt: a.createdAt,
})),
});
});
}
-412
View File
@@ -1,412 +0,0 @@
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import log from "electron-log/main.js";
import { validateAgentToken, type TokenPayload } from "../agent-auth";
import type { AppDatabase } from "../database";
import type { AgentMessageRecord, DesktopEvent, SectionId } from "@shared/types";
import type { DocumentLifecycleInput, DocumentLifecycleOutcome } from "../document-lifecycle";
export function decodeBodyToUtf8(raw: Buffer): string {
if (raw.length === 0) return "";
return new TextDecoder("utf-8", { fatal: true }).decode(raw);
}
function parseContentType(value: string | string[] | undefined): { mediaType: string; charsets: string[] } | null {
const raw = Array.isArray(value) ? value[0] : value;
if (!raw) return null;
const [mediaTypeRaw, ...params] = raw.split(";");
const mediaType = mediaTypeRaw?.trim().toLowerCase();
if (!mediaType) return null;
const charsets: string[] = [];
for (const param of params) {
const [keyRaw, valueRaw] = param.split("=");
if (keyRaw?.trim().toLowerCase() === "charset") {
charsets.push(valueRaw?.trim().replace(/^"|"$/g, "").toLowerCase() ?? "");
}
}
return { mediaType, charsets };
}
function getJsonContentTypeError(req: IncomingMessage): string | null {
const parsed = parseContentType(req.headers["content-type"]);
if (!parsed) return null;
if (parsed.mediaType !== "application/json") return "Content-Type must be application/json.";
if (parsed.charsets.length > 1) return "JSON request bodies must declare one charset.";
if (parsed.charsets.some((charset) => charset !== "utf-8" && charset !== "utf8")) {
return "JSON request bodies must declare charset=utf-8.";
}
return null;
}
function firstHeaderValue(value: string | string[] | undefined): string | null {
if (Array.isArray(value)) return value[0] ?? null;
return value ?? null;
}
function extractCompanyIdFromUrl(rawUrl: string | undefined): string | null {
try {
const url = new URL(rawUrl ?? "/", "http://127.0.0.1");
const match = /^\/api\/companies\/([^/]+)/.exec(url.pathname);
return match ? decodeURIComponent(match[1]) : null;
} catch {
return null;
}
}
/**
* Extract a required route parameter or throw a 400-level error.
* The HTTP router guarantees all named params are present when a route
* matches, but TypeScript cannot prove this statically.
*/
export function requireParam(params: Record<string, string>, name: string): string {
const value = params[name];
if (value === undefined) throw Object.assign(new Error(`Missing route parameter: ${name}`), { statusCode: 400 });
return value;
}
export type RequestHandler = (
req: IncomingMessage,
res: ServerResponse,
params: Record<string, string>,
token: TokenPayload,
body: unknown,
) => void | Promise<void>;
export interface Route {
method: string;
pattern: RegExp;
paramNames: string[];
handler: RequestHandler;
}
export interface ApiServerEventCallbacks {
onDomainChanged: () => void;
onWakeAgent: (agentId: string, companyId: string, trigger: string) => string | null;
onTaskCreated: (taskId: string, companyId: string) => void;
onTaskStatusChanged: (taskId: string, companyId: string, previousStatus: string, newStatus: string) => void;
onCommentPosted: (taskId: string, companyId: string, authorAgentId: string | null) => void;
onApprovalCreated: (approvalId: string, companyId: string, taskId: string | null, approvalType: string, summary: string) => void;
onApprovalResolved: (approvalId: string, companyId: string, decision: string) => void;
/**
* Persist a document through the shared `document-lifecycle` pipeline.
* Collapses the two previous entry points (this HTTP route and the
* desktop IPC handler) onto a single fan-out so thread, checkpoint,
* automation, and deliverable-review side effects land consistently
* regardless of which surface authored the write.
*/
createDocument: (input: DocumentLifecycleInput) => DocumentLifecycleOutcome;
onAutoAssignTask: (taskId: string, companyId: string) => string | null;
onHumanMessage: (companyId: string, toAgentId?: string | null, threadId?: string | null, waitStateId?: string | null) => void;
/**
* Tries to hand a freshly persisted message to the recipient's live
* pi run via the runtime steer channel. Returns true when the worker
* accepted the steer; dispatch should then skip the regular wake
* pipeline for this recipient.
*/
injectRuntimeMessage: (recipientAgentId: string, companyId: string, message: AgentMessageRecord) => boolean;
emitEvent: (event: DesktopEvent) => void;
notify: (options: { title: string; body: string; urgency: "critical" | "informational"; navigation?: { section: SectionId; entityId?: string }; batchKey?: string }) => void;
}
const REQUIRED_API_SERVER_CALLBACKS: readonly (keyof ApiServerEventCallbacks)[] = [
"onDomainChanged",
"onWakeAgent",
"onTaskCreated",
"onTaskStatusChanged",
"onCommentPosted",
"onApprovalCreated",
"onApprovalResolved",
"createDocument",
"onAutoAssignTask",
"onHumanMessage",
"injectRuntimeMessage",
"emitEvent",
"notify",
];
function assertCompleteApiServerCallbacks(
callbacks: Partial<ApiServerEventCallbacks> | null | undefined,
): asserts callbacks is ApiServerEventCallbacks {
if (!callbacks) {
throw new Error("Missing AgentApiServer callbacks.");
}
for (const name of REQUIRED_API_SERVER_CALLBACKS) {
if (typeof callbacks[name] !== "function") {
throw new Error(`Missing AgentApiServer callback: ${name}`);
}
}
}
/**
* Token-bucket rate limiter scoped per (companyId, agentId).
*
* An agent that exceeds the burst capacity (`maxTokens`) is rejected with 429
* until enough wall-clock time has passed for the bucket to refill at
* `refillRatePerSec` tokens/second. The bucket is in-memory and resets on
* process restart — agents misbehaving across restarts are detected at the
* next bucket; this is a defence against runaway loops, not a quota system.
*/
class AgentRateLimiter {
private buckets = new Map<string, { tokens: number; lastRefillMs: number }>();
private readonly maxTokens: number;
private readonly refillRatePerSec: number;
constructor(opts: { maxTokens: number; refillRatePerSec: number }) {
this.maxTokens = opts.maxTokens;
this.refillRatePerSec = opts.refillRatePerSec;
}
/** Returns retry-after seconds when blocked, or `null` to allow. */
consume(key: string): number | null {
const now = Date.now();
const existing = this.buckets.get(key);
const bucket = existing ?? { tokens: this.maxTokens, lastRefillMs: now };
if (existing) {
const elapsedSec = (now - existing.lastRefillMs) / 1000;
bucket.tokens = Math.min(this.maxTokens, existing.tokens + elapsedSec * this.refillRatePerSec);
bucket.lastRefillMs = now;
}
if (bucket.tokens < 1) {
this.buckets.set(key, bucket);
const tokensShort = 1 - bucket.tokens;
return Math.max(1, Math.ceil(tokensShort / this.refillRatePerSec));
}
bucket.tokens -= 1;
this.buckets.set(key, bucket);
return null;
}
}
export class AgentApiServer {
private _routes: Route[] = [];
private _server: ReturnType<typeof createServer> | null = null;
private _port = 0;
private _db: AppDatabase;
private _onDomainChangedCb: () => void;
private _onWakeAgentCb: (agentId: string, companyId: string, trigger: string) => string | null;
private _emitEventCb: (event: DesktopEvent) => void;
private _callbacks: ApiServerEventCallbacks;
private _allowedOrigins: Set<string>;
private _onBrowserActionCb: ((actionId: string, companyId: string) => void) | null = null;
/** 60 req/min sustained, 30 req burst per (companyId, agentId). */
private _rateLimiter = new AgentRateLimiter({ maxTokens: 30, refillRatePerSec: 1 });
constructor(
db: AppDatabase,
callbacks: ApiServerEventCallbacks,
allowedOrigins: Iterable<string> = ["app://-"],
) {
assertCompleteApiServerCallbacks(callbacks);
this._db = db;
this._onDomainChangedCb = callbacks.onDomainChanged;
this._onWakeAgentCb = callbacks.onWakeAgent;
this._emitEventCb = callbacks.emitEvent;
this._allowedOrigins = new Set(Array.from(allowedOrigins).filter(Boolean));
this._callbacks = { ...callbacks };
}
setBrowserActionHandler(handler: (actionId: string, companyId: string) => void) {
this._onBrowserActionCb = handler;
}
async start(): Promise<number> {
return new Promise((resolve, reject) => {
this._server = createServer((req, res) => void this.handleRequest(req, res));
this._server.listen(0, "127.0.0.1", () => {
const srv = this._server;
const addr = srv ? srv.address() : null;
this._port = typeof addr === "object" && addr ? addr.port : 0;
log.info(`[api] Agent API server listening on http://127.0.0.1:${this._port}`);
resolve(this._port);
});
this._server.on("error", reject);
});
}
stop() {
this._server?.close();
this._server = null;
}
addRoute(method: string, path: string, handler: RequestHandler) {
const paramNames: string[] = [];
const patternStr = path.replace(/:(\w+)/g, (_match, name: string) => {
paramNames.push(name);
return "([^/]+)";
});
this._routes.push({ method, pattern: new RegExp(`^${patternStr}$`), paramNames, handler });
}
private authenticateRequest(
req: IncomingMessage,
):
| { ok: true; token: TokenPayload }
| { ok: false; status: number; error: string } {
const authHeader = req.headers.authorization;
if (typeof authHeader !== "string" || !authHeader.startsWith("Bearer ")) {
return { ok: false, status: 401, error: "Missing or invalid Authorization header" };
}
const runHeader = req.headers["x-agent-company-run-id"];
const claimedRunId = firstHeaderValue(runHeader);
const token = validateAgentToken(authHeader.slice(7), {
runId: claimedRunId,
companyId: extractCompanyIdFromUrl(req.url),
requestId: firstHeaderValue(req.headers["x-request-id"]),
});
if (!token) {
return { ok: false, status: 401, error: "Invalid or expired token" };
}
if (typeof claimedRunId !== "string" || claimedRunId.trim() !== token.runId) {
return { ok: false, status: 401, error: "Missing or mismatched X-Agent-Company-Run-Id header" };
}
const runState = this._db.core.runs.getRunAuthState(token.runId);
if (!runState || runState.companyId !== token.companyId || runState.agentId !== token.agentId) {
return { ok: false, status: 403, error: "Token references an unknown or mismatched run" };
}
if (!["queued", "running"].includes(runState.status)) {
return { ok: false, status: 401, error: "Run token is no longer active" };
}
return { ok: true, token };
}
private async handleRequest(req: IncomingMessage, res: ServerResponse) {
const origin = req.headers.origin;
const authHeader = req.headers.authorization;
const hasBearer = typeof authHeader === "string" && authHeader.startsWith("Bearer ");
const isPreflight = req.method === "OPTIONS";
// Origin policy:
// - Bearer-token requests (agent CLIs, non-browser) are allowed without an Origin header.
// If an Origin IS present, it must be in the allowlist.
// - All other requests (browser-originating, including CORS preflight) MUST carry an
// Origin header and it MUST be in the allowlist.
if (origin) {
if (!this._allowedOrigins.has(origin)) {
this.json(res, 403, { error: "Origin not allowed" });
return;
}
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Vary", "Origin");
} else if (!hasBearer || isPreflight) {
this.json(res, 403, { error: "Origin header required" });
return;
}
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PATCH, PUT, DELETE, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Agent-Company-Run-Id");
if (isPreflight) {
res.writeHead(204);
res.end();
return;
}
const url = new URL(req.url ?? "/", `http://127.0.0.1:${this._port}`);
const pathname = url.pathname;
const method = (req.method ?? "GET").toUpperCase();
const auth = this.authenticateRequest(req);
if (!auth.ok) {
this.json(res, auth.status, { error: auth.error });
return;
}
const token = auth.token;
const retryAfter = this._rateLimiter.consume(`${token.companyId}:${token.agentId}`);
if (retryAfter !== null) {
res.setHeader("Retry-After", String(retryAfter));
this.json(res, 429, { error: "Rate limit exceeded", retryAfterSeconds: retryAfter });
return;
}
for (const route of this._routes) {
if (route.method !== method) continue;
const match = pathname.match(route.pattern);
if (!match) continue;
const params: Record<string, string> = {};
route.paramNames.forEach((name: string, i: number) => {
params[name] = match[i + 1] ?? "";
});
let body: unknown = null;
if (["POST", "PATCH", "PUT"].includes(method)) {
const contentTypeError = getJsonContentTypeError(req);
if (contentTypeError) {
this.json(res, 415, { error: contentTypeError });
return;
}
try {
body = await this.readBody(req);
} catch (bodyErr) {
if (bodyErr instanceof Error && bodyErr.message === "Request body too large") {
this.json(res, 413, { error: "Request body too large (max 1 MB)" });
} else {
this.json(res, 400, { error: "Invalid JSON body" });
}
return;
}
}
try {
await route.handler(req, res, params, token, body);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log.error(`[api] ${method} ${pathname}: ${message}`);
this.json(res, 500, { error: "Internal server error" });
}
return;
}
this.json(res, 404, { error: "Not found" });
}
private readBody(req: IncomingMessage): Promise<unknown> {
const MAX_BODY_BYTES = 1_048_576; // 1 MB
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let bytes = 0;
req.on("data", (chunk: Buffer) => {
bytes += chunk.length;
if (bytes > MAX_BODY_BYTES) {
req.destroy();
reject(new Error("Request body too large"));
return;
}
chunks.push(chunk);
});
req.on("end", () => {
try {
const raw = Buffer.concat(chunks);
const data = decodeBodyToUtf8(raw);
resolve(data ? JSON.parse(data) : null);
}
catch { reject(new Error("Invalid JSON")); }
});
req.on("error", reject);
});
}
json(res: ServerResponse, status: number, data: unknown) {
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(data));
}
companyRefError(
res: ServerResponse,
companyId: string,
refs: Array<{ id: string | null | undefined; table: "agents" | "goals" | "projects" | "tasks" | "workspaces" | "approvals"; label: string }>,
) {
const invalid = refs.find((ref) => ref.id && !this._db.core.settings.belongsToCompany(ref.table, ref.id, companyId));
if (!invalid) return false;
this.json(res, 422, { error: `${invalid.label} does not belong to the current company` });
return true;
}
// Accessors for route registrars
get db(): AppDatabase { return this._db; }
get callbacks(): ApiServerEventCallbacks { return this._callbacks; }
get onDomainChanged(): () => void { return this._onDomainChangedCb; }
get onWakeAgent(): (agentId: string, companyId: string, trigger: string) => string | null { return this._onWakeAgentCb; }
get emitEvent(): (event: DesktopEvent) => void { return this._emitEventCb; }
get onBrowserAction(): ((actionId: string, companyId: string) => void) | null { return this._onBrowserActionCb; }
get port(): number { return this._port; }
}
-76
View File
@@ -1,76 +0,0 @@
import { app } from "electron";
interface AppLifecycleDependencies {
getMainWindow: () => Electron.BrowserWindow | null;
ensureWindow: () => Promise<Electron.BrowserWindow | null>;
shouldQuit: () => boolean;
/**
* Invoked on the Electron `before-quit` event. The lifecycle service will
* call `event.preventDefault()` on the first invocation so the owner of
* this hook can perform an awaited drain and then invoke `app.exit()`
* explicitly when the drain resolves. Subsequent `before-quit` events are
* ignored because the drain is already in flight.
*/
onBeforeQuit: () => void;
}
export function createAppLifecycleService({
getMainWindow,
ensureWindow,
shouldQuit,
onBeforeQuit,
}: AppLifecycleDependencies) {
function register(hasSingleInstanceLock: boolean) {
app.on("activate", async () => {
const mainWindow = getMainWindow();
if (mainWindow && !mainWindow.isDestroyed()) {
if (!mainWindow.isVisible()) {
mainWindow.show();
}
mainWindow.focus();
return;
}
await ensureWindow();
});
app.on("second-instance", () => {
if (!hasSingleInstanceLock) {
return;
}
const mainWindow = getMainWindow();
if (!mainWindow) {
return;
}
if (!mainWindow.isVisible()) {
mainWindow.show();
}
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
mainWindow.focus();
});
app.on("window-all-closed", () => {
// macOS keeps the app alive when all windows close (standard platform behavior),
// except after the user picked Quit (before-quit already fired -> shouldQuit()).
if (process.platform === "darwin" && !shouldQuit()) {
return;
}
app.quit();
});
let drainStarted = false;
app.on("before-quit", (event) => {
if (drainStarted) return;
drainStarted = true;
// Block Electron's default quit until the owner's drain resolves. The
// owner must call app.exit() itself when drain is complete.
event.preventDefault();
onBeforeQuit();
});
}
return {
register,
};
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Wraps a notification callback so bursts collapse into one trailing call.
* Agent run loops emit change events per tool turn; without coalescing every
* emission forces the renderer through a full snapshot reload, which starves
* the UI thread once the database grows.
*/
export function coalesce(fn: () => void, delayMs: number): () => void {
let timer: NodeJS.Timeout | null = null;
return () => {
if (timer) return;
timer = setTimeout(() => {
timer = null;
fn();
}, delayMs);
};
}
+43
View File
@@ -0,0 +1,43 @@
import { BrowserWindow } from "electron";
import { join } from "node:path";
import { attachMainWindowSecurity } from "./window-security";
export interface CreateWindowOptions {
preloadPath: string;
rendererDist: string;
devServerUrl?: string;
}
export function createMainWindow(options: CreateWindowOptions): BrowserWindow {
const win = new BrowserWindow({
width: 1280,
height: 860,
minWidth: 980,
minHeight: 700,
title: "AgentCompany",
show: false,
titleBarStyle: process.platform === "darwin" ? "hiddenInset" : "hidden",
webPreferences: {
preload: options.preloadPath,
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
webSecurity: true,
allowRunningInsecureContent: false,
},
});
attachMainWindowSecurity(
win,
options.devServerUrl ? new URL(options.devServerUrl).origin : null,
);
win.once("ready-to-show", () => win.show());
if (options.devServerUrl) {
win.loadURL(options.devServerUrl);
} else {
win.loadFile(join(options.rendererDist, "index.html"));
}
return win;
}
+6
View File
@@ -0,0 +1,6 @@
export function normalizeExternalBrowserUrl(value: string): string {
const url = new URL(value);
if (url.protocol !== "https:") throw new Error("External URLs must use HTTPS.");
if (url.username || url.password) throw new Error("External URLs must not include credentials.");
return url.toString();
}
+381
View File
@@ -0,0 +1,381 @@
import { dialog, ipcMain, shell, type BrowserWindow } from "electron";
import type { DatabaseConnection } from "../database/connection";
import { IPC_CHANNELS } from "../../shared/ipc";
import { fail, ok, type DesktopResult } from "../../shared/result";
import type { AppSnapshot } from "../../shared/domain";
import { SnapshotService } from "../services/snapshot-service";
import { OrganizationService } from "../services/organization-service";
import { ApprovalService } from "../services/approval-service";
import { ChatService } from "../services/chat-service";
import { ProviderService } from "../services/provider-service";
import { ProviderOAuthService } from "../services/provider-oauth-service";
import { SkillService } from "../services/skill-service";
import { SocialService } from "../services/social-service";
import { FileService } from "../services/file-service";
import { MigrationService } from "../services/migration-service";
import { OnboardingService } from "../services/onboarding-service";
import { ComplianceService } from "../services/compliance-service";
import { SearchService } from "../services/search-service";
import { IdentityAccessService } from "../services/identity-access-service";
import { OperationsService } from "../services/operations-service";
import { SdlcService } from "../services/sdlc-service";
import { AuditService } from "../services/audit-service";
import { RollbackService } from "../services/rollback-service";
import { TaskExecutionService } from "../services/task-execution-service";
import { DocumentService } from "../services/document-service";
import { KnowledgeService } from "../services/knowledge-service";
import { AutomationService } from "../services/automation-service";
import { MeetingService } from "../services/meeting-service";
import { SprintService } from "../services/sprint-service";
import { ComposioConnectionService } from "../services/composio/composio-connection-service";
import { ConnectorGrantsService } from "../services/composio/connector-grants";
import { coalesce } from "./coalesce";
import { normalizeExternalBrowserUrl } from "./external-url";
export interface IpcDeps {
connection: DatabaseConnection;
mainWindow: BrowserWindow;
artifactRoot: string;
}
export function registerIpcHandlers(deps: IpcDeps): void {
const snapshot = new SnapshotService(deps.connection.db);
const organization = new OrganizationService(deps.connection.db);
const approvals = new ApprovalService(deps.connection.db);
const chat = new ChatService(deps.connection.db);
const providers = new ProviderService(deps.connection.db);
const providerOAuth = new ProviderOAuthService();
const skills = new SkillService(deps.connection.db, deps.artifactRoot);
const notifyDataChanged = coalesce(() => {
try {
if (!deps.mainWindow.isDestroyed() && !deps.mainWindow.webContents.isDestroyed()) {
deps.mainWindow.webContents.send(IPC_CHANNELS.rendererEvent, { type: "snapshot-changed" });
}
} catch { /* renderer may have been disposed during agent run */ }
}, 200);
const taskExecution = new TaskExecutionService(deps.connection.db, providers, skills, notifyDataChanged);
const social = new SocialService(deps.connection.db);
const files = new FileService(deps.connection.db, deps.artifactRoot);
const migrations = new MigrationService(deps.connection.db);
const compliance = new ComplianceService(deps.connection.db);
const search = new SearchService(deps.connection.db);
const identityAccess = new IdentityAccessService(deps.connection.db);
const operations = new OperationsService(deps.connection.db);
const sdlc = new SdlcService(deps.connection.db);
const audit = new AuditService(deps.connection.db);
const rollback = new RollbackService(deps.connection.db);
const documents = new DocumentService(deps.connection.db);
const knowledge = new KnowledgeService(deps.connection.db);
const automation = new AutomationService(deps.connection.db);
const meetingService = new MeetingService(deps.connection.db);
const sprintService = new SprintService(deps.connection.db);
const onboarding = new OnboardingService({
db: deps.connection.db,
saveProviderCredential: (input) => providers.saveCredential(input),
});
const mutate = <T>(operation: () => T): DesktopResult<AppSnapshot> => {
try {
deps.connection.transaction(operation);
notifyDataChanged();
return ok(snapshot.load());
} catch (error) {
return fail("operation_failed", error instanceof Error ? error.message : String(error));
}
};
ipcMain.handle(IPC_CHANNELS.loadSnapshot, () => {
try {
return ok(snapshot.load());
} catch (error) {
return fail("snapshot_failed", error instanceof Error ? error.message : String(error));
}
});
ipcMain.handle(IPC_CHANNELS.bootstrapOnboarding, (_event, input) => {
try {
let summary;
deps.connection.transaction(() => {
summary = onboarding.bootstrap(input);
});
notifyDataChanged();
return ok(summary);
} catch (error) {
return fail("onboarding_failed", error instanceof Error ? error.message : String(error));
}
});
ipcMain.handle(IPC_CHANNELS.createOrganization, (_event, input) => mutate(() => organization.createOrganization(input)));
ipcMain.handle(IPC_CHANNELS.setActiveOrganization, (_event, input) => mutate(() => organization.setActiveOrganization(input.orgId)));
ipcMain.handle(IPC_CHANNELS.deleteOrganization, (_event, input) => mutate(() => organization.deleteOrganization(input.orgId)));
ipcMain.handle(IPC_CHANNELS.createAgent, (_event, input) => mutate(() => organization.createAgent(input)));
ipcMain.handle(IPC_CHANNELS.updateAgent, (_event, input) => mutate(() => organization.updateAgent(input)));
ipcMain.handle(IPC_CHANNELS.createTask, (_event, input) => mutate(() => organization.createTask(input)));
ipcMain.handle(IPC_CHANNELS.updateTask, (_event, input) => mutate(() => organization.updateTask(input)));
ipcMain.handle(IPC_CHANNELS.deleteTask, (_event, input) => mutate(() => organization.deleteTask(input)));
ipcMain.handle(IPC_CHANNELS.updateTaskStatus, (_event, input) => mutate(() => organization.updateTaskStatus(input.orgId, input.taskId, input.status)));
ipcMain.handle(IPC_CHANNELS.runTask, async (_event, input) => {
try {
await taskExecution.runTask(input);
notifyDataChanged();
return ok(snapshot.load());
} catch (error) {
notifyDataChanged();
return fail("task_run_failed", error instanceof Error ? error.message : String(error));
}
});
ipcMain.handle(IPC_CHANNELS.createGoal, (_event, input) => mutate(() => organization.createGoal(input)));
ipcMain.handle(IPC_CHANNELS.createProject, (_event, input) => mutate(() => organization.createProject(input)));
ipcMain.handle(IPC_CHANNELS.createWorkspace, (_event, input) => mutate(() => organization.createWorkspace(input)));
ipcMain.handle(IPC_CHANNELS.createDocument, (_event, input) => mutate(() => documents.create(input)));
ipcMain.handle(IPC_CHANNELS.updateDocument, (_event, input) => mutate(() => documents.update(input)));
ipcMain.handle(IPC_CHANNELS.deleteDocument, (_event, input) => mutate(() => documents.delete(input)));
ipcMain.handle(IPC_CHANNELS.createKnowledgeEntry, (_event, input) => mutate(() => knowledge.create(input)));
ipcMain.handle(IPC_CHANNELS.deleteKnowledgeEntry, (_event, input) => mutate(() => knowledge.delete(input)));
ipcMain.handle(IPC_CHANNELS.createAutomationRule, (_event, input) => mutate(() => automation.create(input)));
ipcMain.handle(IPC_CHANNELS.updateAutomationRule, (_event, input) => mutate(() => automation.update(input)));
ipcMain.handle(IPC_CHANNELS.deleteAutomationRule, (_event, input) => mutate(() => automation.delete(input)));
ipcMain.handle(IPC_CHANNELS.createMeeting, (_event, input) => mutate(() => meetingService.create(input)));
ipcMain.handle(IPC_CHANNELS.deleteMeeting, (_event, input) => mutate(() => meetingService.delete(input)));
ipcMain.handle(IPC_CHANNELS.createSprint, (_event, input) => mutate(() => sprintService.create(input)));
ipcMain.handle(IPC_CHANNELS.deleteSprint, (_event, input) => mutate(() => sprintService.delete(input)));
ipcMain.handle(IPC_CHANNELS.createApproval, (_event, input) => mutate(() => approvals.create(input)));
ipcMain.handle(IPC_CHANNELS.decideApproval, (_event, input) => mutate(() => approvals.decide(input)));
ipcMain.handle(IPC_CHANNELS.syncEmployeeIdentity, (_event, input) => mutate(() => identityAccess.syncEmployeeIdentity(input)));
ipcMain.handle(IPC_CHANNELS.grantRole, (_event, input) => mutate(() => identityAccess.grantRole(input)));
ipcMain.handle(IPC_CHANNELS.createChatSpace, (_event, input) => mutate(() => chat.createSpace(input)));
ipcMain.handle(IPC_CHANNELS.sendMessage, (_event, input) => mutate(() => chat.sendMessage(input)));
ipcMain.handle(IPC_CHANNELS.editMessage, (_event, input) => mutate(() => chat.editMessage(input)));
ipcMain.handle(IPC_CHANNELS.deleteMessage, (_event, input) => mutate(() => chat.deleteMessage(input)));
ipcMain.handle(IPC_CHANNELS.markChatSpaceRead, (_event, input) => mutate(() => chat.markSpaceRead(input)));
ipcMain.handle(IPC_CHANNELS.saveProviderCredential, (_event, input) => mutate(() => providers.saveCredential(input)));
ipcMain.handle(IPC_CHANNELS.revokeProviderCredential, (_event, input) => mutate(() => providers.revokeCredential(input)));
ipcMain.handle(IPC_CHANNELS.updateProviderPolicy, (_event, input) => mutate(() => providers.updatePolicy(input)));
ipcMain.handle(IPC_CHANNELS.checkProviderHealth, async (_event, input) => {
try {
await providers.checkHealth(input.orgId, input.providerId);
notifyDataChanged();
return ok(snapshot.load());
} catch (error) {
return fail("provider_health_failed", error instanceof Error ? error.message : String(error));
}
});
ipcMain.handle(IPC_CHANNELS.listProviderModels, async (_event, input) => {
try {
return ok(await providers.listModels(input));
} catch (error) {
return fail("provider_models_failed", error instanceof Error ? error.message : String(error));
}
});
ipcMain.handle(IPC_CHANNELS.launchProviderSignIn, (_event, input) => {
try {
providers.launchSignInWindow(input);
return ok(null);
} catch (error) {
return fail("provider_sign_in_failed", error instanceof Error ? error.message : String(error));
}
});
ipcMain.handle(IPC_CHANNELS.startProviderOAuth, async (_event, input) => {
const emit = (event: Record<string, unknown>) =>
deps.mainWindow.webContents.send(IPC_CHANNELS.rendererEvent, { type: "provider-oauth", providerKey: input.providerKey, ...event });
try {
const secret = await providerOAuth.run(
{ providerKey: input.providerKey, enterpriseDomain: input.enterpriseDomain ?? null },
{
onAuth: ({ url, instructions }) => {
const browserUrl = normalizeExternalBrowserUrl(url);
void shell.openExternal(browserUrl);
emit({ phase: "authorize", url: browserUrl, instructions });
},
onProgress: (message) => emit({ phase: "progress", message }),
},
);
if (input.orgId && input.providerId && input.employeeId) {
providers.saveCredential({
orgId: input.orgId,
providerId: input.providerId,
secret,
model: input.model ?? null,
employeeId: input.employeeId,
});
emit({ phase: "active" });
notifyDataChanged();
return ok({ secret: null });
}
emit({ phase: "active" });
return ok({ secret });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
emit({ phase: message === "Login cancelled" ? "cancelled" : "error", message });
return fail("provider_oauth_failed", message);
}
});
ipcMain.handle(IPC_CHANNELS.cancelProviderOAuth, (_event, input) => {
providerOAuth.cancel(input.providerKey);
return ok(null);
});
ipcMain.handle(IPC_CHANNELS.openExternalUrl, async (_event, url: string) => {
try {
await shell.openExternal(normalizeExternalBrowserUrl(url));
return ok(null);
} catch (error) {
return fail("external_url_failed", error instanceof Error ? error.message : String(error));
}
});
ipcMain.handle(IPC_CHANNELS.createProviderResponse, async (_event, input) => {
try {
await providers.createResponse(input);
notifyDataChanged();
return ok(snapshot.load());
} catch (error) {
return fail("provider_response_failed", error instanceof Error ? error.message : String(error));
}
});
ipcMain.handle(IPC_CHANNELS.streamProviderResponse, async (_event, input) => {
try {
await providers.streamResponse(input, (streamEvent) => {
deps.mainWindow.webContents.send(IPC_CHANNELS.rendererEvent, { type: "provider-response-stream", event: streamEvent });
});
notifyDataChanged();
return ok(snapshot.load());
} catch (error) {
return fail("provider_stream_failed", error instanceof Error ? error.message : String(error));
}
});
ipcMain.handle(IPC_CHANNELS.cancelProviderResponse, async (_event, input) => {
try {
await providers.cancelResponse(input);
notifyDataChanged();
return ok(snapshot.load());
} catch (error) {
return fail("provider_cancel_failed", error instanceof Error ? error.message : String(error));
}
});
ipcMain.handle(IPC_CHANNELS.importSkillDirectory, (_event, input) => mutate(() => skills.importDirectory(input)));
ipcMain.handle(IPC_CHANNELS.bindSkill, (_event, input) => mutate(() => skills.bind(input)));
ipcMain.handle(IPC_CHANNELS.resolveAgentSkills, (_event, input) => mutate(() => skills.resolveRuntimeSnapshot(input)));
ipcMain.handle(IPC_CHANNELS.runSkillTests, (_event, input) => mutate(() => skills.runTests(input)));
ipcMain.handle(IPC_CHANNELS.createSocialAccount, (_event, input) => mutate(() => social.createAccount(input)));
ipcMain.handle(IPC_CHANNELS.createScheduledPost, (_event, input) => mutate(() => social.createScheduledPost(input)));
ipcMain.handle(IPC_CHANNELS.setScheduledPostState, (_event, input) => mutate(() => social.setScheduledPostState(input)));
ipcMain.handle(IPC_CHANNELS.setFilePinned, (_event, input) => mutate(() => files.setPinned(input.orgId, input.fileId, input.pinned)));
ipcMain.handle(IPC_CHANNELS.launchSocialLogin, (_event, input) => {
try {
social.launchLoginWindow(input);
return ok(null);
} catch (error) {
return fail("social_login_failed", error instanceof Error ? error.message : String(error));
}
});
ipcMain.handle(IPC_CHANNELS.importFile, async (_event, input) => {
try {
const fileId = await files.importFile(input);
if (fileId) notifyDataChanged();
return ok(fileId);
} catch (error) {
return fail("file_import_failed", error instanceof Error ? error.message : String(error));
}
});
ipcMain.handle(IPC_CHANNELS.openFile, async (_event, input) => {
try {
await files.openFile(input);
return ok(null);
} catch (error) {
return fail("file_open_failed", error instanceof Error ? error.message : String(error));
}
});
ipcMain.handle(IPC_CHANNELS.pickDirectory, async () => {
try {
const result = await dialog.showOpenDialog(deps.mainWindow, { properties: ["openDirectory", "createDirectory"] });
if (result.canceled || result.filePaths.length === 0) return ok(null);
return ok(result.filePaths[0]);
} catch (error) {
return fail("pick_directory_failed", error instanceof Error ? error.message : String(error));
}
});
ipcMain.handle(IPC_CHANNELS.createMigrationReport, (_event, input) => mutate(() => migrations.createReport(input)));
ipcMain.handle(IPC_CHANNELS.createRollbackSnapshot, (_event, input) => mutate(() => rollback.createSnapshot(input)));
ipcMain.handle(IPC_CHANNELS.setRollbackSnapshotActive, (_event, input) => mutate(() => rollback.setActive(input)));
ipcMain.handle(IPC_CHANNELS.resolveMigrationReconciliation, (_event, input) => mutate(() => rollback.resolveReconciliationItem(input)));
ipcMain.handle(IPC_CHANNELS.setFeatureFlag, (_event, input) => mutate(() => operations.setFeatureFlag(input)));
ipcMain.handle(IPC_CHANNELS.runChaosDrill, (_event, input) => mutate(() => operations.runChaosDrill(input)));
ipcMain.handle(IPC_CHANNELS.runSdlcReview, (_event, input) => mutate(() => sdlc.runReview(input)));
ipcMain.handle(IPC_CHANNELS.runEvalSuite, (_event, input) => mutate(() => sdlc.runEvalSuite(input)));
ipcMain.handle(IPC_CHANNELS.setPrBotGate, (_event, input) => mutate(() => sdlc.setPrBotGate(input)));
ipcMain.handle(IPC_CHANNELS.createComplianceExport, (_event, input) => mutate(() => compliance.createExport(input)));
ipcMain.handle(IPC_CHANNELS.createAuditReplay, (_event, input) => mutate(() => audit.createThreadReplay(input)));
ipcMain.handle(IPC_CHANNELS.createLegalHold, (_event, input) => mutate(() => compliance.createLegalHold(input)));
ipcMain.handle(IPC_CHANNELS.setLegalHoldState, (_event, input) => mutate(() => compliance.setLegalHoldState(input)));
ipcMain.handle(IPC_CHANNELS.createRetentionFreeze, (_event, input) => mutate(() => compliance.createRetentionFreeze(input)));
ipcMain.handle(IPC_CHANNELS.setRetentionFreezeState, (_event, input) => mutate(() => compliance.setRetentionFreezeState(input)));
ipcMain.handle(IPC_CHANNELS.search, (_event, input) => {
try {
return ok(search.search(input));
} catch (error) {
return fail("search_failed", error instanceof Error ? error.message : String(error));
}
});
const composioConnections = new ComposioConnectionService(deps.connection.db, process.cwd());
const connectorGrants = new ConnectorGrantsService(deps.connection.db);
ipcMain.handle(IPC_CHANNELS.listComposioToolkits, async (_event, input) => {
try {
return ok(await composioConnections.listToolkits(input.orgId));
} catch (error) {
return fail("composio_toolkits_failed", error instanceof Error ? error.message : String(error));
}
});
ipcMain.handle(IPC_CHANNELS.connectComposioToolkit, async (_event, input) => {
try {
const result = await composioConnections.connectToolkit(input.orgId, input.toolkitSlug, input.toolkitName);
notifyDataChanged();
return ok(result);
} catch (error) {
return fail("composio_connect_failed", error instanceof Error ? error.message : String(error));
}
});
ipcMain.handle(IPC_CHANNELS.refreshComposioAccounts, async (_event, input) => {
try {
await composioConnections.refreshAccounts(input.orgId);
notifyDataChanged();
return ok(snapshot.load());
} catch (error) {
return fail("composio_refresh_failed", error instanceof Error ? error.message : String(error));
}
});
ipcMain.handle(IPC_CHANNELS.grantConnector, (_event, input) => mutate(() => connectorGrants.grant({
orgId: input.orgId,
agentId: input.agentId,
toolkitSlug: input.toolkitSlug,
grantedByType: "employee",
grantedById: input.employeeId,
})));
ipcMain.handle(IPC_CHANNELS.revokeConnector, (_event, input) => mutate(() => connectorGrants.revoke({
orgId: input.orgId,
agentId: input.agentId,
toolkitSlug: input.toolkitSlug,
})));
ipcMain.handle(IPC_CHANNELS.saveComposioSettings, (_event, input) => mutate(() => {
const now = new Date().toISOString();
const db = deps.connection.db;
const upsert = db.prepare(`
insert into composio_settings(org_id, key, value, updated_at) values (?, ?, ?, ?)
on conflict(org_id, key) do update set value = excluded.value, updated_at = excluded.updated_at
`);
upsert.run(input.orgId, "api_key", input.apiKey, now);
upsert.run(input.orgId, "toolkits", JSON.stringify(input.toolkits), now);
upsert.run(input.orgId, "entity_id", input.orgId, now);
return null;
}));
ipcMain.handle(IPC_CHANNELS.windowMinimize, () => deps.mainWindow.minimize());
ipcMain.handle(IPC_CHANNELS.windowMaximize, () => deps.mainWindow.isMaximized() ? deps.mainWindow.unmaximize() : deps.mainWindow.maximize());
ipcMain.handle(IPC_CHANNELS.windowClose, () => deps.mainWindow.close());
}
@@ -0,0 +1,38 @@
import type { BrowserWindow } from "electron";
import type { DatabaseSync } from "node:sqlite";
import { IPC_CHANNELS } from "../../shared/ipc";
import { ProviderBackgroundService } from "../services/provider-background-service";
const DEFAULT_INTERVAL_MS = 8_000;
export interface ProviderBackgroundWorkerInput {
db: DatabaseSync;
mainWindow: BrowserWindow;
intervalMs?: number;
}
export function startProviderBackgroundWorker(input: ProviderBackgroundWorkerInput): () => void {
const service = new ProviderBackgroundService(input.db);
let running = false;
const intervalMs = input.intervalMs ?? DEFAULT_INTERVAL_MS;
const tick = async (): Promise<void> => {
if (running) return;
running = true;
try {
const changedCount = await service.recoverBackgroundResponses();
if (changedCount > 0 && !input.mainWindow.isDestroyed()) {
input.mainWindow.webContents.send(IPC_CHANNELS.rendererEvent, { type: "snapshot-changed" });
}
} finally {
running = false;
}
};
const interval = setInterval(() => {
void tick();
}, intervalMs);
void tick();
return () => clearInterval(interval);
}
+60
View File
@@ -0,0 +1,60 @@
import type { DatabaseSync } from "node:sqlite";
import { AuditService } from "../services/audit-service";
const INTERRUPTED_MESSAGE = "Interrupted by application shutdown before completion.";
const COMPOSIO_API_KEY = "***REMOVED***";
const DEFAULT_COMPOSIO_TOOLKITS = ["github", "gmail", "slack", "notion", "linear", "google_calendar"];
/**
* A single app instance executes runs in-process, so any task_run still
* `running` at boot was interrupted by a previous shutdown and has no
* executor. The run returns to the queue — interruption is not failure, so
* the work continues without spending the task's retry budget — and the
* queue worker claims it within one tick. In-flight provider requests from
* the dead process cannot be resumed: foreground ones fail, and background
* ones fail only when they never received the external response id the
* provider background worker needs to resume them.
*/
export function recoverInterruptedWork(db: DatabaseSync): void {
const now = new Date().toISOString();
const audit = new AuditService(db);
const runs = db.prepare("select id, org_id, task_id from task_runs where state = 'running'")
.all() as Array<{ id: string; org_id: string; task_id: string }>;
for (const run of runs) {
db.prepare("update task_runs set state = 'queued', started_at = null where id = ?").run(run.id);
audit.record({
orgId: run.org_id,
entityType: "task_run",
entityId: run.id,
actorType: "system",
actorId: null,
action: "requeued",
payload: { taskId: run.task_id, reason: "interrupted_by_shutdown" },
});
}
db.prepare(`
update provider_requests
set state = 'failed', completed_at = ?, error_message = ?
where state in ('pending', 'streaming')
and (execution_mode = 'foreground' or external_response_id is null)
`).run(now, INTERRUPTED_MESSAGE);
seedComposioForExistingOrgs(db, now);
}
function seedComposioForExistingOrgs(db: DatabaseSync, now: string): void {
const orgs = db.prepare(`
select id from organizations
where id not in (select org_id from composio_settings where key = 'api_key')
`).all() as Array<{ id: string }>;
if (orgs.length === 0) return;
const upsert = db.prepare(`
insert into composio_settings(org_id, key, value, updated_at) values (?, ?, ?, ?)
on conflict(org_id, key) do update set value = excluded.value, updated_at = excluded.updated_at
`);
for (const org of orgs) {
upsert.run(org.id, "api_key", COMPOSIO_API_KEY, now);
upsert.run(org.id, "toolkits", JSON.stringify(DEFAULT_COMPOSIO_TOOLKITS), now);
upsert.run(org.id, "entity_id", org.id, now);
}
}
+79
View File
@@ -0,0 +1,79 @@
import type { BrowserWindow } from "electron";
import type { DatabaseConnection } from "../database/connection";
import { IPC_CHANNELS } from "../../shared/ipc";
import { TaskExecutionService } from "../services/task-execution-service";
const DEFAULT_INTERVAL_MS = 5_000;
export interface TaskQueueWorkerInput {
connection: DatabaseConnection;
mainWindow: BrowserWindow;
intervalMs?: number;
}
export interface QueuedRun {
id: string;
org_id: string;
}
/**
* Atomically claims the oldest queued task_run by transitioning it
* `queued` -> `running` in one transaction; returns the claimed run or null.
*/
export function claimNextQueuedRun(connection: DatabaseConnection): QueuedRun | null {
return connection.transaction(() => {
const row = connection.db
.prepare("select id, org_id from task_runs where state = 'queued' order by created_at asc limit 1")
.get() as QueuedRun | undefined;
if (!row) return null;
const result = connection.db
.prepare("update task_runs set state = 'running', started_at = coalesce(started_at, ?) where id = ? and state = 'queued'")
.run(new Date().toISOString(), row.id);
return result.changes === 1 ? row : null;
});
}
/**
* Autonomous executor: every tick it claims the oldest `queued` task_run (the
* bootstrap CEO run, plus any future heartbeat-created runs) and executes its
* real agent loop. The claim is a single transaction (`queued` -> `running`,
* verified by row changes) so a run is never executed twice.
*/
export function startTaskQueueWorker(input: TaskQueueWorkerInput): () => void {
const notifyDataChanged = () => {
if (!input.mainWindow.isDestroyed()) {
input.mainWindow.webContents.send(IPC_CHANNELS.rendererEvent, { type: "snapshot-changed" });
}
};
const taskExecution = new TaskExecutionService(input.connection.db, undefined, undefined, notifyDataChanged);
const intervalMs = input.intervalMs ?? DEFAULT_INTERVAL_MS;
let running = false;
const tick = async (): Promise<void> => {
if (running) return;
running = true;
try {
const enqueued = input.connection.transaction(() => taskExecution.enqueueHeartbeatRuns());
const claimed = claimNextQueuedRun(input.connection);
if (!claimed) {
if (enqueued.length > 0) notifyDataChanged();
return;
}
notifyDataChanged();
try {
await taskExecution.executeRun(claimed.org_id, claimed.id);
} catch {
// executeRun records the failure on the run row; the UI reflects it below.
}
notifyDataChanged();
} finally {
running = false;
}
};
const interval = setInterval(() => {
void tick();
}, intervalMs);
void tick();
return () => clearInterval(interval);
}
+17
View File
@@ -0,0 +1,17 @@
import type { BrowserWindow } from "electron";
export function attachMainWindowSecurity(win: BrowserWindow, devServerOrigin: string | null): void {
win.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
const blockNavigation = (event: Electron.Event, url: string): void => {
if (devServerOrigin && url.startsWith(devServerOrigin)) return;
if (url.startsWith("file://")) return;
event.preventDefault();
};
win.webContents.on("will-navigate", blockNavigation);
win.webContents.on("will-redirect", blockNavigation);
// The first-party renderer never asks for camera, microphone, geolocation,
// notifications, and the like, so deny every permission outright — matching
// the provider sign-in windows — rather than leaving the default allow.
win.webContents.session.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false));
win.webContents.session.setPermissionCheckHandler(() => false);
}
-97
View File
@@ -1,97 +0,0 @@
import { approvalTypeEnum, type ApprovalType, type ExecutionMode } from "@shared/types";
export type ApprovalPolicyScope = "requester_or_task" | "related_agent";
export type ApprovalPolicyRequiredMode = "any" | ExecutionMode;
export interface ApprovalPolicyDefinition {
approvalType: ApprovalType;
scopeType: ApprovalPolicyScope;
blocksExecution: boolean;
requiredMode: ApprovalPolicyRequiredMode;
autoApprovable: boolean;
}
const DEFAULT_POLICY: Omit<ApprovalPolicyDefinition, "approvalType"> = {
scopeType: "requester_or_task",
blocksExecution: false,
requiredMode: "any",
autoApprovable: false,
};
const POLICY_OVERRIDES: Record<ApprovalType, Partial<Omit<ApprovalPolicyDefinition, "approvalType">>> = {
hire_agent: {
scopeType: "related_agent",
blocksExecution: true,
autoApprovable: true,
},
approve_ceo_strategy: {
blocksExecution: true,
requiredMode: "ai_cofounder",
autoApprovable: true,
},
dangerous_command: {
blocksExecution: true,
},
secret_access: {
blocksExecution: true,
},
budget_request: {},
promotion: {},
termination: {},
vendor_approval: {},
document_review: {
blocksExecution: true,
requiredMode: "ai_cofounder",
},
production_deploy: {
blocksExecution: true,
},
incident_escalation: {},
social_post: {
blocksExecution: true,
},
deliverable_review: {
blocksExecution: true,
requiredMode: "ai_cofounder",
},
budget_override: {
blocksExecution: true,
},
plan_review: {
blocksExecution: true,
requiredMode: "ai_cofounder",
},
scope_review: {
blocksExecution: true,
requiredMode: "ai_cofounder",
},
draft_review: {
blocksExecution: true,
requiredMode: "ai_cofounder",
},
strategy_review: {
blocksExecution: true,
requiredMode: "ai_cofounder",
},
exception_review: {
blocksExecution: true,
requiredMode: "ai_cofounder",
},
};
export const APPROVAL_POLICY_SEED: readonly ApprovalPolicyDefinition[] = approvalTypeEnum.options.map((approvalType) => ({
approvalType,
...DEFAULT_POLICY,
...POLICY_OVERRIDES[approvalType],
}));
export const COFOUNDER_MANDATORY_APPROVAL_TYPES: readonly ApprovalType[] = APPROVAL_POLICY_SEED
.filter((policy) => policy.blocksExecution && policy.requiredMode === "ai_cofounder")
.map((policy) => policy.approvalType);
export const OPERATIONAL_APPROVAL_TYPES: readonly ApprovalType[] = APPROVAL_POLICY_SEED
.filter((policy) =>
policy.blocksExecution &&
policy.requiredMode === "any" &&
policy.scopeType === "requester_or_task")
.map((policy) => policy.approvalType);
-38
View File
@@ -1,38 +0,0 @@
import type { AppDatabase } from "./database";
export function resolveApprovalDecision({
db,
handleApprovalResolved,
companyId,
approvalId,
state,
decisionNote,
detail,
actor = "board",
}: {
db: AppDatabase;
handleApprovalResolved: (approvalId: string, companyId: string, decision: string) => void;
companyId: string;
approvalId: string;
state: "approved" | "rejected" | "revision_requested";
decisionNote?: string;
detail?: string | null;
actor?: string;
}) {
const note = decisionNote ?? "";
db.core.approvals.decideApproval({
companyId,
approvalId,
state,
decisionNote: note,
});
db.core.activity.addActivity({
companyId,
actor,
action: `approval.${state}`,
entityType: "approval",
entityId: approvalId,
detail: detail ?? note,
});
handleApprovalResolved(approvalId, companyId, state);
}
-2
View File
@@ -1,2 +0,0 @@
export { createAutomationService } from "./automation/index";
export type { AutomationEventContext } from "./automation/index";
@@ -1,401 +0,0 @@
import type { AutomationAction } from "@shared/types";
import { dispatchAgentMessage } from "../messaging/dispatch";
import { validateTaskCreation } from "../task-quality-gates";
import { resolveTaskWorkspaceBinding } from "../task-workspace-binding";
import {
createAutomationActionHandler,
type AutomationActionHandler,
type TypedAutomationActionExecution,
} from "./automation-action-runtime";
const HEARTBEAT_WAKE_TRIGGER_MAP: Record<string, string> = {
run_completed: "subtask_completed",
run_failed: "report_failed",
hire_approved: "assignment",
agent_idle: "timer",
};
const ESCALATION_STATUS_TRIGGER_MAP: Record<string, string> = {
in_review: "subtask_completed",
blocked: "report_blocked",
};
const ESCALATION_RULE_TRIGGER_MAP: Record<string, string> = {
run_completed: "subtask_completed",
run_failed: "report_failed",
};
const TASK_PRIORITY_SCORE: Record<string, number> = { critical: 4, high: 3, medium: 2, low: 1 };
export const AUTOMATION_ACTION_HANDLERS = {
assign_task: createAutomationActionHandler("assign_task", handleAssignTask),
create_task: createAutomationActionHandler("create_task", handleCreateTask),
notify_agent: createAutomationActionHandler("notify_agent", handleAgentNotification),
send_message: createAutomationActionHandler("send_message", handleAgentNotification),
trigger_heartbeat: createAutomationActionHandler("trigger_heartbeat", handleTriggerHeartbeat),
create_approval: createAutomationActionHandler("create_approval", handleCreateApproval),
update_task_status: createAutomationActionHandler("update_task_status", handleUpdateTaskStatus),
escalate_to_manager: createAutomationActionHandler("escalate_to_manager", handleEscalateToManager),
cross_department_notify: createAutomationActionHandler("cross_department_notify", handleCrossDepartmentNotify),
schedule_meeting: createAutomationActionHandler("schedule_meeting", handleScheduleMeeting),
create_document: createAutomationActionHandler("create_document", handleCreateDocument),
auto_approve: createAutomationActionHandler("auto_approve", handleAutoApprove),
reassign_task: createAutomationActionHandler("reassign_task", handleReassignTask),
run_task: createAutomationActionHandler("run_task", handleRunTask),
} satisfies Record<AutomationAction, AutomationActionHandler>;
function deriveHeartbeatWakeTrigger(triggerType: string, previousStatus: string | null): string {
if (triggerType === "task_status_changed" && previousStatus === "in_review") return "comment";
return HEARTBEAT_WAKE_TRIGGER_MAP[triggerType] ?? "manual";
}
function deriveEscalationWakeTrigger(newStatus: string | null, ruleTrigger: string | null): string {
if (newStatus && ESCALATION_STATUS_TRIGGER_MAP[newStatus]) return ESCALATION_STATUS_TRIGGER_MAP[newStatus];
if (ruleTrigger && ESCALATION_RULE_TRIGGER_MAP[ruleTrigger]) return ESCALATION_RULE_TRIGGER_MAP[ruleTrigger];
return "report_blocked";
}
function handleAssignTask({ deps, ctx }: TypedAutomationActionExecution<"assign_task">): void {
const { db, logger, wakeAgentIfPossible } = deps;
if (ctx.taskId) {
const assignedId = db.core.tasks.autoAssignTask(ctx.taskId, ctx.companyId);
if (assignedId) {
wakeAgentIfPossible(assignedId, ctx.companyId, "assignment");
}
return;
}
if (!ctx.agentId) return;
const snapshot = db.projection.snapshot.listSnapshot();
const unassigned = snapshot.tasks
.filter((task) => task.companyId === ctx.companyId && !task.assigneeAgentId && ["backlog", "todo"].includes(task.status))
.sort((a, b) => (TASK_PRIORITY_SCORE[b.priority] ?? 0) - (TASK_PRIORITY_SCORE[a.priority] ?? 0));
if (unassigned.length === 0) return;
const task = unassigned[0]!;
db.core.tasks.saveTask({
id: task.id,
companyId: task.companyId,
projectId: task.projectId,
goalId: task.goalId,
parentId: task.parentId,
title: task.title,
description: task.description,
assigneeAgentId: ctx.agentId,
workspaceId: task.workspaceId,
requiresFilesystem: task.requiresFilesystem,
priority: task.priority,
status: "todo",
});
wakeAgentIfPossible(ctx.agentId, ctx.companyId, "assignment");
logger.info(`[automation] Auto-assigned task "${task.title}" to idle agent ${ctx.agentId}`);
}
function handleCreateTask({ deps, callbacks, ctx, config }: TypedAutomationActionExecution<"create_task">): void {
const { db, logger } = deps;
const { handleTaskCreated } = callbacks;
const title = config.title ?? "[Auto] Task from automation rule";
const description = config.description ?? "";
const priority = config.priority ?? "medium";
const assigneeAgentId = config.assigneeAgentId ?? null;
const taskValidation = validateTaskCreation({ title, description });
if (!taskValidation.valid) {
logger.warn(`[automation] Skipping create_task - validation failed: ${taskValidation.errors.join("; ")}`);
return;
}
const workspaceBinding = resolveTaskWorkspaceBinding(db, {
companyId: ctx.companyId,
requiresFilesystem: config.requiresFilesystem,
explicitWorkspaceId: config.workspaceId ?? null,
projectId: ctx.projectId ?? config.projectId ?? null,
assigneeAgentId,
creatorAgentId: ctx.agentId ?? null,
});
if (!workspaceBinding.ok) {
logger.warn(`[automation] Skipping create_task - ${workspaceBinding.error}`);
return;
}
const taskId = db.core.tasks.saveTask({
companyId: ctx.companyId,
projectId: ctx.projectId ?? config.projectId ?? null,
title,
description,
assigneeAgentId,
workspaceId: workspaceBinding.workspaceId,
priority,
status: assigneeAgentId ? "todo" : "backlog",
requiresFilesystem: config.requiresFilesystem,
});
handleTaskCreated(taskId, ctx.companyId);
logger.info(`[automation] Created task: ${title} (${taskId})`);
}
function handleAgentNotification<K extends "notify_agent" | "send_message">({
deps,
rule,
ctx,
config,
}: TypedAutomationActionExecution<K>): void {
const { db, logger, emitEvent, publishDomainChanged, wakeAgentIfPossible } = deps;
const toAgentId = config.toAgentId ?? ctx.agentId ?? null;
const fromAgentId = config.fromAgentId ?? ctx.agentId ?? toAgentId;
if (!fromAgentId) return;
const defaultChannel = toAgentId ? "direct" : "company";
const requestedChannel = config.channel ?? defaultChannel;
const channel = (["direct", "department", "company", "project", "incident"] as const).includes(requestedChannel)
? requestedChannel
: defaultChannel;
let channelTargetId: string | null = config.channelTargetId ?? config.department ?? ctx.department ?? null;
if (channel === "department" && !channelTargetId) {
try {
const sender = db.core.entity.getAgent(fromAgentId);
channelTargetId = sender.department ?? null;
} catch (err) {
logger.warn(`[automation] getAgent failed for sender ${fromAgentId}; dropping department target:`, err instanceof Error ? err.message : String(err));
channelTargetId = null;
}
}
const channelNeedsTarget = channel === "department" || channel === "project";
if (channelNeedsTarget && !channelTargetId) {
logger.info(
`[automation] Skipping rule "${rule.name}" - no ${channel} channel target available for sender ${fromAgentId}`,
);
return;
}
if (channel === "direct" && toAgentId === fromAgentId) {
logger.info(
`[automation] Skipping rule "${rule.name}" - direct messages require two distinct participants`,
);
return;
}
const dispatchResult = dispatchAgentMessage(
{ db, emitEvent, publishDomainChanged, wakeAgentIfPossible },
{
companyId: ctx.companyId,
fromAgentId,
toAgentId: channel === "direct" ? toAgentId : null,
channel,
channelTargetId,
subject: config.subject ?? "Automation notification",
body: config.body ?? `Automation rule triggered for task ${ctx.taskId ?? "n/a"}`,
priority: config.priority ?? "normal",
},
"message",
);
if (!dispatchResult.ok) {
logger.warn(
`[automation] Failed to dispatch message for rule ${rule.name} (${rule.id}): ${dispatchResult.error.code} ${dispatchResult.error.message}`,
);
}
}
function handleTriggerHeartbeat({ deps, rule, ctx, config }: TypedAutomationActionExecution<"trigger_heartbeat">): void {
const { db, wakeAgentIfPossible } = deps;
let targetAgentId = config.agentId ?? null;
if (!targetAgentId && ctx.agentId) {
const triggerType = rule.trigger ?? "";
if (triggerType === "run_completed" || triggerType === "run_failed") {
const manager = db.core.agents.getManagerForAgent(ctx.agentId);
targetAgentId = manager?.id ?? ctx.agentId;
} else {
targetAgentId = ctx.agentId;
}
}
if (targetAgentId) {
wakeAgentIfPossible(targetAgentId, ctx.companyId, deriveHeartbeatWakeTrigger(rule.trigger ?? "", ctx.previousStatus ?? null));
}
}
function handleCreateApproval({ deps, ctx, config }: TypedAutomationActionExecution<"create_approval">): void {
deps.db.core.approvals.requestApproval({
companyId: ctx.companyId,
relatedTaskId: ctx.taskId ?? null,
requestedByAgentId: ctx.agentId ?? null,
type: config.approvalType ?? "document_review",
payloadSummary: config.summary ?? "Auto-generated approval request",
impactSummary: config.impact ?? "Triggered by automation rule",
});
}
function handleUpdateTaskStatus({
deps,
callbacks,
rule,
ctx,
config,
}: TypedAutomationActionExecution<"update_task_status">): void {
if (!ctx.taskId) return;
const { db, publishDomainChanged } = deps;
const { handleTaskStatusChange } = callbacks;
const newStatus = config.status ?? "todo";
const previousTask = db.core.entity.getTask(ctx.taskId);
db.core.tasks.advanceTaskStatus(ctx.taskId, newStatus, "Status changed by automation rule");
publishDomainChanged();
if (previousTask.status !== newStatus && rule.trigger !== "task_status_changed") {
handleTaskStatusChange(ctx.taskId, ctx.companyId, previousTask.status, newStatus);
}
}
function handleEscalateToManager({
deps,
rule,
ctx,
config,
}: TypedAutomationActionExecution<"escalate_to_manager">): void {
const { db, logger, emitEvent, publishDomainChanged, wakeAgentIfPossible } = deps;
const targetAgentId = ctx.agentId;
if (!targetAgentId) return;
let agentName = "an agent";
try {
agentName = db.core.entity.getAgent(targetAgentId).name;
} catch (err) {
logger.warn(`[automation] getAgent failed for escalation target ${targetAgentId}; using generic label:`, err instanceof Error ? err.message : String(err));
}
let taskTitle = ctx.taskId ?? "unknown";
try {
if (ctx.taskId) taskTitle = db.core.entity.getTask(ctx.taskId).title;
} catch (err) {
logger.warn(`[automation] getTask failed for escalation task ${ctx.taskId}; using id as title:`, err instanceof Error ? err.message : String(err));
}
const manager = db.core.agents.getManagerForAgent(targetAgentId);
const escalationBody = config.body ?? `Task "${taskTitle}" needs your attention. ${agentName} has flagged this as requiring manager intervention.`;
const wakeTrigger = deriveEscalationWakeTrigger(ctx.newStatus ?? null, rule.trigger ?? null);
if (manager) {
const escalationResult = dispatchAgentMessage(
{ db, emitEvent, publishDomainChanged, wakeAgentIfPossible },
{
companyId: ctx.companyId,
fromAgentId: targetAgentId,
toAgentId: manager.id,
channel: "direct",
subject: `Escalation from ${agentName}: ${taskTitle}`,
body: escalationBody,
priority: "urgent",
},
wakeTrigger,
);
if (!escalationResult.ok) {
logger.warn(
`[automation] Failed to escalate "${taskTitle}" to manager: ${escalationResult.error.code} ${escalationResult.error.message}`,
);
}
return;
}
logger.info(`[automation] Skipping escalation for top-level agent "${agentName}" - no manager to escalate to.`);
}
function handleCrossDepartmentNotify({
deps,
rule,
ctx,
config,
}: TypedAutomationActionExecution<"cross_department_notify">): void {
const { db, logger, emitEvent, publishDomainChanged, wakeAgentIfPossible } = deps;
const targetDepartment = rule.targetDepartment ?? config.department;
if (!targetDepartment) return;
const departmentAgents = db.core.agents.getIdleAgentsForDepartment(ctx.companyId, targetDepartment);
const senderAgentId = ctx.agentId ?? departmentAgents[0]?.id;
if (!senderAgentId) return;
const dispatchResult = dispatchAgentMessage(
{ db, emitEvent, publishDomainChanged, wakeAgentIfPossible },
{
companyId: ctx.companyId,
fromAgentId: senderAgentId,
toAgentId: null,
channel: "department",
channelTargetId: targetDepartment,
subject: config.subject ?? "Cross-department notification",
body: config.body ?? "Automation triggered a cross-department alert.",
priority: "normal",
},
"message",
);
if (!dispatchResult.ok) {
logger.warn(
`[automation] Failed cross-department notification for ${targetDepartment}: ${dispatchResult.error.code} ${dispatchResult.error.message}`,
);
}
}
function handleScheduleMeeting({ deps, ctx, config }: TypedAutomationActionExecution<"schedule_meeting">): void {
const meetingType = config.meetingType ?? "department_sync";
const title = config.title ?? "Auto-scheduled meeting";
const offsetMinutes = config.scheduleOffsetMinutes ?? 60;
const scheduledAt = config.scheduledAt ?? new Date(Date.now() + offsetMinutes * 60_000).toISOString();
deps.db.core.knowledge.saveMeeting({
companyId: ctx.companyId,
type: meetingType,
title,
organizerAgentId: ctx.agentId ?? null,
participantAgentIds: config.participantAgentIds ?? "[]",
scheduledAt,
durationMinutes: config.durationMinutes ?? 30,
});
}
function handleCreateDocument({ deps, ctx, config }: TypedAutomationActionExecution<"create_document">): void {
deps.db.core.documents.saveDocument({
companyId: ctx.companyId,
type: config.documentType ?? "status_report",
title: config.title ?? "Auto-generated document",
content: config.content ?? "",
authorAgentId: ctx.agentId ?? null,
projectId: ctx.projectId ?? null,
});
}
function handleAutoApprove({ deps, callbacks, ctx }: TypedAutomationActionExecution<"auto_approve">): void {
const snapshot = deps.db.projection.snapshot.listSnapshot();
const pendingApprovals = snapshot.approvals.filter(
(approval) => approval.companyId === ctx.companyId && approval.state === "pending",
);
for (const approval of pendingApprovals.slice(0, 5)) {
deps.db.core.approvals.decideApproval({
companyId: ctx.companyId,
approvalId: approval.id,
state: "approved",
decisionNote: "Auto-approved by automation rule.",
});
callbacks.handleApprovalResolved(approval.id, ctx.companyId, "approved");
}
}
function handleReassignTask({ deps, ctx }: TypedAutomationActionExecution<"reassign_task">): void {
if (!ctx.taskId) return;
const newAssignee = deps.db.core.tasks.autoAssignTask(ctx.taskId, ctx.companyId);
if (newAssignee) {
deps.wakeAgentIfPossible(newAssignee, ctx.companyId, "assignment");
}
}
function handleRunTask({ deps, ctx, config }: TypedAutomationActionExecution<"run_task">): void {
const targetTaskId = config.taskId ?? ctx.taskId;
if (!targetTaskId) return;
const task = deps.db.core.entity.getTask(targetTaskId);
if (!task.assigneeAgentId) return;
deps.wakeAgentIfPossible(task.assigneeAgentId, ctx.companyId, "assignment");
}
@@ -1,62 +0,0 @@
import type { AppDatabase } from "../database";
import type { AutomationAction, DesktopEvent } from "@shared/types";
import type { AutomationEventContext } from "./types";
import type { AutomationActionConfigMap } from "./automation-action-schemas";
export interface ActionDeps {
db: AppDatabase;
logger: {
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
emitEvent: (event: DesktopEvent) => void;
publishDomainChanged: () => void;
wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null;
}
export interface ActionCallbacks {
handleTaskCreated: (taskId: string, companyId: string) => void;
handleApprovalResolved: (approvalId: string, companyId: string, decision: string) => void;
handleTaskStatusChange: (taskId: string, companyId: string, previousStatus: string, newStatus: string) => void;
}
export interface AutomationRuleInput {
id?: string;
name?: string;
action: string;
actionConfigJson: string;
targetDepartment: string | null;
trigger?: string;
}
export interface AutomationRuleExecution extends Omit<AutomationRuleInput, "action"> {
action: AutomationAction;
}
export type AutomationActionExecution = {
deps: ActionDeps;
callbacks: ActionCallbacks;
rule: AutomationRuleExecution;
ctx: AutomationEventContext;
config: AutomationActionConfigMap[AutomationAction];
};
export type TypedAutomationActionExecution<K extends AutomationAction> = Omit<AutomationActionExecution, "config"> & {
config: AutomationActionConfigMap[K];
};
export type TypedAutomationActionHandler<K extends AutomationAction> = (input: TypedAutomationActionExecution<K>) => void;
export type AutomationActionHandler = (input: AutomationActionExecution) => void;
export function createAutomationActionHandler<K extends AutomationAction>(
action: K,
handler: TypedAutomationActionHandler<K>,
): AutomationActionHandler {
return (input) => {
if (input.rule.action !== action) {
throw new Error(`Automation handler ${action} received ${input.rule.action}.`);
}
handler(input as TypedAutomationActionExecution<K>);
};
}
@@ -1,131 +0,0 @@
/**
* Per-action config schemas for automation rules.
*
* Previously `automation-actions.ts` coerced `config.x as string` at every
* branch without runtime validation, which hid config-authoring mistakes
* behind silent default values (e.g. a numeric `priority` landed as the
* literal string "medium"). Each schema describes the contract of the JSON
* stored in `automation_rules.action_config_json`; validation failures are
* surfaced to the caller so the rule is skipped and logged.
*/
import { z } from "zod";
import { approvalTypeEnum, type AutomationAction } from "@shared/types";
const messageChannelSchema = z.enum(["direct", "department", "company", "project", "incident"]);
const messagePrioritySchema = z.enum(["urgent", "normal", "low"]);
const taskPrioritySchema = z.enum(["critical", "high", "medium", "low"]);
export const assignTaskConfigSchema = z.object({}).passthrough();
export const createTaskConfigSchema = z.object({
title: z.string().optional(),
description: z.string().optional(),
priority: taskPrioritySchema.optional(),
assigneeAgentId: z.string().nullable().optional(),
projectId: z.string().optional(),
workspaceId: z.string().nullable().optional(),
requiresFilesystem: z.boolean().default(false),
});
export const notifyAgentConfigSchema = z.object({
toAgentId: z.string().optional(),
fromAgentId: z.string().optional(),
channel: messageChannelSchema.optional(),
channelTargetId: z.string().nullable().optional(),
department: z.string().optional(),
subject: z.string().optional(),
body: z.string().optional(),
priority: messagePrioritySchema.optional(),
});
export const triggerHeartbeatConfigSchema = z.object({
agentId: z.string().optional(),
});
export const createApprovalConfigSchema = z.object({
approvalType: approvalTypeEnum.optional(),
summary: z.string().optional(),
impact: z.string().optional(),
});
export const updateTaskStatusConfigSchema = z.object({
status: z.string().optional(),
});
export const escalateToManagerConfigSchema = z.object({
body: z.string().optional(),
});
export const crossDepartmentNotifyConfigSchema = z.object({
department: z.string().optional(),
subject: z.string().optional(),
body: z.string().optional(),
});
export const scheduleMeetingConfigSchema = z.object({
meetingType: z.string().optional(),
title: z.string().optional(),
participantAgentIds: z.string().optional(),
durationMinutes: z.number().int().positive().optional(),
// Absolute ISO-8601 scheduling is preferred; otherwise callers can specify
// an offset in minutes from now (replaces the previous hard-coded 60min).
scheduledAt: z.string().optional(),
scheduleOffsetMinutes: z.number().int().positive().optional(),
});
export const createDocumentConfigSchema = z.object({
documentType: z.string().optional(),
title: z.string().optional(),
content: z.string().optional(),
});
export const autoApproveConfigSchema = z.object({}).passthrough();
export const reassignTaskConfigSchema = z.object({}).passthrough();
export const runTaskConfigSchema = z.object({
taskId: z.string().optional(),
});
export const ACTION_SCHEMAS = {
assign_task: assignTaskConfigSchema,
create_task: createTaskConfigSchema,
notify_agent: notifyAgentConfigSchema,
send_message: notifyAgentConfigSchema,
trigger_heartbeat: triggerHeartbeatConfigSchema,
create_approval: createApprovalConfigSchema,
update_task_status: updateTaskStatusConfigSchema,
escalate_to_manager: escalateToManagerConfigSchema,
cross_department_notify: crossDepartmentNotifyConfigSchema,
schedule_meeting: scheduleMeetingConfigSchema,
create_document: createDocumentConfigSchema,
auto_approve: autoApproveConfigSchema,
reassign_task: reassignTaskConfigSchema,
run_task: runTaskConfigSchema,
} as const satisfies Record<AutomationAction, z.ZodTypeAny>;
export const AUTOMATION_ACTIONS = Object.freeze(Object.keys(ACTION_SCHEMAS)) as readonly AutomationAction[];
export const WORKFLOW_ACTIONS: ReadonlySet<AutomationAction> = new Set(AUTOMATION_ACTIONS);
export function isAutomationAction(action: string): action is AutomationAction {
return WORKFLOW_ACTIONS.has(action as AutomationAction);
}
export type AutomationActionConfigMap = {
[K in keyof typeof ACTION_SCHEMAS]: z.infer<typeof ACTION_SCHEMAS[K]>;
};
/**
* Parse action config JSON against its per-action schema.
* Returns the validated config or throws with the action name so the caller
* can log/skip the rule instead of silently advancing with defaults.
*/
export function parseActionConfig<K extends keyof AutomationActionConfigMap>(
action: K,
json: string,
): AutomationActionConfigMap[K] {
const raw = JSON.parse(json) as unknown;
const schema = ACTION_SCHEMAS[action] as z.ZodSchema<AutomationActionConfigMap[K]>;
return schema.parse(raw);
}
-30
View File
@@ -1,30 +0,0 @@
import type { AutomationAction } from "@shared/types";
import { AUTOMATION_ACTION_HANDLERS } from "./automation-action-handlers";
import { isAutomationAction, parseActionConfig } from "./automation-action-schemas";
import type {
ActionCallbacks,
ActionDeps,
AutomationRuleExecution,
AutomationRuleInput,
} from "./automation-action-runtime";
import type { AutomationEventContext } from "./types";
export { AUTOMATION_ACTION_HANDLERS } from "./automation-action-handlers";
export type { ActionCallbacks, ActionDeps } from "./automation-action-runtime";
export function executeAutomationAction(
deps: ActionDeps,
callbacks: ActionCallbacks,
rule: AutomationRuleInput,
ctx: AutomationEventContext,
): void {
const action = parseAutomationAction(rule.action);
const executionRule: AutomationRuleExecution = { ...rule, action };
const config = parseActionConfig(action, rule.actionConfigJson);
AUTOMATION_ACTION_HANDLERS[action]({ deps, callbacks, rule: executionRule, ctx, config });
}
function parseAutomationAction(action: string): AutomationAction {
if (isAutomationAction(action)) return action;
throw new Error(`Unknown automation action: ${action}`);
}
@@ -1,375 +0,0 @@
import { z } from "zod";
import type { AppDatabase } from "../database";
import type { NotificationOptions } from "../notification-service";
import { extractApprovalDocumentIds } from "@shared/approval-documents";
import { notifyHireApproved } from "../hire-hook";
import { autoAssignSkillsForHire } from "../skills/auto-assign-on-hire";
import type { AutomationEventContext } from "./types";
import { normalizeWorkflowSteps } from "./automation-workflow";
const workflowApprovalPayloadSchema = z.object({
workflowId: z.string().optional(),
stepId: z.string().optional(),
stepName: z.string().optional(),
}).passthrough();
function localizeNotification(locale: string | null | undefined, en: string, zh: string): string {
return locale === "zh" ? zh : en;
}
function recordReviewFeedbackKnowledge(
db: AppDatabase,
approval: {
companyId: string;
relatedTaskId: string | null;
requestedByAgentId: string | null;
decisionNote: string | null;
},
decision: string,
): void {
if (!approval.relatedTaskId) return;
if (decision === "approved") return;
const task = db.core.entity.getTask(approval.relatedTaskId);
const feedback = approval.decisionNote?.trim() || "Board requested revisions before approval.";
const topic = `Review feedback: ${task.title}`;
const content = [
`Decision: ${decision}`,
`Feedback: ${feedback}`,
"Use this review guidance to improve future deliverables for similar work.",
].join("\n");
db.core.knowledge.saveKnowledgeEntry({
companyId: approval.companyId,
category: "lesson_learned",
topic,
content,
authorAgentId: approval.requestedByAgentId ?? null,
importance: "high",
tagsJson: JSON.stringify(["review_feedback", decision]),
referencedEntityType: "task",
referencedEntityId: task.id,
});
}
export interface ApprovalHandlerDeps {
db: AppDatabase;
logger: {
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
publishDomainChanged: () => void;
notify: (options: NotificationOptions) => void;
wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null;
queueBrowserActionExecution: (actionId: string, companyId: string) => void;
isQuitting: () => boolean;
}
export interface ApprovalHandlerCallbacks {
dispatchAutomation: (trigger: string, ctx: AutomationEventContext) => void;
handleTaskStatusChange: (taskId: string, companyId: string, previousStatus: string, newStatus: string) => void;
runWorkflowPipeline: (workflowId: string, companyId: string) => Promise<void>;
trackTimer: (timer: ReturnType<typeof setTimeout>) => void;
}
export function handleApprovalResolved(
deps: ApprovalHandlerDeps,
callbacks: ApprovalHandlerCallbacks,
approvalId: string,
companyId: string,
decision: string,
): void {
if (deps.isQuitting()) return;
const { db, logger, publishDomainChanged, notify, wakeAgentIfPossible, queueBrowserActionExecution } = deps;
const { dispatchAutomation, handleTaskStatusChange, runWorkflowPipeline, trackTimer } = callbacks;
const explicitlyWokenAgentIds = new Set<string>();
function wakeAgent(agentId: string | null | undefined, trigger: string): void {
if (!agentId) return;
wakeAgentIfPossible(agentId, companyId, trigger);
explicitlyWokenAgentIds.add(agentId);
}
const snapshot = db.projection.snapshot.listSnapshot();
const approval = snapshot.approvals.find((entry) => entry.id === approvalId);
if (!approval) return;
dispatchAutomation("approval_resolved", {
companyId,
agentId: approval.requestedByAgentId ?? undefined,
taskId: approval.relatedTaskId ?? undefined,
});
if (approval.type === "hire_agent" && decision === "approved" && approval.relatedAgentId) {
const agent = db.core.entity.getAgent(approval.relatedAgentId);
if (agent.status === "idle") {
// Deterministic default skill kit. The CEO's operating prompt
// used to carry this responsibility ("ensure every direct report
// has skills"), turning O(reports) API calls into a per-wake
// tax. At hire approval time we know the agent's department and
// the company's seeded catalogue — assign the default bundle so
// the new hire's first run already has the right SKILL.md
// manifest.
const { assignedSkillIds } = autoAssignSkillsForHire(db, {
agentId: agent.id,
companyId,
department: agent.department,
});
if (assignedSkillIds.length > 0) {
db.core.activity.addActivity({
companyId,
actor: "system",
action: "agent.skills_defaulted",
entityType: "agent",
entityId: agent.id,
detail: `${assignedSkillIds.length} default skill(s) assigned on hire.`,
});
}
db.core.activity.addActivity({
companyId,
actor: "system",
action: "agent.hired",
entityType: "agent",
entityId: agent.id,
detail: `${agent.name} hire approved and activated.`,
});
dispatchAutomation("hire_approved", {
companyId,
agentId: agent.id,
});
wakeAgent(agent.id, "assignment");
const ceo = db.core.agents.getCeoForCompany(companyId);
if (ceo && ceo.id !== agent.id) {
wakeAgent(ceo.id, "assignment");
}
publishDomainChanged();
const hireLocale = db.projection.snapshot.listSnapshot().locale;
notify({
title: localizeNotification(hireLocale, "Agent Hired", "新员工入职"),
body: localizeNotification(hireLocale, `${agent.name} joined as ${agent.role}`, `${agent.name} 已加入,职位:${agent.role}`),
urgency: "informational",
navigation: { section: "agents", entityId: agent.id },
batchKey: "agent_hired",
});
// Notify adapter that hire was approved (Paperclip hire-hook parity)
void notifyHireApproved(db, {
companyId,
agentId: agent.id,
source: "approval",
sourceId: approvalId,
approvedAt: new Date().toISOString(),
}).catch(() => {});
}
}
if (approval.type === "deliverable_review") {
const documentIds = extractApprovalDocumentIds(approval.payloadJson);
const docStatus = decision === "approved"
? "approved"
: decision === "rejected" || decision === "revision_requested"
? "draft"
: "in_review";
for (const docId of documentIds) {
db.core.documents.updateDocumentStatus(docId, companyId, docStatus);
}
if (approval.relatedTaskId) {
const task = db.core.entity.getTask(approval.relatedTaskId);
if (task.assigneeAgentId) {
const agent = db.core.entity.getAgent(task.assigneeAgentId);
if (!agent.heartbeatEnabled) {
db.core.agents.setHeartbeat(task.assigneeAgentId, true, agent.heartbeatIntervalSec || 120);
logger.info(`[deliverable-review] Resumed heartbeat for ${agent.name} after user ${decision}.`);
}
}
if (decision === "approved") {
db.core.tasks.advanceTaskStatus(task.id, "done", "Board approved deliverable.");
handleTaskStatusChange(task.id, companyId, task.status, "done");
db.core.activity.addActivity({ companyId, actor: "board", action: "deliverable.approved", entityType: "task", entityId: task.id, detail: approval.decisionNote || "Deliverable approved by board." });
} else if (decision === "rejected" || decision === "revision_requested") {
const previousStatus = task.status;
const statusMessage = decision === "rejected"
? `Board rejected deliverable: ${approval.decisionNote || "No reason given."}`
: `Board requested revisions: ${approval.decisionNote || "No reason given."}`;
recordReviewFeedbackKnowledge(db, approval, decision);
db.core.tasks.advanceTaskStatus(task.id, "todo", statusMessage);
handleTaskStatusChange(task.id, companyId, previousStatus, "todo");
wakeAgent(task.assigneeAgentId, "revision_needed");
}
} else if (approval.requestedByAgentId) {
const agent = db.core.entity.getAgent(approval.requestedByAgentId);
if (!agent.heartbeatEnabled) {
db.core.agents.setHeartbeat(approval.requestedByAgentId, true, agent.heartbeatIntervalSec || 120);
logger.info(`[deliverable-review] Resumed heartbeat for ${agent.name} after user ${decision}.`);
}
if (decision !== "approved") {
wakeAgent(approval.requestedByAgentId, "revision_needed");
}
}
publishDomainChanged();
}
if (approval.type === "draft_review" || approval.type === "strategy_review") {
const documentIds = extractApprovalDocumentIds(approval.payloadJson);
const docStatus = decision === "approved" ? "approved" : "draft";
for (const docId of documentIds) {
db.core.documents.updateDocumentStatus(docId, companyId, docStatus);
}
publishDomainChanged();
}
// Handle budget override approvals
if (approval.type === "budget_override" && decision === "approved") {
try {
const payload = JSON.parse(approval.payloadJson ?? "{}") as {
policyId?: string;
scopeType?: string;
scopeId?: string;
newAmount?: number;
};
if (payload.policyId) {
// Resolve the budget incident that triggered this approval
db.core.budget.resolveBudgetIncident(payload.policyId, companyId);
db.core.activity.addActivity({
companyId,
actor: "board",
action: "budget.override_approved",
entityType: "approval",
entityId: approvalId,
detail: `Budget override approved for ${payload.scopeType} scope`,
});
}
} catch (err) {
logger.error(`[automation] Failed to process budget_override approval: ${err instanceof Error ? err.message : String(err)}`);
}
}
const browserAction = db.core.social.getBrowserActionByApprovalId(approvalId, companyId);
if (browserAction) {
if (decision === "approved") {
db.core.social.updateBrowserAction(browserAction.id, {
status: "queued",
resultSummary: "Approval granted. Dispatching browser action.",
errorMessage: null,
});
publishDomainChanged();
queueBrowserActionExecution(browserAction.id, companyId);
} else if (decision === "rejected") {
db.core.social.updateBrowserAction(browserAction.id, {
status: "cancelled",
resultSummary: "Browser action was rejected during approval.",
errorMessage: "Approval rejected by board.",
finishedAt: new Date().toISOString(),
});
publishDomainChanged();
} else if (decision === "revision_requested") {
db.core.social.updateBrowserAction(browserAction.id, {
status: "approval_required",
resultSummary: "Board requested revisions before execution.",
errorMessage: "Approval requires revisions before the browser action can run.",
});
publishDomainChanged();
}
}
if (approval.type === "social_post" && decision === "approved" && approval.payloadJson) {
try {
const draft = JSON.parse(approval.payloadJson) as {
socialAccountId: string;
platform: string;
content: string;
mediaUrls: string[];
scheduledAt: string | null;
};
const dispatchSocialAction = () => {
const actionId = db.core.social.saveBrowserAction({
companyId: approval.companyId,
socialAccountId: draft.socialAccountId,
agentId: approval.requestedByAgentId ?? "",
actionType: "post",
payloadJson: JSON.stringify({ content: draft.content, mediaUrls: draft.mediaUrls, scheduledAt: draft.scheduledAt }),
status: "queued",
approvalId: approval.id,
});
publishDomainChanged();
queueBrowserActionExecution(actionId, approval.companyId);
};
if (draft.scheduledAt) {
const delay = new Date(draft.scheduledAt).getTime() - Date.now();
if (delay > 0) {
const timer = setTimeout(() => {
if (deps.isQuitting()) return;
dispatchSocialAction();
}, delay);
trackTimer(timer);
} else {
dispatchSocialAction();
}
} else {
dispatchSocialAction();
}
} catch (err) {
logger.error("[automation] Failed to dispatch approved social post:", err);
}
}
// Workflow-step approvals are emitted by runWorkflowPipeline with
// type "dangerous_command" + workflowId/stepId in payloadJson. Gate the
// parse explicitly on that type instead of attempting a speculative
// JSON.parse for every approval.
if (approval.type === "dangerous_command" && approval.payloadJson) {
const payload = workflowApprovalPayloadSchema.safeParse(JSON.parse(approval.payloadJson));
if (payload.success && payload.data.workflowId && payload.data.stepId) {
const { workflowId, stepId } = payload.data;
const workflow = db.automation.rules.getWorkflowRecord(workflowId, companyId);
if (workflow) {
const steps = normalizeWorkflowSteps(JSON.parse(workflow.stepsJson || "[]"));
const stepIndex = steps.findIndex((step) => step.id === stepId);
if (stepIndex >= 0) {
if (decision === "approved") {
steps[stepIndex]!.status = "pending";
db.automation.rules.updateWorkflowExecution({
id: workflow.id,
companyId,
status: "paused",
currentStepIndex: stepIndex,
stepsJson: JSON.stringify(steps),
lastRunAt: workflow.lastRunAt,
});
publishDomainChanged();
void runWorkflowPipeline(workflow.id, companyId).catch((error) => {
logger.error(`[workflow] Failed to resume workflow ${workflow.id} after approval ${approvalId}: ${error instanceof Error ? error.message : String(error)}`);
});
} else {
steps[stepIndex]!.status = decision === "revision_requested" ? "pending" : "failed";
db.automation.rules.updateWorkflowExecution({
id: workflow.id,
companyId,
status: decision === "revision_requested" ? "paused" : "failed",
currentStepIndex: stepIndex,
stepsJson: JSON.stringify(steps),
lastRunAt: workflow.lastRunAt,
});
publishDomainChanged();
}
}
}
}
}
if (approval.requestedByAgentId) {
if (!explicitlyWokenAgentIds.has(approval.requestedByAgentId)) {
wakeAgentIfPossible(approval.requestedByAgentId, companyId, "approval_resolved");
}
}
}
@@ -1,58 +0,0 @@
import type { AppDatabase } from "../database";
import type { AutomationEventContext } from "./types";
type ConditionLogger = {
warn: (...args: unknown[]) => void;
};
export function matchesEventConfig(
db: AppDatabase,
configJson: string,
sourceDepartment: string | null,
ctx: AutomationEventContext,
logger?: ConditionLogger,
): boolean {
if (sourceDepartment && ctx.department && sourceDepartment !== ctx.department) {
return false;
}
// Surface malformed condition JSON to the caller (automation service) so the
// operator sees it in logs instead of silently filtering out the rule.
const conditions = JSON.parse(configJson) as Record<string, unknown>;
if (Object.keys(conditions).length === 0) return true;
if (conditions.status && ctx.newStatus && conditions.status !== ctx.newStatus) return false;
if (conditions.previousStatus && ctx.previousStatus && conditions.previousStatus !== ctx.previousStatus) return false;
if (conditions.priority) {
try {
const task = ctx.taskId ? db.core.entity.getTask(ctx.taskId) : null;
if (task && task.priority !== conditions.priority) return false;
// If task was deleted, skip this condition instead of rejecting the rule.
} catch (err) {
logger?.warn(`[automation-conditions] getTask failed for ${ctx.taskId}; treating priority condition as not applicable:`, err instanceof Error ? err.message : String(err));
}
}
if (typeof conditions.budgetThresholdPct === "number" && ctx.agentId) {
try {
const agent = db.core.entity.getAgent(ctx.agentId);
if (agent.budgetMonthlyUsd > 0) {
const utilization = agent.spentMonthlyUsd / agent.budgetMonthlyUsd;
if (utilization < (conditions.budgetThresholdPct as number) / 100) return false;
}
// If budget is 0 (unlimited), skip budget condition instead of rejecting.
} catch (err) {
logger?.warn(`[automation-conditions] getAgent failed for ${ctx.agentId}; treating budget condition as not applicable:`, err instanceof Error ? err.message : String(err));
}
}
if (conditions.taskId && ctx.taskId && conditions.taskId !== ctx.taskId) return false;
if (conditions.projectId && ctx.projectId && conditions.projectId !== ctx.projectId) return false;
if (conditions.agentId && ctx.agentId && conditions.agentId !== ctx.agentId) return false;
return true;
}
export function matchesConditions(
db: AppDatabase,
rule: { conditionsJson: string; sourceDepartment: string | null },
ctx: AutomationEventContext,
logger?: ConditionLogger,
): boolean {
return matchesEventConfig(db, rule.conditionsJson, rule.sourceDepartment, ctx, logger);
}
-165
View File
@@ -1,165 +0,0 @@
import type { AppDatabase } from "../database";
import type { NotificationOptions } from "../notification-service";
import type { AutomationEventContext } from "./types";
import { logAppError } from "../errors/index.js";
export interface HandlerDeps {
db: AppDatabase;
logger: {
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
publishDomainChanged: () => void;
notify: (options: NotificationOptions) => void;
wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null;
queueBrowserActionExecution: (actionId: string, companyId: string) => void;
isQuitting: () => boolean;
}
export interface HandlerCallbacks {
dispatchAutomation: (trigger: string, ctx: AutomationEventContext) => void;
handleTaskStatusChange: (taskId: string, companyId: string, previousStatus: string, newStatus: string) => void;
runWorkflowPipeline: (workflowId: string, companyId: string) => Promise<void>;
}
export function handleTaskStatusChange(
deps: HandlerDeps,
callbacks: Pick<HandlerCallbacks, "dispatchAutomation">,
taskId: string,
companyId: string,
previousStatus: string,
newStatus: string,
): void {
if (deps.isQuitting()) return;
if (previousStatus === newStatus) {
// Caller should gate this; log the no-op so the offender can be traced.
deps.logger.warn(
`[automation] handleTaskStatusChange called with no-op status (${previousStatus}) for task ${taskId} — caller should gate this.`,
);
return;
}
const { db, wakeAgentIfPossible } = deps;
const { dispatchAutomation } = callbacks;
let task: ReturnType<typeof db.core.entity.getTask> | null = null;
try {
task = db.core.entity.getTask(taskId);
} catch (error) {
logAppError(deps.logger, error, {
kind: "recoverable",
code: "automation.task_status_change.task_not_found",
message: `handleTaskStatusChange: task ${taskId} disappeared before handler could read it`,
context: { taskId, companyId },
}, "warn");
return;
}
if (!task) return;
db.core.activity.addActivity({
companyId,
actor: task.assigneeAgentId ?? "board",
action: "task.status_changed",
entityType: "task",
entityId: taskId,
detail: `${previousStatus} → ${newStatus}`,
});
const context: AutomationEventContext = {
companyId,
taskId,
agentId: task.assigneeAgentId ?? undefined,
previousStatus,
newStatus,
};
try {
if (task.assigneeAgentId) {
const agent = db.core.entity.getAgent(task.assigneeAgentId);
context.department = agent.department ?? undefined;
}
} catch (error) {
logAppError(deps.logger, error, {
kind: "recoverable",
code: "automation.task_status_change.assignee_deleted",
message: `Agent ${task.assigneeAgentId} deleted after task update; continuing without department context`,
context: { taskId, agentId: task.assigneeAgentId, companyId },
}, "warn");
}
dispatchAutomation("task_status_changed", context);
if (newStatus === "blocked") {
dispatchAutomation("task_blocked", context);
if (task.assigneeAgentId) {
const manager = db.core.agents.getManagerForAgent(task.assigneeAgentId);
if (manager) {
wakeAgentIfPossible(manager.id, companyId, "report_blocked");
}
}
}
if (newStatus === "done" && task.parentId) {
try {
const siblings = db.core.tasks.listChildTasks(task.parentId, companyId);
const allDone = siblings.every((entry) => entry.id === taskId ? true : entry.status === "done");
if (allDone) {
const parent = db.core.entity.getTask(task.parentId);
if (parent.assigneeAgentId) {
wakeAgentIfPossible(parent.assigneeAgentId, companyId, "subtask_completed");
} else {
const assignedId = db.core.tasks.autoAssignTask(parent.id, companyId);
if (assignedId) {
wakeAgentIfPossible(assignedId, companyId, "subtask_completed");
}
}
}
} catch (err) {
logAppError(deps.logger, err, {
kind: "recoverable",
code: "automation.task_status_change.parent_lookup_failed",
message: `Parent task ${task.parentId} lookup failed during completion cascade`,
context: { taskId, parentTaskId: task.parentId, companyId },
}, "warn");
}
}
}
export function handleTaskCreated(
deps: HandlerDeps,
callbacks: Pick<HandlerCallbacks, "dispatchAutomation">,
taskId: string,
companyId: string,
): void {
if (deps.isQuitting()) return;
const { db, wakeAgentIfPossible } = deps;
const { dispatchAutomation } = callbacks;
let task: ReturnType<typeof db.core.entity.getTask> | null = null;
try {
task = db.core.entity.getTask(taskId);
} catch (error) {
logAppError(deps.logger, error, {
kind: "recoverable",
code: "automation.task_created.task_not_found",
message: `handleTaskCreated: task ${taskId} disappeared before handler could read it`,
context: { taskId, companyId },
}, "warn");
return;
}
if (!task) return;
dispatchAutomation("task_created", {
companyId,
taskId,
agentId: task.assigneeAgentId ?? undefined,
});
if (!task.assigneeAgentId) {
const assignedId = db.core.tasks.autoAssignTask(taskId, companyId);
if (assignedId) {
wakeAgentIfPossible(assignedId, companyId, "assignment");
}
} else {
wakeAgentIfPossible(task.assigneeAgentId, companyId, "assignment");
}
}
@@ -1,475 +0,0 @@
import type { AppDatabase } from "../database";
import type { RunRecord, SectionId } from "@shared/types";
import type { NotificationOptions } from "../notification-service";
import type { AutomationEventContext, RunFinishedEvent } from "./types";
import { IMPORTANT_DOCUMENT_TYPES } from "./types";
function localizeNotification(locale: string | null | undefined, en: string, zh: string): string {
return locale === "zh" ? zh : en;
}
export interface RunCompletionDeps {
db: AppDatabase;
logger: {
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
publishDomainChanged: () => void;
notify: (options: NotificationOptions) => void;
wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null;
}
export interface RunCompletionCallbacks {
dispatchAutomation: (trigger: string, ctx: AutomationEventContext) => void;
handleTaskStatusChange: (taskId: string, companyId: string, previousStatus: string, newStatus: string) => void;
}
// ─── Pure helpers exported for testing ─────────────────────────────────────
export type DeliverableTaskType = "code" | "research" | "content" | "management" | "general";
/** Returns true for task types that require a tangible artifact before auto-advancing. */
export function needsDeliverableCheck(taskType: DeliverableTaskType | string): boolean {
return taskType === "research" || taskType === "content";
}
export interface DeliverableSignals {
hasKnowledgeEntry: boolean;
hasDocument: boolean;
hasWorkProduct: boolean;
hasSubstantialComment: boolean;
}
export interface DeliverableCheckInput extends DeliverableSignals {
taskType: DeliverableTaskType | string;
}
/**
* Valid artifact surfaces for each task type. A task advances to review
* only when at least one signal from the set is present. The `work_product`
* surface covers filesystem deliverables the agent writes via bash / write
* / edit; before this table included it, a research task that produced a
* markdown file instead of a knowledge entry would stay in `in_progress`
* forever even though the deliverable was sitting on disk.
*/
const VALID_DELIVERABLE_SIGNALS: Record<DeliverableTaskType, ReadonlySet<keyof DeliverableSignals>> = {
research: new Set(["hasKnowledgeEntry", "hasDocument", "hasWorkProduct", "hasSubstantialComment"]),
content: new Set(["hasDocument", "hasWorkProduct"]),
code: new Set(["hasWorkProduct", "hasDocument"]),
management: new Set(["hasKnowledgeEntry", "hasDocument", "hasWorkProduct", "hasSubstantialComment"]),
general: new Set(["hasKnowledgeEntry", "hasDocument", "hasWorkProduct", "hasSubstantialComment"]),
};
function validSignalsForTaskType(taskType: string): ReadonlySet<keyof DeliverableSignals> {
return VALID_DELIVERABLE_SIGNALS[taskType as DeliverableTaskType]
?? VALID_DELIVERABLE_SIGNALS.general;
}
export function hasValidDeliverableFor(taskType: string, signals: DeliverableSignals): boolean {
const valid = validSignalsForTaskType(taskType);
for (const key of valid) {
if (signals[key]) return true;
}
return false;
}
/** Returns the next task status and comment after a successful run. */
export function determinePostRunStatus(input: DeliverableCheckInput): {
nextStatus: "in_review" | "in_progress";
comment: string;
} {
if (!needsDeliverableCheck(input.taskType)) {
return {
nextStatus: "in_review",
comment: "Run completed successfully. Awaiting review.",
};
}
if (hasValidDeliverableFor(input.taskType, input)) {
return {
nextStatus: "in_review",
comment: "Run completed with deliverable. Awaiting review.",
};
}
return {
nextStatus: "in_progress",
comment: `${input.taskType} task run completed but no deliverable artifact was detected. Flagging for manager review.`,
};
}
export interface BudgetThresholdInput {
name: string;
budgetMonthlyUsd: number;
spentMonthlyUsd: number;
}
/**
* Fires budget threshold notifications when utilization >= 80%.
* The notify callback receives a NotificationOptions-style object so callers
* can forward it directly to the notification service.
*/
export function checkBudgetThreshold(
agent: BudgetThresholdInput,
notify: (opts: { title: string; body: string; urgency: "critical" | "informational"; navigation: { section: SectionId }; batchKey?: string }) => void,
localizeNotification: (en: string, zh: string) => string,
): void {
if (agent.budgetMonthlyUsd <= 0) return;
const utilization = agent.spentMonthlyUsd / agent.budgetMonthlyUsd;
if (utilization < 0.8) return;
const pct = Math.round(utilization * 100);
notify({
title: localizeNotification("Budget Warning", "预算警告"),
body: localizeNotification(
`${agent.name} has used ${pct}% of monthly budget`,
`${agent.name} 已使用月度预算的 ${pct}%`,
),
urgency: "informational",
navigation: { section: "costs" },
batchKey: "budget_alerts",
});
if (utilization >= 1.0) {
notify({
title: localizeNotification("Budget Exhausted", "预算耗尽"),
body: localizeNotification(
`${agent.name} has reached 100% of monthly budget and is paused`,
`${agent.name} 已达到月度预算上限并已暂停`,
),
urgency: "critical",
navigation: { section: "costs" },
});
}
}
// ─── Internal DB-backed deliverable check ───────────────────────────────────
function checkRunProducedDeliverable(
db: AppDatabase,
runId: string,
companyId: string,
taskId: string,
taskType: string,
): boolean {
const signals = db.core.knowledge.collectRunDeliverableSignals(runId, companyId, taskId);
return hasValidDeliverableFor(taskType, signals);
}
/** Returns important documents created during this run (matching IMPORTANT_DOCUMENT_TYPES). */
function findImportantDocsForRun(db: AppDatabase, runId: string, companyId: string): Array<{ id: string; type: string; title: string }> {
const placeholders = [...IMPORTANT_DOCUMENT_TYPES].map(() => "?").join(", ");
// Prefer causal binding, fall back to time-window for legacy data
const causal = db.queryAll<{ id: string; type: string; title: string }>(
`select id, type, title from documents where company_id = ? and type in (${placeholders}) and source_run_id = ?`,
companyId, ...IMPORTANT_DOCUMENT_TYPES, runId,
);
if (causal.length > 0) return causal;
return db.queryAll<{ id: string; type: string; title: string }>(
`select id, type, title from documents where company_id = ? and type in (${placeholders}) and source_run_id is null and created_at >= (select started_at from runs where id = ?)`,
companyId, ...IMPORTANT_DOCUMENT_TYPES, runId,
);
}
/** Creates a deliverable_review approval and fires OS notification. */
function createDeliverableReviewApproval(
deps: RunCompletionDeps,
companyId: string,
taskId: string,
agentId: string,
docs: Array<{ id: string; type: string; title: string }>,
reviewerNote?: string,
): string {
const { db, logger, publishDomainChanged, notify } = deps;
const docTitles = docs.map((d) => d.title).join(", ");
const approvalId = db.core.approvals.requestApproval({
companyId,
relatedTaskId: taskId,
requestedByAgentId: agentId,
type: "deliverable_review",
payloadSummary: `Review deliverable: ${docTitles}`.slice(0, 280),
impactSummary: reviewerNote ?? "Agent completed work with important deliverables. Awaiting your final review.",
payloadJson: JSON.stringify({ documentIds: docs.map((d) => d.id), documents: docs }),
});
db.core.tasks.setTaskRequiresUserReview(taskId, false);
const agent = db.core.entity.getAgent(agentId);
if (agent.heartbeatEnabled) {
db.core.agents.setHeartbeat(agentId, false, agent.heartbeatIntervalSec || 120);
logger.info(`[deliverable-review] Paused heartbeat for ${agent.name} — awaiting user review.`);
}
notify({
title: "Deliverable Ready for Review",
body: `${docTitles}`.slice(0, 200),
urgency: "informational",
navigation: { section: "approvals", entityId: approvalId },
batchKey: "deliverable_review",
});
publishDomainChanged();
return approvalId;
}
/**
* Decide the terminal status a successful run should leave the task in.
*
* `in_review` only makes sense when there is a reachable reviewer:
* - a non-top-level assignee always has a manager who reviews;
* - in `ai_cofounder` mode the human is the implicit reviewer;
* - in `ai_ceo` mode the operator opted out of human review, but
* they can re-opt-in per company via `reviewDeliverables`.
*
* Top-level agents in `ai_ceo` mode without `reviewDeliverables` have
* no reviewer — neither a manager to walk up to nor a human gate.
* Routing them through `in_review` strands the task forever (the
* heartbeat candidate filter only picks `todo / in_progress / blocked`
* so the CEO never sees its own stuck rows) and the codebase's old
* workaround was to force-flag `requires_user_review` so the
* `guardInReviewHasReviewer` would let the transition through. That
* was paper over the symptom; the right answer is to skip review
* entirely and advance straight to `done`.
*/
function decideTerminalStatus(
db: RunCompletionDeps["db"],
agent: ReturnType<RunCompletionDeps["db"]["core"]["entity"]["getAgent"]>,
companyId: string,
): "in_review" | "done" {
if (agent.reportsTo !== null) return "in_review";
const mode = db.core.company.getCompanyExecutionMode(companyId);
if (mode !== "ai_ceo") return "in_review";
const settings = db.core.company.getCompanyApprovalSettings(companyId);
return settings?.reviewDeliverables ? "in_review" : "done";
}
/** Auto-resolves the task as a successful autonomous completion. */
function advanceToDone(
deps: RunCompletionDeps,
callbacks: RunCompletionCallbacks,
run: RunRecord,
taskId: string,
statusMessage: string,
): void {
const { db, publishDomainChanged } = deps;
db.core.tasks.advanceTaskStatus(taskId, "done", statusMessage);
callbacks.handleTaskStatusChange(taskId, run.companyId, "in_progress", "done");
publishDomainChanged();
}
/** Advances a task to in_review and optionally flags deliverables for user review. */
function advanceToInReview(
deps: RunCompletionDeps,
callbacks: RunCompletionCallbacks,
run: RunRecord,
taskId: string,
statusMessage: string,
): void {
const { db, logger, publishDomainChanged } = deps;
// The tasks-status guard refuses an `in_review` transition for a
// top-level assignee (reports_to IS NULL) unless the task is
// flagged for user review. The caller (`processRunCompletion`) only
// routes top-level runs through this helper when human review is
// expected (`ai_cofounder` mode or `reviewDeliverables = true`), so
// pre-flagging here is the right semantics — the human IS the
// reviewer in those configurations.
const agent = db.core.entity.getAgent(run.agentId);
if (agent.reportsTo === null) {
db.core.tasks.setTaskRequiresUserReview(taskId, true);
}
db.core.tasks.advanceTaskStatus(taskId, "in_review", statusMessage);
callbacks.handleTaskStatusChange(taskId, run.companyId, "in_progress", "in_review");
const company = db.projection.snapshot.listSnapshot().companies.find((c) => c.id === run.companyId);
if (company?.reviewDeliverables) {
const importantDocs = findImportantDocsForRun(db, run.id, run.companyId);
if (importantDocs.length > 0) {
db.core.tasks.setTaskRequiresUserReview(taskId, true);
if (agent.heartbeatEnabled) {
db.core.agents.setHeartbeat(agent.id, false, agent.heartbeatIntervalSec || 120);
logger.info(`[deliverable-review] Paused heartbeat for ${agent.name} — deliverable flagged for review.`);
}
const chain = db.core.agents.getChainOfCommand(agent.id);
if (chain.length === 0) {
createDeliverableReviewApproval(deps, run.companyId, taskId, run.agentId, importantDocs);
}
}
}
publishDomainChanged();
}
/**
* Pick the right terminal-status helper for this run. `processRunCompletion`
* uses it after every successful "produced something or didn't need to"
* branch; `decideTerminalStatus` is the single source of truth that says
* `done` (no reviewer needed) vs `in_review` (reviewer expected).
*/
function advanceSuccessfulRun(
deps: RunCompletionDeps,
callbacks: RunCompletionCallbacks,
run: RunRecord,
taskId: string,
statusMessage: string,
): "in_review" | "done" {
const agent = deps.db.core.entity.getAgent(run.agentId);
const target = decideTerminalStatus(deps.db, agent, run.companyId);
if (target === "done") {
advanceToDone(deps, callbacks, run, taskId, statusMessage);
} else {
advanceToInReview(deps, callbacks, run, taskId, statusMessage);
}
return target;
}
export function processRunCompletion(
deps: RunCompletionDeps,
callbacks: RunCompletionCallbacks,
run: RunRecord,
event: RunFinishedEvent,
isQuitting: () => boolean,
): void {
if (isQuitting()) return;
const { db, logger: _logger, publishDomainChanged, notify, wakeAgentIfPossible } = deps;
const { dispatchAutomation, handleTaskStatusChange } = callbacks;
if (!run.taskId) return;
let task: ReturnType<typeof db.core.entity.getTask> | null = null;
try {
task = db.core.entity.getTask(run.taskId);
} catch (error) {
deps.logger.warn(`[automation] processRunCompletion: task ${run.taskId} not found for run ${run.id}:`, error);
return;
}
if (!task) return;
const isSuccess = event.status === "succeeded";
const isFailed = event.status === "failed" || event.status === "timed_out";
let effectiveStatus = task.status;
if (isSuccess && effectiveStatus === "in_progress") {
// Delegate the task-type → deliverable-required decision to the pure
// helper so production and tests share a single source of truth.
const taskType = task.taskType ?? "general";
if (needsDeliverableCheck(taskType)) {
const hasDeliverable = checkRunProducedDeliverable(db, run.id, run.companyId, task.id, taskType);
if (!hasDeliverable) {
// Don't auto-advance to in_review: the run succeeded but did not
// produce the required deliverable, so it's not ready for
// manager sign-off.
//
// Park the task as `blocked` rather than leaving it at
// `in_progress`. finishRun has already cleared the run's
// task_leases, so a task still tagged
// `in_progress` has no lock, no run, and no path back to
// `todo` — heartbeat picks it up, checkoutTask rejects it
// (status ∉ todo/backlog), and the assignee is stuck in a
// permanent `checkout_race_lost` loop. Blocking instead puts
// the task in the manager's blocker queue with the diagnostic
// comment attached; the manager patches it back to `todo` (or
// `cancelled`) after deciding how to proceed.
db.core.tasks.advanceTaskStatus(
task.id,
"blocked",
`${taskType} task run completed but no deliverable artifact was detected. Flagging for manager review.`,
);
handleTaskStatusChange(task.id, run.companyId, "in_progress", "blocked");
// Wake manager for review
try {
const agent = db.core.entity.getAgent(run.agentId);
const chain = db.core.agents.getChainOfCommand(agent.id);
if (chain.length > 0) {
wakeAgentIfPossible(chain[0].id, run.companyId, "review_needed");
}
} catch (error) {
deps.logger.warn(
`[automation] deliverable-review manager wake failed for agent ${run.agentId}: ${error instanceof Error ? error.message : String(error)}`,
);
}
publishDomainChanged();
effectiveStatus = "blocked";
// Skip the normal in_review advance
} else {
effectiveStatus = advanceSuccessfulRun(
deps,
callbacks,
run,
task.id,
"Run completed with deliverable.",
);
}
} else {
// Code/management/general tasks: route through the same selector
// so ai_ceo top-level completions land in `done` instead of
// stalling in the `in_review` dead-letter.
effectiveStatus = advanceSuccessfulRun(
deps,
callbacks,
run,
task.id,
"Run completed successfully.",
);
}
}
const automationContext: AutomationEventContext = {
companyId: run.companyId,
taskId: run.taskId ?? undefined,
agentId: run.agentId,
runId: run.id,
};
try {
const agent = db.core.entity.getAgent(run.agentId);
automationContext.department = agent.department ?? undefined;
} catch (err) {
deps.logger.warn(`[automation] getAgent failed for ${run.agentId} during run completion; dropping department from context:`, err instanceof Error ? err.message : String(err));
}
if (isSuccess) {
dispatchAutomation("run_completed", automationContext);
if (task.parentId) {
try {
const siblings = db.core.tasks.listChildTasks(task.parentId, run.companyId);
const allSiblingsDoneOrReview = siblings.every((entry) => entry.status === "done" || entry.status === "in_review");
if (allSiblingsDoneOrReview) {
const parent = db.core.entity.getTask(task.parentId);
if (parent.assigneeAgentId) {
wakeAgentIfPossible(parent.assigneeAgentId, run.companyId, "subtask_completed");
} else {
const assignedId = db.core.tasks.autoAssignTask(parent.id, run.companyId);
if (assignedId) {
wakeAgentIfPossible(assignedId, run.companyId, "subtask_completed");
}
}
}
} catch (err) {
deps.logger.warn(`[automation] Parent task ${task.parentId} cascade failed during run completion:`, err instanceof Error ? err.message : String(err));
}
}
}
if (isFailed) {
dispatchAutomation("run_failed", automationContext);
}
// Budget threshold check runs after every succeeded run; finishRun
// has already summed cost_entries and updated spentMonthlyUsd, so we
// just read the post-run spend and fire if it crosses 80%.
if (isSuccess) {
try {
const agent = db.core.entity.getAgent(run.agentId);
if (agent.budgetMonthlyUsd > 0) {
const utilization = agent.spentMonthlyUsd / agent.budgetMonthlyUsd;
if (utilization >= 0.8) {
dispatchAutomation("budget_threshold", {
...automationContext,
agentId: agent.id,
});
}
}
const budgetLocale = db.projection.snapshot.listSnapshot().locale;
checkBudgetThreshold(
agent,
notify,
(en, zh) => localizeNotification(budgetLocale, en, zh),
);
} catch (err) {
deps.logger.warn(`[automation] Budget follow-up failed for agent ${run.agentId}:`, err instanceof Error ? err.message : String(err));
}
}
}
-248
View File
@@ -1,248 +0,0 @@
import { z } from "zod";
import type { AppDatabase } from "../database";
import type { AgentRecord } from "@shared/types";
import type { WorkflowStepState, AutomationEventContext } from "./types";
import type { ActionDeps, ActionCallbacks } from "./automation-actions";
import { executeAutomationAction } from "./automation-actions";
import { isAutomationAction } from "./automation-action-schemas";
// Raw workflow step shape accepted from the DB `stepsJson` column. All fields
// are optional/loose because legacy rows used free-form typeof checks; zod
// applies the normalized defaults once via `.parse(...)`.
const workflowStepInputSchema = z.object({
id: z.string().optional(),
name: z.string().optional(),
action: z.string().optional(),
configJson: z.string().optional(),
assigneeDepartment: z.string().nullable().optional(),
assigneeAgentId: z.string().nullable().optional(),
requiresApproval: z.boolean().optional(),
timeoutSec: z.number().optional(),
status: z.enum(["pending", "running", "completed", "failed", "skipped"]).optional(),
}).passthrough();
const workflowStepsInputSchema = z.array(workflowStepInputSchema);
export interface WorkflowDeps {
db: AppDatabase;
logger: {
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
publishDomainChanged: () => void;
}
export function normalizeWorkflowSteps(raw: unknown): WorkflowStepState[] {
const parsed = workflowStepsInputSchema.parse(raw);
return parsed.map((step, index) => {
const rawAction = step.action ?? "send_message";
if (!isAutomationAction(rawAction)) {
throw new Error(`Workflow step ${index + 1} has unsupported action "${rawAction}".`);
}
return {
id: step.id && step.id.length > 0 ? step.id : `step-${index + 1}`,
name: step.name && step.name.length > 0 ? step.name : `Step ${index + 1}`,
action: rawAction,
configJson: step.configJson ?? "{}",
assigneeDepartment: step.assigneeDepartment ?? null,
assigneeAgentId: step.assigneeAgentId ?? null,
requiresApproval: step.requiresApproval === true,
timeoutSec: step.timeoutSec ?? 0,
status: step.status ?? "pending",
};
});
}
export function resolveWorkflowStepActor(
db: AppDatabase,
step: WorkflowStepState,
companyId: string,
): AgentRecord | null {
if (step.assigneeAgentId) {
const agent = db.core.entity.getAgent(step.assigneeAgentId);
if (agent.companyId !== companyId) {
throw new Error(`Workflow step assignee ${step.assigneeAgentId} does not belong to company ${companyId}.`);
}
return agent;
}
if (step.assigneeDepartment) {
const idleAgents = db.core.agents.getIdleAgentsForDepartment(companyId, step.assigneeDepartment);
if (idleAgents.length > 0) {
return db.core.entity.getAgent(idleAgents[0]!.id);
}
}
const snapshot = db.projection.snapshot.listSnapshot();
return snapshot.agents.find((agent) => agent.companyId === companyId && !agent.reportsTo && agent.status !== "terminated") ?? null;
}
export function buildWorkflowActionContext(
companyId: string,
step: WorkflowStepState,
actor: AgentRecord | null,
): { config: Record<string, unknown>; ctx: AutomationEventContext } {
let config: Record<string, unknown> = {};
try {
config = JSON.parse(step.configJson) as Record<string, unknown>;
} catch {
throw new Error(`Workflow step "${step.name}" has invalid config JSON.`);
}
return {
config,
ctx: {
companyId,
agentId: actor?.id,
department: actor?.department ?? step.assigneeDepartment ?? undefined,
taskId: typeof config.taskId === "string" ? config.taskId : undefined,
runId: typeof config.runId === "string" ? config.runId : undefined,
projectId: typeof config.projectId === "string" ? config.projectId : undefined,
newStatus: typeof config.status === "string" ? config.status : undefined,
} satisfies AutomationEventContext,
};
}
export async function runWorkflowPipeline(
workflowId: string,
companyId: string,
deps: WorkflowDeps,
actionDeps: ActionDeps,
actionCallbacks: ActionCallbacks,
): Promise<void> {
const { db, publishDomainChanged } = deps;
const workflow = db.automation.rules.getWorkflowRecord(workflowId, companyId);
if (!workflow) {
throw new Error("Workflow not found.");
}
const parsedSteps = normalizeWorkflowSteps(JSON.parse(workflow.stepsJson || "[]"));
if (parsedSteps.some((step) => step.status === "running")) {
throw new Error("Workflow is already running.");
}
if (parsedSteps.length === 0) {
throw new Error("Workflow has no steps.");
}
const resumeFrom = workflow.status === "paused" ? workflow.currentStepIndex : 0;
const steps: WorkflowStepState[] = parsedSteps.map((step, index) => {
if (workflow.status !== "paused") {
return { ...step, status: index < resumeFrom ? "completed" : "pending" };
}
return step;
});
db.automation.rules.updateWorkflowExecution({
id: workflowId,
companyId,
status: "active",
currentStepIndex: resumeFrom,
stepsJson: JSON.stringify(steps),
incrementRunCount: workflow.status !== "paused",
lastRunAt: new Date().toISOString(),
});
publishDomainChanged();
for (let index = resumeFrom; index < steps.length; index += 1) {
const step = steps[index]!;
step.status = "running";
db.automation.rules.updateWorkflowExecution({
id: workflowId,
companyId,
status: "active",
currentStepIndex: index,
stepsJson: JSON.stringify(steps),
lastRunAt: new Date().toISOString(),
});
publishDomainChanged();
const actor = resolveWorkflowStepActor(db, step, companyId);
const { config, ctx } = buildWorkflowActionContext(companyId, step, actor);
if (step.requiresApproval) {
const approvalId = db.core.approvals.requestApproval({
companyId,
requestedByAgentId: actor?.id ?? null,
relatedTaskId: ctx.taskId ?? null,
type: "dangerous_command",
payloadSummary: `Workflow approval required: ${workflow.name} / ${step.name}`,
impactSummary: `Workflow step "${step.name}" requires board approval before ${step.action}.`,
payloadJson: JSON.stringify({ workflowId, stepId: step.id, stepName: step.name }),
});
step.status = "pending";
db.automation.rules.updateWorkflowExecution({
id: workflowId,
companyId,
status: "paused",
currentStepIndex: index,
stepsJson: JSON.stringify(steps),
lastRunAt: new Date().toISOString(),
});
db.core.activity.addActivity({
companyId,
actor: actor?.id ?? "system",
action: "workflow.paused_for_approval",
entityType: "workflow",
entityId: workflowId,
detail: `Paused on step "${step.name}" with approval ${approvalId}.`,
});
publishDomainChanged();
return;
}
try {
executeAutomationAction(
actionDeps,
actionCallbacks,
{
action: step.action,
actionConfigJson: JSON.stringify(config),
targetDepartment: step.assigneeDepartment,
trigger: "workflow_step",
},
ctx,
);
step.status = "completed";
db.core.activity.addActivity({
companyId,
actor: actor?.id ?? "system",
action: "workflow.step_completed",
entityType: "workflow",
entityId: workflowId,
detail: step.name,
});
const isLastStep = index === steps.length - 1;
const completionStatus = isLastStep ? "completed" : "active";
db.automation.rules.updateWorkflowExecution({
id: workflowId,
companyId,
status: completionStatus,
currentStepIndex: index + 1,
stepsJson: JSON.stringify(steps),
lastRunAt: new Date().toISOString(),
});
publishDomainChanged();
} catch (error) {
step.status = "failed";
db.automation.rules.updateWorkflowExecution({
id: workflowId,
companyId,
status: "failed",
currentStepIndex: index,
stepsJson: JSON.stringify(steps),
lastRunAt: new Date().toISOString(),
});
db.core.activity.addActivity({
companyId,
actor: actor?.id ?? "system",
action: "workflow.step_failed",
entityType: "workflow",
entityId: workflowId,
detail: `${step.name}: ${error instanceof Error ? error.message : String(error)}`,
});
publishDomainChanged();
throw error;
}
}
}
-224
View File
@@ -1,224 +0,0 @@
import type { AutomationTrigger, AutomationAction, RunRecord } from "@shared/types";
import type { AutomationDependencies, AutomationEventContext, RunFinishedEvent } from "./types";
import { matchesConditions, matchesEventConfig } from "./automation-conditions";
import { executeAutomationAction } from "./automation-actions";
import type { ActionDeps, ActionCallbacks } from "./automation-actions";
import { runWorkflowPipeline } from "./automation-workflow";
import type { WorkflowDeps } from "./automation-workflow";
import { processRunCompletion } from "./automation-run-completion";
import type { RunCompletionDeps, RunCompletionCallbacks } from "./automation-run-completion";
import { handleApprovalResolved } from "./automation-approval-handler";
import type { ApprovalHandlerDeps, ApprovalHandlerCallbacks } from "./automation-approval-handler";
import { handleTaskStatusChange, handleTaskCreated } from "./automation-handlers";
import type { HandlerDeps } from "./automation-handlers";
export type { AutomationEventContext } from "./types";
export function createAutomationService({
db,
logger,
emitEvent,
publishDomainChanged,
notify,
wakeAgentIfPossible,
queueBrowserActionExecution,
isQuitting,
}: AutomationDependencies) {
const activeAutomationExecutions = new Set<string>();
const activeWorkflowTriggerExecutions = new Set<string>();
const pendingScheduledTimers = new Set<ReturnType<typeof setTimeout>>();
const actionDeps: ActionDeps = {
db,
logger,
emitEvent,
publishDomainChanged,
wakeAgentIfPossible,
};
const workflowDeps: WorkflowDeps = {
db,
logger,
publishDomainChanged,
};
const handlerDeps: HandlerDeps = {
db,
logger,
publishDomainChanged,
notify,
wakeAgentIfPossible,
queueBrowserActionExecution,
isQuitting,
};
const approvalDeps: ApprovalHandlerDeps = {
db,
logger,
publishDomainChanged,
notify,
wakeAgentIfPossible,
queueBrowserActionExecution,
isQuitting,
};
const runCompletionDeps: RunCompletionDeps = {
db,
logger,
publishDomainChanged,
notify,
wakeAgentIfPossible,
};
// Forward-declared so callbacks can reference each other
function _runWorkflowPipeline(workflowId: string, companyId: string): Promise<void> {
return runWorkflowPipeline(workflowId, companyId, workflowDeps, actionDeps, actionCallbacks);
}
function _handleTaskCreated(taskId: string, companyId: string): void {
handleTaskCreated(handlerDeps, { dispatchAutomation }, taskId, companyId);
}
function _handleApprovalResolved(approvalId: string, companyId: string, decision: string): void {
handleApprovalResolved(approvalDeps, approvalCallbacks, approvalId, companyId, decision);
}
function _handleTaskStatusChange(taskId: string, companyId: string, previousStatus: string, newStatus: string): void {
handleTaskStatusChange(handlerDeps, { dispatchAutomation }, taskId, companyId, previousStatus, newStatus);
}
const actionCallbacks: ActionCallbacks = {
handleTaskCreated: _handleTaskCreated,
handleApprovalResolved: _handleApprovalResolved,
handleTaskStatusChange: _handleTaskStatusChange,
};
const approvalCallbacks: ApprovalHandlerCallbacks = {
dispatchAutomation,
handleTaskStatusChange: _handleTaskStatusChange,
runWorkflowPipeline: _runWorkflowPipeline,
trackTimer: (timer) => { pendingScheduledTimers.add(timer); },
};
const runCompletionCallbacks: RunCompletionCallbacks = {
dispatchAutomation,
handleTaskStatusChange: _handleTaskStatusChange,
};
function evaluateAutomationRules(trigger: string, ctx: AutomationEventContext) {
try {
const rules = db.automation.rules.listActiveAutomationRules(ctx.companyId, trigger);
for (const rule of rules) {
if (!matchesConditions(db, rule, ctx, logger)) continue;
const executionKey = JSON.stringify({
ruleId: rule.id,
trigger,
companyId: ctx.companyId,
taskId: ctx.taskId ?? null,
agentId: ctx.agentId ?? null,
runId: ctx.runId ?? null,
previousStatus: ctx.previousStatus ?? null,
newStatus: ctx.newStatus ?? null,
});
if (activeAutomationExecutions.has(executionKey)) {
logger.warn(`[automation] Skipping re-entrant execution for rule "${rule.name}" (${executionKey})`);
continue;
}
activeAutomationExecutions.add(executionKey);
try {
executeAutomationAction(actionDeps, actionCallbacks, rule, ctx);
db.automation.rules.recordAutomationExecution(
rule.id,
ctx.companyId,
rule.name,
trigger,
rule.action,
`Fired for ${trigger} in context: taskId=${ctx.taskId ?? "n/a"}, agentId=${ctx.agentId ?? "n/a"}`,
);
emitEvent({
type: "automation-fired",
ruleId: rule.id,
ruleName: rule.name,
trigger: trigger as AutomationTrigger,
action: rule.action as AutomationAction,
});
logger.info(`[automation] Fired rule "${rule.name}" (${trigger} → ${rule.action})`);
} catch (error) {
logger.error(`[automation] Failed to execute action for rule "${rule.name}": ${error instanceof Error ? error.message : String(error)}`);
db.automation.rules.recordAutomationExecution(
rule.id,
ctx.companyId,
rule.name,
trigger,
rule.action,
`FAILED: ${error instanceof Error ? error.message : String(error)}`,
);
} finally {
activeAutomationExecutions.delete(executionKey);
}
}
} catch (error) {
logger.error(`[automation] Error evaluating rules for trigger "${trigger}": ${error instanceof Error ? error.message : String(error)}`);
}
}
function evaluateWorkflowTriggers(trigger: string, ctx: AutomationEventContext) {
try {
const workflows = db.automation.rules.listTriggerableWorkflows(ctx.companyId, trigger);
for (const workflow of workflows) {
if (!matchesEventConfig(db, workflow.triggerConfigJson, null, ctx, logger)) continue;
const executionKey = JSON.stringify({
workflowId: workflow.id,
trigger,
companyId: ctx.companyId,
taskId: ctx.taskId ?? null,
agentId: ctx.agentId ?? null,
runId: ctx.runId ?? null,
previousStatus: ctx.previousStatus ?? null,
newStatus: ctx.newStatus ?? null,
});
if (activeWorkflowTriggerExecutions.has(executionKey)) {
logger.warn(`[workflow] Skipping re-entrant trigger for workflow "${workflow.name}" (${executionKey})`);
continue;
}
activeWorkflowTriggerExecutions.add(executionKey);
void _runWorkflowPipeline(workflow.id, ctx.companyId)
.then(() => {
logger.info(`[workflow] Triggered workflow "${workflow.name}" via ${trigger}`);
})
.catch((error) => {
logger.error(`[workflow] Trigger failed for "${workflow.name}": ${error instanceof Error ? error.message : String(error)}`);
})
.finally(() => {
activeWorkflowTriggerExecutions.delete(executionKey);
});
}
} catch (error) {
logger.error(`[workflow] Error evaluating workflow triggers for "${trigger}": ${error instanceof Error ? error.message : String(error)}`);
}
}
function dispatchAutomation(trigger: string, ctx: AutomationEventContext) {
if (isQuitting()) return;
evaluateAutomationRules(trigger, ctx);
evaluateWorkflowTriggers(trigger, ctx);
}
function destroy() {
for (const timer of pendingScheduledTimers) {
clearTimeout(timer);
}
pendingScheduledTimers.clear();
}
return {
runWorkflowPipeline: _runWorkflowPipeline,
dispatchAutomation,
processRunCompletion: (run: RunRecord, event: RunFinishedEvent) =>
processRunCompletion(runCompletionDeps, runCompletionCallbacks, run, event, isQuitting),
handleApprovalResolved: _handleApprovalResolved,
handleTaskStatusChange: _handleTaskStatusChange,
handleTaskCreated: _handleTaskCreated,
destroy,
};
}
-58
View File
@@ -1,58 +0,0 @@
import type { AppDatabase } from "../database";
import type {
AutomationAction,
DesktopEvent,
RunStatus,
} from "@shared/types";
import type { NotificationOptions } from "../notification-service";
/** Document types considered important enough to gate behind user review when reviewDeliverables is enabled. */
export const IMPORTANT_DOCUMENT_TYPES = new Set([
"prd", "technical_spec", "design_doc", "project_brief", "proposal",
"budget_proposal", "architecture_decision", "contract", "sop",
]);
export interface WorkflowStepState {
id: string;
name: string;
action: AutomationAction;
configJson: string;
assigneeDepartment: string | null;
assigneeAgentId: string | null;
requiresApproval: boolean;
timeoutSec: number;
status: "pending" | "running" | "completed" | "failed" | "skipped";
}
export interface AutomationEventContext {
companyId: string;
taskId?: string;
agentId?: string;
runId?: string;
projectId?: string;
department?: string;
previousStatus?: string;
newStatus?: string;
costUsd?: number;
}
export interface AutomationDependencies {
db: AppDatabase;
logger: {
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
emitEvent: (event: DesktopEvent) => void;
publishDomainChanged: () => void;
notify: (options: NotificationOptions) => void;
wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null;
queueBrowserActionExecution: (actionId: string, companyId: string) => void;
isQuitting: () => boolean;
}
export interface RunFinishedEvent {
status: RunStatus;
summary: string | null;
errorMessage: string | null;
}
-212
View File
@@ -1,212 +0,0 @@
import { app } from "electron";
import type { AgentApiServer } from "./api-server";
import type { AppDatabase } from "./database";
import type { BrowserManager } from "./browser-manager";
import { prepareHostEnvironment } from "./host-environment-service";
import type { createServiceGraph } from "./service-graph";
import type { createIpcSupport } from "./ipc-support";
import type { createThreadIntegration } from "./thread-integration";
import type { createCheckpointEngine } from "./checkpoint-engine";
import {
DocumentLifecycleProcessor,
makeLifecycleProcessorOptions,
} from "./document-lifecycle-processor";
import { registerLifecycleHandlers } from "./document-lifecycle-handlers";
import { prepareLogger } from "./bootstrap/prepare-logger";
import { prepareDatabase } from "./bootstrap/prepare-database";
import { prepareServices } from "./bootstrap/prepare-services";
import { wireApiServer, type CreateApiServerFn } from "./bootstrap/wire-api-server";
import { recoverScheduledPosts } from "./bootstrap/recover-scheduled-posts";
import { backfillWorkspaceArtifacts } from "./bootstrap/backfill-workspace-artifacts";
import { runStartupHealthChecks } from "./bootstrap/run-startup-health-checks";
import { emitCrashRecoveryNotification, recoverShutdownInterruptedTasks } from "./bootstrap/recover-interrupted-tasks";
interface BootstrapApplicationDependencies {
hasSingleInstanceLock: boolean;
isDev: boolean;
devServerOrigin: string | null;
profileDir: string;
appDatabaseFactory: (profileDir: string) => AppDatabase;
initSigningKey: (profileDir: string) => void;
windowStatePath: string;
preloadPath: string;
rendererDist: string;
iconPath: string;
devServerUrl?: string;
workerPath: string;
createApiServer: CreateApiServerFn;
createRunToken: (agentId: string, companyId: string, runId: string) => string;
seedDefaultAutomationRules: (companyId: string) => void;
isQuittingRef: { current: boolean };
onRuntimeReady?: (state: {
db: AppDatabase;
apiServer: AgentApiServer;
serviceGraph: ReturnType<typeof createServiceGraph>;
ipcSupport: ReturnType<typeof createIpcSupport>;
threadIntegration: ReturnType<typeof createThreadIntegration>;
checkpointEngine: ReturnType<typeof createCheckpointEngine>;
lifecycleProcessor: DocumentLifecycleProcessor;
}) => void;
}
export async function bootstrapApplication(deps: BootstrapApplicationDependencies) {
await app.whenReady();
const log = prepareLogger();
await prepareHostEnvironment({ logger: log });
const db = prepareDatabase({
profileDir: deps.profileDir,
appDatabaseFactory: deps.appDatabaseFactory,
initSigningKey: deps.initSigningKey,
});
const apiServerRef = { current: null as AgentApiServer | null };
const browserManagerRef = { current: null as BrowserManager | null };
const services = prepareServices({
db,
apiServerRef,
browserManagerRef,
isQuittingRef: deps.isQuittingRef,
preloadPath: deps.preloadPath,
rendererDist: deps.rendererDist,
iconPath: deps.iconPath,
isDev: deps.isDev,
devServerUrl: deps.devServerUrl,
devServerOrigin: deps.devServerOrigin,
windowStatePath: deps.windowStatePath,
workerPath: deps.workerPath,
logger: log,
createRunToken: deps.createRunToken,
seedDefaultAutomationRules: deps.seedDefaultAutomationRules,
});
const { serviceGraph, ipcSupport, threadIntegration, notificationService, checkpointEngine, notify, emitEvent, publishDomainChanged } = services;
const wakeAgentIfPossible = (agentId: string | null | undefined, companyId: string, trigger: string) => {
const orchestration = serviceGraph.getOrchestrationService();
if (!orchestration) throw new Error("Orchestration service is not initialized.");
return orchestration.wakeAgentIfPossible(agentId, companyId, trigger);
};
const lifecycleProcessor = new DocumentLifecycleProcessor(
makeLifecycleProcessorOptions({ db, logger: log }),
);
registerLifecycleHandlers(lifecycleProcessor, {
dispatchAutomation: (trigger, ctx) => {
const automation = serviceGraph.getAutomationService();
if (!automation) throw new Error("Automation service is not initialized.");
automation.dispatchAutomation(trigger, ctx);
},
wakeAgentIfPossible,
});
const apiServer = wireApiServer({
db,
serviceGraph,
threadIntegration,
checkpointEngine,
lifecycleProcessor,
emitEvent,
notify,
publishDomainChanged,
isDev: deps.isDev,
devServerUrl: deps.devServerUrl,
createApiServer: deps.createApiServer,
});
apiServerRef.current = apiServer;
await apiServer.start();
deps.onRuntimeReady?.({
db,
apiServer,
serviceGraph,
ipcSupport,
threadIntegration,
checkpointEngine,
lifecycleProcessor,
});
lifecycleProcessor.start();
app.on("before-quit", () => lifecycleProcessor.stop());
if (apiServer && serviceGraph.getBrowserActionService()) {
apiServer.setBrowserActionHandler((actionId, companyId) => {
const browserActions = serviceGraph.getBrowserActionService();
if (!browserActions) {
const message = "Browser action service is not initialized.";
log.error(`[browser] ${message} action=${actionId}`);
db.core.social.updateBrowserAction(actionId, {
status: "failed",
resultSummary: "Browser action dispatch failed before execution completed.",
errorMessage: message,
finishedAt: new Date().toISOString(),
});
db.core.activity.addActivity({
companyId,
actor: "system",
action: "browser.action_dispatch_failed",
entityType: "browser_action",
entityId: actionId,
detail: message,
});
publishDomainChanged();
return;
}
browserActions.queueBrowserActionExecution(actionId, companyId);
});
}
const scheduledPostTimers = recoverScheduledPosts({
db,
logger: log,
isQuittingRef: deps.isQuittingRef,
getBrowserActionService: () => serviceGraph.getBrowserActionService(),
});
// One-time backfill: surface deliverables that cognitive runs wrote
// to disk while the projector was suppressed by the pre-observationPath
// bug (regression of T1 workspace fix, repaired in 2c12c1a). Gated by
// a settings flag so subsequent boots skip the workspace walk; the
// post-fix projector handles new file activity inside each run.
backfillWorkspaceArtifacts({ db, logger: log });
// Register cleanup for bootstrap-owned resources during quit.
// Runs BEFORE service-graph's onBeforeQuit (which is registered later).
app.on("before-quit", () => {
for (const timer of scheduledPostTimers) clearTimeout(timer);
scheduledPostTimers.clear();
notificationService.destroy();
});
// Show the window immediately so the user sees the app launch without delay.
await serviceGraph.getDesktopShellService()?.ensureWindow();
serviceGraph.getAppLifecycleService()?.register(deps.hasSingleInstanceLock);
// Run connector health checks AFTER the window is visible to unblock startup.
await runStartupHealthChecks(db, serviceGraph.getConnectorHealthService(), log);
serviceGraph.getOrchestrationService()?.startAllSchedulers();
emitCrashRecoveryNotification({
db,
logger: log,
emitEvent,
notify,
publishDomainChanged,
wakeAgentIfPossible,
});
serviceGraph.getOrchestrationService()?.resumeQueuedRuns();
recoverShutdownInterruptedTasks({
db,
logger: log,
emitEvent,
notify,
publishDomainChanged,
wakeAgentIfPossible,
});
return { db, apiServer, serviceGraph, ipcSupport, threadIntegration };
}
@@ -1,226 +0,0 @@
import { existsSync, readdirSync, statSync } from "node:fs";
import { relative, resolve } from "node:path";
import type { AppDatabase } from "../database";
/**
* One-shot rescan that surfaces files written by cognitive runs whose
* artifact projector was suppressed by the pre-`observationPath` bug
* (regression of T1, fixed in commit 2c12c1a).
*
* The post-fix projector observes per-run via mtime baselines. Files
* that already existed on disk before the projector was repaired never
* advance their mtime past the new run's baseline, so they remain
* invisible to subsequent runs — they are real deliverables that the
* DocumentCenter still does not list. This module walks every company
* primary workspace once at startup, applies the same registrability
* filter as `artifact-projector.ts`, and upserts a `work_products` row
* for each file that is missing one.
*
* Idempotent through `findWorkProductByExternalId` — a file that was
* already projected by some earlier run will short-circuit on lookup
* and be skipped. Bounded by `MAX_BACKFILL_FILES_PER_WORKSPACE` so a
* pathological workspace tree cannot lock the bootstrap path.
*
* Gated by a one-time settings flag (`artifact_rescan_v1_done`) so a
* second startup does not re-walk every workspace tree on disk; the
* projector handles all subsequent file activity.
*/
const SETTINGS_FLAG_KEY = "artifact_rescan_v1_done";
const MAX_BACKFILL_FILES_PER_WORKSPACE = 5_000;
const SKIP_DIR_NAMES: ReadonlySet<string> = new Set([
".git",
"node_modules",
".next",
"dist",
"build",
"out",
".cache",
".pnpm-store",
]);
const IGNORED_PATH_SEGMENTS: readonly string[] = [
"/.git/",
"/node_modules/",
"/.next/",
"/dist/",
"/build/",
"/out/",
"/.cache/",
"/.pnpm-store/",
"/.DS_Store",
];
const IGNORED_EXACT_NAMES: ReadonlySet<string> = new Set([
".DS_Store",
"package-lock.json",
"pnpm-lock.yaml",
"yarn.lock",
".gitignore",
]);
const IGNORED_EXTENSIONS: ReadonlySet<string> = new Set([
".lock",
".log",
".map",
".min.js",
".min.css",
]);
interface Logger {
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
}
export interface BackfillWorkspaceArtifactsDeps {
db: AppDatabase;
logger: Logger;
}
export interface BackfillWorkspaceArtifactsResult {
/** True when the rescan ran (first launch after the fix). */
ran: boolean;
/** Files inspected across every company workspace. */
filesScanned: number;
/** Files inserted as work_products (previously missing rows). */
filesInserted: number;
/** Files skipped because a row already existed (idempotency). */
filesAlreadyTracked: number;
/** Files skipped by the registrability filter. */
filesIgnored: number;
}
export function backfillWorkspaceArtifacts(
deps: BackfillWorkspaceArtifactsDeps,
): BackfillWorkspaceArtifactsResult {
const empty: BackfillWorkspaceArtifactsResult = {
ran: false,
filesScanned: 0,
filesInserted: 0,
filesAlreadyTracked: 0,
filesIgnored: 0,
};
const flag = deps.db.core.settings.getSetting(SETTINGS_FLAG_KEY);
if (flag === "true") return empty;
let filesScanned = 0;
let filesInserted = 0;
let filesAlreadyTracked = 0;
let filesIgnored = 0;
const snapshot = deps.db.projection.snapshot.listSnapshot();
for (const company of snapshot.companies) {
const observation = deps.db.core.workspaces.getCompanyObservationWorkspace(company.id);
if (!observation) continue;
const workspaceRoot = observation.localPath;
if (!workspaceRoot || !existsSync(workspaceRoot)) continue;
let stat;
try {
stat = statSync(workspaceRoot);
} catch {
continue;
}
if (!stat.isDirectory()) continue;
const stack: string[] = [workspaceRoot];
let countForThisWorkspace = 0;
while (stack.length > 0) {
if (countForThisWorkspace >= MAX_BACKFILL_FILES_PER_WORKSPACE) {
deps.logger.warn(
`[backfill-workspace-artifacts] hit per-workspace cap (${MAX_BACKFILL_FILES_PER_WORKSPACE}) for company=${company.id} workspace=${observation.id}; remaining files will be picked up by future runs as they are touched`,
);
break;
}
const dir = stack.pop();
if (!dir) break;
let entries: { name: string; isDirectory: () => boolean; isSymbolicLink: () => boolean }[];
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (entry.isSymbolicLink()) continue;
const abs = resolve(dir, entry.name);
if (entry.isDirectory()) {
if (SKIP_DIR_NAMES.has(entry.name)) continue;
stack.push(abs);
continue;
}
try {
const fileStat = statSync(abs);
if (!fileStat.isFile()) continue;
} catch {
continue;
}
filesScanned += 1;
countForThisWorkspace += 1;
const rel = relative(workspaceRoot, abs);
if (rel.length === 0) {
filesIgnored += 1;
continue;
}
const probe = `/${rel}/`;
if (IGNORED_PATH_SEGMENTS.some((seg) => probe.includes(seg))) {
filesIgnored += 1;
continue;
}
const lastSlash = rel.lastIndexOf("/");
const basename = lastSlash >= 0 ? rel.slice(lastSlash + 1) : rel;
if (IGNORED_EXACT_NAMES.has(basename)) {
filesIgnored += 1;
continue;
}
const dot = basename.lastIndexOf(".");
if (dot > 0) {
const ext = basename.slice(dot).toLowerCase();
if (IGNORED_EXTENSIONS.has(ext)) {
filesIgnored += 1;
continue;
}
const prevDot = basename.lastIndexOf(".", dot - 1);
if (prevDot > 0) {
const compound = basename.slice(prevDot).toLowerCase();
if (IGNORED_EXTENSIONS.has(compound)) {
filesIgnored += 1;
continue;
}
}
}
const existing = deps.db.core.documents.findWorkProductByExternalId(
company.id,
"filesystem",
rel,
observation.id,
);
if (existing) {
filesAlreadyTracked += 1;
continue;
}
const title = lastSlash >= 0 ? rel.slice(lastSlash + 1) : rel;
deps.db.core.documents.createWorkProduct({
companyId: company.id,
taskId: null,
workspaceId: observation.id,
type: "file",
provider: "filesystem",
externalId: rel,
title,
url: null,
});
filesInserted += 1;
}
}
}
deps.db.core.settings.setSetting(SETTINGS_FLAG_KEY, "true");
deps.logger.info(
`[backfill-workspace-artifacts] scanned=${filesScanned} inserted=${filesInserted} already-tracked=${filesAlreadyTracked} ignored=${filesIgnored}`,
);
return {
ran: true,
filesScanned,
filesInserted,
filesAlreadyTracked,
filesIgnored,
};
}
-14
View File
@@ -1,14 +0,0 @@
import type { AppDatabase } from "../database";
export interface PrepareDatabaseDeps {
profileDir: string;
appDatabaseFactory: (profileDir: string) => AppDatabase;
initSigningKey: (profileDir: string) => void;
}
export function prepareDatabase(deps: PrepareDatabaseDeps): AppDatabase {
const db = deps.appDatabaseFactory(deps.profileDir);
db.init();
deps.initSigningKey(db.profileDir);
return db;
}
-16
View File
@@ -1,16 +0,0 @@
import { app } from "electron";
import log from "electron-log/main.js";
export function prepareLogger() {
log.initialize();
log.transports.console.format = "[{h}:{i}:{s}] [{level}] {text}";
log.transports.file.format = "[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] {text}";
if (app.isPackaged) {
log.transports.console.level = "warn";
log.transports.file.level = "info";
} else {
log.transports.console.level = "info";
log.transports.file.level = "debug";
}
return log;
}
-142
View File
@@ -1,142 +0,0 @@
import type { AgentApiServer } from "../api-server";
import type { AppDatabase } from "../database";
import type { BrowserManager } from "../browser-manager";
import { createServiceGraph } from "../service-graph";
import { createIpcSupport } from "../ipc-support";
import { createNotificationService } from "../notification-service";
import { createThreadIntegration } from "../thread-integration";
import { createCheckpointEngine } from "../checkpoint-engine";
import { createRunHousekeeping } from "../run-housekeeping";
import type { DesktopEvent } from "@shared/types";
import type { NotificationOptions } from "../notification-service";
export interface PrepareServicesDeps {
db: AppDatabase;
apiServerRef: { current: AgentApiServer | null };
browserManagerRef: { current: BrowserManager | null };
isQuittingRef: { current: boolean };
preloadPath: string;
rendererDist: string;
iconPath: string;
isDev: boolean;
devServerUrl?: string;
devServerOrigin: string | null;
windowStatePath: string;
workerPath: string;
logger: {
debug: (...args: unknown[]) => void;
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
createRunToken: (agentId: string, companyId: string, runId: string) => string;
seedDefaultAutomationRules: (companyId: string) => void;
}
export interface ServiceGraphHandles {
serviceGraph: ReturnType<typeof createServiceGraph>;
ipcSupport: ReturnType<typeof createIpcSupport>;
threadIntegration: ReturnType<typeof createThreadIntegration>;
notificationService: ReturnType<typeof createNotificationService>;
checkpointEngine: ReturnType<typeof createCheckpointEngine>;
notify: (options: NotificationOptions) => void;
emitEvent: (event: DesktopEvent) => void;
publishDomainChanged: () => void;
}
export function prepareServices(deps: PrepareServicesDeps): ServiceGraphHandles {
let ipcSupport: ReturnType<typeof createIpcSupport> | null = null;
let serviceGraph: ReturnType<typeof createServiceGraph> | null = null;
const notificationService = createNotificationService({
getMainWindow: () => serviceGraph?.getDesktopShellService()?.getMainWindow() ?? null,
emitEvent: (event: DesktopEvent) => ipcSupport?.emitEvent(event),
logger: deps.logger,
});
const notify = (options: NotificationOptions) => {
if (!ipcSupport) {
throw new Error("IPC support is not initialized.");
}
ipcSupport.notify(options);
};
const emitEvent = (event: DesktopEvent) => ipcSupport?.emitEvent(event);
const publishDomainChanged = () => ipcSupport?.publishDomainChanged();
const threadIntegration = createThreadIntegration({
db: deps.db,
logger: deps.logger,
emitEvent,
publishDomainChanged,
notify: (options) => notify({ ...options, urgency: "informational" }),
});
const checkpointEngine = createCheckpointEngine({
db: deps.db,
logger: deps.logger,
threadIntegration,
publishDomainChanged,
notify,
});
const runHousekeeping = createRunHousekeeping({
db: deps.db,
logger: deps.logger,
threadIntegration,
});
serviceGraph = createServiceGraph({
db: deps.db,
apiServerRef: deps.apiServerRef,
browserManagerRef: deps.browserManagerRef,
isQuittingRef: deps.isQuittingRef,
preloadPath: deps.preloadPath,
rendererDist: deps.rendererDist,
iconPath: deps.iconPath,
isDev: deps.isDev,
devServerUrl: deps.devServerUrl,
windowStatePath: deps.windowStatePath,
workerPath: deps.workerPath,
logger: deps.logger,
emitEvent,
publishDomainChanged,
notify,
createRunToken: deps.createRunToken,
seedDefaultAutomationRules: deps.seedDefaultAutomationRules,
onRunStarted: (runId, taskId, agentId, companyId) =>
threadIntegration.onRunStarted(runId, taskId, agentId, companyId),
onRunCompleted: (runId, taskId, agentId, companyId, status, summary, errorMessage) =>
runHousekeeping.onRunCompleted(runId, taskId, agentId, companyId, status, summary, errorMessage),
checkpointEngine,
// Lazy forwarder — ipcSupport is constructed below but only invoked
// inside serviceGraph.init(), which runs after that point.
registerHandle: (channel, handler) => {
if (!ipcSupport) {
throw new Error("IPC support is not initialized.");
}
ipcSupport.registerHandle(channel, handler);
},
});
ipcSupport = createIpcSupport({
isDev: deps.isDev,
devServerOrigin: deps.devServerOrigin,
logger: deps.logger,
getMainWindow: () => serviceGraph?.getDesktopShellService()?.getMainWindow() ?? null,
getDb: () => ({
hasCompany: (companyId: string) => deps.db.core.settings.hasCompany(companyId),
belongsToCompany: (table: string, id: string, companyId: string) =>
deps.db.core.settings.belongsToCompany(table as Parameters<typeof deps.db.core.settings.belongsToCompany>[0], id, companyId),
}),
notificationService,
});
serviceGraph.init();
return {
serviceGraph,
ipcSupport,
threadIntegration,
notificationService,
checkpointEngine,
notify,
emitEvent,
publishDomainChanged,
};
}
@@ -1,73 +0,0 @@
import type { AppDatabase } from "../database";
import type { DesktopEvent } from "@shared/types";
import type { NotificationOptions } from "../notification-service";
interface Logger {
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
}
export interface RecoverInterruptedTasksDeps {
db: AppDatabase;
logger: Logger;
emitEvent: (event: DesktopEvent) => void;
notify: (options: NotificationOptions) => void;
publishDomainChanged: () => void;
wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null;
}
/** Emit the session-recovery notification for runs interrupted by crash. */
export function emitCrashRecoveryNotification(deps: RecoverInterruptedTasksDeps): void {
const interruptedCount = deps.db.lastInterruptedCount;
if (interruptedCount <= 0) return;
deps.logger.warn(`[bootstrap] Detected ${interruptedCount} interrupted runs from previous session. Sending recovery notification.`);
const interruptedRuns = deps.db.core.runs.getInterruptedRunsForRecovery();
if (interruptedRuns.length === 0) return;
deps.emitEvent({ type: "recovery-needed", interruptedRuns });
deps.notify({
title: "Session Recovered",
body: `${interruptedRuns.length} interrupted task(s) detected from previous session`,
urgency: "informational",
navigation: { section: "overview" },
});
}
/**
* Resume tasks interrupted by a clean shutdown. worker-service marks running/
* queued runs as `interrupted` on destroy but does not reset the tasks; without
* this recovery the assigned agents never re-wake to continue them.
*/
export function recoverShutdownInterruptedTasks(deps: RecoverInterruptedTasksDeps): void {
try {
const snapshot = deps.db.projection.snapshot.listSnapshot();
const shutdownRuns = snapshot.runs.filter(
(r) => r.status === "interrupted" &&
(r.summary === "Application shut down." || r.summary === "Worker process crashed."),
);
if (shutdownRuns.length === 0) return;
const resumedAgents = new Set<string>();
for (const run of shutdownRuns) {
try {
if (run.taskId) {
const task = deps.db.core.entity.getTask(run.taskId);
if (task.status === "in_progress" || task.status === "blocked") {
deps.db.core.tasks.advanceTaskStatus(run.taskId, "todo", "Auto-resumed after app restart.");
deps.db.core.tasks.releaseTaskLock(run.taskId, run.id);
}
}
if (!resumedAgents.has(run.agentId)) {
resumedAgents.add(run.agentId);
deps.wakeAgentIfPossible(run.agentId, run.companyId, "app_restart_resume");
}
} catch (error) {
deps.logger.warn(`[bootstrap] Skipped shutdown recovery for run ${run.id} (task/agent may have been deleted):`, error);
}
}
if (resumedAgents.size > 0) {
deps.logger.info(`[bootstrap] Resumed ${shutdownRuns.length} interrupted task(s) across ${resumedAgents.size} agent(s) after clean shutdown recovery.`);
deps.publishDomainChanged();
}
} catch (err) {
deps.logger.warn(`[bootstrap] Shutdown recovery failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
@@ -1,49 +0,0 @@
import type { AppDatabase } from "../database";
interface Logger {
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
}
export interface RecoverScheduledPostsDeps {
db: AppDatabase;
logger: Logger;
isQuittingRef: { current: boolean };
getBrowserActionService: () => { queueBrowserActionExecution: (actionId: string, companyId: string) => void } | null;
}
export function recoverScheduledPosts(deps: RecoverScheduledPostsDeps): Set<ReturnType<typeof setTimeout>> {
const timers = new Set<ReturnType<typeof setTimeout>>();
try {
const snapshot = deps.db.projection.snapshot.listSnapshot();
for (const company of snapshot.companies) {
const queuedActions = deps.db.core.social.listBrowserActions(company.id, { status: "queued" });
for (const action of queuedActions) {
if (!action.payloadJson) continue;
try {
const payload = JSON.parse(action.payloadJson) as { scheduledAt?: string };
if (!payload.scheduledAt) continue;
const delay = new Date(payload.scheduledAt).getTime() - Date.now();
if (delay <= 0) continue;
const timer = setTimeout(() => {
timers.delete(timer);
if (deps.isQuittingRef.current) return;
const browserActions = deps.getBrowserActionService();
if (browserActions) {
browserActions.queueBrowserActionExecution(action.id, company.id);
}
}, delay);
timers.add(timer);
deps.logger.info(`[bootstrap] Re-registered scheduled post timer: action ${action.id} in ${Math.round(delay / 1000)}s`);
} catch (error) {
deps.logger.warn(
`[bootstrap] malformed scheduled-post payload for action ${action.id}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
} catch (error) {
deps.logger.warn(`[bootstrap] Failed to re-register scheduled post timers: ${error instanceof Error ? error.message : String(error)}`);
}
return timers;
}
@@ -1,28 +0,0 @@
import type { AppDatabase } from "../database";
import type { createConnectorHealthService } from "../connector-health-service";
interface Logger {
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
}
type ConnectorHealthService = ReturnType<typeof createConnectorHealthService>;
export async function runStartupHealthChecks(
db: AppDatabase,
connectorHealth: ConnectorHealthService | null | undefined,
logger: Logger,
): Promise<void> {
if (!connectorHealth) return;
const connectors = db.projection.snapshot.listSnapshot().connectors;
const results = await Promise.allSettled(
connectors.map((connector) => connectorHealth.checkConnector(connector.id)),
);
for (let i = 0; i < results.length; i++) {
const result = results[i]!;
if (result.status === "rejected") {
logger.warn(`[bootstrap] Connector health check failed for ${connectors[i]?.id}: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`);
}
}
logger.info(`[bootstrap] Connector health checks completed (${results.filter((r) => r.status === "fulfilled").length}/${connectors.length} succeeded)`);
}
-143
View File
@@ -1,143 +0,0 @@
import log from "electron-log/main.js";
import type { AgentApiServer, ApiServerEventCallbacks } from "../api-server";
import type { AppDatabase } from "../database";
import type { DesktopEvent } from "@shared/types";
import type { NotificationOptions } from "../notification-service";
import type { createServiceGraph } from "../service-graph";
import type { createThreadIntegration } from "../thread-integration";
import type { createCheckpointEngine } from "../checkpoint-engine";
import type { AutomationEventContext } from "../automation/types";
import { createDocumentWithLifecycle } from "../document-lifecycle";
import type { DocumentLifecycleProcessor } from "../document-lifecycle-processor";
type ServiceGraph = ReturnType<typeof createServiceGraph>;
type ThreadIntegration = ReturnType<typeof createThreadIntegration>;
type CheckpointEngine = ReturnType<typeof createCheckpointEngine>;
export interface CreateApiServerFn {
(db: AppDatabase, callbacks: ApiServerEventCallbacks, allowedOrigins: string[]): AgentApiServer;
}
export interface WireApiServerDeps {
db: AppDatabase;
serviceGraph: ServiceGraph;
threadIntegration: ThreadIntegration;
checkpointEngine: CheckpointEngine;
lifecycleProcessor: DocumentLifecycleProcessor;
emitEvent: (event: DesktopEvent) => void;
notify: (options: NotificationOptions) => void;
publishDomainChanged: () => void;
isDev: boolean;
devServerUrl?: string;
createApiServer: CreateApiServerFn;
}
export function wireApiServer(deps: WireApiServerDeps): AgentApiServer {
const wakeAgentIfPossible = (agentId: string | null | undefined, companyId: string, trigger: string) => {
const orchestration = deps.serviceGraph.getOrchestrationService();
if (!orchestration) throw new Error("Orchestration service is not initialized.");
return orchestration.wakeAgentIfPossible(agentId, companyId, trigger);
};
const dispatchAutomation = (trigger: string, ctx: AutomationEventContext) => {
const automation = deps.serviceGraph.getAutomationService();
if (!automation) throw new Error("Automation service is not initialized.");
automation.dispatchAutomation(trigger, ctx);
};
const onTaskCreated = (taskId: string, companyId: string) => {
const automation = deps.serviceGraph.getAutomationService();
if (!automation) throw new Error("Automation service is not initialized.");
automation.handleTaskCreated(taskId, companyId);
deps.threadIntegration.onTaskCreated(taskId, companyId);
};
const onTaskStatusChanged = (taskId: string, companyId: string, prev: string, next: string) => {
const automation = deps.serviceGraph.getAutomationService();
if (!automation) throw new Error("Automation service is not initialized.");
automation.handleTaskStatusChange(taskId, companyId, prev, next);
deps.threadIntegration.onTaskStatusChanged(taskId, companyId, prev, next);
if (prev === "todo" && next === "in_progress") {
try {
const task = deps.db.core.entity.getTask(taskId);
deps.checkpointEngine.onTaskStarted(taskId, companyId, task.assigneeAgentId);
} catch (error) {
log.warn(
`[wire-api-server] checkpointEngine.onTaskStarted failed for task ${taskId}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
};
const onApprovalResolved = (approvalId: string, companyId: string, decision: string) => {
const automation = deps.serviceGraph.getAutomationService();
if (!automation) throw new Error("Automation service is not initialized.");
automation.handleApprovalResolved(approvalId, companyId, decision);
deps.threadIntegration.onApprovalResolved(approvalId, companyId, decision);
};
const onApprovalCreated = (
approvalId: string,
companyId: string,
taskId: string | null,
approvalType: string,
summary: string,
) => {
deps.threadIntegration.onApprovalCreated(approvalId, companyId, taskId, approvalType, summary);
};
// Cross-wire the same callbacks into the pi runtime bridge so the
// internal-tool path (C4) triggers identical thread/automation
// notifications as the HTTP route.
const piRuntime = deps.serviceGraph.getPiRuntime();
if (!piRuntime) {
throw new Error("Pi runtime is not initialized; cannot wire internal-tool callbacks.");
}
piRuntime.runtimeBridge.setInternalToolCallbacks({
onApprovalCreated,
onApprovalResolved,
onDomainChanged: deps.publishDomainChanged,
});
const allowedOrigins = [deps.isDev && deps.devServerUrl ? new URL(deps.devServerUrl).origin : null, "app://-"]
.filter((origin): origin is string => Boolean(origin));
return deps.createApiServer(deps.db, {
onDomainChanged: deps.publishDomainChanged,
onWakeAgent: (agentId, companyId, trigger) => wakeAgentIfPossible(agentId, companyId, trigger),
onTaskCreated,
onTaskStatusChanged,
onCommentPosted: (taskId, companyId, authorAgentId) => {
dispatchAutomation("comment_posted", { companyId, taskId, agentId: authorAgentId ?? undefined });
},
onApprovalCreated,
onApprovalResolved,
createDocument: (input) =>
createDocumentWithLifecycle(
{
db: deps.db,
threadIntegration: deps.threadIntegration,
checkpointEngine: deps.checkpointEngine,
lifecycleProcessor: deps.lifecycleProcessor,
publishDomainChanged: deps.publishDomainChanged,
logger: log,
},
input,
),
onAutoAssignTask: (taskId, companyId) => deps.db.core.tasks.autoAssignTask(taskId, companyId),
onHumanMessage: (companyId, toAgentId, threadId, waitStateId) => {
deps.threadIntegration.onHumanMessage(companyId, toAgentId, threadId, waitStateId);
deps.publishDomainChanged();
},
injectRuntimeMessage: (recipientAgentId, companyId, message) => {
const injector = deps.serviceGraph.getRuntimeMessageInjector();
if (!injector) return false;
try {
return injector.tryInject(recipientAgentId, companyId, message);
} catch (error) {
log.warn(
`[wire-api-server] runtime message injection failed for agent ${recipientAgentId}: ${error instanceof Error ? error.message : String(error)}`,
);
return false;
}
},
emitEvent: deps.emitEvent,
notify: deps.notify,
}, allowedOrigins);
}
-7
View File
@@ -1,7 +0,0 @@
// Empty in the public build. The Boss edition build (`boss-build/build-boss-edition.mjs`)
// rewrites this literal at package time to embed the Fireworks Fire Pass API
// key so the onboarding card boots in the "active" state without any user
// interaction. `database-base.init()` reads the constant and, when non-empty,
// seeds both the credentials table and the connector envBindingText in a single
// pass.
export const BOSS_FIREWORKS_API_KEY: string = "";
-203
View File
@@ -1,203 +0,0 @@
import { join } from "node:path";
import log from "electron-log/main.js";
import type { AppDatabase } from "./database";
import { BrowserManager } from "./browser-manager";
import { classifyUnknownError, logAppError } from "./errors/index.js";
interface BrowserActionDependencies {
db: AppDatabase;
browserManagerRef: { current: BrowserManager | null };
publishDomainChanged: () => void;
}
export function createBrowserActionService({
db,
browserManagerRef,
publishDomainChanged,
}: BrowserActionDependencies) {
function requireBrowserManager() {
if (!browserManagerRef.current) {
throw new Error("Browser manager is not initialized.");
}
return browserManagerRef.current;
}
function formatError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function recordDispatchFailure(actionId: string, companyId: string, error: unknown) {
const message = formatError(error);
logAppError(log, error, {
kind: "upstream",
code: "browser_action.dispatch.failed",
message: `Browser action ${actionId} dispatch failed`,
cause: error,
context: { actionId, companyId },
});
const action = db.core.social.getBrowserAction(actionId, companyId);
if (action && (action.status === "queued" || action.status === "running")) {
db.core.social.updateBrowserAction(actionId, {
status: "failed",
resultSummary: "Browser action dispatch failed before execution completed.",
errorMessage: message,
finishedAt: new Date().toISOString(),
});
}
db.core.activity.addActivity({
companyId,
actor: action?.agentId ?? "system",
action: "browser.action_dispatch_failed",
entityType: "browser_action",
entityId: actionId,
detail: message,
});
log.info("[browser] action dispatch telemetry", {
actionId,
companyId,
final_outcome: "dispatch_failed",
error: message,
});
publishDomainChanged();
}
function queueBrowserActionExecution(actionId: string, companyId: string): void {
void dispatchBrowserAction(actionId, companyId).catch((error) => {
try {
recordDispatchFailure(actionId, companyId, error);
} catch (recordError) {
logAppError(log, recordError, {
kind: "fatal",
code: "browser_action.dispatch_failure_record.failed",
message: `Failed to record browser action ${actionId} dispatch failure`,
cause: recordError,
context: { actionId, companyId, dispatchError: formatError(error) },
});
}
});
}
async function dispatchBrowserAction(actionId: string, companyId: string) {
const action = db.core.social.getBrowserAction(actionId, companyId);
if (!action) {
throw new Error(`Browser action not found: ${actionId}`);
}
if (action.status !== "queued") {
throw new Error(`Browser action ${actionId} is ${action.status} and cannot be dispatched.`);
}
if (action.approvalId) {
const approval = db.core.approvals.getApprovalRecord(action.approvalId);
if (!approval || approval.companyId !== companyId) {
throw new Error(`Browser action approval ${action.approvalId} does not belong to company ${companyId}.`);
}
if (approval.state !== "approved") {
db.core.social.updateBrowserAction(actionId, {
status: "approval_required",
resultSummary: "Browser action is still waiting for approval.",
errorMessage: "Approval must be granted before execution.",
});
publishDomainChanged();
return;
}
}
const account = db.core.social.getSocialAccount(action.socialAccountId, companyId);
if (!account) {
db.core.social.updateBrowserAction(actionId, {
status: "failed",
errorMessage: "Social account no longer exists.",
finishedAt: new Date().toISOString(),
});
publishDomainChanged();
return;
}
let payload: Record<string, unknown>;
try {
payload = JSON.parse(action.payloadJson) as Record<string, unknown>;
} catch (err) {
const classified = classifyUnknownError(err, {
kind: "user_input",
code: "browser_action.payload.invalid_json",
message: `Browser action ${actionId} has invalid JSON payload`,
context: { actionId },
});
log.warn(`[${classified.code}]`, classified.message, classified.context);
db.core.social.updateBrowserAction(actionId, {
status: "failed",
errorMessage: "Browser action payload is not valid JSON.",
finishedAt: new Date().toISOString(),
});
publishDomainChanged();
return;
}
db.core.social.updateBrowserAction(actionId, {
status: "running",
startedAt: new Date().toISOString(),
errorMessage: null,
});
publishDomainChanged();
try {
const result = await requireBrowserManager().executeAction({
id: actionId,
socialAccountId: account.id,
platform: account.platform,
actionType: action.actionType,
payload,
});
db.core.social.updateBrowserAction(actionId, {
status: result.cancelled ? "cancelled" : result.success ? "succeeded" : "failed",
resultSummary: result.summary,
screenshotPath: result.screenshotPath,
errorMessage: result.error,
finishedAt: new Date().toISOString(),
});
} catch (error) {
db.core.social.updateBrowserAction(actionId, {
status: "failed",
errorMessage: error instanceof Error ? error.message : String(error),
finishedAt: new Date().toISOString(),
});
}
publishDomainChanged();
}
async function clearSocialAccountData(socialAccountId: string, screenshotPaths: string[] = []) {
if (!browserManagerRef.current) {
return;
}
await browserManagerRef.current.clearSocialAccountData(socialAccountId, screenshotPaths);
}
function cancelAction(actionId: string) {
return browserManagerRef.current?.cancelAction(actionId) ?? false;
}
async function launchLoginBrowser(socialAccountId: string, platform: string, loginUrl?: string) {
return requireBrowserManager().launchLoginBrowser(socialAccountId, platform, loginUrl);
}
async function resetBrowserManager() {
await browserManagerRef.current?.shutdown();
browserManagerRef.current = new BrowserManager(join(db.artifactsDir, "browser"));
}
async function extractProfileFromSession(socialAccountId: string, platform: string) {
return requireBrowserManager().extractProfileFromSession(socialAccountId, platform);
}
return {
dispatchBrowserAction,
queueBrowserActionExecution,
clearSocialAccountData,
cancelAction,
launchLoginBrowser,
extractProfileFromSession,
resetBrowserManager,
};
}
-360
View File
@@ -1,360 +0,0 @@
import { BrowserWindow, session } from "electron";
import { existsSync, mkdirSync } from "node:fs";
import { readdir, rm, stat } from "node:fs/promises";
import { join, resolve, sep } from "node:path";
import log from "electron-log/main.js";
import type { BrowserAction, BrowserActionResult, QueuedAction } from "./browser/browser-types";
import { getPlatformRules, attachWindowSecurity, assertAllowedUrl, waitForLoad, runAction } from "./browser/browser-action-executor";
import { hasAuthenticatedCookie } from "./browser/browser-action-auth";
export type { BrowserAction, BrowserActionResult } from "./browser/browser-types";
/**
* Hard ceiling for a single browser action end-to-end (navigation, screenshot,
* login verification, scripted DOM interaction). A hung page would otherwise
* block every queued action for the same account forever; the renderer cannot
* be trusted to always call `cancelAction`. Five minutes matches the longest
* legitimate flow (first-time OAuth with 2FA).
*/
const ACTION_TIMEOUT_MS = 5 * 60 * 1000;
const SCREENSHOT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
export class BrowserManager {
private screenshotDir: string;
private queues = new Map<string, QueuedAction[]>();
private processing = new Set<string>();
private loginWindows = new Map<string, BrowserWindow>();
private initializedSessions = new Set<string>();
private activeActionWindows = new Map<string, BrowserWindow>();
private cancelledActionIds = new Set<string>();
constructor(profileDir: string) {
this.screenshotDir = join(profileDir, "browser-screenshots");
if (!existsSync(this.screenshotDir)) mkdirSync(this.screenshotDir, { recursive: true });
void this.pruneExpiredScreenshots();
}
private async pruneExpiredScreenshots() {
const cutoff = Date.now() - SCREENSHOT_RETENTION_MS;
try {
const entries = await readdir(this.screenshotDir, { withFileTypes: true });
await Promise.all(entries
.filter((entry) => entry.isFile() && entry.name.endsWith(".png"))
.map(async (entry) => {
const screenshotPath = join(this.screenshotDir, entry.name);
const fileStat = await stat(screenshotPath);
if (fileStat.mtimeMs < cutoff) {
await rm(screenshotPath, { force: true });
}
}));
} catch (error) {
log.warn(`[browser] Failed to prune expired screenshots: ${error instanceof Error ? error.message : String(error)}`);
}
}
private getSession(socialAccountId: string): Electron.Session {
const partition = `persist:social-${socialAccountId}`;
const socialSession = session.fromPartition(partition);
if (!this.initializedSessions.has(partition)) {
socialSession.setPermissionCheckHandler(() => false);
socialSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false));
this.initializedSessions.add(partition);
}
return socialSession;
}
async clearSocialAccountData(socialAccountId: string, screenshotPaths: string[] = []) {
const existing = this.loginWindows.get(socialAccountId);
if (existing && !existing.isDestroyed()) {
existing.close();
}
this.loginWindows.delete(socialAccountId);
this.queues.delete(socialAccountId);
this.processing.delete(socialAccountId);
const ses = this.getSession(socialAccountId);
try {
await ses.clearStorageData();
await ses.clearCache();
await ses.clearAuthCache();
} catch (error) {
log.warn(`[browser] Failed to fully clear session for ${socialAccountId}: ${error instanceof Error ? error.message : String(error)}`);
}
const resolvedRoot = resolve(this.screenshotDir);
await Promise.all(
screenshotPaths
.filter((entry): entry is string => typeof entry === "string" && entry.length > 0)
.filter((screenshotPath) => {
const resolvedPath = resolve(screenshotPath);
return resolvedPath === resolvedRoot || resolvedPath.startsWith(`${resolvedRoot}${sep}`);
})
.map((screenshotPath) => rm(screenshotPath, { force: true }).catch(() => undefined)),
);
}
async launchLoginBrowser(
socialAccountId: string,
platform: string,
loginUrl?: string,
): Promise<BrowserActionResult> {
const existing = this.loginWindows.get(socialAccountId);
if (existing && !existing.isDestroyed()) {
existing.focus();
return { success: true, cancelled: false, summary: "Login window already open", screenshotPath: null, error: null };
}
const rules = getPlatformRules(platform);
const url = loginUrl || rules.loginUrl;
const ses = this.getSession(socialAccountId);
assertAllowedUrl(platform, url, "login");
log.info(`[browser] Opening login window for ${platform} → ${url}`);
const win = new BrowserWindow({
width: 1280,
height: 860,
title: `Login to ${platform} — AgentCompany`,
webPreferences: {
session: ses,
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
},
});
this.loginWindows.set(socialAccountId, win);
attachWindowSecurity(win, platform);
win.setMenuBarVisibility(false);
try {
await win.loadURL(url);
} catch (err) {
log.error(`[browser] Failed to load ${url}: ${err}`);
}
return new Promise<BrowserActionResult>((resolve) => {
win.on("closed", () => {
this.loginWindows.delete(socialAccountId);
void this.verifyLoginSession(socialAccountId, platform).then((verified) => {
log.info(`[browser] Login window closed for ${platform} — verified=${verified}`);
resolve({
success: verified,
cancelled: false,
summary: verified
? `Authenticated session detected for ${platform}`
: `No authenticated ${platform} session detected`,
screenshotPath: null,
error: verified ? null : `Authentication for ${platform} was not completed.`,
});
});
});
});
}
async executeAction(action: BrowserAction): Promise<BrowserActionResult> {
return new Promise((resolve, reject) => {
const queue = this.queues.get(action.socialAccountId) ?? [];
queue.push({ action, resolve, reject });
this.queues.set(action.socialAccountId, queue);
void this.processQueue(action.socialAccountId);
});
}
cancelAction(actionId: string): boolean {
this.cancelledActionIds.add(actionId);
const activeWindow = this.activeActionWindows.get(actionId);
if (activeWindow && !activeWindow.isDestroyed()) {
activeWindow.close();
return true;
}
for (const [socialAccountId, queue] of this.queues.entries()) {
const index = queue.findIndex((item) => item.action.id === actionId);
if (index < 0) {
continue;
}
const [item] = queue.splice(index, 1);
item?.resolve({
success: false,
cancelled: true,
summary: "Action cancelled before execution.",
screenshotPath: null,
error: "Action cancelled.",
});
if (queue.length === 0) {
this.queues.delete(socialAccountId);
}
return true;
}
return false;
}
private async processQueue(socialAccountId: string) {
if (this.processing.has(socialAccountId)) return;
const queue = this.queues.get(socialAccountId);
if (!queue || queue.length === 0) return;
this.processing.add(socialAccountId);
try {
while (queue.length > 0) {
const item = queue.shift()!;
try {
const result = await this.runActionWithDeadline(item.action);
item.resolve(result);
} catch (error) {
item.reject(error instanceof Error ? error : new Error(String(error)));
}
}
} finally {
this.processing.delete(socialAccountId);
if ((this.queues.get(socialAccountId)?.length ?? 0) === 0) {
this.queues.delete(socialAccountId);
}
}
}
private runActionWithDeadline(action: BrowserAction): Promise<BrowserActionResult> {
return new Promise<BrowserActionResult>((resolve, reject) => {
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
this.cancelledActionIds.add(action.id);
const activeWindow = this.activeActionWindows.get(action.id);
if (activeWindow && !activeWindow.isDestroyed()) {
activeWindow.close();
}
this.activeActionWindows.delete(action.id);
log.warn(`[browser] action ${action.id} exceeded ${ACTION_TIMEOUT_MS}ms timeout`);
resolve({
success: false,
cancelled: true,
summary: "Action timed out and was cancelled.",
screenshotPath: null,
error: "Action timed out.",
});
}, ACTION_TIMEOUT_MS);
runAction(
action,
this.screenshotDir,
this.getSession(action.socialAccountId),
this.cancelledActionIds,
this.activeActionWindows,
).then(
(result) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(result);
},
(error) => {
if (settled) return;
settled = true;
clearTimeout(timer);
reject(error instanceof Error ? error : new Error(String(error)));
},
);
});
}
private async verifyLoginSession(socialAccountId: string, platform: string): Promise<boolean> {
const ses = this.getSession(socialAccountId);
const rules = getPlatformRules(platform);
const cookieSets = await Promise.all(
rules.cookieOrigins.map(async (origin) => {
try {
return await ses.cookies.get({ url: origin });
} catch (err) {
log.warn(`[browser-manager] cookies.get failed for origin ${origin}:`, err instanceof Error ? err.message : String(err));
return [];
}
}),
);
const hasCookies = hasAuthenticatedCookie(cookieSets, rules.authenticatedCookieNames);
if (!hasCookies) {
return false;
}
const verificationWindow = new BrowserWindow({
show: false,
webPreferences: {
session: ses,
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
},
});
try {
await verificationWindow.loadURL(rules.authCheckUrl);
await waitForLoad(verificationWindow, 10000);
const finalUrl = verificationWindow.webContents.getURL();
const finalUrlLower = finalUrl.toLowerCase();
const bouncedToLogin = rules.unauthenticatedUrlMarkers.some((marker) => finalUrlLower.includes(marker.toLowerCase()));
return !bouncedToLogin;
} catch (error) {
log.warn(`[browser] Login verification failed for ${platform}: ${error instanceof Error ? error.message : String(error)}`);
return false;
} finally {
if (!verificationWindow.isDestroyed()) {
verificationWindow.close();
}
}
}
async extractProfileFromSession(socialAccountId: string, platform: string): Promise<{ accountName: string; displayName: string }> {
const ses = this.getSession(socialAccountId);
const win = new BrowserWindow({
show: false,
webPreferences: { session: ses, nodeIntegration: false, contextIsolation: true, sandbox: true },
});
try {
const rules = getPlatformRules(platform);
await win.loadURL(rules.authCheckUrl);
await waitForLoad(win, 12000);
const result = await win.webContents.executeJavaScript(`
(() => {
const meta = document.querySelector('meta[property="al:ios:url"], meta[name="user-login"], meta[property="profile:username"]');
if (meta) return { accountName: meta.getAttribute("content") || "", displayName: "" };
const title = document.title || "";
return { accountName: "", displayName: title.split(/[|–—\\-/]/).map(s => s.trim()).find(s => s.length > 0 && s.length < 40) || "" };
})()
`);
return result && (result.accountName || result.displayName) ? result : { accountName: "", displayName: "" };
} catch (err) {
log.warn(`[browser-manager] extractProfileFromSession evaluation failed:`, err instanceof Error ? err.message : String(err));
return { accountName: "", displayName: "" };
} finally {
if (!win.isDestroyed()) win.close();
}
}
async shutdown() {
// Wait for each pending window-close to finish so any callers that chain
// `await shutdown()` before recreating the manager don't observe torn
// state (half-closed login windows still holding a partition).
const closePromises: Promise<void>[] = [];
for (const [, win] of this.loginWindows) {
if (!win.isDestroyed()) {
closePromises.push(
new Promise<void>((resolve) => {
win.once("closed", () => resolve());
win.close();
}),
);
}
}
await Promise.all(closePromises);
this.loginWindows.clear();
this.activeActionWindows.clear();
this.cancelledActionIds.clear();
this.queues.clear();
this.processing.clear();
}
}
-49
View File
@@ -1,49 +0,0 @@
import type { Session } from "electron";
import { browserNotLoggedIn } from "./browser-action-errors";
import { getPlatformRules } from "./browser-url-helpers";
type CookieLike = {
name?: string;
};
export async function assertAuthenticatedSession(
session: Pick<Session, "cookies">,
platform: string,
actionType: string,
): Promise<void> {
const rules = getPlatformRules(platform);
const cookieSets = await Promise.all(
rules.cookieOrigins.map((origin) => session.cookies.get({ url: origin }).catch(() => [])),
);
if (!hasAuthenticatedCookie(cookieSets, rules.authenticatedCookieNames)) {
throw browserNotLoggedIn(platform, actionType);
}
}
export function hasAuthenticatedCookie(
cookieSets: readonly (readonly CookieLike[])[],
authenticatedCookieNames?: readonly string[],
): boolean {
if (!authenticatedCookieNames || authenticatedCookieNames.length === 0) {
return cookieSets.some((cookies) => cookies.length > 0);
}
const expectedNames = new Set(authenticatedCookieNames.map((name) => name.toLowerCase()));
return cookieSets.some((cookies) =>
cookies.some((cookie) => cookie.name && expectedNames.has(cookie.name.toLowerCase())),
);
}
export async function assertAuthenticatedPage(
getUrl: () => string,
platform: string,
actionType: string,
): Promise<void> {
const finalUrl = getUrl().toLowerCase();
const rules = getPlatformRules(platform);
const bouncedToLogin = rules.unauthenticatedUrlMarkers.some((marker) =>
finalUrl.includes(marker.toLowerCase()),
);
if (bouncedToLogin) {
throw browserNotLoggedIn(platform, actionType);
}
}
-251
View File
@@ -1,251 +0,0 @@
import type { WebContents } from "electron";
import { browserNotLoggedIn, browserSelectorDrift, browserVerificationFailed } from "./browser-action-errors";
import { isAppError } from "../errors/index.js";
type RuntimeEvaluateResult = {
result?: { value?: unknown };
exceptionDetails?: { text?: string };
};
type ElementProbe = {
selector: string;
x: number;
y: number;
text: string;
};
const POLL_INTERVAL_MS = 250;
export class BrowserCdpClient {
private attachedByClient = false;
private retryCount = 0;
constructor(
private readonly webContents: WebContents,
private readonly platform: string,
private readonly actionType: string,
) {}
async attach(): Promise<void> {
if (this.webContents.debugger.isAttached()) return;
this.webContents.debugger.attach("1.3");
this.attachedByClient = true;
}
async dispose(): Promise<void> {
if (this.attachedByClient && this.webContents.debugger.isAttached()) {
this.webContents.debugger.detach();
}
}
getRetryCount(): number {
return this.retryCount;
}
async evaluate<T>(expression: string): Promise<T> {
await this.attach();
const response = await this.webContents.debugger.sendCommand("Runtime.evaluate", {
expression,
awaitPromise: true,
returnByValue: true,
}) as RuntimeEvaluateResult;
if (response.exceptionDetails) {
throw browserVerificationFailed(
this.platform,
this.actionType,
response.exceptionDetails.text ?? "runtime evaluation failed",
);
}
return response.result?.value as T;
}
async clickFirst(candidates: readonly string[], selectorKey: string, timeoutMs = 10_000): Promise<string> {
const element = await this.waitForElement(candidates, selectorKey, timeoutMs);
await this.webContents.debugger.sendCommand("Input.dispatchMouseEvent", {
type: "mouseMoved",
x: element.x,
y: element.y,
button: "none",
});
await this.webContents.debugger.sendCommand("Input.dispatchMouseEvent", {
type: "mousePressed",
x: element.x,
y: element.y,
button: "left",
clickCount: 1,
});
await this.webContents.debugger.sendCommand("Input.dispatchMouseEvent", {
type: "mouseReleased",
x: element.x,
y: element.y,
button: "left",
clickCount: 1,
});
return element.selector;
}
async clickOptional(candidates: readonly string[], selectorKey: string, timeoutMs = 1_500): Promise<string | null> {
try {
return await this.clickFirst(candidates, selectorKey, timeoutMs);
} catch (error) {
if (!isAppError(error) || error.code !== "browser.selector_drift") throw error;
return null;
}
}
async waitForFirst(candidates: readonly string[], selectorKey: string, timeoutMs = 10_000): Promise<string> {
return (await this.waitForElement(candidates, selectorKey, timeoutMs)).selector;
}
async waitForAuthenticatedSurface(
candidates: readonly string[],
authenticatedCookieNames?: readonly string[],
timeoutMs = 5_000,
): Promise<string> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const selector = await this.evaluate<string | null>(
buildAuthenticatedSurfaceExpression(candidates, authenticatedCookieNames ?? []),
);
if (selector) return selector;
this.retryCount += 1;
await delay(POLL_INTERVAL_MS);
}
throw browserNotLoggedIn(this.platform, this.actionType);
}
async focusAndType(candidates: readonly string[], text: string, selectorKey: string): Promise<string> {
const selector = await this.clickFirst(candidates, selectorKey);
await this.webContents.debugger.sendCommand("Input.insertText", { text });
return selector;
}
async waitForText(
candidates: readonly string[],
expectedText: string,
timeoutMs = 12_000,
): Promise<void> {
const fingerprint = expectedText.trim().slice(0, 40);
if (fingerprint.length === 0) {
throw browserVerificationFailed(this.platform, this.actionType, "empty text fingerprint");
}
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const matched = await this.evaluate<boolean>(buildTextMatchExpression(candidates, fingerprint));
if (matched) return;
this.retryCount += 1;
await delay(POLL_INTERVAL_MS);
}
throw browserVerificationFailed(this.platform, this.actionType, `text not found after submit: ${fingerprint}`);
}
async waitForAnyText(candidates: readonly string[], timeoutMs = 10_000): Promise<string[]> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const texts = await this.evaluate<string[]>(buildVisibleTextsExpression(candidates));
if (texts.length > 0) return texts;
this.retryCount += 1;
await delay(POLL_INTERVAL_MS);
}
throw browserSelectorDrift(this.platform, this.actionType, "content_items");
}
private async waitForElement(
candidates: readonly string[],
selectorKey: string,
timeoutMs: number,
): Promise<ElementProbe> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const element = await this.evaluate<ElementProbe | null>(buildElementProbeExpression(candidates));
if (element) return element;
this.retryCount += 1;
await delay(POLL_INTERVAL_MS);
}
throw browserSelectorDrift(this.platform, this.actionType, selectorKey);
}
}
function buildElementProbeExpression(candidates: readonly string[]): string {
return `(() => {
const candidates = ${JSON.stringify(candidates)};
for (const selector of candidates) {
const elements = Array.from(document.querySelectorAll(selector));
for (const element of elements) {
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
const disabled = element.disabled === true || element.getAttribute("aria-disabled") === "true";
if (rect.width <= 0 || rect.height <= 0 || style.visibility === "hidden" || style.display === "none" || disabled) continue;
element.scrollIntoView({ block: "center", inline: "center" });
const nextRect = element.getBoundingClientRect();
return {
selector,
x: nextRect.left + nextRect.width / 2,
y: nextRect.top + nextRect.height / 2,
text: (element.innerText || element.textContent || "").slice(0, 200)
};
}
}
return null;
})()`;
}
function buildTextMatchExpression(candidates: readonly string[], fingerprint: string): string {
return `(() => {
const candidates = ${JSON.stringify(candidates)};
const fingerprint = ${JSON.stringify(fingerprint)};
for (const selector of candidates) {
for (const element of Array.from(document.querySelectorAll(selector))) {
const text = element.innerText || element.textContent || "";
if (text.includes(fingerprint)) return true;
}
}
return false;
})()`;
}
function buildVisibleTextsExpression(candidates: readonly string[]): string {
return `(() => {
const out = [];
const candidates = ${JSON.stringify(candidates)};
for (const selector of candidates) {
for (const element of Array.from(document.querySelectorAll(selector))) {
const rect = element.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) continue;
const text = (element.innerText || element.textContent || "").trim();
if (text.length > 0) out.push(text.slice(0, 300));
if (out.length >= 20) return out;
}
}
return out;
})()`;
}
function buildAuthenticatedSurfaceExpression(
candidates: readonly string[],
authenticatedCookieNames: readonly string[],
): string {
return `(() => {
const candidates = ${JSON.stringify(candidates)};
for (const selector of candidates) {
for (const element of Array.from(document.querySelectorAll(selector))) {
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
if (rect.width <= 0 || rect.height <= 0 || style.visibility === "hidden" || style.display === "none") continue;
return selector;
}
}
const cookie = document.cookie || "";
const authenticatedCookieNames = ${JSON.stringify(authenticatedCookieNames.map((name) => name.toLowerCase()))};
if (authenticatedCookieNames.length === 0 && cookie.trim().length > 0) return "document.cookie";
const cookieNames = new Set(cookie.split(";").map((part) => part.split("=")[0]?.trim().toLowerCase()).filter(Boolean));
for (const name of authenticatedCookieNames) {
if (cookieNames.has(name)) return "document.cookie:" + name;
}
return null;
})()`;
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
-33
View File
@@ -1,33 +0,0 @@
import { RecoverableError, UpstreamError, UserInputError } from "../errors/index.js";
export function browserNotLoggedIn(platform: string, actionType: string): UserInputError {
return new UserInputError({
code: "browser.not_logged_in",
message: `${platform} session is not authenticated for ${actionType}.`,
context: { platform, actionType },
});
}
export function browserSelectorDrift(
platform: string,
actionType: string,
selectorKey: string,
): UpstreamError {
return new UpstreamError({
code: "browser.selector_drift",
message: `${platform} selector set did not expose ${selectorKey} for ${actionType}.`,
context: { platform, actionType, selectorKey },
});
}
export function browserVerificationFailed(
platform: string,
actionType: string,
expectation: string,
): RecoverableError {
return new RecoverableError({
code: "browser.verification_failed",
message: `${platform} ${actionType} did not pass verification: ${expectation}.`,
context: { platform, actionType, expectation },
});
}
-361
View File
@@ -1,361 +0,0 @@
import { BrowserWindow } from "electron";
import { existsSync, writeFileSync } from "node:fs";
import type { NativeImage } from "electron";
import log from "electron-log/main.js";
import type { BrowserAction, BrowserActionResult } from "./browser-types";
import {
assertAllowedUrl,
attachWindowSecurity,
getComposeUrl,
getFeedUrl,
getPlatformRules,
getSearchUrl,
} from "./browser-url-helpers";
import { BrowserCdpClient } from "./browser-action-cdp";
import { assertAuthenticatedPage, assertAuthenticatedSession } from "./browser-action-auth";
import { getPlatformSelectors } from "./platform-selectors";
import type { PlatformSelectors } from "./platform-selectors";
import { browserVerificationFailed } from "./browser-action-errors";
import { shouldCaptureSuccessScreenshot } from "./browser-screenshot-policy";
import { isAppError } from "../errors/index.js";
export { getPlatformRules, assertAllowedUrl, attachWindowSecurity } from "./browser-url-helpers";
type BrowserActionOutcome =
| "success"
| "logged_out"
| "selector_drift"
| "network"
| "rate_limited"
| "cancelled"
| "verification_failed";
interface BrowserActionTelemetry {
platform: string;
action_type: string;
selector_hit: string | null;
selector_hits: string[];
retries: number;
duration_ms: number;
final_outcome: BrowserActionOutcome;
}
export function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function waitForLoad(
win: BrowserWindow,
timeoutMs = 15000,
context: { platform: string; actionType: string } = { platform: "browser", actionType: "load" },
): Promise<void> {
return new Promise((resolve, reject) => {
if (!win.webContents.isLoading()) {
resolve();
return;
}
const cleanup = () => {
clearTimeout(timer);
win.webContents.off("did-finish-load", onFinish);
win.webContents.off("did-fail-load", onFail);
win.off("closed", onClosed);
};
const fail = (expectation: string) => {
cleanup();
reject(browserVerificationFailed(context.platform, context.actionType, expectation));
};
const onFinish = () => {
cleanup();
resolve();
};
const onFail = (
_event: Electron.Event,
errorCode: number,
errorDescription: string,
_validatedUrl: string,
isMainFrame: boolean,
) => {
if (isMainFrame) fail(`page load failed: ${errorDescription} (${errorCode})`);
};
const onClosed = () => fail("window closed during page load");
const timer = setTimeout(() => fail(`page load exceeded ${timeoutMs}ms`), timeoutMs);
win.webContents.once("did-finish-load", onFinish);
win.webContents.on("did-fail-load", onFail);
win.once("closed", onClosed);
});
}
export async function runAction(
action: BrowserAction,
screenshotDir: string,
session: Electron.Session,
cancelledActionIds: Set<string>,
activeActionWindows: Map<string, BrowserWindow>,
): Promise<BrowserActionResult> {
const payload = action.payload;
const screenshotPath = `${screenshotDir}/${action.id}.png`;
const platform = action.platform;
const startedAt = Date.now();
const selectorHits: string[] = [];
log.info(`[browser] Executing ${action.actionType} for ${platform}/${action.socialAccountId}`);
const win = new BrowserWindow({
width: 1280,
height: 860,
title: `AgentCompany — ${action.actionType}`,
show: true,
webPreferences: {
session,
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
},
});
activeActionWindows.set(action.id, win);
attachWindowSecurity(win, platform);
win.setMenuBarVisibility(false);
const cdp = new BrowserCdpClient(win.webContents, platform, action.actionType);
const selectors = getPlatformSelectors(platform);
const assertNotCancelled = () => {
if (cancelledActionIds.has(action.id)) {
throw new Error("BROWSER_ACTION_CANCELLED");
}
};
const remember = (selector: string | null) => {
if (selector) selectorHits.push(selector);
};
try {
assertNotCancelled();
await assertAuthenticatedSession(session, platform, action.actionType);
let summary = "Action completed";
switch (action.actionType) {
case "navigate": {
const url = String(payload.url || getFeedUrl(platform));
await loadActionUrl(win, platform, action.actionType, url);
remember(await confirmAuthenticatedPage(win, cdp, selectors, platform, action.actionType));
const title = await cdp.evaluate<string>("document.title");
summary = `Navigated to ${title}`;
break;
}
case "post": {
const content = requirePayloadText(payload.content, "content");
await loadActionUrl(win, platform, action.actionType, getComposeUrl(platform));
remember(await confirmAuthenticatedPage(win, cdp, selectors, platform, action.actionType));
remember(await cdp.clickOptional(selectors.composerOpen, "composer_open"));
remember(await cdp.focusAndType(selectors.editor, content, "editor"));
assertNotCancelled();
remember(await cdp.clickFirst(selectors.submit, "submit"));
await cdp.waitForText(selectors.feedItems, content);
summary = `Posted to ${platform}: "${content.slice(0, 50)}..."`;
break;
}
case "reply": {
const targetUrl = requirePayloadText(payload.targetUrl, "targetUrl");
const replyContent = requirePayloadText(payload.content, "content");
await loadActionUrl(win, platform, action.actionType, targetUrl);
remember(await confirmAuthenticatedPage(win, cdp, selectors, platform, action.actionType));
remember(await cdp.focusAndType(selectors.replyEditor, replyContent, "reply_editor"));
assertNotCancelled();
remember(await cdp.clickFirst(selectors.replySubmit, "reply_submit"));
await cdp.waitForText(selectors.feedItems, replyContent);
summary = `Replied to ${targetUrl.slice(0, 50)}`;
break;
}
case "like": {
const targetUrl = requirePayloadText(payload.targetUrl, "targetUrl");
await loadActionUrl(win, platform, action.actionType, targetUrl);
remember(await confirmAuthenticatedPage(win, cdp, selectors, platform, action.actionType));
remember(await cdp.clickFirst(selectors.likeButton, "like_button"));
await cdp.waitForFirst(selectors.likedButton, "liked_state", 5_000);
summary = `Liked post at ${targetUrl.slice(0, 50)}`;
break;
}
case "follow": {
const targetUrl = String(payload.targetUrl || payload.profileUrl || "");
if (!targetUrl) throw browserVerificationFailed(platform, action.actionType, "missing targetUrl/profileUrl");
await loadActionUrl(win, platform, action.actionType, targetUrl);
remember(await confirmAuthenticatedPage(win, cdp, selectors, platform, action.actionType));
remember(await cdp.clickFirst(selectors.followButton, "follow_button"));
await cdp.waitForFirst(selectors.followingButton, "following_state", 5_000);
summary = `Followed user at ${targetUrl.slice(0, 50)}`;
break;
}
case "browse_feed": {
const feedUrl = String(payload.url || getFeedUrl(platform));
const maxPosts = Number(payload.maxPosts || 10);
await loadActionUrl(win, platform, action.actionType, feedUrl);
remember(await confirmAuthenticatedPage(win, cdp, selectors, platform, action.actionType));
for (let i = 0; i < 3; i += 1) {
await cdp.evaluate<void>("window.scrollBy(0, window.innerHeight)");
}
const posts = await cdp.waitForAnyText(selectors.feedItems);
summary = `Browsed feed — found ${Math.min(posts.length, maxPosts)} posts`;
break;
}
case "search": {
const query = requirePayloadText(payload.query, "query");
await loadActionUrl(win, platform, action.actionType, getSearchUrl(platform, query));
remember(await confirmAuthenticatedPage(win, cdp, selectors, platform, action.actionType));
const results = await cdp.waitForAnyText(selectors.searchItems);
summary = `Search "${query.slice(0, 30)}" — ${results.length} results`;
break;
}
case "screenshot": {
const url = String(payload.url || getFeedUrl(platform));
await loadActionUrl(win, platform, action.actionType, url);
remember(await confirmAuthenticatedPage(win, cdp, selectors, platform, action.actionType));
const title = await cdp.evaluate<string>("document.title");
summary = `Screenshot captured: ${title}`;
break;
}
default:
throw new Error(`Unsupported browser action type: ${action.actionType}`);
}
assertNotCancelled();
const successScreenshotPath = shouldCaptureSuccessScreenshot(action)
? await captureScreenshot(win, screenshotPath, action.id, "success")
: null;
await closeWindow(win);
logActionTelemetry({
platform,
action_type: action.actionType,
selector_hit: selectorHits.at(-1) ?? null,
selector_hits: selectorHits,
retries: cdp.getRetryCount(),
duration_ms: Date.now() - startedAt,
final_outcome: "success",
});
return {
success: true,
cancelled: false,
summary,
screenshotPath: successScreenshotPath,
error: null,
};
} catch (error) {
const wasCancelled = cancelledActionIds.has(action.id)
|| (error instanceof Error && error.message === "BROWSER_ACTION_CANCELLED");
const failureScreenshotPath = wasCancelled
? null
: await captureScreenshot(win, screenshotPath, action.id, "failure");
await closeWindow(win);
const msg = wasCancelled ? "Action cancelled." : describeBrowserActionError(error);
const finalOutcome = classifyBrowserOutcome(error, wasCancelled);
if (!wasCancelled) {
log.error(`[browser] Action ${action.actionType} failed: ${msg}`);
}
logActionTelemetry({
platform,
action_type: action.actionType,
selector_hit: selectorHits.at(-1) ?? null,
selector_hits: selectorHits,
retries: cdp.getRetryCount(),
duration_ms: Date.now() - startedAt,
final_outcome: finalOutcome,
});
return {
success: false,
cancelled: wasCancelled,
summary: wasCancelled ? "Action cancelled" : "Action failed",
screenshotPath: failureScreenshotPath,
error: msg,
};
} finally {
await cdp.dispose().catch(() => undefined);
activeActionWindows.delete(action.id);
cancelledActionIds.delete(action.id);
}
}
async function confirmAuthenticatedPage(
win: BrowserWindow,
cdp: BrowserCdpClient,
selectors: PlatformSelectors,
platform: string,
actionType: string,
): Promise<string> {
await assertAuthenticatedPage(() => win.webContents.getURL(), platform, actionType);
return cdp.waitForAuthenticatedSurface(
selectors.authenticated,
getPlatformRules(platform).authenticatedCookieNames,
);
}
async function loadActionUrl(
win: BrowserWindow,
platform: string,
actionType: string,
url: string,
): Promise<void> {
assertAllowedUrl(platform, url, actionType);
await win.loadURL(url);
await waitForLoad(win, 15000, { platform, actionType });
}
function requirePayloadText(value: unknown, key: string): string {
const text = typeof value === "string" ? value.trim() : "";
if (text.length === 0) {
throw new Error(`Browser action payload missing ${key}.`);
}
return text;
}
async function captureScreenshot(
win: BrowserWindow,
screenshotPath: string,
actionId: string,
phase: "success" | "failure",
): Promise<string | null> {
try {
const image: NativeImage = await win.webContents.capturePage();
if (!image.isEmpty()) {
writeFileSync(screenshotPath, image.toPNG());
return existsSync(screenshotPath) ? screenshotPath : null;
}
} catch (err) {
log.warn(`[browser] ${phase} screenshot failed for action ${actionId}: ${err instanceof Error ? err.message : String(err)}`);
}
return null;
}
async function closeWindow(win: BrowserWindow): Promise<void> {
if (win.isDestroyed()) return;
await delay(250);
if (!win.isDestroyed()) win.close();
}
function describeBrowserActionError(error: unknown): string {
if (isAppError(error)) return `[${error.code}] ${error.message}`;
return error instanceof Error ? error.message : String(error);
}
function classifyBrowserOutcome(error: unknown, wasCancelled: boolean): BrowserActionOutcome {
if (wasCancelled) return "cancelled";
if (isAppError(error)) {
if (error.code === "browser.not_logged_in") return "logged_out";
if (error.code === "browser.selector_drift") return "selector_drift";
if (error.code === "browser.verification_failed") return "verification_failed";
}
if (error instanceof Error && /429|rate[_ -]?limit|too many requests/i.test(error.message)) return "rate_limited";
return "network";
}
function logActionTelemetry(telemetry: BrowserActionTelemetry): void {
log.info("[browser] action telemetry", telemetry);
}
@@ -1,7 +0,0 @@
import type { BrowserAction } from "./browser-types";
export function shouldCaptureSuccessScreenshot(action: BrowserAction): boolean {
return action.actionType === "screenshot"
|| action.payload.captureSuccessScreenshot === true
|| action.payload.retainSuccessScreenshot === true;
}
-183
View File
@@ -1,183 +0,0 @@
export interface BrowserAction {
id: string;
socialAccountId: string;
platform: string;
actionType: string;
payload: Record<string, unknown>;
}
export interface BrowserActionResult {
success: boolean;
cancelled: boolean;
summary: string;
screenshotPath: string | null;
error: string | null;
}
export interface QueuedAction {
action: BrowserAction;
resolve: (result: BrowserActionResult) => void;
reject: (error: Error) => void;
}
export interface PlatformRule {
loginUrl: string;
authCheckUrl: string;
allowedHosts: string[];
cookieOrigins: string[];
authenticatedCookieNames?: string[];
unauthenticatedUrlMarkers: string[];
}
export const PLATFORM_RULES: Record<string, PlatformRule> = {
twitter: {
loginUrl: "https://x.com/i/flow/login",
authCheckUrl: "https://x.com/home",
allowedHosts: ["x.com", "twitter.com"],
cookieOrigins: ["https://x.com", "https://twitter.com"],
authenticatedCookieNames: ["auth_token"],
unauthenticatedUrlMarkers: ["/i/flow/login", "/login"],
},
linkedin: {
loginUrl: "https://www.linkedin.com/login",
authCheckUrl: "https://www.linkedin.com/feed/",
allowedHosts: ["linkedin.com"],
cookieOrigins: ["https://www.linkedin.com"],
authenticatedCookieNames: ["li_at"],
unauthenticatedUrlMarkers: ["/login", "/checkpoint"],
},
instagram: {
loginUrl: "https://www.instagram.com/accounts/login/",
authCheckUrl: "https://www.instagram.com/",
allowedHosts: ["instagram.com"],
cookieOrigins: ["https://www.instagram.com"],
authenticatedCookieNames: ["sessionid", "ds_user_id"],
unauthenticatedUrlMarkers: ["/accounts/login"],
},
facebook: {
loginUrl: "https://www.facebook.com/login",
authCheckUrl: "https://www.facebook.com/",
allowedHosts: ["facebook.com"],
cookieOrigins: ["https://www.facebook.com"],
authenticatedCookieNames: ["c_user", "xs"],
unauthenticatedUrlMarkers: ["/login"],
},
reddit: {
loginUrl: "https://www.reddit.com/login",
authCheckUrl: "https://www.reddit.com/",
allowedHosts: ["reddit.com"],
cookieOrigins: ["https://www.reddit.com"],
authenticatedCookieNames: ["reddit_session", "token_v2"],
unauthenticatedUrlMarkers: ["/login"],
},
youtube: {
loginUrl: "https://accounts.google.com/signin",
authCheckUrl: "https://www.youtube.com/",
allowedHosts: ["youtube.com", "google.com"],
cookieOrigins: ["https://www.youtube.com", "https://accounts.google.com"],
authenticatedCookieNames: ["SAPISID", "LOGIN_INFO"],
unauthenticatedUrlMarkers: ["/signin", "/ServiceLogin"],
},
tiktok: {
loginUrl: "https://www.tiktok.com/login",
authCheckUrl: "https://www.tiktok.com/foryou",
allowedHosts: ["tiktok.com"],
cookieOrigins: ["https://www.tiktok.com"],
authenticatedCookieNames: ["sessionid", "sessionid_ss"],
unauthenticatedUrlMarkers: ["/login"],
},
bluesky: {
loginUrl: "https://bsky.app",
authCheckUrl: "https://bsky.app/",
allowedHosts: ["bsky.app"],
cookieOrigins: ["https://bsky.app"],
unauthenticatedUrlMarkers: ["/login"],
},
discord: {
loginUrl: "https://discord.com/login",
authCheckUrl: "https://discord.com/channels/@me",
allowedHosts: ["discord.com"],
cookieOrigins: ["https://discord.com"],
unauthenticatedUrlMarkers: ["/login"],
},
threads: {
loginUrl: "https://www.threads.net/login",
authCheckUrl: "https://www.threads.net/",
allowedHosts: ["threads.net"],
cookieOrigins: ["https://www.threads.net"],
authenticatedCookieNames: ["sessionid", "ds_user_id"],
unauthenticatedUrlMarkers: ["/login"],
},
pinterest: {
loginUrl: "https://www.pinterest.com/login/",
authCheckUrl: "https://www.pinterest.com/",
allowedHosts: ["pinterest.com"],
cookieOrigins: ["https://www.pinterest.com"],
authenticatedCookieNames: ["_pinterest_sess"],
unauthenticatedUrlMarkers: ["/login"],
},
telegram: {
loginUrl: "https://web.telegram.org/",
authCheckUrl: "https://web.telegram.org/",
allowedHosts: ["telegram.org", "t.me"],
cookieOrigins: ["https://web.telegram.org"],
unauthenticatedUrlMarkers: ["/auth"],
},
whatsapp: {
loginUrl: "https://web.whatsapp.com/",
authCheckUrl: "https://web.whatsapp.com/",
allowedHosts: ["whatsapp.com"],
cookieOrigins: ["https://web.whatsapp.com"],
unauthenticatedUrlMarkers: [],
},
wechat: {
loginUrl: "https://mp.weixin.qq.com/",
authCheckUrl: "https://mp.weixin.qq.com/",
allowedHosts: ["mp.weixin.qq.com", "weixin.qq.com"],
cookieOrigins: ["https://mp.weixin.qq.com"],
unauthenticatedUrlMarkers: ["/cgi-bin/loginpage"],
},
weibo: {
loginUrl: "https://weibo.com/login.php",
authCheckUrl: "https://weibo.com/",
allowedHosts: ["weibo.com", "weibo.cn"],
cookieOrigins: ["https://weibo.com"],
authenticatedCookieNames: ["SUB", "SUBP"],
unauthenticatedUrlMarkers: ["/login", "/signup"],
},
xiaohongshu: {
loginUrl: "https://www.xiaohongshu.com/explore",
authCheckUrl: "https://www.xiaohongshu.com/",
allowedHosts: ["xiaohongshu.com"],
cookieOrigins: ["https://www.xiaohongshu.com"],
unauthenticatedUrlMarkers: ["/login"],
},
douyin: {
loginUrl: "https://www.douyin.com/",
authCheckUrl: "https://www.douyin.com/",
allowedHosts: ["douyin.com"],
cookieOrigins: ["https://www.douyin.com"],
unauthenticatedUrlMarkers: [],
},
bilibili: {
loginUrl: "https://passport.bilibili.com/login",
authCheckUrl: "https://www.bilibili.com/",
allowedHosts: ["bilibili.com"],
cookieOrigins: ["https://www.bilibili.com", "https://passport.bilibili.com"],
unauthenticatedUrlMarkers: ["/login"],
},
zhihu: {
loginUrl: "https://www.zhihu.com/signin",
authCheckUrl: "https://www.zhihu.com/",
allowedHosts: ["zhihu.com"],
cookieOrigins: ["https://www.zhihu.com"],
unauthenticatedUrlMarkers: ["/signin", "/sign-in"],
},
other: {
loginUrl: "https://google.com",
authCheckUrl: "https://google.com",
allowedHosts: ["google.com"],
cookieOrigins: ["https://google.com"],
unauthenticatedUrlMarkers: ["/login"],
},
};
-128
View File
@@ -1,128 +0,0 @@
import { BrowserWindow } from "electron";
import log from "electron-log/main.js";
import { PLATFORM_RULES } from "./browser-types";
export function getPlatformRules(platform: string) {
return PLATFORM_RULES[platform] ?? PLATFORM_RULES.other;
}
export function assertAllowedUrl(platform: string, rawUrl: string, actionType: string) {
const allowedHosts = getPlatformRules(platform).allowedHosts;
let url: URL;
try {
url = new URL(rawUrl);
} catch {
throw new Error(`Invalid URL for ${actionType}: ${rawUrl}`);
}
if (url.protocol !== "https:") {
throw new Error(`Navigation blocked for ${actionType}. Only https URLs are allowed.`);
}
const host = url.hostname.toLowerCase();
const isAllowed = allowedHosts.some((allowedHost) => host === allowedHost || host.endsWith(`.${allowedHost}`));
if (!isAllowed) {
throw new Error(`Navigation blocked for ${actionType}. ${host} is outside the allowed ${platform} domains.`);
}
}
export function isAllowedNavigation(platform: string, rawUrl: string): boolean {
try {
assertAllowedUrl(platform, rawUrl, "navigate");
return true;
} catch {
return false;
}
}
export function attachWindowSecurity(win: BrowserWindow, platform: string) {
win.webContents.setWindowOpenHandler(({ url }) => {
if (!isAllowedNavigation(platform, url)) {
return { action: "deny" };
}
setImmediate(() => {
void win.loadURL(url).catch((error) => {
log.warn(`[browser] Failed to open allowed popup target for ${platform}: ${error instanceof Error ? error.message : String(error)}`);
});
});
return { action: "deny" };
});
win.webContents.on("will-navigate", (event, navigationUrl) => {
if (isAllowedNavigation(platform, navigationUrl)) {
return;
}
event.preventDefault();
log.warn(`[browser] Blocked navigation outside allowlist for ${platform}: ${navigationUrl}`);
});
win.webContents.on("will-redirect", (event, navigationUrl) => {
if (isAllowedNavigation(platform, navigationUrl)) {
return;
}
event.preventDefault();
log.warn(`[browser] Blocked redirect outside allowlist for ${platform}: ${navigationUrl}`);
});
}
export function getComposeUrl(platform: string): string {
const urls: Record<string, string> = {
twitter: "https://x.com/compose/tweet",
linkedin: "https://www.linkedin.com/feed/",
instagram: "https://www.instagram.com/",
facebook: "https://www.facebook.com/",
reddit: "https://www.reddit.com/submit",
bluesky: "https://bsky.app/",
youtube: "https://www.youtube.com/",
tiktok: "https://www.tiktok.com/",
discord: "https://discord.com/channels/@me",
threads: "https://www.threads.net/",
pinterest: "https://www.pinterest.com/pin-creation-tool/",
telegram: "https://web.telegram.org/",
whatsapp: "https://web.whatsapp.com/",
wechat: "https://mp.weixin.qq.com/",
weibo: "https://weibo.com/",
xiaohongshu: "https://www.xiaohongshu.com/explore",
douyin: "https://www.douyin.com/",
bilibili: "https://www.bilibili.com/",
zhihu: "https://www.zhihu.com/question/waiting",
};
return urls[platform] || getPlatformRules(platform).loginUrl;
}
export function getFeedUrl(platform: string): string {
const urls: Record<string, string> = {
twitter: "https://x.com/home",
linkedin: "https://www.linkedin.com/feed/",
instagram: "https://www.instagram.com/",
facebook: "https://www.facebook.com/",
reddit: "https://www.reddit.com/",
bluesky: "https://bsky.app/",
youtube: "https://www.youtube.com/",
tiktok: "https://www.tiktok.com/",
discord: "https://discord.com/channels/@me",
threads: "https://www.threads.net/",
pinterest: "https://www.pinterest.com/",
telegram: "https://web.telegram.org/",
whatsapp: "https://web.whatsapp.com/",
wechat: "https://mp.weixin.qq.com/",
weibo: "https://weibo.com/",
xiaohongshu: "https://www.xiaohongshu.com/explore",
douyin: "https://www.douyin.com/",
bilibili: "https://www.bilibili.com/",
zhihu: "https://www.zhihu.com/",
};
return urls[platform] || getPlatformRules(platform).loginUrl;
}
export function getSearchUrl(platform: string, query: string): string {
const q = encodeURIComponent(query);
const urls: Record<string, string> = {
twitter: `https://x.com/search?q=${q}`,
linkedin: `https://www.linkedin.com/search/results/all/?keywords=${q}`,
reddit: `https://www.reddit.com/search/?q=${q}`,
discord: `https://discord.com/channels/@me`,
pinterest: `https://www.pinterest.com/search/pins/?q=${q}`,
weibo: `https://s.weibo.com/weibo?q=${q}`,
xiaohongshu: `https://www.xiaohongshu.com/search_result?keyword=${q}`,
bilibili: `https://search.bilibili.com/all?keyword=${q}`,
zhihu: `https://www.zhihu.com/search?type=content&q=${q}`,
};
return urls[platform] || `https://google.com/search?q=${q}`;
}
-135
View File
@@ -1,135 +0,0 @@
export interface PlatformSelectors {
authenticated: readonly string[];
composerOpen: readonly string[];
editor: readonly string[];
submit: readonly string[];
replyEditor: readonly string[];
replySubmit: readonly string[];
likeButton: readonly string[];
likedButton: readonly string[];
followButton: readonly string[];
followingButton: readonly string[];
feedItems: readonly string[];
searchItems: readonly string[];
}
const GENERIC_SELECTORS: PlatformSelectors = {
authenticated: [
"[data-testid='AppTabBar_Profile_Link']",
"a[href*='/in/']",
"img[alt*='profile' i]",
"[aria-label*='profile' i]",
],
composerOpen: [
"button[aria-label*='post' i]",
"button[aria-label*='compose' i]",
"button.share-box-feed-entry__trigger",
],
editor: [
"[role='textbox'][contenteditable='true']",
"textarea",
".ql-editor[contenteditable='true']",
],
submit: [
"button[type='submit']",
"button[aria-label*='post' i]",
"button[aria-label*='send' i]",
],
replyEditor: [
"[role='textbox'][contenteditable='true']",
"textarea",
],
replySubmit: [
"button[type='submit']",
"button[aria-label*='reply' i]",
"button[aria-label*='send' i]",
],
likeButton: [
"button[aria-label*='like' i]",
"[role='button'][aria-label*='like' i]",
],
likedButton: [
"button[aria-pressed='true'][aria-label*='like' i]",
"[data-testid='unlike']",
],
followButton: [
"button[aria-label*='follow' i]",
"[role='button'][aria-label*='follow' i]",
],
followingButton: [
"button[aria-label*='following' i]",
"button[aria-label*='requested' i]",
],
feedItems: ["article", "[role='article']", ".feed-shared-update-v2"],
searchItems: ["article", "[role='article']", ".search-result", ".feed-shared-update-v2"],
};
const PLATFORM_SELECTORS: Record<string, Partial<PlatformSelectors>> = {
twitter: {
authenticated: [
"[data-testid='AppTabBar_Profile_Link']",
"[data-testid='SideNav_AccountSwitcher_Button']",
"a[aria-label*='Profile']",
],
editor: [
"[data-testid='tweetTextarea_0']",
"[role='textbox'][contenteditable='true']",
],
submit: [
"[data-testid='tweetButton']",
"[data-testid='tweetButtonInline']",
],
replyEditor: [
"[data-testid='tweetTextarea_0']",
"[role='textbox'][contenteditable='true']",
],
replySubmit: [
"[data-testid='tweetButton']",
"[data-testid='tweetButtonInline']",
"button[type='submit']",
],
likeButton: ["[data-testid='like']", "button[aria-label*='Like']"],
likedButton: ["[data-testid='unlike']", "button[aria-label*='Unlike']"],
feedItems: ["article", "[data-testid='tweet']"],
searchItems: ["article", "[data-testid='tweet']"],
},
linkedin: {
authenticated: [
"a[href*='/in/']",
"img.global-nav__me-photo",
"button.global-nav__primary-link-me-menu-trigger",
],
composerOpen: [
"button.share-box-feed-entry__trigger",
"button.artdeco-button--muted",
],
editor: [
"[role='textbox'][contenteditable='true']",
".ql-editor[contenteditable='true']",
],
submit: [
"button.share-actions__primary-action",
"button.artdeco-button--primary[type='submit']",
],
feedItems: [".feed-shared-update-v2", "article", ".update-components-text"],
searchItems: [".reusable-search__result-container", ".feed-shared-update-v2", "article"],
},
};
export function getPlatformSelectors(platform: string): PlatformSelectors {
const overrides = PLATFORM_SELECTORS[platform] ?? {};
return {
authenticated: overrides.authenticated ?? GENERIC_SELECTORS.authenticated,
composerOpen: overrides.composerOpen ?? GENERIC_SELECTORS.composerOpen,
editor: overrides.editor ?? GENERIC_SELECTORS.editor,
submit: overrides.submit ?? GENERIC_SELECTORS.submit,
replyEditor: overrides.replyEditor ?? overrides.editor ?? GENERIC_SELECTORS.replyEditor,
replySubmit: overrides.replySubmit ?? overrides.submit ?? GENERIC_SELECTORS.replySubmit,
likeButton: overrides.likeButton ?? GENERIC_SELECTORS.likeButton,
likedButton: overrides.likedButton ?? GENERIC_SELECTORS.likedButton,
followButton: overrides.followButton ?? GENERIC_SELECTORS.followButton,
followingButton: overrides.followingButton ?? GENERIC_SELECTORS.followingButton,
feedItems: overrides.feedItems ?? GENERIC_SELECTORS.feedItems,
searchItems: overrides.searchItems ?? overrides.feedItems ?? GENERIC_SELECTORS.searchItems,
};
}
-237
View File
@@ -1,237 +0,0 @@
import type { AppDatabase } from "./database";
import type { NotificationOptions } from "./notification-service";
import type { BudgetPolicyRecord, BudgetStatusSummary } from "@shared/types";
interface BudgetEnforcementDependencies {
db: AppDatabase;
logger: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void };
notify: (options: NotificationOptions) => void;
publishDomainChanged: () => void;
}
export interface BudgetGateResult {
allowed: boolean;
blockedBy: Array<{
policyId: string;
scopeType: string;
scopeId: string;
spent: number;
limit: number;
}>;
}
/**
* Returns a human-readable label for a budget policy scope.
* Looks up entity names from the database for agent and project scopes.
*/
function getScopeLabel(
db: AppDatabase,
policy: BudgetPolicyRecord,
logger: { warn: (...args: unknown[]) => void },
): string {
if (policy.scopeType === "agent") {
try {
const agent = db.core.entity.getAgent(policy.scopeId);
return `Agent "${agent.name}"`;
} catch (err) {
logger.warn(`[budget] getAgent failed for scope ${policy.scopeId}:`, err instanceof Error ? err.message : String(err));
return `Agent ${policy.scopeId.slice(0, 8)}`;
}
}
if (policy.scopeType === "project") {
try {
const row = db.queryAll<{ name: string }>("select name from projects where id = ?", policy.scopeId);
return row.length > 0 ? `Project "${row[0].name}"` : `Project ${policy.scopeId.slice(0, 8)}`;
} catch (err) {
logger.warn(`[budget] project name lookup failed for scope ${policy.scopeId}:`, err instanceof Error ? err.message : String(err));
return `Project ${policy.scopeId.slice(0, 8)}`;
}
}
return "Company budget";
}
export function createBudgetEnforcementService({
db,
logger,
notify,
publishDomainChanged,
}: BudgetEnforcementDependencies) {
/**
* Evaluates whether a new run should be allowed based on active budget policies.
*
* Per D-01 (strictest-wins): if ANY policy scope is exhausted, the run is blocked.
* Per D-02: if no policies exist, the run is allowed (unlimited).
* Per D-04/D-05: this only blocks NEW dispatches -- in-flight runs are unaffected.
*/
function evaluateBudgetGate(
companyId: string,
agentId: string,
projectId: string | null,
): BudgetGateResult {
const policies = db.core.budget.getActivePoliciesForRun(companyId, agentId, projectId);
if (policies.length === 0) {
return { allowed: true, blockedBy: [] };
}
const blockedBy: BudgetGateResult["blockedBy"] = [];
for (const policy of policies) {
if (!policy.hardStopEnabled) continue;
const spent = db.core.budget.getSpentInWindow(policy);
if (spent >= policy.amount) {
blockedBy.push({
policyId: policy.id,
scopeType: policy.scopeType,
scopeId: policy.scopeId,
spent,
limit: policy.amount,
});
}
}
return { allowed: blockedBy.length === 0, blockedBy };
}
/**
* Checks all active policies for the given run context and creates/updates
* budget incidents at warn and hard-stop thresholds.
*
* Per D-07: fires both warn and hard-stop in the same code path.
* Per D-08: deduplicates incidents via upsertBudgetIncident.
*/
function checkAndCreateIncidents(
companyId: string,
agentId: string,
projectId: string | null,
): void {
const policies = db.core.budget.getActivePoliciesForRun(companyId, agentId, projectId);
for (const policy of policies) {
// amount <= 0 disables the policy (and would otherwise divide by zero below).
if (policy.amount <= 0) continue;
const spent = db.core.budget.getSpentInWindow(policy);
const utilization = spent / policy.amount;
const warnThreshold = policy.warnPercent / 100;
const scopeLabel = getScopeLabel(db, policy, logger);
if (utilization >= warnThreshold) {
db.core.budget.upsertBudgetIncident(policy, "warn_threshold", spent);
if (policy.notifyEnabled) {
notify({
title: "Budget Warning",
body: `${scopeLabel} at ${Math.round(utilization * 100)}% of budget ($${(spent / 100).toFixed(2)} / $${(policy.amount / 100).toFixed(2)})`,
urgency: "informational",
navigation: { section: "costs" },
batchKey: "budget_warning",
});
}
}
if (utilization >= 1.0 && policy.hardStopEnabled) {
db.core.budget.upsertBudgetIncident(policy, "hard_stop", spent);
if (policy.notifyEnabled) {
notify({
title: "Budget Exhausted",
body: `${scopeLabel} budget exhausted - new runs blocked`,
urgency: "critical",
navigation: { section: "costs" },
batchKey: "budget_exhaustion",
});
}
}
}
publishDomainChanged();
}
/**
* Checks if a policy's limit was increased above the observed spend amount,
* and auto-resolves open incidents if so.
*
* Per D-06: fires auto_resolved_limit_increase status.
*/
function checkAutoResolve(companyId: string, policyId: string): void {
const policy = db.core.budget.getBudgetPolicy(policyId);
if (!policy) return;
const spent = db.core.budget.getSpentInWindow(policy);
let resolved = false;
if (spent < policy.amount) {
const count = db.core.budget.resolveIncidentsForPolicy(policyId, "auto_resolved_limit_increase");
if (count > 0) resolved = true;
}
const warnAmount = Math.floor((policy.amount * policy.warnPercent) / 100);
if (spent < warnAmount) {
const count = db.core.budget.resolveWarnIncidentsForPolicy(policyId, "auto_resolved_limit_increase");
if (count > 0) resolved = true;
}
if (resolved) {
unpauseBudgetBlockedAgents(companyId);
logger.info(`Budget auto-resolve: policy ${policyId} incidents resolved (spent=${spent}, limit=${policy.amount})`);
publishDomainChanged();
}
}
function unpauseBudgetBlockedAgents(companyId: string): void {
const paused = db.queryAll<{ id: string; budgetMonthlyUsd: number; spentMonthlyUsd: number }>(
`SELECT id, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd
FROM agents WHERE company_id = ? AND status = 'paused' AND budget_monthly_usd > 0`,
companyId,
);
for (const agent of paused) {
if (agent.budgetMonthlyUsd > agent.spentMonthlyUsd) {
db.queryAll("UPDATE agents SET status = 'idle', updated_at = ? WHERE id = ?", new Date().toISOString(), agent.id);
logger.info(`[budget] Auto-unpaused agent ${agent.id}: budget $${agent.budgetMonthlyUsd} > spent $${agent.spentMonthlyUsd}`);
}
}
}
/**
* Closes incidents whose calendar month window has ended.
* Per D-06: window rollover cleanup.
*/
function autoCloseExpiredIncidents(companyId: string): number {
const count = db.core.budget.autoCloseExpiredIncidents(companyId);
if (count > 0) {
logger.info(`Budget auto-close: ${count} expired incident(s) for company ${companyId}`);
publishDomainChanged();
}
return count;
}
/**
* Returns a summary of all budget policies and their current spend status.
*/
function getBudgetStatus(companyId: string): BudgetStatusSummary {
const policies = db.core.budget.listBudgetPolicies(companyId);
const openIncidents = db.core.budget.listBudgetIncidents(companyId, "open");
return {
policies: policies.map((policy) => {
const spent = db.core.budget.getSpentInWindow(policy);
const hasOpenIncident = openIncidents.some((i) => i.policyId === policy.id);
return {
policyId: policy.id,
scopeType: policy.scopeType,
scopeId: policy.scopeId,
amount: policy.amount,
spent,
warnPercent: policy.warnPercent,
hardStopEnabled: policy.hardStopEnabled,
hasOpenIncident,
};
}),
};
}
return {
evaluateBudgetGate,
checkAndCreateIncidents,
checkAutoResolve,
autoCloseExpiredIncidents,
getBudgetStatus,
};
}
-171
View File
@@ -1,171 +0,0 @@
/**
* Checkpoint Engine
*
* Creates approval checkpoints based on company executionMode.
* In ai_cofounder mode, key orchestration junctures pause for user confirmation.
* In ai_ceo mode, only high-risk actions require approval.
*
* Policy decisions (which types require review, which can be auto-approved)
* are centralized in execution-policy.ts — the single source of truth.
*/
import type { AppDatabase } from "./database";
import type { ApprovalType, ExecutionMode, SectionId } from "@shared/types";
import type { ThreadIntegrationService } from "./thread-integration";
import {
CHECKPOINT_TASK_TYPES,
STRATEGY_DOCUMENT_TYPES,
} from "./execution-policy";
import { classifyUnknownError } from "./errors/index.js";
interface CheckpointDeps {
db: AppDatabase;
logger: { info: (...args: unknown[]) => void; error: (...args: unknown[]) => void };
threadIntegration: ThreadIntegrationService;
publishDomainChanged: () => void;
notify: (options: { title: string; body: string; urgency: "critical" | "informational"; navigation?: { section: SectionId; entityId?: string } }) => void;
}
export function getCompanyExecutionMode(db: AppDatabase, companyId: string): ExecutionMode {
return db.core.company.getCompanyExecutionMode(companyId) as ExecutionMode;
}
export function createCheckpointEngine(deps: CheckpointDeps) {
const { db, logger, threadIntegration, publishDomainChanged, notify } = deps;
return {
/**
* Called when a task transitions to in_progress for the first time.
* In co-founder mode, creates a plan_review checkpoint for top-level
* non-code tasks so the user can review the plan before execution
* begins. Subtasks inherit their parent's review — creating a
* checkpoint for every subtask made the whole team stall on a
* waterfall of approvals that the operator had no chance to process
* in real time.
*/
onTaskStarted(taskId: string, companyId: string, agentId: string | null): string | null {
try {
const mode = getCompanyExecutionMode(db, companyId);
if (mode !== "ai_cofounder") return null;
const task = db.core.entity.getTask(taskId);
const taskType = db.core.tasks.getTaskType(taskId);
// Only create plan checkpoints for non-code task types
if (!taskType || !CHECKPOINT_TASK_TYPES.has(taskType)) return null;
// Subtasks inherit the parent's plan review; creating a fresh
// checkpoint per subtask produces an unbounded approval fan-out
// that blocks the assignee's heartbeat (shouldPauseHeartbeat)
// the moment delegation starts.
if (task.parentId) return null;
const approvalId = db.core.approvals.requestApproval({
companyId,
relatedTaskId: taskId,
requestedByAgentId: agentId,
type: "plan_review",
payloadSummary: `Review plan for: ${task.title}`.slice(0, 280),
impactSummary: `Task "${task.title}" is starting. Review the scope and approach before execution proceeds.`.slice(0, 400),
payloadJson: JSON.stringify({ taskId, taskTitle: task.title, taskType }),
});
threadIntegration.onApprovalCreated(approvalId, companyId, taskId, "plan_review", `Plan review: ${task.title}`);
notify({
title: "Plan Review Needed",
body: `"${task.title}" needs your review before work begins`,
urgency: "informational",
navigation: { section: "approvals", entityId: approvalId },
});
publishDomainChanged();
return approvalId;
} catch (err) {
const classified = classifyUnknownError(err, {
kind: "recoverable",
code: "checkpoint.on_task_started.failed",
message: "onTaskStarted failed",
});
logger.error(`[${classified.code}] ${classified.message}: ${err instanceof Error ? err.message : String(err)}`);
return null;
}
},
/**
* Called when a document is created in co-founder mode.
* Creates a draft_review checkpoint so the user can review before
* the document is considered final.
*/
onDocumentDrafted(companyId: string, documentId: string, documentTitle: string, documentType: string, authorAgentId: string | null, taskId: string | null): string | null {
try {
const mode = getCompanyExecutionMode(db, companyId);
if (mode !== "ai_cofounder") return null;
// Strategy-related document types get strategy_review instead
const approvalType: ApprovalType = STRATEGY_DOCUMENT_TYPES.has(documentType)
? "strategy_review"
: "draft_review";
const approvalId = db.core.approvals.requestApproval({
companyId,
relatedTaskId: taskId,
requestedByAgentId: authorAgentId,
type: approvalType,
payloadSummary: `Review ${documentType.replaceAll("_", " ")}: ${documentTitle}`.slice(0, 280),
impactSummary: `New ${documentType.replaceAll("_", " ")} drafted. Please review before it becomes final.`.slice(0, 400),
payloadJson: JSON.stringify({ documentId, documentTitle, documentType }),
});
threadIntegration.onApprovalCreated(approvalId, companyId, taskId, approvalType, `${approvalType}: ${documentTitle}`);
notify({
title: "Draft Review Needed",
body: `"${documentTitle}" needs your review`,
urgency: "informational",
navigation: { section: "approvals", entityId: approvalId },
});
publishDomainChanged();
return approvalId;
} catch (err) {
const classified = classifyUnknownError(err, {
kind: "recoverable",
code: "checkpoint.on_document_drafted.failed",
message: "onDocumentDrafted failed",
});
logger.error(`[${classified.code}] ${classified.message}: ${err instanceof Error ? err.message : String(err)}`);
return null;
}
},
/**
* Checks if a company in ai_cofounder mode has unresolved checkpoints
* that should pause heartbeat for an agent.
*/
shouldPauseHeartbeat(companyId: string, agentId: string): boolean {
try {
const mode = getCompanyExecutionMode(db, companyId);
if (mode !== "ai_cofounder") return false;
// Check for unresolved wait states for THIS agent's threads (not company-wide)
if (threadIntegration.hasUnresolvedWaitStatesForAgent(companyId, agentId)) {
return true;
}
// Check for pending approvals related to this agent's tasks
return db.core.approvals.hasPendingReviewApprovalForAgent(companyId, agentId);
} catch (err) {
logger.error(`[checkpoint] shouldPauseHeartbeat failed: ${err instanceof Error ? err.message : String(err)}`);
return true;
}
},
/**
* Returns execution mode for a company. Convenience accessor.
*/
getExecutionMode(companyId: string): ExecutionMode {
return getCompanyExecutionMode(db, companyId);
},
};
}
-170
View File
@@ -1,170 +0,0 @@
import { spawn } from "node:child_process";
import { writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
import { randomBytes } from "node:crypto";
import { spawnSync } from "node:child_process";
import log from "electron-log/main.js";
function quoteShell(value: string) {
return `'${value.replace(/'/g, `'"'"'`)}'`;
}
function escapeAppleScript(value: string) {
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
/* ------------------------------------------------------------------ */
/* Shared terminal spawning — used by generic install flows */
/* ------------------------------------------------------------------ */
function writeWindowsBatchFile(lines: string[]): string {
const fileName = `agent-company-${randomBytes(6).toString("hex")}.bat`;
const batchPath = join(tmpdir(), fileName);
writeFileSync(batchPath, lines.join("\r\n"), { encoding: "utf8" });
return batchPath;
}
function spawnTerminalWithScript(script: string, _title: string, _cwd: string): boolean {
if (process.platform === "darwin") {
const appleScript = `tell application "Terminal" to do script "${escapeAppleScript(script)}"\ntell application "Terminal" to activate`;
const child = spawn("osascript", ["-e", appleScript], {
detached: true,
stdio: "ignore",
});
child.unref();
return true;
}
if (process.platform === "win32") {
const child = spawn("cmd.exe", ["/c", "start", "", "cmd.exe", "/k", script], {
detached: true,
stdio: "ignore",
shell: false,
});
child.unref();
return true;
}
// Linux — try $TERMINAL first, then a broad list of common emulators.
// Supported list documented in docs/platform-support.md.
const linuxCandidates: Array<[string, string[]]> = [];
const envTerminal = process.env.TERMINAL?.trim();
if (envTerminal) {
linuxCandidates.push([envTerminal, ["-e", "sh", "-lc", script]]);
}
linuxCandidates.push(
["x-terminal-emulator", ["-e", "sh", "-lc", script]],
["gnome-terminal", ["--", "sh", "-lc", script]],
["konsole", ["-e", "sh", "-lc", script]],
["xfce4-terminal", ["-e", `sh -lc ${quoteShell(script)}`]],
["kitty", ["sh", "-lc", script]],
["alacritty", ["-e", "sh", "-lc", script]],
["wezterm", ["start", "--", "sh", "-lc", script]],
["tilix", ["-e", "sh", "-lc", script]],
["terminator", ["-e", `sh -lc ${quoteShell(script)}`]],
);
for (const [binary, args] of linuxCandidates) {
const availability = spawnSync("sh", ["-lc", `command -v ${quoteShell(binary)}`], {
stdio: "ignore",
});
if ((availability.status ?? 1) !== 0) {
continue;
}
try {
const child = spawn(binary, args, {
detached: true,
stdio: "ignore",
});
child.unref();
return true;
} catch (err) {
log.warn(`[connector-auth] Failed to spawn terminal ${binary}; trying next candidate:`, err instanceof Error ? err.message : String(err));
}
}
throw new Error("No supported terminal application was found.");
}
/* ------------------------------------------------------------------ */
/* Generic terminal command (for install flows, etc.) */
/* ------------------------------------------------------------------ */
const TERMINAL_COMMAND_MAP: Record<string, { title: string; unix: string | null; win32: string | null }> = {
install_node_darwin: {
title: "Install Node.js",
unix: "brew install node@22",
win32: null,
},
install_node_win32: {
title: "Install Node.js",
unix: null,
win32: "winget install -e --id OpenJS.NodeJS.LTS",
},
install_node_linux: {
title: "Install Node.js",
unix: `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh | bash && \\. "$HOME/.nvm/nvm.sh" && nvm install 22`,
win32: null,
},
install_claude_unix: {
title: "Install Claude Code",
unix: "curl -fsSL https://claude.ai/install.sh | bash",
win32: null,
},
install_claude_win32: {
title: "Install Claude Code",
unix: null,
win32: "irm https://claude.ai/install.ps1 | iex",
},
install_codex: {
title: "Install Codex",
unix: "npm i -g @openai/codex",
win32: "npm i -g @openai/codex",
},
install_gemini: {
title: "Install Gemini CLI",
unix: "npm install -g @google/gemini-cli",
win32: "npm install -g @google/gemini-cli",
},
};
export async function openGenericTerminal(input: { commandKey: string }) {
const spec = TERMINAL_COMMAND_MAP[input.commandKey];
if (!spec) {
throw new Error(`Unknown terminal command key: ${input.commandKey}`);
}
const cwd = process.env.HOME || homedir() || process.cwd();
const command = process.platform === "win32" ? spec.win32 : spec.unix;
if (!command) {
throw new Error(`Command "${input.commandKey}" is not available on this platform.`);
}
if (process.platform === "win32") {
const batchPath = writeWindowsBatchFile([
`@echo off`,
`echo AgentCompany - ${spec.title}`,
`echo.`,
`cd /d "${cwd}"`,
command,
`echo.`,
`echo Done. You can close this terminal.`,
`pause`,
]);
return spawnTerminalWithScript(`"${batchPath}"`, spec.title, cwd);
}
const shell = process.env.SHELL || "/bin/zsh";
const script = [
"clear",
`echo ${quoteShell(`AgentCompany — ${spec.title}`)}`,
"echo",
`cd ${quoteShell(cwd)}`,
command,
"echo",
`echo ${quoteShell("Done. You can close this terminal.")}`,
`exec ${quoteShell(shell)} -l`,
].join("; ");
return spawnTerminalWithScript(script, spec.title, cwd);
}
-38
View File
@@ -1,38 +0,0 @@
/**
* Per-auth-kind configuration documentation builders for the Settings →
* Providers panel. Extracted from `connector-definitions.ts` to keep that
* file under the 400-line cap.
*/
import type { PiOAuthProviderId } from "./connector-definitions";
export function buildOAuthDoc(label: string, oauthId: PiOAuthProviderId): string {
return [
`# ${label}`,
"",
"Authenticate via OAuth in your system browser. AgentCompany opens",
"the provider's authorization page; the redirect handler runs locally on",
"`http://localhost:53692/callback`.",
"",
`OAuth provider id: \`${oauthId}\`.`,
"",
"Tokens are encrypted by the OS keychain (or per-profile vault) and",
"stored in the `credentials` table. Tokens are refreshed automatically",
"before each run (60 second window).",
].join("\n");
}
export function buildApiKeyDoc(label: string, envVar: string): string {
return [
`# ${label}`,
"",
`API key authentication. Either paste the literal key or use a system`,
`environment variable name (default: \`${envVar}\`).`,
"",
"Keys are encrypted at rest via OS keychain (or per-profile vault).",
].join("\n");
}
export function buildCloudDoc(label: string, body: string): string {
return [`# ${label}`, "", body].join("\n");
}
-9
View File
@@ -1,9 +0,0 @@
import { CONNECTORS, type ConnectorDefinition } from "./connector-definitions";
export function findConnectorDefinition(id: string): ConnectorDefinition | undefined {
return CONNECTORS.find((connector) => connector.id === id);
}
export function listFeaturedOnboardingConnectors(): ConnectorDefinition[] {
return CONNECTORS.filter((connector) => connector.onboardingFeatured === true);
}
-105
View File
@@ -1,105 +0,0 @@
/**
* Pi-mono connector catalog.
*
* The catalog is pure declarative data — there is no adapter module, no
* embedded CLI command, and no capability matrix. All provider dispatch is
* handled by pi-mono (`@mariozechner/pi-ai`) at runtime, which keys off the
* `provider` field and the auth descriptor.
*
* Shape and invariants are validated by `tests/unit/connector-catalog.test.ts`:
*
* - Exactly 26 entries: 5 OAuth, 18 API key, 3 Cloud
* - Each entry references a pi-ai `KnownProvider` (or one of the two
* Fireworks ids resolved locally in `src/main/model-resolver.ts`)
* - OAuth entries carry one of the five pi-ai OAuth provider ids
* - API-key entries carry an `envKey` environment variable name
* - The onboarding rail includes at least the `claude-pro` connector
*
* Entries live in three companion files to keep every module under the
* 400-line cap:
*
* - `connector-definitions/oauth.ts` — 5 OAuth providers
* - `connector-definitions/apikey.ts` — 18 API-key providers
* - `connector-definitions/cloud.ts` — 3 Cloud providers
*/
import type { KnownProvider } from "@mariozechner/pi-ai";
import type { ConnectorId } from "@shared/types";
import { OAUTH_CONNECTORS, API_KEY_CONNECTORS, CLOUD_CONNECTORS, LOCAL_CONNECTORS } from "./connector-definitions/index";
/**
* OAuth provider id recognised by the AgentCompany credential layer. These
* match pi-ai's five built-in OAuth handler ids verbatim.
*/
export type PiOAuthProviderId =
| "anthropic"
| "openai-codex"
| "github-copilot"
| "google-gemini-cli"
| "google-antigravity";
export interface ConnectorAuthOAuth {
type: "oauth";
oauthId: PiOAuthProviderId;
}
export interface ConnectorAuthApiKey {
type: "api_key";
/** Fallback environment variable name (e.g. `ANTHROPIC_API_KEY`). */
envKey: string;
}
export interface ConnectorAuthAws {
type: "aws";
}
export interface ConnectorAuthAzure {
type: "azure";
}
export interface ConnectorAuthGcpAdc {
type: "gcp_adc";
}
export interface ConnectorAuthLocal {
type: "local";
/** Pre-filled base URL the onboarding card shows on first save. */
defaultBaseUrl: string;
}
export type ConnectorAuth =
| ConnectorAuthOAuth
| ConnectorAuthApiKey
| ConnectorAuthAws
| ConnectorAuthAzure
| ConnectorAuthGcpAdc
| ConnectorAuthLocal;
export type LocalModelResolverProvider = "fireworks" | "fireworks-firepass" | "ollama" | "lm-studio";
export type ConnectorProvider = KnownProvider | LocalModelResolverProvider;
export interface ConnectorDefinition {
/** Stable AgentCompany id; the `connectors` table primary key. */
id: ConnectorId;
/** pi-ai `KnownProvider` id (or a locally-resolved provider for Fireworks). */
provider: ConnectorProvider;
/** Human-readable label for the Settings panel and onboarding rail. */
label: string;
/** One-line explanation shown under the label. */
description: string;
/** pi-ai model id used when the user has not picked a model yet. */
defaultModel: string;
/** Declarative auth descriptor; drives the credential resolver and OAuth flow. */
auth: ConnectorAuth;
/** Whether to surface this connector in the first-run onboarding picker. */
onboardingFeatured?: boolean;
/** Optional extra guidance for the Settings panel. */
notes?: string;
}
export const CONNECTORS: ConnectorDefinition[] = [
...OAUTH_CONNECTORS,
...API_KEY_CONNECTORS,
...CLOUD_CONNECTORS,
...LOCAL_CONNECTORS,
];
-177
View File
@@ -1,177 +0,0 @@
/**
* API-key pi-mono connectors.
*
* Each entry maps to a pi-ai `KnownProvider` from the pinned
* `@mariozechner/pi-ai` runtime, except for the two
* Fireworks entries, which are resolved locally in `src/main/model-resolver.ts`.
*
* `envKey` is the standard environment variable pi-mono reads when no
* `auth.json` entry is present. AgentCompany's credential store also honours
* it as a fallback so the Settings panel can tell users which variable to
* set.
*/
import type { ConnectorDefinition } from "../connector-definitions";
export const API_KEY_CONNECTORS: ConnectorDefinition[] = [
{
id: "anthropic",
provider: "anthropic",
label: "Anthropic API",
description:
"Direct Anthropic Messages API. Use this for production workloads where OAuth subscription tiers are not appropriate.",
defaultModel: "claude-sonnet-4-5",
auth: { type: "api_key", envKey: "ANTHROPIC_API_KEY" },
onboardingFeatured: true,
},
{
id: "openai",
provider: "openai",
label: "OpenAI API",
description:
"OpenAI Responses + Chat Completions endpoints. Covers GPT-5 family and Codex deployments.",
defaultModel: "gpt-5.4",
auth: { type: "api_key", envKey: "OPENAI_API_KEY" },
onboardingFeatured: true,
},
{
id: "google",
provider: "google",
label: "Google Gemini API",
description:
"Google Gemini Generative Language API. Uses the standard GEMINI_API_KEY environment variable.",
defaultModel: "gemini-2.5-pro",
auth: { type: "api_key", envKey: "GEMINI_API_KEY" },
},
{
id: "mistral",
provider: "mistral",
label: "Mistral",
description: "Mistral La Plateforme — Codestral, Devstral, and Mistral Medium/Large families.",
defaultModel: "mistral-medium-latest",
auth: { type: "api_key", envKey: "MISTRAL_API_KEY" },
},
{
id: "groq",
provider: "groq",
label: "Groq",
description:
"Groq LPU cloud. High-throughput inference for Llama 3, Qwen, DeepSeek, and Groq Compound.",
defaultModel: "groq/compound",
auth: { type: "api_key", envKey: "GROQ_API_KEY" },
},
{
id: "cerebras",
provider: "cerebras",
label: "Cerebras",
description: "Cerebras Inference cloud — wafer-scale hardware for Qwen, Llama, and GPT-OSS.",
defaultModel: "qwen-3-235b-a22b-instruct-2507",
auth: { type: "api_key", envKey: "CEREBRAS_API_KEY" },
},
{
id: "xai",
provider: "xai",
label: "xAI",
description: "xAI Grok API including Grok 4 Fast and Grok Code Fast.",
defaultModel: "grok-4-fast",
auth: { type: "api_key", envKey: "XAI_API_KEY" },
},
{
id: "openrouter",
provider: "openrouter",
label: "OpenRouter",
description:
"OpenRouter aggregator. Access Anthropic, OpenAI, Google, open-weights, and community models through one key.",
defaultModel: "anthropic/claude-sonnet-4.5",
auth: { type: "api_key", envKey: "OPENROUTER_API_KEY" },
},
{
id: "vercel-ai-gateway",
provider: "vercel-ai-gateway",
label: "Vercel AI Gateway",
description:
"Vercel's multi-provider proxy. Request models by `provider/model` slug and share one billing profile.",
defaultModel: "anthropic/claude-sonnet-4.5",
auth: { type: "api_key", envKey: "AI_GATEWAY_API_KEY" },
},
{
id: "zai",
provider: "zai",
label: "Z.AI",
description: "Z.AI GLM-4.5/4.6 family — coding-tuned Chinese models with tool use.",
defaultModel: "glm-4.6",
auth: { type: "api_key", envKey: "ZAI_API_KEY" },
},
{
id: "opencode",
provider: "opencode",
label: "OpenCode Zen",
description:
"OpenCode Zen managed endpoint. Pi-mono uses the same OPENCODE_API_KEY as OpenCode Go.",
defaultModel: "claude-sonnet-4.5",
auth: { type: "api_key", envKey: "OPENCODE_API_KEY" },
},
{
id: "opencode-go",
provider: "opencode-go",
label: "OpenCode Go",
description:
"OpenCode Go managed endpoint. Shares OPENCODE_API_KEY with OpenCode Zen.",
defaultModel: "kimi-k2.5",
auth: { type: "api_key", envKey: "OPENCODE_API_KEY" },
},
{
id: "huggingface",
provider: "huggingface",
label: "Hugging Face",
description:
"Hugging Face Inference Endpoints. Hosts Kimi K2, GLM, DeepSeek, MiniMax, and other open-weights.",
defaultModel: "moonshotai/Kimi-K2.5",
auth: { type: "api_key", envKey: "HF_TOKEN" },
},
{
id: "kimi-coding",
provider: "kimi-coding",
label: "Kimi For Coding",
description:
"Moonshot AI Kimi For Coding endpoint — tuned for agentic coding workloads.",
defaultModel: "kimi-for-coding",
auth: { type: "api_key", envKey: "KIMI_API_KEY" },
},
{
id: "minimax",
provider: "minimax",
label: "MiniMax",
description: "MiniMax international endpoint for M2 coding models.",
defaultModel: "MiniMax-M2.7",
auth: { type: "api_key", envKey: "MINIMAX_API_KEY" },
},
{
id: "minimax-cn",
provider: "minimax-cn",
label: "MiniMax (China)",
description: "MiniMax mainland China endpoint. Uses a separate key from the international tier.",
defaultModel: "MiniMax-M2.7",
auth: { type: "api_key", envKey: "MINIMAX_CN_API_KEY" },
},
{
id: "fireworks",
provider: "fireworks",
label: "Fireworks AI",
description:
"Fireworks inference cloud. Resolved locally through `src/main/model-resolver.ts` — pi-ai does not ship a Fireworks catalog entry.",
defaultModel: "accounts/fireworks/models/kimi-k2.5-turbo",
auth: { type: "api_key", envKey: "FIREWORKS_API_KEY" },
},
{
id: "fireworks-firepass",
provider: "fireworks-firepass",
label: "Fireworks Fire Pass",
description:
"Fireworks Fire Pass — $7/week subscription for unlimited Kimi K2.6 Turbo. Uses the same FIREWORKS_API_KEY; the Fire Pass entitlement is attached to the account.",
defaultModel: "accounts/fireworks/routers/kimi-k2p6-turbo",
auth: { type: "api_key", envKey: "FIREWORKS_API_KEY" },
notes:
"Fire Pass routes through the dedicated router id accounts/fireworks/routers/kimi-k2p6-turbo on both the OpenAI- and Anthropic-compat endpoints. See https://docs.fireworks.ai/firepass.",
},
];
-47
View File
@@ -1,47 +0,0 @@
/**
* Cloud pi-mono connectors.
*
* Cloud providers have bespoke credential discovery — multi-variable AWS /
* Azure setups, or gcloud Application Default Credentials for Vertex. The
* `auth.type` marker tells the credential resolver which discovery path to
* run in the pinned `@mariozechner/pi-ai` runtime.
*/
import type { ConnectorDefinition } from "../connector-definitions";
export const CLOUD_CONNECTORS: ConnectorDefinition[] = [
{
id: "amazon-bedrock",
provider: "amazon-bedrock",
label: "AWS Bedrock",
description:
"Anthropic, Amazon Nova, and Llama models served through AWS Bedrock. Honours standard AWS credential discovery (profile, IAM keys, Bedrock bearer tokens, ECS roles, IRSA).",
defaultModel: "us.anthropic.claude-sonnet-4-20250514-v1:0",
auth: { type: "aws" },
onboardingFeatured: true,
notes:
"Credentials resolved from AWS_PROFILE | AWS_ACCESS_KEY_ID+AWS_SECRET_ACCESS_KEY | AWS_BEARER_TOKEN_BEDROCK | AWS_CONTAINER_CREDENTIALS_* | AWS_WEB_IDENTITY_TOKEN_FILE. Region defaults to us-east-1.",
},
{
id: "azure-openai",
provider: "azure-openai-responses",
label: "Azure OpenAI",
description:
"Azure-hosted OpenAI deployments via the Responses API. Requires AZURE_OPENAI_API_KEY plus a resource identifier.",
defaultModel: "gpt-5.4",
auth: { type: "azure" },
notes:
"Provide AZURE_OPENAI_API_KEY plus AZURE_OPENAI_BASE_URL or AZURE_OPENAI_RESOURCE_NAME. Optional AZURE_OPENAI_API_VERSION, AZURE_OPENAI_DEPLOYMENT_NAME_MAP.",
},
{
id: "google-vertex",
provider: "google-vertex",
label: "Google Vertex AI",
description:
"Gemini, Claude, and third-party models on Google Vertex AI. Uses Application Default Credentials from `gcloud auth application-default login` or GOOGLE_APPLICATION_CREDENTIALS.",
defaultModel: "gemini-2.5-pro",
auth: { type: "gcp_adc" },
notes:
"Set GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION. Optional GOOGLE_CLOUD_API_KEY takes precedence over ADC when present.",
},
];
-12
View File
@@ -1,12 +0,0 @@
/**
* Barrel for the connector catalog partition files. The three per-auth
* modules live side-by-side instead of next to `connector-definitions.ts`
* so `ls src/main/` stays skimmable; this re-export exists so existing
* imports like `./connector-definitions-data-oauth` continue to resolve
* via `./connector-definitions/oauth` with the same symbol names.
*/
export { OAUTH_CONNECTORS } from "./oauth";
export { API_KEY_CONNECTORS } from "./apikey";
export { CLOUD_CONNECTORS } from "./cloud";
export { LOCAL_CONNECTORS } from "./local";
-41
View File
@@ -1,41 +0,0 @@
/**
* Local pi-mono connectors.
*
* Ollama and LM Studio both expose an OpenAI-compatible
* `/v1/chat/completions` endpoint, so they are routed through pi-ai's
* `openai-completions` API path with a per-installation `baseUrl` that
* the user enters during onboarding. The provider ids are deliberately
* absent from `@mariozechner/pi-ai`'s `KnownProvider` set — model
* dispatch resolves them locally via `src/main/model-resolver.ts` the
* same way `fireworks` and `fireworks-firepass` do.
*
* No environment variable name accompanies the auth descriptor: a local
* server's address is a user choice, never read from `process.env`.
*/
import type { ConnectorDefinition } from "../connector-definitions";
export const LOCAL_CONNECTORS: ConnectorDefinition[] = [
{
id: "ollama",
provider: "ollama",
label: "Ollama",
description:
"Local Ollama server (https://ollama.com) — runs open-weights models on your own machine through its OpenAI-compatible endpoint.",
defaultModel: "llama3.2",
auth: { type: "local", defaultBaseUrl: "http://localhost:11434/v1" },
notes:
"Ollama exposes /v1/chat/completions and /v1/models. The default port is 11434; override with a loopback or private-network address.",
},
{
id: "lm-studio",
provider: "lm-studio",
label: "LM Studio",
description:
"Local LM Studio server (https://lmstudio.ai) — host any loaded model through its OpenAI-compatible endpoint.",
defaultModel: "local-model",
auth: { type: "local", defaultBaseUrl: "http://localhost:1234/v1" },
notes:
"Enable LM Studio's local server from the Developer tab; the default port is 1234. Use a loopback or private-network address.",
},
];
-74
View File
@@ -1,74 +0,0 @@
/**
* OAuth-based pi-mono connectors.
*
* Each entry targets one of the five built-in OAuth providers from the
* pinned `@mariozechner/pi-ai` runtime. The
* runtime opens the provider's browser auth flow, stores the refreshed
* token in the `credentials` table, and passes it as the API key when
* invoking pi-ai.
*
* The `oauthId` surfaced here matches pi-ai's five registered provider ids
* verbatim (`anthropic` / `openai-codex` / `github-copilot` /
* `google-gemini-cli` / `google-antigravity`) and is passed straight to
* `getOAuthProvider`.
*/
import type { ConnectorDefinition } from "../connector-definitions";
export const OAUTH_CONNECTORS: ConnectorDefinition[] = [
{
id: "claude-pro",
provider: "anthropic",
label: "Claude Pro/Max",
description:
"Anthropic Claude via Claude Pro / Max subscription OAuth. Runs entirely through pi-mono's built-in OAuth handler; no API key required.",
defaultModel: "claude-sonnet-4-5",
auth: { type: "oauth", oauthId: "anthropic" },
onboardingFeatured: true,
notes:
"Requires an active Claude Pro or Max subscription. Tokens refresh automatically.",
},
{
id: "openai-codex",
provider: "openai-codex",
label: "ChatGPT Plus/Pro (Codex)",
description:
"OpenAI Codex via a ChatGPT Plus or Pro subscription. Personal-use only — production workloads should use the OpenAI API connector.",
defaultModel: "gpt-5.3-codex",
auth: { type: "oauth", oauthId: "openai-codex" },
onboardingFeatured: true,
notes: "Personal use only; requires ChatGPT Plus or Pro.",
},
{
id: "github-copilot",
provider: "github-copilot",
label: "GitHub Copilot",
description:
"Models exposed through a GitHub Copilot subscription. Supports github.com and GitHub Enterprise Server.",
defaultModel: "claude-opus-4.5",
auth: { type: "oauth", oauthId: "github-copilot" },
notes:
"If a model shows 'not supported', enable it in VS Code: Copilot Chat → model selector → Enable.",
},
{
id: "gemini-cli",
provider: "google-gemini-cli",
label: "Google Gemini CLI",
description:
"Google Gemini models via Cloud Code Assist OAuth. Free tier works with any Google account (rate-limited).",
defaultModel: "gemini-2.5-pro",
auth: { type: "oauth", oauthId: "google-gemini-cli" },
notes:
"For paid Cloud Code Assist, set GOOGLE_CLOUD_PROJECT. Google-gemini-cli OAuth provider id in pi-ai.",
},
{
id: "antigravity",
provider: "google-antigravity",
label: "Google Antigravity",
description:
"Google Antigravity sandbox with Gemini 3, Claude, and GPT-OSS models. Free with any Google account.",
defaultModel: "claude-sonnet-4-5",
auth: { type: "oauth", oauthId: "google-antigravity" },
notes: "Maps to pi-ai OAuth provider id `google-antigravity`.",
},
];
-172
View File
@@ -1,172 +0,0 @@
import { getEnvApiKey } from "@mariozechner/pi-ai";
import type { AppDatabase } from "./database";
import { getConnectorDefinition, isPiKnownProvider, parseEnvBindingText } from "./connectors";
import type { ConnectorId } from "@shared/types";
import type { CredentialStore } from "./credentials";
interface ConnectorHealthDependencies {
db: AppDatabase;
logger: {
info: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
publishDomainChanged: () => void;
credentialStore: CredentialStore;
}
type HealthState = {
status: "ready" | "auth_required" | "not_installed" | "error";
authState: "ok" | "required" | "error" | "unknown";
lastError: string | null;
};
export function createConnectorHealthService({
db,
logger,
publishDomainChanged,
credentialStore,
}: ConnectorHealthDependencies) {
function randomFallbackCompanyId() {
const snapshot = db.projection.snapshot.listSnapshot();
return snapshot.companies[0]?.id ?? "00000000-0000-0000-0000-000000000000";
}
function evaluateCredential(id: ConnectorId): HealthState {
const definition = getConnectorDefinition(id);
const stored = credentialStore.read(id);
switch (definition.auth.type) {
case "oauth": {
if (stored?.kind !== "oauth") {
return { status: "auth_required", authState: "required", lastError: "OAuth login required." };
}
const creds = stored.value;
const expires = typeof creds.expires === "number" ? creds.expires : null;
if (expires !== null && expires <= Date.now()) {
return { status: "auth_required", authState: "required", lastError: "OAuth token expired." };
}
return { status: "ready", authState: "ok", lastError: null };
}
case "api_key": {
if (stored?.kind === "api_key") {
return { status: "ready", authState: "ok", lastError: null };
}
if (isPiKnownProvider(definition.provider) && getEnvApiKey(definition.provider)) {
return { status: "ready", authState: "ok", lastError: null };
}
// Re-parse envBindingText on every probe: it is user-editable and must
// not be cached into credentialStore. An explicit binding for the
// connector's declared envKey (non-empty value) counts as ready.
const connector = db.core.connectors.findConnector(id);
const boundValue = connector
? parseEnvBindingText(connector.envBindingText)[definition.auth.envKey]
: undefined;
if (boundValue && boundValue.length > 0) {
return { status: "ready", authState: "ok", lastError: null };
}
return {
status: "auth_required",
authState: "required",
lastError: `API key required (${definition.auth.envKey}).`,
};
}
case "aws": {
if (stored?.kind !== "aws") {
return { status: "auth_required", authState: "required", lastError: "AWS credentials not configured." };
}
const cfg = stored.value;
if (cfg.authMode === "profile" && !cfg.awsProfile) {
return { status: "error", authState: "error", lastError: "AWS profile missing." };
}
if (cfg.authMode === "iam" && (!cfg.accessKeyId || !cfg.secretAccessKey)) {
return { status: "error", authState: "error", lastError: "AWS IAM keys missing." };
}
if (cfg.authMode === "bearer" && !cfg.bearerToken) {
return { status: "error", authState: "error", lastError: "AWS bearer token missing." };
}
return { status: "ready", authState: "ok", lastError: null };
}
case "azure": {
if (stored?.kind !== "azure") {
return { status: "auth_required", authState: "required", lastError: "Azure credentials not configured." };
}
const cfg = stored.value;
if (!cfg.apiKey) {
return { status: "error", authState: "error", lastError: "Azure OpenAI API key missing." };
}
if (!cfg.baseUrl && !cfg.resourceName) {
return { status: "error", authState: "error", lastError: "Azure requires baseUrl or resourceName." };
}
return { status: "ready", authState: "ok", lastError: null };
}
case "gcp_adc": {
if (stored?.kind !== "gcp_adc") {
return { status: "auth_required", authState: "required", lastError: "GCP ADC not configured." };
}
const cfg = stored.value;
if (!cfg.project || !cfg.location) {
return { status: "error", authState: "error", lastError: "GCP project/location missing." };
}
return { status: "ready", authState: "ok", lastError: null };
}
case "local": {
if (stored?.kind !== "local") {
return { status: "auth_required", authState: "required", lastError: "Local server URL not configured." };
}
if (!stored.value.baseUrl) {
return { status: "error", authState: "error", lastError: "Local server URL missing." };
}
return { status: "ready", authState: "ok", lastError: null };
}
}
}
async function checkConnector(id: ConnectorId) {
const companyId = db.core.settings.getCurrentCompanyId() ?? randomFallbackCompanyId();
db.core.connectors.updateConnectorHealth({
id,
status: "detected",
authState: db.core.connectors.getConnector(id).authState,
version: null,
lastError: null,
});
publishDomainChanged();
try {
const result = evaluateCredential(id);
db.core.connectors.updateConnectorHealth({
id,
status: result.status,
authState: result.authState,
version: null,
lastError: result.lastError,
});
db.core.activity.addActivity({
companyId,
actor: "system",
action: "connector.tested",
entityType: "connector",
entityId: id,
detail: result.status,
});
logger.info(`[connector] Health check for ${id}: ${result.status}`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`[connector] Health check failed for ${id}: ${errorMessage}`);
db.core.connectors.updateConnectorHealth({
id,
status: "error",
authState: "unknown",
version: null,
lastError: errorMessage,
});
}
publishDomainChanged();
}
return {
checkConnector,
randomFallbackCompanyId,
};
}
Loaded 100 of 1904 files, more files were not shown because too many files have changed in this diff. Show more