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:
1 parent
2350f744e3
commit
07d0b4587d
1904 files changed
+1133
-282075
No files matched your search
@@ -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 };
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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() + "…";
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)`);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 = "";
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"],
|
||||
},
|
||||
};
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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.",
|
||||
},
|
||||
];
|
||||
@@ -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.",
|
||||
},
|
||||
];
|
||||
@@ -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";
|
||||
@@ -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.",
|
||||
},
|
||||
];
|
||||
@@ -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`.",
|
||||
},
|
||||
];
|
||||
@@ -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
Reference in new issue
Block a user