diff --git a/.gitignore b/.gitignore index 59c01bca..725b0fe6 100644 --- a/.gitignore +++ b/.gitignore @@ -12,9 +12,11 @@ win-unpacked .claude build .omc +.omx .planning paperclip-reference - +Reference +.pnpm-store # Test artifacts test-results/ playwright-report/ @@ -30,8 +32,11 @@ playwright-report/ # Environment files .env +.env.* .env.local .env.*.local +!.env.example +!.env.release.example # Editor and OS files .DS_Store @@ -55,8 +60,10 @@ oldreference/ # Local design scratch files homepagedesign.html -# Landing page (deployed separately to Cloudflare Pages) -landing/ +# Landing page is part of the repo and should be versioned + +# Boss edition build (contains credentials — never commit) +boss-build/ # Package manager logs npm-debug.log* @@ -65,4 +72,5 @@ yarn-debug.log* yarn-error.log* build/release/AgentCompany-0.4.61-arm64.dmg CLAUDE.md -build/release/AgentCompany-0.4.90-arm64.dmg \ No newline at end of file +build/release/AgentCompany-0.4.90-arm64.dmg +local-release/ diff --git a/README.md b/README.md index f9caa591..46819d5f 100644 --- a/README.md +++ b/README.md @@ -126,12 +126,14 @@ pnpm build:verify pnpm build # Compile + package for current platform → /build/release pnpm build:dir # Compile + unpackaged directory pnpm build:all # Build for all platforms +pnpm release:local # Signed/uploading release pipeline (see docs/release/local-r2.md) ``` - **Targets:** macOS (`.dmg`), Windows (`.exe` / NSIS), Linux (`.AppImage`) - Packaging uses `electron-builder` - Local builds use ad-hoc signing (`CSC_IDENTITY_AUTO_DISCOVERY=false`) - Defaults to `--publish never`; override with `AGENTCOMPANY_ELECTRON_PUBLISH` +- Cloudflare R2 release hosting + auto-update pipeline: `docs/release/local-r2.md` --- diff --git a/eslint.config.mjs b/eslint.config.mjs index d4a25da9..fe588538 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -8,10 +8,17 @@ export default tseslint.config( { ignores: [ "out/**", + "**/out/**", "build/**", "dist/**", "oldreference/**", + "Reference/**", + "boss-build/**", + "builtin-skills/**", "node_modules/**", + "**/node_modules/**", + ".next/**", + "**/.next/**", "test-results/**", "playwright-report/**", "coverage/**", @@ -52,8 +59,10 @@ export default tseslint.config( ], "@typescript-eslint/no-explicit-any": "off", "react-hooks/exhaustive-deps": "warn", + "react-hooks/incompatible-library": "off", "react-hooks/set-state-in-effect": "off", "react-hooks/refs": "off", + "react-hooks/preserve-manual-memoization": "off", }, }, { diff --git a/packages/adapter-claude-local/src/server/execute-helpers.ts b/packages/adapter-claude-local/src/server/execute-helpers.ts new file mode 100644 index 00000000..e08cda1a --- /dev/null +++ b/packages/adapter-claude-local/src/server/execute-helpers.ts @@ -0,0 +1,261 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { AdapterExecutionContext, ManagedSkillRef } from "@agentcompany/adapter-utils"; +import type { RunProcessResult } from "@agentcompany/adapter-utils/server-utils"; +import { + asString, + asNumber, + asStringArray, + parseObject, + buildAgentCompanyEnv, + ensureAbsoluteDirectory, + ensureCommandResolvable, + ensurePathInEnv, + createManagedSkillSymlinks, +} from "@agentcompany/adapter-utils/server-utils"; + +const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); +export const SKILLS_CANDIDATES = [ + path.resolve(__moduleDir, "../../skills"), // published: /dist/server/ -> /skills/ + path.resolve(__moduleDir, "../../../../../skills"), // dev: src/server/ -> repo root/skills/ +]; + +export async function resolveAgentCompanySkillsDir(): Promise { + for (const candidate of SKILLS_CANDIDATES) { + const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false); + if (isDir) return candidate; + } + return null; +} + +/** + * Create a tmpdir with `.claude/skills/` containing symlinks to skills from + * managed skill entries (preferred) or the repo's static `skills/` directory + * (backward compatibility fallback), so `--add-dir` makes Claude Code discover + * them as proper registered skills. + */ +export async function buildSkillsDir( + managedSkills: ManagedSkillRef[], + onLog: (stream: "stdout" | "stderr", chunk: string) => Promise, +): Promise { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "agent-company-skills-")); + const target = path.join(tmp, ".claude", "skills"); + await fs.mkdir(target, { recursive: true }); + + // Use managed skills when available; fall back to static repo scan + if (managedSkills.length > 0) { + await createManagedSkillSymlinks(managedSkills, target, onLog); + return tmp; + } + + const skillsDir = await resolveAgentCompanySkillsDir(); + if (!skillsDir) return tmp; + const entries = await fs.readdir(skillsDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + await fs.symlink( + path.join(skillsDir, entry.name), + path.join(target, entry.name), + ); + } + } + return tmp; +} + +export interface ClaudeExecutionInput { + runId: string; + agent: AdapterExecutionContext["agent"]; + config: Record; + context: Record; + authToken?: string; +} + +export interface ClaudeRuntimeConfig { + command: string; + cwd: string; + workspaceId: string | null; + workspaceRepoUrl: string | null; + workspaceRepoRef: string | null; + env: Record; + timeoutSec: number; + graceSec: number; + extraArgs: string[]; +} + +export function buildLoginResult(input: { + proc: RunProcessResult; + loginUrl: string | null; +}) { + return { + exitCode: input.proc.exitCode, + signal: input.proc.signal, + timedOut: input.proc.timedOut, + stdout: input.proc.stdout, + stderr: input.proc.stderr, + loginUrl: input.loginUrl, + }; +} + +export function hasNonEmptyEnvValue(env: Record, key: string): boolean { + const raw = env[key]; + return typeof raw === "string" && raw.trim().length > 0; +} + +export function resolveClaudeBillingType(env: Record): "api" | "subscription" { + // Claude uses API-key auth when ANTHROPIC_API_KEY is present; otherwise rely on local login/session auth. + return hasNonEmptyEnvValue(env, "ANTHROPIC_API_KEY") ? "api" : "subscription"; +} + +export async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise { + const { runId, agent, config, context, authToken } = input; + + const command = asString(config.command, "claude"); + const workspaceContext = parseObject(context.agentCompanyWorkspace); + const workspaceCwd = asString(workspaceContext.cwd, ""); + const workspaceSource = asString(workspaceContext.source, ""); + const workspaceStrategy = asString(workspaceContext.strategy, ""); + const workspaceId = asString(workspaceContext.workspaceId, "") || null; + const workspaceRepoUrl = asString(workspaceContext.repoUrl, "") || null; + const workspaceRepoRef = asString(workspaceContext.repoRef, "") || null; + const workspaceBranch = asString(workspaceContext.branchName, "") || null; + const workspaceWorktreePath = asString(workspaceContext.worktreePath, "") || null; + const workspaceHints = Array.isArray(context.agentCompanyWorkspaces) + ? context.agentCompanyWorkspaces.filter( + (value): value is Record => typeof value === "object" && value !== null, + ) + : []; + const runtimeServiceIntents = Array.isArray(context.agentCompanyRuntimeServiceIntents) + ? context.agentCompanyRuntimeServiceIntents.filter( + (value): value is Record => typeof value === "object" && value !== null, + ) + : []; + const runtimeServices = Array.isArray(context.agentCompanyRuntimeServices) + ? context.agentCompanyRuntimeServices.filter( + (value): value is Record => typeof value === "object" && value !== null, + ) + : []; + const runtimePrimaryUrl = asString(context.agentCompanyRuntimePrimaryUrl, ""); + const configuredCwd = asString(config.cwd, ""); + const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0; + const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; + const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd(); + await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); + + const envConfig = parseObject(config.env); + const hasExplicitApiKey = + typeof envConfig.AGENT_COMPANY_API_KEY === "string" && envConfig.AGENT_COMPANY_API_KEY.trim().length > 0; + const env: Record = { ...buildAgentCompanyEnv(agent) }; + env.AGENT_COMPANY_RUN_ID = runId; + + const wakeTaskId = + (typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) || + (typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) || + null; + const wakeReason = + typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0 + ? context.wakeReason.trim() + : null; + const wakeCommentId = + (typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) || + (typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) || + null; + const approvalId = + typeof context.approvalId === "string" && context.approvalId.trim().length > 0 + ? context.approvalId.trim() + : null; + const approvalStatus = + typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0 + ? context.approvalStatus.trim() + : null; + const linkedIssueIds = Array.isArray(context.issueIds) + ? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0) + : []; + + if (wakeTaskId) { + env.AGENT_COMPANY_TASK_ID = wakeTaskId; + } + if (wakeReason) { + env.AGENT_COMPANY_WAKE_REASON = wakeReason; + } + if (wakeCommentId) { + env.AGENT_COMPANY_WAKE_COMMENT_ID = wakeCommentId; + } + if (approvalId) { + env.AGENT_COMPANY_APPROVAL_ID = approvalId; + } + if (approvalStatus) { + env.AGENT_COMPANY_APPROVAL_STATUS = approvalStatus; + } + if (linkedIssueIds.length > 0) { + env.AGENT_COMPANY_LINKED_ISSUE_IDS = linkedIssueIds.join(","); + } + if (effectiveWorkspaceCwd) { + env.AGENT_COMPANY_WORKSPACE_CWD = effectiveWorkspaceCwd; + } + if (workspaceSource) { + env.AGENT_COMPANY_WORKSPACE_SOURCE = workspaceSource; + } + if (workspaceStrategy) { + env.AGENT_COMPANY_WORKSPACE_STRATEGY = workspaceStrategy; + } + if (workspaceId) { + env.AGENT_COMPANY_WORKSPACE_ID = workspaceId; + } + if (workspaceRepoUrl) { + env.AGENT_COMPANY_WORKSPACE_REPO_URL = workspaceRepoUrl; + } + if (workspaceRepoRef) { + env.AGENT_COMPANY_WORKSPACE_REPO_REF = workspaceRepoRef; + } + if (workspaceBranch) { + env.AGENT_COMPANY_WORKSPACE_BRANCH = workspaceBranch; + } + if (workspaceWorktreePath) { + env.AGENT_COMPANY_WORKSPACE_WORKTREE_PATH = workspaceWorktreePath; + } + if (workspaceHints.length > 0) { + env.AGENT_COMPANY_WORKSPACES_JSON = JSON.stringify(workspaceHints); + } + if (runtimeServiceIntents.length > 0) { + env.AGENT_COMPANY_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents); + } + if (runtimeServices.length > 0) { + env.AGENT_COMPANY_RUNTIME_SERVICES_JSON = JSON.stringify(runtimeServices); + } + if (runtimePrimaryUrl) { + env.AGENT_COMPANY_RUNTIME_PRIMARY_URL = runtimePrimaryUrl; + } + + for (const [key, value] of Object.entries(envConfig)) { + if (typeof value === "string") env[key] = value; + } + + if (!hasExplicitApiKey && authToken) { + env.AGENT_COMPANY_API_KEY = authToken; + } + + const runtimeEnv = ensurePathInEnv(env); + await ensureCommandResolvable(command, cwd, runtimeEnv); + + const timeoutSec = asNumber(config.timeoutSec, 0); + const graceSec = asNumber(config.graceSec, 20); + const extraArgs = (() => { + const fromExtraArgs = asStringArray(config.extraArgs); + if (fromExtraArgs.length > 0) return fromExtraArgs; + return asStringArray(config.args); + })(); + + return { + command, + cwd, + workspaceId, + workspaceRepoUrl, + workspaceRepoRef, + env, + timeoutSec, + graceSec, + extraArgs, + }; +} diff --git a/packages/adapter-claude-local/src/server/execute.ts b/packages/adapter-claude-local/src/server/execute.ts index 1e5fe2db..fbdb989c 100644 --- a/packages/adapter-claude-local/src/server/execute.ts +++ b/packages/adapter-claude-local/src/server/execute.ts @@ -1,7 +1,5 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import type { AdapterExecutionContext, AdapterExecutionResult } from "@agentcompany/adapter-utils"; import type { RunProcessResult } from "@agentcompany/adapter-utils/server-utils"; import type { ManagedSkillRef } from "@agentcompany/adapter-utils"; @@ -9,18 +7,12 @@ import { asString, asNumber, asBoolean, - asStringArray, parseObject, parseJson, - buildAgentCompanyEnv, redactEnvForLogs, - ensureAbsoluteDirectory, - ensureCommandResolvable, - ensurePathInEnv, filterConflictingArgs, renderTemplate, runChildProcess, - createManagedSkillSymlinks, } from "@agentcompany/adapter-utils/server-utils"; import { parseClaudeStreamJson, @@ -29,250 +21,12 @@ import { isClaudeMaxTurnsResult, isClaudeUnknownSessionError, } from "./parse.js"; - -const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); -const SKILLS_CANDIDATES = [ - path.resolve(__moduleDir, "../../skills"), // published: /dist/server/ -> /skills/ - path.resolve(__moduleDir, "../../../../../skills"), // dev: src/server/ -> repo root/skills/ -]; - -async function resolveAgentCompanySkillsDir(): Promise { - for (const candidate of SKILLS_CANDIDATES) { - const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false); - if (isDir) return candidate; - } - return null; -} - -/** - * Create a tmpdir with `.claude/skills/` containing symlinks to skills from - * managed skill entries (preferred) or the repo's static `skills/` directory - * (backward compatibility fallback), so `--add-dir` makes Claude Code discover - * them as proper registered skills. - */ -async function buildSkillsDir( - managedSkills: ManagedSkillRef[], - onLog: (stream: "stdout" | "stderr", chunk: string) => Promise, -): Promise { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "agent-company-skills-")); - const target = path.join(tmp, ".claude", "skills"); - await fs.mkdir(target, { recursive: true }); - - // Use managed skills when available; fall back to static repo scan - if (managedSkills.length > 0) { - await createManagedSkillSymlinks(managedSkills, target, onLog); - return tmp; - } - - const skillsDir = await resolveAgentCompanySkillsDir(); - if (!skillsDir) return tmp; - const entries = await fs.readdir(skillsDir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isDirectory()) { - await fs.symlink( - path.join(skillsDir, entry.name), - path.join(target, entry.name), - ); - } - } - return tmp; -} - -interface ClaudeExecutionInput { - runId: string; - agent: AdapterExecutionContext["agent"]; - config: Record; - context: Record; - authToken?: string; -} - -interface ClaudeRuntimeConfig { - command: string; - cwd: string; - workspaceId: string | null; - workspaceRepoUrl: string | null; - workspaceRepoRef: string | null; - env: Record; - timeoutSec: number; - graceSec: number; - extraArgs: string[]; -} - -function buildLoginResult(input: { - proc: RunProcessResult; - loginUrl: string | null; -}) { - return { - exitCode: input.proc.exitCode, - signal: input.proc.signal, - timedOut: input.proc.timedOut, - stdout: input.proc.stdout, - stderr: input.proc.stderr, - loginUrl: input.loginUrl, - }; -} - -function hasNonEmptyEnvValue(env: Record, key: string): boolean { - const raw = env[key]; - return typeof raw === "string" && raw.trim().length > 0; -} - -function resolveClaudeBillingType(env: Record): "api" | "subscription" { - // Claude uses API-key auth when ANTHROPIC_API_KEY is present; otherwise rely on local login/session auth. - return hasNonEmptyEnvValue(env, "ANTHROPIC_API_KEY") ? "api" : "subscription"; -} - -async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise { - const { runId, agent, config, context, authToken } = input; - - const command = asString(config.command, "claude"); - const workspaceContext = parseObject(context.agentCompanyWorkspace); - const workspaceCwd = asString(workspaceContext.cwd, ""); - const workspaceSource = asString(workspaceContext.source, ""); - const workspaceStrategy = asString(workspaceContext.strategy, ""); - const workspaceId = asString(workspaceContext.workspaceId, "") || null; - const workspaceRepoUrl = asString(workspaceContext.repoUrl, "") || null; - const workspaceRepoRef = asString(workspaceContext.repoRef, "") || null; - const workspaceBranch = asString(workspaceContext.branchName, "") || null; - const workspaceWorktreePath = asString(workspaceContext.worktreePath, "") || null; - const workspaceHints = Array.isArray(context.agentCompanyWorkspaces) - ? context.agentCompanyWorkspaces.filter( - (value): value is Record => typeof value === "object" && value !== null, - ) - : []; - const runtimeServiceIntents = Array.isArray(context.agentCompanyRuntimeServiceIntents) - ? context.agentCompanyRuntimeServiceIntents.filter( - (value): value is Record => typeof value === "object" && value !== null, - ) - : []; - const runtimeServices = Array.isArray(context.agentCompanyRuntimeServices) - ? context.agentCompanyRuntimeServices.filter( - (value): value is Record => typeof value === "object" && value !== null, - ) - : []; - const runtimePrimaryUrl = asString(context.agentCompanyRuntimePrimaryUrl, ""); - const configuredCwd = asString(config.cwd, ""); - const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0; - const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; - const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd(); - await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); - - const envConfig = parseObject(config.env); - const hasExplicitApiKey = - typeof envConfig.AGENT_COMPANY_API_KEY === "string" && envConfig.AGENT_COMPANY_API_KEY.trim().length > 0; - const env: Record = { ...buildAgentCompanyEnv(agent) }; - env.AGENT_COMPANY_RUN_ID = runId; - - const wakeTaskId = - (typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) || - (typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) || - null; - const wakeReason = - typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0 - ? context.wakeReason.trim() - : null; - const wakeCommentId = - (typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) || - (typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) || - null; - const approvalId = - typeof context.approvalId === "string" && context.approvalId.trim().length > 0 - ? context.approvalId.trim() - : null; - const approvalStatus = - typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0 - ? context.approvalStatus.trim() - : null; - const linkedIssueIds = Array.isArray(context.issueIds) - ? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0) - : []; - - if (wakeTaskId) { - env.AGENT_COMPANY_TASK_ID = wakeTaskId; - } - if (wakeReason) { - env.AGENT_COMPANY_WAKE_REASON = wakeReason; - } - if (wakeCommentId) { - env.AGENT_COMPANY_WAKE_COMMENT_ID = wakeCommentId; - } - if (approvalId) { - env.AGENT_COMPANY_APPROVAL_ID = approvalId; - } - if (approvalStatus) { - env.AGENT_COMPANY_APPROVAL_STATUS = approvalStatus; - } - if (linkedIssueIds.length > 0) { - env.AGENT_COMPANY_LINKED_ISSUE_IDS = linkedIssueIds.join(","); - } - if (effectiveWorkspaceCwd) { - env.AGENT_COMPANY_WORKSPACE_CWD = effectiveWorkspaceCwd; - } - if (workspaceSource) { - env.AGENT_COMPANY_WORKSPACE_SOURCE = workspaceSource; - } - if (workspaceStrategy) { - env.AGENT_COMPANY_WORKSPACE_STRATEGY = workspaceStrategy; - } - if (workspaceId) { - env.AGENT_COMPANY_WORKSPACE_ID = workspaceId; - } - if (workspaceRepoUrl) { - env.AGENT_COMPANY_WORKSPACE_REPO_URL = workspaceRepoUrl; - } - if (workspaceRepoRef) { - env.AGENT_COMPANY_WORKSPACE_REPO_REF = workspaceRepoRef; - } - if (workspaceBranch) { - env.AGENT_COMPANY_WORKSPACE_BRANCH = workspaceBranch; - } - if (workspaceWorktreePath) { - env.AGENT_COMPANY_WORKSPACE_WORKTREE_PATH = workspaceWorktreePath; - } - if (workspaceHints.length > 0) { - env.AGENT_COMPANY_WORKSPACES_JSON = JSON.stringify(workspaceHints); - } - if (runtimeServiceIntents.length > 0) { - env.AGENT_COMPANY_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents); - } - if (runtimeServices.length > 0) { - env.AGENT_COMPANY_RUNTIME_SERVICES_JSON = JSON.stringify(runtimeServices); - } - if (runtimePrimaryUrl) { - env.AGENT_COMPANY_RUNTIME_PRIMARY_URL = runtimePrimaryUrl; - } - - for (const [key, value] of Object.entries(envConfig)) { - if (typeof value === "string") env[key] = value; - } - - if (!hasExplicitApiKey && authToken) { - env.AGENT_COMPANY_API_KEY = authToken; - } - - const runtimeEnv = ensurePathInEnv(env); - await ensureCommandResolvable(command, cwd, runtimeEnv); - - const timeoutSec = asNumber(config.timeoutSec, 0); - const graceSec = asNumber(config.graceSec, 20); - const extraArgs = (() => { - const fromExtraArgs = asStringArray(config.extraArgs); - if (fromExtraArgs.length > 0) return fromExtraArgs; - return asStringArray(config.args); - })(); - - return { - command, - cwd, - workspaceId, - workspaceRepoUrl, - workspaceRepoRef, - env, - timeoutSec, - graceSec, - extraArgs, - }; -} +import { + buildSkillsDir, + buildLoginResult, + resolveClaudeBillingType, + buildClaudeRuntimeConfig, +} from "./execute-helpers.js"; export async function runClaudeLogin(input: { runId: string; diff --git a/packages/adapter-codex-local/src/server/execute-helpers.ts b/packages/adapter-codex-local/src/server/execute-helpers.ts new file mode 100644 index 00000000..dba277fb --- /dev/null +++ b/packages/adapter-codex-local/src/server/execute-helpers.ts @@ -0,0 +1,95 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { AdapterExecutionContext, ManagedSkillRef } from "@agentcompany/adapter-utils"; +import { + createManagedSkillSymlinks, +} from "@agentcompany/adapter-utils/server-utils"; + +const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); +export const SKILLS_CANDIDATES = [ + path.resolve(__moduleDir, "../../skills"), // published: /dist/server/ -> /skills/ + path.resolve(__moduleDir, "../../../../../skills"), // dev: src/server/ -> repo root/skills/ +]; + +export const CODEX_ROLLOUT_NOISE_RE = + /^\d{4}-\d{2}-\d{2}T[^\s]+\s+ERROR\s+codex_core::rollout::list:\s+state db missing rollout path for thread\s+[a-z0-9-]+$/i; + +export function stripCodexRolloutNoise(text: string): string { + const parts = text.split(/\r?\n/); + const kept: string[] = []; + for (const part of parts) { + const trimmed = part.trim(); + if (!trimmed) { + kept.push(part); + continue; + } + if (CODEX_ROLLOUT_NOISE_RE.test(trimmed)) continue; + kept.push(part); + } + return kept.join("\n"); +} + +export function hasNonEmptyEnvValue(env: Record, key: string): boolean { + const raw = env[key]; + return typeof raw === "string" && raw.trim().length > 0; +} + +export function resolveCodexBillingType(env: Record): "api" | "subscription" { + // Codex uses API-key auth when OPENAI_API_KEY is present; otherwise rely on local login/session auth. + return hasNonEmptyEnvValue(env, "OPENAI_API_KEY") ? "api" : "subscription"; +} + +export function codexHomeDir(): string { + const fromEnv = process.env.CODEX_HOME; + if (typeof fromEnv === "string" && fromEnv.trim().length > 0) return fromEnv.trim(); + return path.join(os.homedir(), ".codex"); +} + +export async function resolveAgentCompanySkillsDir(): Promise { + for (const candidate of SKILLS_CANDIDATES) { + const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false); + if (isDir) return candidate; + } + return null; +} + +export async function ensureCodexSkillsInjected( + managedSkills: ManagedSkillRef[], + onLog: AdapterExecutionContext["onLog"], +) { + const skillsHome = path.join(codexHomeDir(), "skills"); + + // Use managed skills when available; fall back to static repo scan + if (managedSkills.length > 0) { + await createManagedSkillSymlinks(managedSkills, skillsHome, onLog); + return; + } + + const skillsDir = await resolveAgentCompanySkillsDir(); + if (!skillsDir) return; + + await fs.mkdir(skillsHome, { recursive: true }); + const entries = await fs.readdir(skillsDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const source = path.join(skillsDir, entry.name); + const target = path.join(skillsHome, entry.name); + const existing = await fs.lstat(target).catch(() => null); + if (existing) continue; + + try { + await fs.symlink(source, target); + await onLog( + "stderr", + `[agent-company] Injected Codex skill "${entry.name}" into ${skillsHome}\n`, + ); + } catch (err) { + await onLog( + "stderr", + `[agent-company] Failed to inject Codex skill "${entry.name}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`, + ); + } + } +} diff --git a/packages/adapter-codex-local/src/server/execute.ts b/packages/adapter-codex-local/src/server/execute.ts index edbcdff8..606b9798 100644 --- a/packages/adapter-codex-local/src/server/execute.ts +++ b/packages/adapter-codex-local/src/server/execute.ts @@ -1,7 +1,5 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import type { AdapterExecutionContext, AdapterExecutionResult, ManagedSkillRef } from "@agentcompany/adapter-utils"; import { asString, @@ -17,96 +15,14 @@ import { filterConflictingArgs, renderTemplate, runChildProcess, - createManagedSkillSymlinks, } from "@agentcompany/adapter-utils/server-utils"; import { firstNonEmptyLine } from "@agentcompany/adapter-utils/test-utils"; import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js"; - -const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); -const SKILLS_CANDIDATES = [ - path.resolve(__moduleDir, "../../skills"), // published: /dist/server/ -> /skills/ - path.resolve(__moduleDir, "../../../../../skills"), // dev: src/server/ -> repo root/skills/ -]; -const CODEX_ROLLOUT_NOISE_RE = - /^\d{4}-\d{2}-\d{2}T[^\s]+\s+ERROR\s+codex_core::rollout::list:\s+state db missing rollout path for thread\s+[a-z0-9-]+$/i; - -function stripCodexRolloutNoise(text: string): string { - const parts = text.split(/\r?\n/); - const kept: string[] = []; - for (const part of parts) { - const trimmed = part.trim(); - if (!trimmed) { - kept.push(part); - continue; - } - if (CODEX_ROLLOUT_NOISE_RE.test(trimmed)) continue; - kept.push(part); - } - return kept.join("\n"); -} - -function hasNonEmptyEnvValue(env: Record, key: string): boolean { - const raw = env[key]; - return typeof raw === "string" && raw.trim().length > 0; -} - -function resolveCodexBillingType(env: Record): "api" | "subscription" { - // Codex uses API-key auth when OPENAI_API_KEY is present; otherwise rely on local login/session auth. - return hasNonEmptyEnvValue(env, "OPENAI_API_KEY") ? "api" : "subscription"; -} - -function codexHomeDir(): string { - const fromEnv = process.env.CODEX_HOME; - if (typeof fromEnv === "string" && fromEnv.trim().length > 0) return fromEnv.trim(); - return path.join(os.homedir(), ".codex"); -} - -async function resolveAgentCompanySkillsDir(): Promise { - for (const candidate of SKILLS_CANDIDATES) { - const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false); - if (isDir) return candidate; - } - return null; -} - -async function ensureCodexSkillsInjected( - managedSkills: ManagedSkillRef[], - onLog: AdapterExecutionContext["onLog"], -) { - const skillsHome = path.join(codexHomeDir(), "skills"); - - // Use managed skills when available; fall back to static repo scan - if (managedSkills.length > 0) { - await createManagedSkillSymlinks(managedSkills, skillsHome, onLog); - return; - } - - const skillsDir = await resolveAgentCompanySkillsDir(); - if (!skillsDir) return; - - await fs.mkdir(skillsHome, { recursive: true }); - const entries = await fs.readdir(skillsDir, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const source = path.join(skillsDir, entry.name); - const target = path.join(skillsHome, entry.name); - const existing = await fs.lstat(target).catch(() => null); - if (existing) continue; - - try { - await fs.symlink(source, target); - await onLog( - "stderr", - `[agent-company] Injected Codex skill "${entry.name}" into ${skillsHome}\n`, - ); - } catch (err) { - await onLog( - "stderr", - `[agent-company] Failed to inject Codex skill "${entry.name}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`, - ); - } - } -} +import { + stripCodexRolloutNoise, + resolveCodexBillingType, + ensureCodexSkillsInjected, +} from "./execute-helpers.js"; export async function execute(ctx: AdapterExecutionContext): Promise { const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx; diff --git a/packages/adapter-gemini-local/src/server/execute-helpers.ts b/packages/adapter-gemini-local/src/server/execute-helpers.ts new file mode 100644 index 00000000..1df96bab --- /dev/null +++ b/packages/adapter-gemini-local/src/server/execute-helpers.ts @@ -0,0 +1,125 @@ +import fs from "node:fs/promises"; +import type { Dirent } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { AdapterExecutionContext, ManagedSkillRef } from "@agentcompany/adapter-utils"; +import { + createManagedSkillSymlinks, +} from "@agentcompany/adapter-utils/server-utils"; + +const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); +export const SKILLS_CANDIDATES = [ + path.resolve(__moduleDir, "../../skills"), + path.resolve(__moduleDir, "../../../../../skills"), +]; + +export function hasNonEmptyEnvValue(env: Record, key: string): boolean { + const raw = env[key]; + return typeof raw === "string" && raw.trim().length > 0; +} + +export function resolveGeminiBillingType(env: Record): "api" | "subscription" { + return hasNonEmptyEnvValue(env, "GEMINI_API_KEY") || hasNonEmptyEnvValue(env, "GOOGLE_API_KEY") + ? "api" + : "subscription"; +} + +export function renderEnvNote(env: Record): string { + const agentCompanyKeys = Object.keys(env) + .filter((key) => key.startsWith("AGENT_COMPANY_")) + .sort(); + if (agentCompanyKeys.length === 0) return ""; + return [ + "Runtime note:", + `The following AGENT_COMPANY_* environment variables are available in this run: ${agentCompanyKeys.join(", ")}`, + "Do not assume these variables are missing without checking your shell environment.", + "", + "", + ].join("\n"); +} + +export function renderApiAccessNote(env: Record): string { + if (!hasNonEmptyEnvValue(env, "AGENT_COMPANY_API_URL") || !hasNonEmptyEnvValue(env, "AGENT_COMPANY_API_KEY")) return ""; + return [ + "API access note:", + "Use run_shell_command with curl to make API requests.", + "GET example:", + ` run_shell_command({ command: "curl -s -H \\"Authorization: Bearer $AGENT_COMPANY_API_KEY\\" \\"$AGENT_COMPANY_API_URL/api/agents/me\\"" })`, + "POST/PATCH example:", + ` run_shell_command({ command: "curl -s -X POST -H \\"Authorization: Bearer $AGENT_COMPANY_API_KEY\\" -H 'Content-Type: application/json' -H \\"X-Agent-Company-Run-Id: $AGENT_COMPANY_RUN_ID\\" -d '{...}' \\"$AGENT_COMPANY_API_URL/api/issues/{id}/checkout\\"" })`, + "", + "", + ].join("\n"); +} + +export async function resolveAgentCompanySkillsDir(): Promise { + for (const candidate of SKILLS_CANDIDATES) { + const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false); + if (isDir) return candidate; + } + return null; +} + +export function geminiSkillsHome(): string { + return path.join(os.homedir(), ".gemini", "skills"); +} + +/** + * Inject AgentCompany skills directly into `~/.gemini/skills/` via symlinks. + * Uses managed skills when available; falls back to static repo scan. + */ +export async function ensureGeminiSkillsInjected( + managedSkills: ManagedSkillRef[], + onLog: AdapterExecutionContext["onLog"], +): Promise { + const skillsHome = geminiSkillsHome(); + + // Use managed skills when available; fall back to static repo scan + if (managedSkills.length > 0) { + await createManagedSkillSymlinks(managedSkills, skillsHome, onLog); + return; + } + + const skillsDir = await resolveAgentCompanySkillsDir(); + if (!skillsDir) return; + + try { + await fs.mkdir(skillsHome, { recursive: true }); + } catch (err) { + await onLog( + "stderr", + `[agent-company] Failed to prepare Gemini skills directory ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`, + ); + return; + } + + let entries: Dirent[]; + try { + entries = await fs.readdir(skillsDir, { withFileTypes: true }); + } catch (err) { + await onLog( + "stderr", + `[agent-company] Failed to read AgentCompany skills from ${skillsDir}: ${err instanceof Error ? err.message : String(err)}\n`, + ); + return; + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const source = path.join(skillsDir, entry.name); + const target = path.join(skillsHome, entry.name); + const existing = await fs.lstat(target).catch(() => null); + if (existing) continue; + + try { + await fs.symlink(source, target); + await onLog("stderr", `[agent-company] Linked Gemini skill: ${entry.name}\n`); + } catch (err) { + await onLog( + "stderr", + `[agent-company] Failed to link Gemini skill "${entry.name}": ${err instanceof Error ? err.message : String(err)}\n`, + ); + } + } +} diff --git a/packages/adapter-gemini-local/src/server/execute.ts b/packages/adapter-gemini-local/src/server/execute.ts index 5d4b1271..e98489df 100644 --- a/packages/adapter-gemini-local/src/server/execute.ts +++ b/packages/adapter-gemini-local/src/server/execute.ts @@ -1,8 +1,6 @@ import fs from "node:fs/promises"; -import type { Dirent } from "node:fs"; import os from "node:os"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import type { AdapterExecutionContext, AdapterExecutionResult, ManagedSkillRef } from "@agentcompany/adapter-utils"; import { asBoolean, @@ -18,7 +16,6 @@ import { redactEnvForLogs, renderTemplate, runChildProcess, - createManagedSkillSymlinks, } from "@agentcompany/adapter-utils/server-utils"; import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js"; import { @@ -29,122 +26,12 @@ import { parseGeminiJsonl, } from "./parse.js"; import { firstNonEmptyLine } from "@agentcompany/adapter-utils/test-utils"; - -const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); -const SKILLS_CANDIDATES = [ - path.resolve(__moduleDir, "../../skills"), - path.resolve(__moduleDir, "../../../../../skills"), -]; - -function hasNonEmptyEnvValue(env: Record, key: string): boolean { - const raw = env[key]; - return typeof raw === "string" && raw.trim().length > 0; -} - -function resolveGeminiBillingType(env: Record): "api" | "subscription" { - return hasNonEmptyEnvValue(env, "GEMINI_API_KEY") || hasNonEmptyEnvValue(env, "GOOGLE_API_KEY") - ? "api" - : "subscription"; -} - -function renderEnvNote(env: Record): string { - const agentCompanyKeys = Object.keys(env) - .filter((key) => key.startsWith("AGENT_COMPANY_")) - .sort(); - if (agentCompanyKeys.length === 0) return ""; - return [ - "Runtime note:", - `The following AGENT_COMPANY_* environment variables are available in this run: ${agentCompanyKeys.join(", ")}`, - "Do not assume these variables are missing without checking your shell environment.", - "", - "", - ].join("\n"); -} - -function renderApiAccessNote(env: Record): string { - if (!hasNonEmptyEnvValue(env, "AGENT_COMPANY_API_URL") || !hasNonEmptyEnvValue(env, "AGENT_COMPANY_API_KEY")) return ""; - return [ - "API access note:", - "Use run_shell_command with curl to make API requests.", - "GET example:", - ` run_shell_command({ command: "curl -s -H \\"Authorization: Bearer $AGENT_COMPANY_API_KEY\\" \\"$AGENT_COMPANY_API_URL/api/agents/me\\"" })`, - "POST/PATCH example:", - ` run_shell_command({ command: "curl -s -X POST -H \\"Authorization: Bearer $AGENT_COMPANY_API_KEY\\" -H 'Content-Type: application/json' -H \\"X-Agent-Company-Run-Id: $AGENT_COMPANY_RUN_ID\\" -d '{...}' \\"$AGENT_COMPANY_API_URL/api/issues/{id}/checkout\\"" })`, - "", - "", - ].join("\n"); -} - -async function resolveAgentCompanySkillsDir(): Promise { - for (const candidate of SKILLS_CANDIDATES) { - const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false); - if (isDir) return candidate; - } - return null; -} - -function geminiSkillsHome(): string { - return path.join(os.homedir(), ".gemini", "skills"); -} - -/** - * Inject AgentCompany skills directly into `~/.gemini/skills/` via symlinks. - * Uses managed skills when available; falls back to static repo scan. - */ -async function ensureGeminiSkillsInjected( - managedSkills: ManagedSkillRef[], - onLog: AdapterExecutionContext["onLog"], -): Promise { - const skillsHome = geminiSkillsHome(); - - // Use managed skills when available; fall back to static repo scan - if (managedSkills.length > 0) { - await createManagedSkillSymlinks(managedSkills, skillsHome, onLog); - return; - } - - const skillsDir = await resolveAgentCompanySkillsDir(); - if (!skillsDir) return; - - try { - await fs.mkdir(skillsHome, { recursive: true }); - } catch (err) { - await onLog( - "stderr", - `[agent-company] Failed to prepare Gemini skills directory ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`, - ); - return; - } - - let entries: Dirent[]; - try { - entries = await fs.readdir(skillsDir, { withFileTypes: true }); - } catch (err) { - await onLog( - "stderr", - `[agent-company] Failed to read AgentCompany skills from ${skillsDir}: ${err instanceof Error ? err.message : String(err)}\n`, - ); - return; - } - - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const source = path.join(skillsDir, entry.name); - const target = path.join(skillsHome, entry.name); - const existing = await fs.lstat(target).catch(() => null); - if (existing) continue; - - try { - await fs.symlink(source, target); - await onLog("stderr", `[agent-company] Linked Gemini skill: ${entry.name}\n`); - } catch (err) { - await onLog( - "stderr", - `[agent-company] Failed to link Gemini skill "${entry.name}": ${err instanceof Error ? err.message : String(err)}\n`, - ); - } - } -} +import { + resolveGeminiBillingType, + renderEnvNote, + renderApiAccessNote, + ensureGeminiSkillsInjected, +} from "./execute-helpers.js"; export async function execute(ctx: AdapterExecutionContext): Promise { const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx; @@ -275,25 +162,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise { - const notes: string[] = [useStdin - ? "Prompt passed via stdin (exceeds command-line length limit)." - : "Prompt is passed to Gemini as the final positional argument."]; - notes.push("Added --approval-mode yolo for unattended execution."); - if (!instructionsFilePath) return notes; - if (instructionsPrefix.length > 0) { - notes.push( - `Loaded agent instructions from ${instructionsFilePath}`, - `Prepended instructions + path directive to prompt (relative references from ${instructionsDir}).`, - ); - return notes; - } - notes.push( - `Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`, - ); - return notes; - })(); - const renderedPrompt = renderTemplate(promptTemplate, { agentId: agent.id, companyId: agent.companyId, @@ -313,6 +181,24 @@ export async function execute(ctx: AdapterExecutionContext): Promise SAFE_ARG_LIMIT; + const commandNotes = (() => { + const notes: string[] = [useStdin + ? "Prompt passed via stdin (exceeds command-line length limit)." + : "Prompt is passed to Gemini as the final positional argument."]; + notes.push("Added --approval-mode yolo for unattended execution."); + if (!instructionsFilePath) return notes; + if (instructionsPrefix.length > 0) { + notes.push( + `Loaded agent instructions from ${instructionsFilePath}`, + `Prepended instructions + path directive to prompt (relative references from ${instructionsDir}).`, + ); + return notes; + } + notes.push( + `Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`, + ); + return notes; + })(); let promptFilePath: string | null = null; if (useStdin) { promptFilePath = path.join(os.tmpdir(), `agentcompany-gemini-prompt-${runId}.txt`); diff --git a/packages/adapter-utils/src/server-utils-parsers.ts b/packages/adapter-utils/src/server-utils-parsers.ts new file mode 100644 index 00000000..3c3391d2 --- /dev/null +++ b/packages/adapter-utils/src/server-utils-parsers.ts @@ -0,0 +1,138 @@ +export const MAX_CAPTURE_BYTES = 4 * 1024 * 1024; +export const MAX_EXCERPT_BYTES = 32 * 1024; + +const SENSITIVE_ENV_KEY = /(key|token|secret|password|passwd|authorization|cookie)/i; + +export function parseObject(value: unknown): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return {}; + } + return value as Record; +} + +export function asString(value: unknown, fallback: string): string { + return typeof value === "string" && value.length > 0 ? value : fallback; +} + +export function asNumber(value: unknown, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +export function asBoolean(value: unknown, fallback: boolean): boolean { + return typeof value === "boolean" ? value : fallback; +} + +export function asStringArray(value: unknown): string[] { + return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; +} + +export function parseJson(value: string): Record | null { + try { + return JSON.parse(value) as Record; + } catch { + return null; + } +} + +export function appendWithCap(prev: string, chunk: string, cap = MAX_CAPTURE_BYTES) { + const combined = prev + chunk; + return combined.length > cap ? combined.slice(combined.length - cap) : combined; +} + +export function resolvePathValue(obj: Record, dottedPath: string) { + const parts = dottedPath.split("."); + let cursor: unknown = obj; + + for (const part of parts) { + if (typeof cursor !== "object" || cursor === null || Array.isArray(cursor)) { + return ""; + } + cursor = (cursor as Record)[part]; + } + + if (cursor === null || cursor === undefined) return ""; + if (typeof cursor === "string") return cursor; + if (typeof cursor === "number" || typeof cursor === "boolean") return String(cursor); + + try { + return JSON.stringify(cursor); + } catch { + return ""; + } +} + +export function renderTemplate(template: string, data: Record) { + return template.replace(/{{\s*([a-zA-Z0-9_.-]+)\s*}}/g, (_, path) => resolvePathValue(data, path)); +} + +/** + * Remove flags from `extraArgs` that the adapter will already add itself. + * Handles both `--flag value` pairs and `--flag=value` forms. + * This prevents duplicate/conflicting CLI arguments when users have shell + * aliases or wrapper functions that inject the same flags. + */ +export function filterConflictingArgs(extraArgs: string[], adapterFlags: string[]): string[] { + const flagSet = new Set(adapterFlags.map((f) => f.replace(/=.*$/, ""))); + const filtered: string[] = []; + for (let i = 0; i < extraArgs.length; i++) { + const arg = extraArgs[i]; + const bareFlag = arg.replace(/=.*$/, ""); + if (flagSet.has(bareFlag)) { + // Skip this flag and its value if the value is a separate argument + if (!arg.includes("=") && i + 1 < extraArgs.length && !extraArgs[i + 1].startsWith("-")) { + i++; // skip the value argument too + } + continue; + } + filtered.push(arg); + } + return filtered; +} + +export function redactEnvForLogs(env: Record): Record { + const redacted: Record = {}; + for (const [key, value] of Object.entries(env)) { + redacted[key] = SENSITIVE_ENV_KEY.test(key) ? "***REDACTED***" : value; + } + return redacted; +} + +export function buildAgentCompanyEnv(agent: { id: string; companyId: string }): Record { + const resolveHostForUrl = (rawHost: string): string => { + const host = rawHost.trim(); + if (!host || host === "0.0.0.0" || host === "::") return "localhost"; + if (host.includes(":") && !host.startsWith("[") && !host.endsWith("]")) return `[${host}]`; + return host; + }; + const vars: Record = { + AGENT_COMPANY_AGENT_ID: agent.id, + AGENT_COMPANY_COMPANY_ID: agent.companyId, + }; + const runtimeHost = resolveHostForUrl( + process.env.AGENT_COMPANY_LISTEN_HOST ?? process.env.HOST ?? "localhost", + ); + const runtimePort = process.env.AGENT_COMPANY_LISTEN_PORT ?? process.env.PORT ?? "3100"; + const apiUrl = process.env.AGENT_COMPANY_API_URL ?? `http://${runtimeHost}:${runtimePort}`; + vars.AGENT_COMPANY_API_URL = apiUrl; + return vars; +} + +export function defaultPathForPlatform() { + if (process.platform === "win32") { + const home = process.env.USERPROFILE ?? process.env.HOME ?? ""; + const appData = process.env.APPDATA ?? (home ? `${home}\\AppData\\Roaming` : ""); + const localAppData = process.env.LOCALAPPDATA ?? (home ? `${home}\\AppData\\Local` : ""); + const parts = [ + "C:\\Windows\\System32", + "C:\\Windows", + "C:\\Windows\\System32\\Wbem", + appData ? `${appData}\\npm` : "", + localAppData ? `${localAppData}\\pnpm` : "", + home ? `${home}\\.local\\bin` : "", + home ? `${home}\\.cargo\\bin` : "", + "C:\\Program Files\\nodejs", + ].filter(Boolean); + return parts.join(";"); + } + return "/usr/local/bin:/opt/homebrew/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin"; +} diff --git a/packages/adapter-utils/src/server-utils-process.ts b/packages/adapter-utils/src/server-utils-process.ts new file mode 100644 index 00000000..05dec7e3 --- /dev/null +++ b/packages/adapter-utils/src/server-utils-process.ts @@ -0,0 +1,380 @@ +import { spawn, execSync, type ChildProcess } from "node:child_process"; +import { constants as fsConstants, promises as fs } from "node:fs"; +import path from "node:path"; +import { StringDecoder } from "node:string_decoder"; +import { appendWithCap, defaultPathForPlatform } from "./server-utils-parsers.js"; + +/** + * Decode a Buffer as GBK (CP936). Used as a fallback when UTF-8 decoding + * produces replacement characters on Chinese Windows systems where the + * system ANSI code page is GBK and chcp 65001 is ineffective for piped I/O. + */ +function decodeGbkBuffer(buf: Buffer): string { + try { + return new TextDecoder("gbk").decode(buf); + } catch { + return buf.toString("utf8"); + } +} + +export interface RunProcessResult { + exitCode: number | null; + signal: string | null; + timedOut: boolean; + stdout: string; + stderr: string; +} + +interface RunningProcess { + child: ChildProcess; + graceSec: number; +} + +interface SpawnTarget { + command: string; + args: string[]; +} + +type ChildProcessWithEvents = ChildProcess & { + on(event: "error", listener: (err: Error) => void): ChildProcess; + on( + event: "close", + listener: (code: number | null, signal: NodeJS.Signals | null) => void, + ): ChildProcess; +}; + +export const runningProcesses = new Map(); + +export function normalizeCommandValue(command: string): string { + let normalized = command.trim(); + while (normalized.length >= 2) { + const quote = normalized[0]; + if ((quote !== '"' && quote !== "'") || normalized.at(-1) !== quote) { + break; + } + normalized = normalized.slice(1, -1).trim(); + } + return normalized; +} + +function windowsPathExts(env: NodeJS.ProcessEnv): string[] { + return (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean); +} + +async function pathExists(candidate: string) { + try { + await fs.access(candidate, process.platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK); + return true; + } catch { + return false; + } +} + +async function resolveCommandPath(command: string, cwd: string, env: NodeJS.ProcessEnv): Promise { + const normalizedCommand = normalizeCommandValue(command); + const hasPathSeparator = normalizedCommand.includes("/") || normalizedCommand.includes("\\"); + if (hasPathSeparator) { + const absolute = path.isAbsolute(normalizedCommand) ? normalizedCommand : path.resolve(cwd, normalizedCommand); + return (await pathExists(absolute)) ? absolute : null; + } + + const pathValue = env.PATH ?? env.Path ?? ""; + const delimiter = process.platform === "win32" ? ";" : ":"; + const dirs = pathValue.split(delimiter).filter(Boolean); + const exts = process.platform === "win32" ? windowsPathExts(env) : [""]; + const hasExtension = process.platform === "win32" && path.extname(command).length > 0; + + for (const dir of dirs) { + const candidates = + process.platform === "win32" + ? hasExtension + ? [path.join(dir, normalizedCommand)] + : exts.map((ext) => path.join(dir, `${normalizedCommand}${ext}`)) + : [path.join(dir, normalizedCommand)]; + for (const candidate of candidates) { + if (await pathExists(candidate)) return candidate; + } + } + + return null; +} + +function quoteForCmd(arg: string) { + if (!arg.length) return '""'; + const escaped = arg.replace(/"/g, '""'); + return /[\s"&<>|^()]/.test(escaped) ? `"${escaped}"` : escaped; +} + +async function resolveSpawnTarget( + command: string, + args: string[], + cwd: string, + env: NodeJS.ProcessEnv, +): Promise { + const normalizedCommand = normalizeCommandValue(command); + const resolved = await resolveCommandPath(normalizedCommand, cwd, env); + const executable = resolved ?? normalizedCommand; + + if (process.platform !== "win32") { + return { command: executable, args }; + } + + // On Windows, ALL spawned processes need UTF-8 code page (65001) to prevent + // Chinese/CJK character corruption. The default Windows code page (e.g. 936/GBK) + // corrupts multi-byte UTF-8 characters passed through cmd.exe, curl, etc. + const shell = env.ComSpec || process.env.ComSpec || "cmd.exe"; + const commandLine = [quoteForCmd(executable), ...args.map(quoteForCmd)].join(" "); + return { + command: shell, + args: ["/d", "/s", "/c", `chcp 65001 >nul && ${commandLine}`], + }; +} + +export function ensurePathInEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + if (typeof env.PATH === "string" && env.PATH.length > 0) return env; + if (typeof env.Path === "string" && env.Path.length > 0) return env; + return { ...env, PATH: defaultPathForPlatform() }; +} + +export async function ensureAbsoluteDirectory( + cwd: string, + opts: { createIfMissing?: boolean } = {}, +) { + if (!path.isAbsolute(cwd)) { + throw new Error(`Working directory must be an absolute path: "${cwd}"`); + } + + const assertDirectory = async () => { + const stats = await fs.stat(cwd); + if (!stats.isDirectory()) { + throw new Error(`Working directory is not a directory: "${cwd}"`); + } + }; + + try { + await assertDirectory(); + return; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (!opts.createIfMissing || code !== "ENOENT") { + if (code === "ENOENT") { + throw new Error(`Working directory does not exist: "${cwd}"`); + } + throw err instanceof Error ? err : new Error(String(err)); + } + } + + try { + await fs.mkdir(cwd, { recursive: true }); + await assertDirectory(); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + throw new Error(`Could not create working directory "${cwd}": ${reason}`); + } +} + +export async function ensureCommandResolvable(command: string, cwd: string, env: NodeJS.ProcessEnv) { + const normalizedCommand = normalizeCommandValue(command); + const resolved = await resolveCommandPath(normalizedCommand, cwd, env); + if (resolved) return; + if (normalizedCommand.includes("/") || normalizedCommand.includes("\\")) { + const absolute = path.isAbsolute(normalizedCommand) ? normalizedCommand : path.resolve(cwd, normalizedCommand); + throw new Error(`Command is not executable: "${command}" (resolved: "${absolute}")`); + } + throw new Error(`Command not found in PATH: "${command}"`); +} + +export async function runChildProcess( + runId: string, + command: string, + args: string[], + opts: { + cwd: string; + env: Record; + timeoutSec: number; + graceSec: number; + onLog: (stream: "stdout" | "stderr", chunk: string) => Promise; + onLogError?: (err: unknown, runId: string, message: string) => void; + stdin?: string; + }, +): Promise { + const onLogError = opts.onLogError ?? ((err, id, msg) => console.warn({ err, runId: id }, msg)); + + return new Promise((resolve, reject) => { + const rawMerged: NodeJS.ProcessEnv = { ...opts.env }; + + // Strip Claude Code nesting-guard env vars so spawned `claude` processes + // don't refuse to start with "cannot be launched inside another session". + // These vars leak in when the AgentCompany server itself is started from + // within a Claude Code session (e.g. `npx agentcompany run` in a terminal + // owned by Claude Code) or when cron inherits a contaminated shell env. + const CLAUDE_CODE_NESTING_VARS = [ + "CLAUDECODE", + "CLAUDE_CODE_ENTRYPOINT", + "CLAUDE_CODE_SESSION", + "CLAUDE_CODE_PARENT_SESSION", + ] as const; + for (const key of CLAUDE_CODE_NESTING_VARS) { + delete rawMerged[key]; + } + + const mergedEnv = ensurePathInEnv(rawMerged); + // On Windows, force UTF-8 encoding in child processes to prevent Chinese/CJK + // character corruption caused by the default system code page (e.g. 936/GBK). + if (process.platform === "win32") { + mergedEnv.PYTHONUTF8 = "1"; + mergedEnv.PYTHONIOENCODING = "utf-8"; + if (!mergedEnv.LANG) mergedEnv.LANG = "en_US.UTF-8"; + } + + void resolveSpawnTarget(command, args, opts.cwd, mergedEnv) + .then((target) => { + const child = spawn(target.command, target.args, { + cwd: opts.cwd, + env: mergedEnv, + shell: false, + windowsHide: true, + stdio: [opts.stdin != null ? "pipe" : "ignore", "pipe", "pipe"], + }) as ChildProcessWithEvents; + + if (opts.stdin != null && child.stdin) { + child.stdin.write(opts.stdin); + child.stdin.end(); + } + + runningProcesses.set(runId, { child, graceSec: opts.graceSec }); + + let timedOut = false; + let stdout = ""; + let stderr = ""; + let logChain: Promise = Promise.resolve(); + + // Use StringDecoder to handle multi-byte characters split across chunks. + // On Windows, child process stdout may arrive in GBK (CP936) instead + // of UTF-8 when chcp 65001 is ineffective (piped stdout has no console). + // We detect this per-stream and fall back to GBK decoding if needed. + const stdoutDecoder = new StringDecoder("utf8"); + const stderrDecoder = new StringDecoder("utf8"); + let stdoutIsGbk = false; + let stderrIsGbk = false; + + const timeout = + opts.timeoutSec > 0 + ? setTimeout(() => { + timedOut = true; + child.kill("SIGTERM"); + setTimeout(() => { + if (!child.killed) { + child.kill("SIGKILL"); + } + }, Math.max(1, opts.graceSec) * 1000); + }, opts.timeoutSec * 1000) + : null; + + child.stdout?.on("data", (chunk: unknown) => { + let text: string; + if (chunk instanceof Buffer) { + if (stdoutIsGbk) { + text = decodeGbkBuffer(chunk); + } else { + text = stdoutDecoder.write(chunk); + if (text.includes("\ufffd") && chunk.length > 0) { + stdoutIsGbk = true; + text = decodeGbkBuffer(chunk); + } + } + } else { + text = String(chunk); + } + stdout = appendWithCap(stdout, text); + logChain = logChain + .then(() => opts.onLog("stdout", text)) + .catch((err) => onLogError(err, runId, "failed to append stdout log chunk")); + }); + + child.stderr?.on("data", (chunk: unknown) => { + let text: string; + if (chunk instanceof Buffer) { + if (stderrIsGbk) { + text = decodeGbkBuffer(chunk); + } else { + text = stderrDecoder.write(chunk); + if (text.includes("\ufffd") && chunk.length > 0) { + stderrIsGbk = true; + text = decodeGbkBuffer(chunk); + } + } + } else { + text = String(chunk); + } + stderr = appendWithCap(stderr, text); + logChain = logChain + .then(() => opts.onLog("stderr", text)) + .catch((err) => onLogError(err, runId, "failed to append stderr log chunk")); + }); + + child.on("error", (err: Error) => { + if (timeout) clearTimeout(timeout); + runningProcesses.delete(runId); + const errno = (err as NodeJS.ErrnoException).code; + const pathValue = mergedEnv.PATH ?? mergedEnv.Path ?? ""; + const msg = + errno === "ENOENT" + ? `Failed to start command "${command}" in "${opts.cwd}". Verify adapter command, working directory, and PATH (${pathValue}).` + : `Failed to start command "${command}" in "${opts.cwd}": ${err.message}`; + reject(new Error(msg)); + }); + + child.on("close", (code: number | null, signal: NodeJS.Signals | null) => { + if (timeout) clearTimeout(timeout); + runningProcesses.delete(runId); + // Flush any remaining partial multi-byte characters from the decoders. + const stdoutTail = stdoutDecoder.end(); + const stderrTail = stderrDecoder.end(); + if (stdoutTail) stdout = appendWithCap(stdout, stdoutTail); + if (stderrTail) stderr = appendWithCap(stderr, stderrTail); + void logChain.finally(() => { + resolve({ + exitCode: code, + signal, + timedOut, + stdout, + stderr, + }); + }); + }); + }) + .catch(reject); + }); +} + +/** + * Kill a process and all its descendants. + * Windows: `taskkill /T /F` kills the full process tree. + * Unix: SIGKILL the process directly (CLI tools are leaf processes). + */ +function killProcessTree(pid: number): void { + if (process.platform === "win32") { + try { + execSync(`taskkill /T /F /PID ${pid}`, { stdio: "ignore", timeout: 5000 }); + } catch { /* process already exited */ } + } else { + try { + process.kill(pid, "SIGKILL"); + } catch { /* already dead */ } + } +} + +/** + * Force-kill every child process tracked in `runningProcesses` and clear the map. + * Called by the worker during shutdown to ensure no CLI processes survive app quit. + */ +export function shutdownAllRunning(): void { + for (const [runId, entry] of runningProcesses) { + const pid = entry.child.pid; + if (pid != null) { + killProcessTree(pid); + } + runningProcesses.delete(runId); + } +} diff --git a/packages/adapter-utils/src/server-utils-skills-sync.ts b/packages/adapter-utils/src/server-utils-skills-sync.ts new file mode 100644 index 00000000..f5f03ae6 --- /dev/null +++ b/packages/adapter-utils/src/server-utils-skills-sync.ts @@ -0,0 +1,329 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import type { AdapterSkillEntry, AdapterSkillSnapshot } from "./types.js"; +import type { AgentCompanySkillEntry, InstalledSkillTarget } from "./server-utils-skills.js"; + +interface PersistentSkillSnapshotOptions { + adapterType: string; + availableEntries: AgentCompanySkillEntry[]; + desiredSkills: string[]; + installed: Map; + skillsHome: string; + locationLabel?: string | null; + installedDetail?: string | null; + missingDetail: string; + externalConflictDetail: string; + externalDetail: string; + warnings?: string[]; +} + +function normalizePathSlashes(value: string): string { + return value.replaceAll("\\", "/"); +} + +function isMaintainerOnlySkillTarget(candidate: string): boolean { + return normalizePathSlashes(candidate).includes("/.agents/skills/"); +} + +function skillLocationLabel(value: string | null | undefined): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function buildManagedSkillOrigin(entry: { required?: boolean }): Pick< + AdapterSkillEntry, + "origin" | "originLabel" | "readOnly" +> { + if (entry.required) { + return { + origin: "paperclip_required", + originLabel: "Required by AgentCompany", + readOnly: false, + }; + } + return { + origin: "company_managed", + originLabel: "Managed by AgentCompany", + readOnly: false, + }; +} + +function canonicalizeDesiredAgentCompanySkillReference( + reference: string, + availableEntries: Array<{ key: string; runtimeName?: string | null }>, +): string { + const normalizedReference = reference.trim().toLowerCase(); + if (!normalizedReference) return ""; + + const exactKey = availableEntries.find((entry) => entry.key.trim().toLowerCase() === normalizedReference); + if (exactKey) return exactKey.key; + + const byRuntimeName = availableEntries.filter((entry) => + typeof entry.runtimeName === "string" && entry.runtimeName.trim().toLowerCase() === normalizedReference, + ); + if (byRuntimeName.length === 1) return byRuntimeName[0]!.key; + + const slugMatches = availableEntries.filter((entry) => + entry.key.trim().toLowerCase().split("/").pop() === normalizedReference, + ); + if (slugMatches.length === 1) return slugMatches[0]!.key; + + return normalizedReference; +} + +/** + * Build an AdapterSkillSnapshot for persistent-mode adapters (Gemini, Codex). + * Compares available entries against installed state and desired skill list. + */ +export function buildPersistentSkillSnapshot( + options: PersistentSkillSnapshotOptions, +): AdapterSkillSnapshot { + const { + adapterType, + availableEntries, + desiredSkills, + installed, + skillsHome, + locationLabel, + installedDetail, + missingDetail, + externalConflictDetail, + externalDetail, + } = options; + const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry])); + const desiredSet = new Set(desiredSkills); + const entries: AdapterSkillEntry[] = []; + const warnings = [...(options.warnings ?? [])]; + + for (const available of availableEntries) { + const installedEntry = installed.get(available.runtimeName) ?? null; + const desired = desiredSet.has(available.key); + let state: AdapterSkillEntry["state"] = "available"; + let managed = false; + let detail: string | null = null; + + if (installedEntry?.targetPath === available.source) { + managed = true; + state = desired ? "installed" : "stale"; + detail = installedDetail ?? null; + } else if (installedEntry) { + state = "external"; + detail = desired ? externalConflictDetail : externalDetail; + } else if (desired) { + state = "missing"; + detail = missingDetail; + } + + entries.push({ + key: available.key, + runtimeName: available.runtimeName, + desired, + managed, + state, + sourcePath: available.source, + targetPath: path.join(skillsHome, available.runtimeName), + detail, + required: Boolean(available.required), + requiredReason: available.requiredReason ?? null, + ...buildManagedSkillOrigin(available), + }); + } + + for (const desiredSkill of desiredSkills) { + if (availableByKey.has(desiredSkill)) continue; + warnings.push(`Desired skill "${desiredSkill}" is not available from the AgentCompany skills directory.`); + entries.push({ + key: desiredSkill, + runtimeName: null, + desired: true, + managed: true, + state: "missing", + sourcePath: null, + targetPath: null, + detail: "AgentCompany cannot find this skill in the local runtime skills directory.", + origin: "external_unknown", + originLabel: "External or unavailable", + readOnly: false, + }); + } + + for (const [name, installedEntry] of installed.entries()) { + if (availableEntries.some((entry) => entry.runtimeName === name)) continue; + entries.push({ + key: name, + runtimeName: name, + desired: false, + managed: false, + state: "external", + origin: "user_installed", + originLabel: "User-installed", + locationLabel: skillLocationLabel(locationLabel), + readOnly: true, + sourcePath: null, + targetPath: installedEntry.targetPath ?? path.join(skillsHome, name), + detail: externalDetail, + }); + } + + entries.sort((left, right) => left.key.localeCompare(right.key)); + + return { + adapterType, + supported: true, + mode: "persistent", + desiredSkills, + entries, + warnings, + }; +} + +/** + * Read the desired skill sync preference from adapter config. + */ +export function readAgentCompanySkillSyncPreference(config: Record): { + explicit: boolean; + desiredSkills: string[]; +} { + const raw = config.agentCompanySkillSync; + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + return { explicit: false, desiredSkills: [] }; + } + const syncConfig = raw as Record; + const desiredValues = syncConfig.desiredSkills; + const desired = Array.isArray(desiredValues) + ? desiredValues + .filter((value): value is string => typeof value === "string") + .map((value) => value.trim()) + .filter(Boolean) + : []; + return { + explicit: Object.prototype.hasOwnProperty.call(raw, "desiredSkills"), + desiredSkills: Array.from(new Set(desired)), + }; +} + +/** + * Resolve the canonical desired skill list from config and available entries. + * Required skills are always included; explicit preferences are canonicalized. + */ +export function resolveAgentCompanyDesiredSkillNames( + config: Record, + availableEntries: Array<{ key: string; runtimeName?: string | null; required?: boolean }>, +): string[] { + const preference = readAgentCompanySkillSyncPreference(config); + const requiredSkills = availableEntries + .filter((entry) => entry.required) + .map((entry) => entry.key); + if (!preference.explicit) { + return Array.from(new Set(requiredSkills)); + } + const desiredSkills = preference.desiredSkills + .map((reference) => canonicalizeDesiredAgentCompanySkillReference(reference, availableEntries)) + .filter(Boolean); + return Array.from(new Set([...requiredSkills, ...desiredSkills])); +} + +/** + * Write desired skills back to adapter config. + */ +export function writeAgentCompanySkillSyncPreference( + config: Record, + desiredSkills: string[], +): Record { + const next = { ...config }; + const raw = next.agentCompanySkillSync; + const current = + typeof raw === "object" && raw !== null && !Array.isArray(raw) + ? { ...(raw as Record) } + : {}; + current.desiredSkills = Array.from( + new Set( + desiredSkills + .map((value) => value.trim()) + .filter(Boolean), + ), + ); + next.agentCompanySkillSync = current; + return next; +} + +/** + * Create or repair a symlink from source to target skill directory. + * Returns the action taken: "created", "repaired", or "skipped". + */ +export async function ensureAgentCompanySkillSymlink( + source: string, + target: string, + linkSkill: (source: string, target: string) => Promise = (linkSource, linkTarget) => + fs.symlink(linkSource, linkTarget), +): Promise<"created" | "repaired" | "skipped"> { + const existing = await fs.lstat(target).catch(() => null); + if (!existing) { + await linkSkill(source, target); + return "created"; + } + + if (!existing.isSymbolicLink()) { + return "skipped"; + } + + const linkedPath = await fs.readlink(target).catch(() => null); + if (!linkedPath) return "skipped"; + + const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath); + if (resolvedLinkedPath === source) { + return "skipped"; + } + + const linkedPathExists = await fs.stat(resolvedLinkedPath).then(() => true).catch(() => false); + if (linkedPathExists) { + return "skipped"; + } + + await fs.unlink(target); + await linkSkill(source, target); + return "repaired"; +} + +/** + * Remove symlinks in skillsHome that point to maintainer-only paths + * (.agents/skills/) and are not in the allowed set. + */ +export async function removeMaintainerOnlySkillSymlinks( + skillsHome: string, + allowedSkillNames: Iterable, +): Promise { + const allowed = new Set(Array.from(allowedSkillNames)); + try { + const entries = await fs.readdir(skillsHome, { withFileTypes: true }); + const removed: string[] = []; + for (const entry of entries) { + if (allowed.has(entry.name)) continue; + + const target = path.join(skillsHome, entry.name); + const existing = await fs.lstat(target).catch(() => null); + if (!existing?.isSymbolicLink()) continue; + + const linkedPath = await fs.readlink(target).catch(() => null); + if (!linkedPath) continue; + + const resolvedLinkedPath = path.isAbsolute(linkedPath) + ? linkedPath + : path.resolve(path.dirname(target), linkedPath); + if ( + !isMaintainerOnlySkillTarget(linkedPath) && + !isMaintainerOnlySkillTarget(resolvedLinkedPath) + ) { + continue; + } + + await fs.unlink(target); + removed.push(entry.name); + } + + return removed; + } catch { + return []; + } +} diff --git a/packages/adapter-utils/src/server-utils-skills.ts b/packages/adapter-utils/src/server-utils-skills.ts new file mode 100644 index 00000000..f240b571 --- /dev/null +++ b/packages/adapter-utils/src/server-utils-skills.ts @@ -0,0 +1,176 @@ +import { promises as fs, type Dirent } from "node:fs"; +import path from "node:path"; +import type { ManagedSkillRef } from "./types.js"; +import { parseObject, asString, asBoolean } from "./server-utils-parsers.js"; + +/** + * Create symlinks for managed skills in the target directory. + * Per D-13: uses 'junction' type on Windows to avoid Developer Mode requirement. + * Individual skill symlink failures are logged and reported but do not block other skills. + */ +export async function createManagedSkillSymlinks( + managedSkills: ManagedSkillRef[], + targetDir: string, + onLog: (stream: "stdout" | "stderr", chunk: string) => Promise, +): Promise { + await fs.mkdir(targetDir, { recursive: true }); + const symlinkType = process.platform === "win32" ? ("junction" as const) : undefined; + for (const skill of managedSkills) { + const target = path.join(targetDir, skill.runtimeName); + const existing = await fs.lstat(target).catch(() => null); + if (existing) { + await fs.rm(target, { recursive: true, force: true }); + } + try { + await fs.symlink(skill.sourcePath, target, symlinkType); + await onLog("stderr", `[agent-company] Injected managed skill "${skill.runtimeName}"\n`); + } catch (err) { + await onLog( + "stderr", + `[agent-company] Failed to inject skill "${skill.runtimeName}": ${err instanceof Error ? err.message : String(err)}\n`, + ); + } + } +} + +// --------------------------------------------------------------------------- +// Skill resolution helpers -- ported from Paperclip reference with +// AgentCompany naming. These utilities let each adapter discover, list, +// resolve, and synchronize skills from a shared skills directory. +// --------------------------------------------------------------------------- + +export const AGENTCOMPANY_SKILL_ROOT_RELATIVE_CANDIDATES = [ + "../../skills", + "../../../../../skills", +]; + +export interface AgentCompanySkillEntry { + key: string; + runtimeName: string; + source: string; + required?: boolean; + requiredReason?: string | null; +} + +export interface InstalledSkillTarget { + targetPath: string | null; + kind: "symlink" | "directory" | "file"; +} + +export function resolveInstalledEntryTarget( + skillsHome: string, + entryName: string, + dirent: Dirent, + linkedPath: string | null, +): InstalledSkillTarget { + const fullPath = path.join(skillsHome, entryName); + if (dirent.isSymbolicLink()) { + return { + targetPath: linkedPath ? path.resolve(path.dirname(fullPath), linkedPath) : null, + kind: "symlink", + }; + } + if (dirent.isDirectory()) { + return { targetPath: fullPath, kind: "directory" }; + } + return { targetPath: fullPath, kind: "file" }; +} + +export function normalizeConfiguredAgentCompanyRuntimeSkills(value: unknown): AgentCompanySkillEntry[] { + if (!Array.isArray(value)) return []; + const out: AgentCompanySkillEntry[] = []; + for (const rawEntry of value) { + const entry = parseObject(rawEntry); + const key = asString(entry.key, asString(entry.name, "")).trim(); + const runtimeName = asString(entry.runtimeName, asString(entry.name, "")).trim(); + const source = asString(entry.source, "").trim(); + if (!key || !runtimeName || !source) continue; + out.push({ + key, + runtimeName, + source, + required: asBoolean(entry.required, false), + requiredReason: + typeof entry.requiredReason === "string" && entry.requiredReason.trim().length > 0 + ? entry.requiredReason.trim() + : null, + }); + } + return out; +} + +/** + * Resolve the AgentCompany skills root directory from relative path candidates. + */ +export async function resolveAgentCompanySkillsDir( + moduleDir: string, + additionalCandidates: string[] = [], +): Promise { + const candidates = [ + ...AGENTCOMPANY_SKILL_ROOT_RELATIVE_CANDIDATES.map((relativePath) => path.resolve(moduleDir, relativePath)), + ...additionalCandidates.map((candidate) => path.resolve(candidate)), + ]; + const seenRoots = new Set(); + + for (const root of candidates) { + if (seenRoots.has(root)) continue; + seenRoots.add(root); + const isDirectory = await fs.stat(root).then((stats) => stats.isDirectory()).catch(() => false); + if (isDirectory) return root; + } + + return null; +} + +/** + * List skill entries from the AgentCompany skills directory. + */ +export async function listAgentCompanySkillEntries( + moduleDir: string, + additionalCandidates: string[] = [], +): Promise { + const root = await resolveAgentCompanySkillsDir(moduleDir, additionalCandidates); + if (!root) return []; + + try { + const entries = await fs.readdir(root, { withFileTypes: true }); + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => ({ + key: `agentcompany/agentcompany/${entry.name}`, + runtimeName: entry.name, + source: path.join(root, entry.name), + required: true, + requiredReason: "Bundled AgentCompany skills are always available for local adapters.", + })); + } catch { + return []; + } +} + +/** + * Read installed skill targets from a skill home directory (symlinks, dirs, files). + */ +export async function readInstalledSkillTargets(skillsHome: string): Promise> { + const entries = await fs.readdir(skillsHome, { withFileTypes: true }).catch(() => []); + const out = new Map(); + for (const entry of entries) { + const fullPath = path.join(skillsHome, entry.name); + const linkedPath = entry.isSymbolicLink() ? await fs.readlink(fullPath).catch(() => null) : null; + out.set(entry.name, resolveInstalledEntryTarget(skillsHome, entry.name, entry, linkedPath)); + } + return out; +} + +/** + * Read runtime skill entries from adapter config or discover from module directory. + */ +export async function readAgentCompanyRuntimeSkillEntries( + config: Record, + moduleDir: string, + additionalCandidates: string[] = [], +): Promise { + const configuredEntries = normalizeConfiguredAgentCompanyRuntimeSkills(config.agentCompanyRuntimeSkills); + if (configuredEntries.length > 0) return configuredEntries; + return listAgentCompanySkillEntries(moduleDir, additionalCandidates); +} diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index af964e6f..bda42b6c 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -1,1003 +1,4 @@ -import { spawn, execSync, type ChildProcess } from "node:child_process"; -import { constants as fsConstants, promises as fs, type Dirent } from "node:fs"; -import path from "node:path"; -import { StringDecoder } from "node:string_decoder"; -import type { AdapterSkillEntry, AdapterSkillSnapshot, ManagedSkillRef } from "./types.js"; - -/** - * Decode a Buffer as GBK (CP936). Used as a fallback when UTF-8 decoding - * produces replacement characters on Chinese Windows systems where the - * system ANSI code page is GBK and chcp 65001 is ineffective for piped I/O. - */ -function decodeGbkBuffer(buf: Buffer): string { - try { - return new TextDecoder("gbk").decode(buf); - } catch { - return buf.toString("utf8"); - } -} - -export interface RunProcessResult { - exitCode: number | null; - signal: string | null; - timedOut: boolean; - stdout: string; - stderr: string; -} - -interface RunningProcess { - child: ChildProcess; - graceSec: number; -} - -interface SpawnTarget { - command: string; - args: string[]; -} - -type ChildProcessWithEvents = ChildProcess & { - on(event: "error", listener: (err: Error) => void): ChildProcess; - on( - event: "close", - listener: (code: number | null, signal: NodeJS.Signals | null) => void, - ): ChildProcess; -}; - -export const runningProcesses = new Map(); -export const MAX_CAPTURE_BYTES = 4 * 1024 * 1024; -export const MAX_EXCERPT_BYTES = 32 * 1024; -const SENSITIVE_ENV_KEY = /(key|token|secret|password|passwd|authorization|cookie)/i; - -export function parseObject(value: unknown): Record { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - return {}; - } - return value as Record; -} - -export function asString(value: unknown, fallback: string): string { - return typeof value === "string" && value.length > 0 ? value : fallback; -} - -export function asNumber(value: unknown, fallback: number): number { - return typeof value === "number" && Number.isFinite(value) ? value : fallback; -} - -export function asBoolean(value: unknown, fallback: boolean): boolean { - return typeof value === "boolean" ? value : fallback; -} - -export function asStringArray(value: unknown): string[] { - return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; -} - -export function parseJson(value: string): Record | null { - try { - return JSON.parse(value) as Record; - } catch { - return null; - } -} - -export function appendWithCap(prev: string, chunk: string, cap = MAX_CAPTURE_BYTES) { - const combined = prev + chunk; - return combined.length > cap ? combined.slice(combined.length - cap) : combined; -} - -export function resolvePathValue(obj: Record, dottedPath: string) { - const parts = dottedPath.split("."); - let cursor: unknown = obj; - - for (const part of parts) { - if (typeof cursor !== "object" || cursor === null || Array.isArray(cursor)) { - return ""; - } - cursor = (cursor as Record)[part]; - } - - if (cursor === null || cursor === undefined) return ""; - if (typeof cursor === "string") return cursor; - if (typeof cursor === "number" || typeof cursor === "boolean") return String(cursor); - - try { - return JSON.stringify(cursor); - } catch { - return ""; - } -} - -export function renderTemplate(template: string, data: Record) { - return template.replace(/{{\s*([a-zA-Z0-9_.-]+)\s*}}/g, (_, path) => resolvePathValue(data, path)); -} - -/** - * Remove flags from `extraArgs` that the adapter will already add itself. - * Handles both `--flag value` pairs and `--flag=value` forms. - * This prevents duplicate/conflicting CLI arguments when users have shell - * aliases or wrapper functions that inject the same flags. - */ -export function filterConflictingArgs(extraArgs: string[], adapterFlags: string[]): string[] { - const flagSet = new Set(adapterFlags.map((f) => f.replace(/=.*$/, ""))); - const filtered: string[] = []; - for (let i = 0; i < extraArgs.length; i++) { - const arg = extraArgs[i]; - const bareFlag = arg.replace(/=.*$/, ""); - if (flagSet.has(bareFlag)) { - // Skip this flag and its value if the value is a separate argument - if (!arg.includes("=") && i + 1 < extraArgs.length && !extraArgs[i + 1].startsWith("-")) { - i++; // skip the value argument too - } - continue; - } - filtered.push(arg); - } - return filtered; -} - -export function redactEnvForLogs(env: Record): Record { - const redacted: Record = {}; - for (const [key, value] of Object.entries(env)) { - redacted[key] = SENSITIVE_ENV_KEY.test(key) ? "***REDACTED***" : value; - } - return redacted; -} - -export function buildAgentCompanyEnv(agent: { id: string; companyId: string }): Record { - const resolveHostForUrl = (rawHost: string): string => { - const host = rawHost.trim(); - if (!host || host === "0.0.0.0" || host === "::") return "localhost"; - if (host.includes(":") && !host.startsWith("[") && !host.endsWith("]")) return `[${host}]`; - return host; - }; - const vars: Record = { - AGENT_COMPANY_AGENT_ID: agent.id, - AGENT_COMPANY_COMPANY_ID: agent.companyId, - }; - const runtimeHost = resolveHostForUrl( - process.env.AGENT_COMPANY_LISTEN_HOST ?? process.env.HOST ?? "localhost", - ); - const runtimePort = process.env.AGENT_COMPANY_LISTEN_PORT ?? process.env.PORT ?? "3100"; - const apiUrl = process.env.AGENT_COMPANY_API_URL ?? `http://${runtimeHost}:${runtimePort}`; - vars.AGENT_COMPANY_API_URL = apiUrl; - return vars; -} - -export function defaultPathForPlatform() { - if (process.platform === "win32") { - const home = process.env.USERPROFILE ?? process.env.HOME ?? ""; - const appData = process.env.APPDATA ?? (home ? `${home}\\AppData\\Roaming` : ""); - const localAppData = process.env.LOCALAPPDATA ?? (home ? `${home}\\AppData\\Local` : ""); - const parts = [ - "C:\\Windows\\System32", - "C:\\Windows", - "C:\\Windows\\System32\\Wbem", - appData ? `${appData}\\npm` : "", - localAppData ? `${localAppData}\\pnpm` : "", - home ? `${home}\\.local\\bin` : "", - home ? `${home}\\.cargo\\bin` : "", - "C:\\Program Files\\nodejs", - ].filter(Boolean); - return parts.join(";"); - } - return "/usr/local/bin:/opt/homebrew/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin"; -} - -function windowsPathExts(env: NodeJS.ProcessEnv): string[] { - return (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean); -} - -async function pathExists(candidate: string) { - try { - await fs.access(candidate, process.platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK); - return true; - } catch { - return false; - } -} - -async function resolveCommandPath(command: string, cwd: string, env: NodeJS.ProcessEnv): Promise { - const hasPathSeparator = command.includes("/") || command.includes("\\"); - if (hasPathSeparator) { - const absolute = path.isAbsolute(command) ? command : path.resolve(cwd, command); - return (await pathExists(absolute)) ? absolute : null; - } - - const pathValue = env.PATH ?? env.Path ?? ""; - const delimiter = process.platform === "win32" ? ";" : ":"; - const dirs = pathValue.split(delimiter).filter(Boolean); - const exts = process.platform === "win32" ? windowsPathExts(env) : [""]; - const hasExtension = process.platform === "win32" && path.extname(command).length > 0; - - for (const dir of dirs) { - const candidates = - process.platform === "win32" - ? hasExtension - ? [path.join(dir, command)] - : exts.map((ext) => path.join(dir, `${command}${ext}`)) - : [path.join(dir, command)]; - for (const candidate of candidates) { - if (await pathExists(candidate)) return candidate; - } - } - - return null; -} - -function quoteForCmd(arg: string) { - if (!arg.length) return '""'; - const escaped = arg.replace(/"/g, '""'); - return /[\s"&<>|^()]/.test(escaped) ? `"${escaped}"` : escaped; -} - -async function resolveSpawnTarget( - command: string, - args: string[], - cwd: string, - env: NodeJS.ProcessEnv, -): Promise { - const resolved = await resolveCommandPath(command, cwd, env); - const executable = resolved ?? command; - - if (process.platform !== "win32") { - return { command: executable, args }; - } - - // On Windows, ALL spawned processes need UTF-8 code page (65001) to prevent - // Chinese/CJK character corruption. The default Windows code page (e.g. 936/GBK) - // corrupts multi-byte UTF-8 characters passed through cmd.exe, curl, etc. - const shell = env.ComSpec || process.env.ComSpec || "cmd.exe"; - const commandLine = [quoteForCmd(executable), ...args.map(quoteForCmd)].join(" "); - return { - command: shell, - args: ["/d", "/s", "/c", `chcp 65001 >nul && ${commandLine}`], - }; -} - -export function ensurePathInEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { - if (typeof env.PATH === "string" && env.PATH.length > 0) return env; - if (typeof env.Path === "string" && env.Path.length > 0) return env; - return { ...env, PATH: defaultPathForPlatform() }; -} - -export async function ensureAbsoluteDirectory( - cwd: string, - opts: { createIfMissing?: boolean } = {}, -) { - if (!path.isAbsolute(cwd)) { - throw new Error(`Working directory must be an absolute path: "${cwd}"`); - } - - const assertDirectory = async () => { - const stats = await fs.stat(cwd); - if (!stats.isDirectory()) { - throw new Error(`Working directory is not a directory: "${cwd}"`); - } - }; - - try { - await assertDirectory(); - return; - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (!opts.createIfMissing || code !== "ENOENT") { - if (code === "ENOENT") { - throw new Error(`Working directory does not exist: "${cwd}"`); - } - throw err instanceof Error ? err : new Error(String(err)); - } - } - - try { - await fs.mkdir(cwd, { recursive: true }); - await assertDirectory(); - } catch (err) { - const reason = err instanceof Error ? err.message : String(err); - throw new Error(`Could not create working directory "${cwd}": ${reason}`); - } -} - -export async function ensureCommandResolvable(command: string, cwd: string, env: NodeJS.ProcessEnv) { - const resolved = await resolveCommandPath(command, cwd, env); - if (resolved) return; - if (command.includes("/") || command.includes("\\")) { - const absolute = path.isAbsolute(command) ? command : path.resolve(cwd, command); - throw new Error(`Command is not executable: "${command}" (resolved: "${absolute}")`); - } - throw new Error(`Command not found in PATH: "${command}"`); -} - -export async function runChildProcess( - runId: string, - command: string, - args: string[], - opts: { - cwd: string; - env: Record; - timeoutSec: number; - graceSec: number; - onLog: (stream: "stdout" | "stderr", chunk: string) => Promise; - onLogError?: (err: unknown, runId: string, message: string) => void; - stdin?: string; - }, -): Promise { - const onLogError = opts.onLogError ?? ((err, id, msg) => console.warn({ err, runId: id }, msg)); - - return new Promise((resolve, reject) => { - const rawMerged: NodeJS.ProcessEnv = { ...opts.env }; - - // Strip Claude Code nesting-guard env vars so spawned `claude` processes - // don't refuse to start with "cannot be launched inside another session". - // These vars leak in when the AgentCompany server itself is started from - // within a Claude Code session (e.g. `npx agentcompany run` in a terminal - // owned by Claude Code) or when cron inherits a contaminated shell env. - const CLAUDE_CODE_NESTING_VARS = [ - "CLAUDECODE", - "CLAUDE_CODE_ENTRYPOINT", - "CLAUDE_CODE_SESSION", - "CLAUDE_CODE_PARENT_SESSION", - ] as const; - for (const key of CLAUDE_CODE_NESTING_VARS) { - delete rawMerged[key]; - } - - const mergedEnv = ensurePathInEnv(rawMerged); - // On Windows, force UTF-8 encoding in child processes to prevent Chinese/CJK - // character corruption caused by the default system code page (e.g. 936/GBK). - if (process.platform === "win32") { - mergedEnv.PYTHONUTF8 = "1"; - mergedEnv.PYTHONIOENCODING = "utf-8"; - if (!mergedEnv.LANG) mergedEnv.LANG = "en_US.UTF-8"; - } - void resolveSpawnTarget(command, args, opts.cwd, mergedEnv) - .then((target) => { - const child = spawn(target.command, target.args, { - cwd: opts.cwd, - env: mergedEnv, - shell: false, - windowsHide: true, - stdio: [opts.stdin != null ? "pipe" : "ignore", "pipe", "pipe"], - }) as ChildProcessWithEvents; - - if (opts.stdin != null && child.stdin) { - child.stdin.write(opts.stdin); - child.stdin.end(); - } - - runningProcesses.set(runId, { child, graceSec: opts.graceSec }); - - let timedOut = false; - let stdout = ""; - let stderr = ""; - let logChain: Promise = Promise.resolve(); - - // Use StringDecoder to handle multi-byte characters split across chunks. - // On Windows, child process stdout may arrive in GBK (CP936) instead - // of UTF-8 when chcp 65001 is ineffective (piped stdout has no console). - // We detect this per-stream and fall back to GBK decoding if needed. - const stdoutDecoder = new StringDecoder("utf8"); - const stderrDecoder = new StringDecoder("utf8"); - let stdoutIsGbk = false; - let stderrIsGbk = false; - - const timeout = - opts.timeoutSec > 0 - ? setTimeout(() => { - timedOut = true; - child.kill("SIGTERM"); - setTimeout(() => { - if (!child.killed) { - child.kill("SIGKILL"); - } - }, Math.max(1, opts.graceSec) * 1000); - }, opts.timeoutSec * 1000) - : null; - - child.stdout?.on("data", (chunk: unknown) => { - let text: string; - if (chunk instanceof Buffer) { - if (stdoutIsGbk) { - text = decodeGbkBuffer(chunk); - } else { - text = stdoutDecoder.write(chunk); - if (text.includes("\ufffd") && chunk.length > 0) { - stdoutIsGbk = true; - text = decodeGbkBuffer(chunk); - } - } - } else { - text = String(chunk); - } - stdout = appendWithCap(stdout, text); - logChain = logChain - .then(() => opts.onLog("stdout", text)) - .catch((err) => onLogError(err, runId, "failed to append stdout log chunk")); - }); - - child.stderr?.on("data", (chunk: unknown) => { - let text: string; - if (chunk instanceof Buffer) { - if (stderrIsGbk) { - text = decodeGbkBuffer(chunk); - } else { - text = stderrDecoder.write(chunk); - if (text.includes("\ufffd") && chunk.length > 0) { - stderrIsGbk = true; - text = decodeGbkBuffer(chunk); - } - } - } else { - text = String(chunk); - } - stderr = appendWithCap(stderr, text); - logChain = logChain - .then(() => opts.onLog("stderr", text)) - .catch((err) => onLogError(err, runId, "failed to append stderr log chunk")); - }); - - child.on("error", (err: Error) => { - if (timeout) clearTimeout(timeout); - runningProcesses.delete(runId); - const errno = (err as NodeJS.ErrnoException).code; - const pathValue = mergedEnv.PATH ?? mergedEnv.Path ?? ""; - const msg = - errno === "ENOENT" - ? `Failed to start command "${command}" in "${opts.cwd}". Verify adapter command, working directory, and PATH (${pathValue}).` - : `Failed to start command "${command}" in "${opts.cwd}": ${err.message}`; - reject(new Error(msg)); - }); - - child.on("close", (code: number | null, signal: NodeJS.Signals | null) => { - if (timeout) clearTimeout(timeout); - runningProcesses.delete(runId); - // Flush any remaining partial multi-byte characters from the decoders. - const stdoutTail = stdoutDecoder.end(); - const stderrTail = stderrDecoder.end(); - if (stdoutTail) stdout = appendWithCap(stdout, stdoutTail); - if (stderrTail) stderr = appendWithCap(stderr, stderrTail); - void logChain.finally(() => { - resolve({ - exitCode: code, - signal, - timedOut, - stdout, - stderr, - }); - }); - }); - }) - .catch(reject); - }); -} - -/** - * Kill a process and all its descendants. - * Windows: `taskkill /T /F` kills the full process tree. - * Unix: SIGKILL the process directly (CLI tools are leaf processes). - */ -function killProcessTree(pid: number): void { - if (process.platform === "win32") { - try { - execSync(`taskkill /T /F /PID ${pid}`, { stdio: "ignore", timeout: 5000 }); - } catch { /* process already exited */ } - } else { - try { - process.kill(pid, "SIGKILL"); - } catch { /* already dead */ } - } -} - -/** - * Force-kill every child process tracked in `runningProcesses` and clear the map. - * Called by the worker during shutdown to ensure no CLI processes survive app quit. - */ -export function shutdownAllRunning(): void { - for (const [runId, entry] of runningProcesses) { - const pid = entry.child.pid; - if (pid != null) { - killProcessTree(pid); - } - runningProcesses.delete(runId); - } -} - -/** - * Create symlinks for managed skills in the target directory. - * Per D-13: uses 'junction' type on Windows to avoid Developer Mode requirement. - * Falls back gracefully -- skill injection is best-effort and should not block runs. - */ -export async function createManagedSkillSymlinks( - managedSkills: ManagedSkillRef[], - targetDir: string, - onLog: (stream: "stdout" | "stderr", chunk: string) => Promise, -): Promise { - await fs.mkdir(targetDir, { recursive: true }); - const symlinkType = process.platform === "win32" ? ("junction" as const) : undefined; - for (const skill of managedSkills) { - const target = path.join(targetDir, skill.runtimeName); - const existing = await fs.lstat(target).catch(() => null); - if (existing) { - // Remove stale symlink from previous run - await fs.rm(target, { recursive: true, force: true }).catch(() => {}); - } - try { - await fs.symlink(skill.sourcePath, target, symlinkType); - await onLog("stderr", `[agent-company] Injected managed skill "${skill.runtimeName}"\n`); - } catch (err) { - await onLog( - "stderr", - `[agent-company] Failed to inject skill "${skill.runtimeName}": ${err instanceof Error ? err.message : String(err)}\n`, - ); - } - } -} - -// --------------------------------------------------------------------------- -// Skill resolution helpers -- ported from Paperclip reference with -// AgentCompany naming. These utilities let each adapter discover, list, -// resolve, and synchronize skills from a shared skills directory. -// --------------------------------------------------------------------------- - -const AGENTCOMPANY_SKILL_ROOT_RELATIVE_CANDIDATES = [ - "../../skills", - "../../../../../skills", -]; - -export interface AgentCompanySkillEntry { - key: string; - runtimeName: string; - source: string; - required?: boolean; - requiredReason?: string | null; -} - -export interface InstalledSkillTarget { - targetPath: string | null; - kind: "symlink" | "directory" | "file"; -} - -interface PersistentSkillSnapshotOptions { - adapterType: string; - availableEntries: AgentCompanySkillEntry[]; - desiredSkills: string[]; - installed: Map; - skillsHome: string; - locationLabel?: string | null; - installedDetail?: string | null; - missingDetail: string; - externalConflictDetail: string; - externalDetail: string; - warnings?: string[]; -} - -function normalizePathSlashes(value: string): string { - return value.replaceAll("\\", "/"); -} - -function isMaintainerOnlySkillTarget(candidate: string): boolean { - return normalizePathSlashes(candidate).includes("/.agents/skills/"); -} - -function skillLocationLabel(value: string | null | undefined): string | null { - if (typeof value !== "string") return null; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -function buildManagedSkillOrigin(entry: { required?: boolean }): Pick< - AdapterSkillEntry, - "origin" | "originLabel" | "readOnly" -> { - if (entry.required) { - return { - origin: "paperclip_required", - originLabel: "Required by AgentCompany", - readOnly: false, - }; - } - return { - origin: "company_managed", - originLabel: "Managed by AgentCompany", - readOnly: false, - }; -} - -function resolveInstalledEntryTarget( - skillsHome: string, - entryName: string, - dirent: Dirent, - linkedPath: string | null, -): InstalledSkillTarget { - const fullPath = path.join(skillsHome, entryName); - if (dirent.isSymbolicLink()) { - return { - targetPath: linkedPath ? path.resolve(path.dirname(fullPath), linkedPath) : null, - kind: "symlink", - }; - } - if (dirent.isDirectory()) { - return { targetPath: fullPath, kind: "directory" }; - } - return { targetPath: fullPath, kind: "file" }; -} - -function normalizeConfiguredAgentCompanyRuntimeSkills(value: unknown): AgentCompanySkillEntry[] { - if (!Array.isArray(value)) return []; - const out: AgentCompanySkillEntry[] = []; - for (const rawEntry of value) { - const entry = parseObject(rawEntry); - const key = asString(entry.key, asString(entry.name, "")).trim(); - const runtimeName = asString(entry.runtimeName, asString(entry.name, "")).trim(); - const source = asString(entry.source, "").trim(); - if (!key || !runtimeName || !source) continue; - out.push({ - key, - runtimeName, - source, - required: asBoolean(entry.required, false), - requiredReason: - typeof entry.requiredReason === "string" && entry.requiredReason.trim().length > 0 - ? entry.requiredReason.trim() - : null, - }); - } - return out; -} - -function canonicalizeDesiredAgentCompanySkillReference( - reference: string, - availableEntries: Array<{ key: string; runtimeName?: string | null }>, -): string { - const normalizedReference = reference.trim().toLowerCase(); - if (!normalizedReference) return ""; - - const exactKey = availableEntries.find((entry) => entry.key.trim().toLowerCase() === normalizedReference); - if (exactKey) return exactKey.key; - - const byRuntimeName = availableEntries.filter((entry) => - typeof entry.runtimeName === "string" && entry.runtimeName.trim().toLowerCase() === normalizedReference, - ); - if (byRuntimeName.length === 1) return byRuntimeName[0]!.key; - - const slugMatches = availableEntries.filter((entry) => - entry.key.trim().toLowerCase().split("/").pop() === normalizedReference, - ); - if (slugMatches.length === 1) return slugMatches[0]!.key; - - return normalizedReference; -} - -// --------------------------------------------------------------------------- -// Exported skill resolution functions -// --------------------------------------------------------------------------- - -/** - * Resolve the AgentCompany skills root directory from relative path candidates. - */ -export async function resolveAgentCompanySkillsDir( - moduleDir: string, - additionalCandidates: string[] = [], -): Promise { - const candidates = [ - ...AGENTCOMPANY_SKILL_ROOT_RELATIVE_CANDIDATES.map((relativePath) => path.resolve(moduleDir, relativePath)), - ...additionalCandidates.map((candidate) => path.resolve(candidate)), - ]; - const seenRoots = new Set(); - - for (const root of candidates) { - if (seenRoots.has(root)) continue; - seenRoots.add(root); - const isDirectory = await fs.stat(root).then((stats) => stats.isDirectory()).catch(() => false); - if (isDirectory) return root; - } - - return null; -} - -/** - * List skill entries from the AgentCompany skills directory. - */ -export async function listAgentCompanySkillEntries( - moduleDir: string, - additionalCandidates: string[] = [], -): Promise { - const root = await resolveAgentCompanySkillsDir(moduleDir, additionalCandidates); - if (!root) return []; - - try { - const entries = await fs.readdir(root, { withFileTypes: true }); - return entries - .filter((entry) => entry.isDirectory()) - .map((entry) => ({ - key: `agentcompany/agentcompany/${entry.name}`, - runtimeName: entry.name, - source: path.join(root, entry.name), - required: true, - requiredReason: "Bundled AgentCompany skills are always available for local adapters.", - })); - } catch { - return []; - } -} - -/** - * Read installed skill targets from a skill home directory (symlinks, dirs, files). - */ -export async function readInstalledSkillTargets(skillsHome: string): Promise> { - const entries = await fs.readdir(skillsHome, { withFileTypes: true }).catch(() => []); - const out = new Map(); - for (const entry of entries) { - const fullPath = path.join(skillsHome, entry.name); - const linkedPath = entry.isSymbolicLink() ? await fs.readlink(fullPath).catch(() => null) : null; - out.set(entry.name, resolveInstalledEntryTarget(skillsHome, entry.name, entry, linkedPath)); - } - return out; -} - -/** - * Build an AdapterSkillSnapshot for persistent-mode adapters (Gemini, Codex). - * Compares available entries against installed state and desired skill list. - */ -export function buildPersistentSkillSnapshot( - options: PersistentSkillSnapshotOptions, -): AdapterSkillSnapshot { - const { - adapterType, - availableEntries, - desiredSkills, - installed, - skillsHome, - locationLabel, - installedDetail, - missingDetail, - externalConflictDetail, - externalDetail, - } = options; - const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry])); - const desiredSet = new Set(desiredSkills); - const entries: AdapterSkillEntry[] = []; - const warnings = [...(options.warnings ?? [])]; - - for (const available of availableEntries) { - const installedEntry = installed.get(available.runtimeName) ?? null; - const desired = desiredSet.has(available.key); - let state: AdapterSkillEntry["state"] = "available"; - let managed = false; - let detail: string | null = null; - - if (installedEntry?.targetPath === available.source) { - managed = true; - state = desired ? "installed" : "stale"; - detail = installedDetail ?? null; - } else if (installedEntry) { - state = "external"; - detail = desired ? externalConflictDetail : externalDetail; - } else if (desired) { - state = "missing"; - detail = missingDetail; - } - - entries.push({ - key: available.key, - runtimeName: available.runtimeName, - desired, - managed, - state, - sourcePath: available.source, - targetPath: path.join(skillsHome, available.runtimeName), - detail, - required: Boolean(available.required), - requiredReason: available.requiredReason ?? null, - ...buildManagedSkillOrigin(available), - }); - } - - for (const desiredSkill of desiredSkills) { - if (availableByKey.has(desiredSkill)) continue; - warnings.push(`Desired skill "${desiredSkill}" is not available from the AgentCompany skills directory.`); - entries.push({ - key: desiredSkill, - runtimeName: null, - desired: true, - managed: true, - state: "missing", - sourcePath: null, - targetPath: null, - detail: "AgentCompany cannot find this skill in the local runtime skills directory.", - origin: "external_unknown", - originLabel: "External or unavailable", - readOnly: false, - }); - } - - for (const [name, installedEntry] of installed.entries()) { - if (availableEntries.some((entry) => entry.runtimeName === name)) continue; - entries.push({ - key: name, - runtimeName: name, - desired: false, - managed: false, - state: "external", - origin: "user_installed", - originLabel: "User-installed", - locationLabel: skillLocationLabel(locationLabel), - readOnly: true, - sourcePath: null, - targetPath: installedEntry.targetPath ?? path.join(skillsHome, name), - detail: externalDetail, - }); - } - - entries.sort((left, right) => left.key.localeCompare(right.key)); - - return { - adapterType, - supported: true, - mode: "persistent", - desiredSkills, - entries, - warnings, - }; -} - -/** - * Read runtime skill entries from adapter config or discover from module directory. - */ -export async function readAgentCompanyRuntimeSkillEntries( - config: Record, - moduleDir: string, - additionalCandidates: string[] = [], -): Promise { - const configuredEntries = normalizeConfiguredAgentCompanyRuntimeSkills(config.agentCompanyRuntimeSkills); - if (configuredEntries.length > 0) return configuredEntries; - return listAgentCompanySkillEntries(moduleDir, additionalCandidates); -} - -/** - * Read the desired skill sync preference from adapter config. - */ -export function readAgentCompanySkillSyncPreference(config: Record): { - explicit: boolean; - desiredSkills: string[]; -} { - const raw = config.agentCompanySkillSync; - if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { - return { explicit: false, desiredSkills: [] }; - } - const syncConfig = raw as Record; - const desiredValues = syncConfig.desiredSkills; - const desired = Array.isArray(desiredValues) - ? desiredValues - .filter((value): value is string => typeof value === "string") - .map((value) => value.trim()) - .filter(Boolean) - : []; - return { - explicit: Object.prototype.hasOwnProperty.call(raw, "desiredSkills"), - desiredSkills: Array.from(new Set(desired)), - }; -} - -/** - * Resolve the canonical desired skill list from config and available entries. - * Required skills are always included; explicit preferences are canonicalized. - */ -export function resolveAgentCompanyDesiredSkillNames( - config: Record, - availableEntries: Array<{ key: string; runtimeName?: string | null; required?: boolean }>, -): string[] { - const preference = readAgentCompanySkillSyncPreference(config); - const requiredSkills = availableEntries - .filter((entry) => entry.required) - .map((entry) => entry.key); - if (!preference.explicit) { - return Array.from(new Set(requiredSkills)); - } - const desiredSkills = preference.desiredSkills - .map((reference) => canonicalizeDesiredAgentCompanySkillReference(reference, availableEntries)) - .filter(Boolean); - return Array.from(new Set([...requiredSkills, ...desiredSkills])); -} - -/** - * Write desired skills back to adapter config. - */ -export function writeAgentCompanySkillSyncPreference( - config: Record, - desiredSkills: string[], -): Record { - const next = { ...config }; - const raw = next.agentCompanySkillSync; - const current = - typeof raw === "object" && raw !== null && !Array.isArray(raw) - ? { ...(raw as Record) } - : {}; - current.desiredSkills = Array.from( - new Set( - desiredSkills - .map((value) => value.trim()) - .filter(Boolean), - ), - ); - next.agentCompanySkillSync = current; - return next; -} - -/** - * Create or repair a symlink from source to target skill directory. - * Returns the action taken: "created", "repaired", or "skipped". - */ -export async function ensureAgentCompanySkillSymlink( - source: string, - target: string, - linkSkill: (source: string, target: string) => Promise = (linkSource, linkTarget) => - fs.symlink(linkSource, linkTarget), -): Promise<"created" | "repaired" | "skipped"> { - const existing = await fs.lstat(target).catch(() => null); - if (!existing) { - await linkSkill(source, target); - return "created"; - } - - if (!existing.isSymbolicLink()) { - return "skipped"; - } - - const linkedPath = await fs.readlink(target).catch(() => null); - if (!linkedPath) return "skipped"; - - const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath); - if (resolvedLinkedPath === source) { - return "skipped"; - } - - const linkedPathExists = await fs.stat(resolvedLinkedPath).then(() => true).catch(() => false); - if (linkedPathExists) { - return "skipped"; - } - - await fs.unlink(target); - await linkSkill(source, target); - return "repaired"; -} - -/** - * Remove symlinks in skillsHome that point to maintainer-only paths - * (.agents/skills/) and are not in the allowed set. - */ -export async function removeMaintainerOnlySkillSymlinks( - skillsHome: string, - allowedSkillNames: Iterable, -): Promise { - const allowed = new Set(Array.from(allowedSkillNames)); - try { - const entries = await fs.readdir(skillsHome, { withFileTypes: true }); - const removed: string[] = []; - for (const entry of entries) { - if (allowed.has(entry.name)) continue; - - const target = path.join(skillsHome, entry.name); - const existing = await fs.lstat(target).catch(() => null); - if (!existing?.isSymbolicLink()) continue; - - const linkedPath = await fs.readlink(target).catch(() => null); - if (!linkedPath) continue; - - const resolvedLinkedPath = path.isAbsolute(linkedPath) - ? linkedPath - : path.resolve(path.dirname(target), linkedPath); - if ( - !isMaintainerOnlySkillTarget(linkedPath) && - !isMaintainerOnlySkillTarget(resolvedLinkedPath) - ) { - continue; - } - - await fs.unlink(target); - removed.push(entry.name); - } - - return removed; - } catch { - return []; - } -} +export * from "./server-utils-parsers.js"; +export * from "./server-utils-process.js"; +export * from "./server-utils-skills.js"; +export * from "./server-utils-skills-sync.js"; diff --git a/packages/adapter-utils/src/test-utils.ts b/packages/adapter-utils/src/test-utils.ts index 8cbb8b69..26280a81 100644 --- a/packages/adapter-utils/src/test-utils.ts +++ b/packages/adapter-utils/src/test-utils.ts @@ -1,5 +1,6 @@ import path from "node:path"; import type { AdapterEnvironmentCheck, AdapterEnvironmentTestResult } from "./types.js"; +import { normalizeCommandValue } from "./server-utils-process.js"; export function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { if (checks.some((check) => check.level === "error")) return "fail"; @@ -21,7 +22,8 @@ export function firstNonEmptyLine(text: string): string { } export function commandLooksLike(command: string, expected: string): boolean { - const base = path.basename(command).toLowerCase(); + const normalized = normalizeCommandValue(command).replace(/\\/g, "/"); + const base = path.posix.basename(normalized).toLowerCase(); return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`; } diff --git a/scripts/build-desktop.mjs b/scripts/build-desktop.mjs index a5ac6f39..1d739f71 100644 --- a/scripts/build-desktop.mjs +++ b/scripts/build-desktop.mjs @@ -8,9 +8,13 @@ const packageJsonPath = join(__dirname, "..", "package.json"); const buildDir = join(__dirname, "..", "build"); const releaseDir = join(buildDir, "release"); const runtimeConfigPath = join(buildDir, "electron-builder.runtime.json"); +const localElectronDistPath = join(__dirname, "..", "node_modules", "electron", "dist"); +const customMacSignPath = join(__dirname, "electron-builder-mac-sign.mjs"); const extraArgs = process.argv.slice(2); const env = { ...process.env }; +const explicitElectronDistPath = env.AGENTCOMPANY_ELECTRON_DIST?.trim() || ""; +const noDeprecationFlag = "--no-deprecation"; const requestedUpdateUrl = env.AGENTCOMPANY_UPDATE_URL?.trim() ?? ""; const strictRelease = env.AGENTCOMPANY_STRICT_RELEASE === "true"; const hasMacSigningMaterial = Boolean( @@ -19,24 +23,128 @@ const hasMacSigningMaterial = Boolean( env.APPLE_API_KEY?.trim(), ); const isDirBuild = extraArgs.includes("--dir"); +const r2AccountId = env.AGENTCOMPANY_R2_ACCOUNT_ID?.trim() ?? env.R2_ACCOUNT_ID?.trim() ?? ""; +const r2Bucket = env.AGENTCOMPANY_R2_BUCKET?.trim() ?? env.R2_BUCKET?.trim() ?? ""; +const r2PublicUrl = env.AGENTCOMPANY_R2_PUBLIC_URL?.trim() ?? env.R2_PUBLIC_BASE_URL?.trim() ?? ""; +const r2Path = env.AGENTCOMPANY_R2_PATH?.trim() ?? env.AGENTCOMPANY_RELEASE_PREFIX?.trim() ?? "desktop"; +const r2Region = env.AGENTCOMPANY_R2_REGION?.trim() || "auto"; +const r2Endpoint = env.AGENTCOMPANY_R2_ENDPOINT?.trim() || (r2AccountId ? `https://${r2AccountId}.r2.cloudflarestorage.com` : ""); +const filteredUnsignedMacWarningSnippets = [ + "skipped macOS code signing reason=identity explicitly is set to null", + "arm64 requires signing, but identity is set to null and signing is being skipped", +]; -function bumpPatchVersion(currentVersion) { +function validateBuildVersion(currentVersion) { const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(currentVersion); if (!match) { throw new Error(`[build-desktop] Unsupported package version format: ${currentVersion}`); } - const [, major, minor, patch] = match; - return `${major}.${minor}.${Number.parseInt(patch, 10) + 1}`; + return currentVersion; +} + +function normalizeBaseUrl(url) { + return url.replace(/\/+$/, ""); +} + +function normalizePublishPath(targetPath) { + return targetPath.replace(/^\/+|\/+$/g, ""); +} + +function resolveUpdateUrl() { + if (requestedUpdateUrl && !requestedUpdateUrl.includes("example.invalid")) { + return normalizeBaseUrl(requestedUpdateUrl); + } + if (!r2PublicUrl) { + return ""; + } + const baseUrl = normalizeBaseUrl(r2PublicUrl); + const publishPath = normalizePublishPath(r2Path); + return publishPath ? `${baseUrl}/${publishPath}` : baseUrl; +} + +const resolvedUpdateUrl = resolveUpdateUrl(); +const hasR2PublishConfig = Boolean( + r2Bucket && + r2Endpoint && + resolvedUpdateUrl && + (env.AGENTCOMPANY_R2_ACCESS_KEY_ID?.trim() || env.R2_ACCESS_KEY_ID?.trim() || env.AWS_ACCESS_KEY_ID?.trim()) && + (env.AGENTCOMPANY_R2_SECRET_ACCESS_KEY?.trim() || env.R2_SECRET_ACCESS_KEY?.trim() || env.AWS_SECRET_ACCESS_KEY?.trim()), +); + +if (!env.AWS_ACCESS_KEY_ID && (env.AGENTCOMPANY_R2_ACCESS_KEY_ID?.trim() || env.R2_ACCESS_KEY_ID?.trim())) { + env.AWS_ACCESS_KEY_ID = env.AGENTCOMPANY_R2_ACCESS_KEY_ID?.trim() || env.R2_ACCESS_KEY_ID?.trim(); +} +if (!env.AWS_SECRET_ACCESS_KEY && (env.AGENTCOMPANY_R2_SECRET_ACCESS_KEY?.trim() || env.R2_SECRET_ACCESS_KEY?.trim())) { + env.AWS_SECRET_ACCESS_KEY = env.AGENTCOMPANY_R2_SECRET_ACCESS_KEY?.trim() || env.R2_SECRET_ACCESS_KEY?.trim(); +} +if (!env.AWS_REGION) { + env.AWS_REGION = r2Region; +} +if (!env.AWS_DEFAULT_REGION) { + env.AWS_DEFAULT_REGION = env.AWS_REGION; +} +if (!env.AWS_EC2_METADATA_DISABLED) { + env.AWS_EC2_METADATA_DISABLED = "true"; +} +if (!env.AGENTCOMPANY_UPDATE_URL && resolvedUpdateUrl) { + env.AGENTCOMPANY_UPDATE_URL = resolvedUpdateUrl; +} + +function resolveCurrentPlatformArg() { + switch (process.platform) { + case "darwin": + return "--mac"; + case "win32": + return "--win"; + default: + return "--linux"; + } +} + +function resolveCurrentArchArg() { + switch (process.arch) { + case "arm64": + return "--arm64"; + case "x64": + return "--x64"; + default: + return null; + } +} + +function hasExplicitArchArg(args) { + return args.some((arg) => + arg === "--arm64" || + arg === "--x64" || + arg === "--ia32" || + arg === "--universal" || + arg.startsWith("--arm64=") || + arg.startsWith("--x64=") || + arg.startsWith("--ia32=") || + arg.startsWith("--universal="), + ); +} + +function resolveCurrentMacArch() { + if (process.platform !== "darwin" || env.AGENTCOMPANY_MAC_ALL_ARCHES === "true" || hasExplicitArchArg(extraArgs)) { + return null; + } + return process.arch === "arm64" ? "arm64" : process.arch === "x64" ? "x64" : null; } async function resolveBuildVersion() { const raw = await readFile(packageJsonPath, "utf8"); const pkg = JSON.parse(raw); - const nextVersion = bumpPatchVersion(pkg.version); - return nextVersion; + return validateBuildVersion(pkg.version); } async function writeRuntimeConfig(buildVersion) { + const forcedMacArch = resolveCurrentMacArch(); + const isUnsignedLocalMacBuild = ( + process.platform === "darwin" && + !hasMacSigningMaterial && + env.AGENTCOMPANY_ENABLE_LOCAL_DMG !== "true" + ); const runtimeConfig = { extends: join(__dirname, "..", "electron-builder.yml"), directories: { @@ -46,34 +154,76 @@ async function writeRuntimeConfig(buildVersion) { version: buildVersion, }, }; - if (requestedUpdateUrl) { + if (explicitElectronDistPath) { + runtimeConfig.electronDist = explicitElectronDistPath; + } else if (isDirBuild) { + runtimeConfig.electronDist = localElectronDistPath; + } + if (hasR2PublishConfig) { + runtimeConfig.publish = [ + { + provider: "s3", + bucket: r2Bucket, + endpoint: r2Endpoint, + region: r2Region, + path: normalizePublishPath(r2Path) || undefined, + acl: null, + publishAutoUpdate: true, + }, + ]; + } else if (resolvedUpdateUrl) { runtimeConfig.publish = [ { provider: "generic", - url: requestedUpdateUrl, + url: resolvedUpdateUrl, }, ]; } - if (process.platform === "darwin" && !hasMacSigningMaterial && env.AGENTCOMPANY_ENABLE_LOCAL_DMG !== "true") { + if (isUnsignedLocalMacBuild) { if (strictRelease) { throw new Error("[build-desktop] Strict release mode requires macOS signing/notarization material."); } runtimeConfig.mac = { hardenedRuntime: false, + identity: null, }; if (!isDirBuild) { - runtimeConfig.mac.target = ["zip"]; + runtimeConfig.mac.target = forcedMacArch + ? [{ target: "zip", arch: [forcedMacArch] }] + : ["zip"]; } + } else if (process.platform === "darwin" && !hasMacSigningMaterial) { + runtimeConfig.mac = { + hardenedRuntime: false, + identity: null, + }; } else if (process.platform === "darwin") { runtimeConfig.mac = { hardenedRuntime: true, }; + if (hasMacSigningMaterial) { + runtimeConfig.mac.sign = customMacSignPath; + } + } + if (process.platform === "darwin" && !isDirBuild && forcedMacArch && !isUnsignedLocalMacBuild) { + runtimeConfig.mac ??= {}; + runtimeConfig.mac.target = [ + { target: "dmg", arch: [forcedMacArch] }, + { target: "zip", arch: [forcedMacArch] }, + ]; } await writeFile(runtimeConfigPath, `${JSON.stringify(runtimeConfig, null, 2)}\n`, "utf8"); } if (env.CSC_IDENTITY_AUTO_DISCOVERY === undefined) { - env.CSC_IDENTITY_AUTO_DISCOVERY = "false"; + env.CSC_IDENTITY_AUTO_DISCOVERY = hasMacSigningMaterial ? "true" : "false"; +} + +const existingNodeOptions = env.NODE_OPTIONS?.trim(); +if (!existingNodeOptions) { + env.NODE_OPTIONS = noDeprecationFlag; +} else if (!existingNodeOptions.includes(noDeprecationFlag)) { + env.NODE_OPTIONS = `${existingNodeOptions} ${noDeprecationFlag}`; } const allowedPublishModes = new Set(["always", "never", "onTag", "onTagOrDraft"]); @@ -89,6 +239,14 @@ const explicitPlatformArg = extraArgs.some((arg) => arg.startsWith("--win=") || arg.startsWith("--linux="), ); +const explicitArchArg = hasExplicitArchArg(extraArgs); +const shouldForceLocalMacZip = ( + process.platform === "darwin" && + !hasMacSigningMaterial && + env.AGENTCOMPANY_ENABLE_LOCAL_DMG !== "true" && + !explicitPlatformArg && + !isDirBuild +); const requestedPublishMode = env.AGENTCOMPANY_ELECTRON_PUBLISH?.trim(); let publishMode = "never"; @@ -107,31 +265,102 @@ const builderArgs = ["exec", "electron-builder", "--config", runtimeConfigPath]; if (!explicitPublishArg) { builderArgs.push("--publish", publishMode); } -if ( - process.platform === "darwin" && - !hasMacSigningMaterial && - env.AGENTCOMPANY_ENABLE_LOCAL_DMG !== "true" && - !explicitPlatformArg && - !isDirBuild -) { - builderArgs.push("--mac", "zip"); +if (!explicitPlatformArg) { + builderArgs.push(resolveCurrentPlatformArg()); +} +if (!explicitArchArg) { + const currentArchArg = resolveCurrentArchArg(); + if (currentArchArg) { + builderArgs.push(currentArchArg); + } +} +if (shouldForceLocalMacZip) { + builderArgs.push("zip"); } builderArgs.push(...extraArgs); -if (strictRelease && !requestedUpdateUrl) { - throw new Error("[build-desktop] Strict release mode requires AGENTCOMPANY_UPDATE_URL to be configured."); +if (strictRelease && !resolvedUpdateUrl) { + throw new Error("[build-desktop] Strict release mode requires AGENTCOMPANY_UPDATE_URL or an R2 public URL to be configured."); +} +if (publishMode !== "never" && !hasR2PublishConfig && !resolvedUpdateUrl) { + throw new Error( + "[build-desktop] Publishing requires either AGENTCOMPANY_UPDATE_URL (generic feed only) " + + "or full Cloudflare R2 publish configuration.", + ); +} + +const transientBuildFailurePatterns = [ + /chmod .*Electron Framework.*no such file or directory/i, +]; + +function shouldRetryElectronBuilder(output) { + return transientBuildFailurePatterns.some((pattern) => pattern.test(output)); +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function shouldSuppressBuildLine(line) { + return filteredUnsignedMacWarningSnippets.some((snippet) => line.includes(snippet)); +} + +function forwardChildOutput(stream, writer, onLine) { + if (!stream) return; + let pending = ""; + stream.on("data", (chunk) => { + pending += chunk.toString(); + const lines = pending.split(/\r?\n/); + pending = lines.pop() ?? ""; + for (const line of lines) { + onLine(line); + if (!shouldSuppressBuildLine(line)) { + writer.write(`${line}\n`); + } + } + }); + stream.on("end", () => { + if (!pending) return; + onLine(pending); + if (!shouldSuppressBuildLine(pending)) { + writer.write(`${pending}\n`); + } + }); +} + +function runElectronBuilder() { + return new Promise((resolve, reject) => { + const child = spawn("pnpm", builderArgs, { + stdio: ["ignore", "pipe", "pipe"], + env, + shell: process.platform === "win32", + }); + let output = ""; + const appendOutputLine = (line) => { + output += `${line}\n`; + }; + forwardChildOutput(child.stdout, process.stdout, appendOutputLine); + forwardChildOutput(child.stderr, process.stderr, appendOutputLine); + + child.on("error", reject); + child.on("close", (code, signal) => { + resolve({ code: code ?? 1, signal, output }); + }); + }); } -// On Windows, antivirus scanners may hold file locks on .asar/.exe files. -// Retry the rm with increasing delays to give the OS time to release them. for (let attempt = 0; attempt < 5; attempt++) { try { await rm(buildDir, { recursive: true, force: true }); break; } catch (rmErr) { - if (attempt < 4 && rmErr.code === "EBUSY") { + const retryable = rmErr.code === "EBUSY" || rmErr.code === "ENOTEMPTY" || rmErr.code === "EPERM"; + if (attempt < 4 && retryable) { const delay = (attempt + 1) * 3000; - console.log(`[build-desktop] Build dir locked (EBUSY), retrying in ${delay / 1000}s... (attempt ${attempt + 1}/5)`); + console.log( + `[build-desktop] Build dir cleanup blocked (${rmErr.code}), retrying in ${delay / 1000}s... ` + + `(attempt ${attempt + 1}/5)`, + ); await new Promise((r) => setTimeout(r, delay)); } else { throw rmErr; @@ -140,31 +369,28 @@ for (let attempt = 0; attempt < 5; attempt++) { } await mkdir(buildDir, { recursive: true }); const buildVersion = env.AGENTCOMPANY_BUILD_VERSION?.trim() || await resolveBuildVersion(); -const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")); -if (packageJson.version !== buildVersion) { - packageJson.version = buildVersion; - await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, "utf8"); -} await writeRuntimeConfig(buildVersion); env.AGENTCOMPANY_BUILD_VERSION = buildVersion; env.npm_package_version = buildVersion; console.log(`[build-desktop] Cleaned ${buildDir} and staged build version ${buildVersion}.`); - -const child = spawn("pnpm", builderArgs, { - stdio: "inherit", - env, - shell: process.platform === "win32", -}); - -child.on("exit", (code, signal) => { - if (signal) { - process.kill(process.pid, signal); - return; +for (let attempt = 0; attempt < 2; attempt++) { + try { + const result = await runElectronBuilder(); + if (result.signal) { + process.kill(process.pid, result.signal); + break; + } + if (result.code === 0) { + process.exit(0); + } + if (attempt === 0 && shouldRetryElectronBuilder(result.output)) { + console.warn("[build-desktop] electron-builder hit a transient macOS framework extraction error; retrying once."); + await delay(1000); + continue; + } + process.exit(result.code); + } catch (error) { + console.error(error); + process.exit(1); } - process.exit(code ?? 1); -}); - -child.on("error", (error) => { - console.error(error); - process.exit(1); -}); +} diff --git a/scripts/electron-builder-mac-sign.mjs b/scripts/electron-builder-mac-sign.mjs new file mode 100644 index 00000000..30aa5831 --- /dev/null +++ b/scripts/electron-builder-mac-sign.mjs @@ -0,0 +1,151 @@ +import { spawn } from "node:child_process"; +import { readdir } from "node:fs/promises"; +import { extname } from "node:path"; + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function runCommand(command, args, capture = false) { + return await new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit", + }); + let stdout = ""; + let stderr = ""; + + if (capture) { + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + } + + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) { + resolve({ stdout, stderr }); + return; + } + reject(new Error(stderr || stdout || `${command} failed with exit code ${code ?? 1}`)); + }); + }); +} + +async function runCodesign(args) { + await runCommand("/usr/bin/codesign", args, false); +} + +function buildCodesignArgs(targetPath, identity, keychain, fileOptions = {}) { + const args = [ + "--sign", identity, + "--force", + ]; + + if (keychain) { + args.push("--keychain", keychain); + } + if (fileOptions.timestamp !== false) { + args.push("--timestamp"); + } + if (fileOptions.hardenedRuntime) { + args.push("--options", "runtime"); + } + if (fileOptions.entitlements) { + args.push("--entitlements", fileOptions.entitlements); + } + if (Array.isArray(fileOptions.additionalArguments) && fileOptions.additionalArguments.length > 0) { + args.push(...fileOptions.additionalArguments); + } + + args.push(targetPath); + return args; +} + +async function isMachBinary(filePath) { + const extension = extname(filePath); + if (extension === ".dylib" || extension === ".node") { + return true; + } + + const { stdout } = await runCommand("/usr/bin/file", ["-b", filePath], true); + return stdout.includes("Mach-O"); +} + +async function collectSignTargets(rootPath) { + const bundleTargets = []; + const binaryTargets = []; + + async function walk(currentPath) { + const entries = await readdir(currentPath, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = `${currentPath}/${entry.name}`; + + if (entry.isSymbolicLink()) { + continue; + } + + if (entry.isDirectory()) { + if ( + entry.name.endsWith(".app") || + entry.name.endsWith(".framework") || + entry.name.endsWith(".xpc") || + entry.name.endsWith(".appex") || + entry.name.endsWith(".plugin") + ) { + bundleTargets.push(fullPath); + } + await walk(fullPath); + continue; + } + + if (await isMachBinary(fullPath)) { + binaryTargets.push(fullPath); + } + } + } + + await walk(rootPath); + + bundleTargets.sort((left, right) => right.length - left.length); + binaryTargets.sort((left, right) => right.length - left.length); + + return { bundleTargets, binaryTargets }; +} + +async function signTarget(targetPath, context) { + const fileOptions = context.optionsForFile?.(targetPath) ?? {}; + const args = buildCodesignArgs(targetPath, context.identity, context.keychain, fileOptions); + + let lastError; + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + await runCodesign(args); + return; + } catch (error) { + lastError = error; + if (attempt === 2) { + throw error; + } + await delay(5000); + } + } + + throw lastError; +} + +export default async function sign(context) { + const { bundleTargets, binaryTargets } = await collectSignTargets(context.app); + + for (const target of binaryTargets) { + await signTarget(target, context); + } + + for (const target of bundleTargets) { + await signTarget(target, context); + } + + await signTarget(context.app, context); +} diff --git a/scripts/generate-landing-release-links.mjs b/scripts/generate-landing-release-links.mjs new file mode 100644 index 00000000..2f5bff1c --- /dev/null +++ b/scripts/generate-landing-release-links.mjs @@ -0,0 +1,252 @@ +import { readdir, readFile, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +const rootDir = process.cwd(); +const localReleaseDir = join(rootDir, "local-release"); +const outputPath = join(rootDir, "landing", "components", "release-links.generated.ts"); + +async function loadEnvFile(filePath) { + if (!existsSync(filePath)) return; + const content = await readFile(filePath, "utf8"); + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const separatorIndex = line.indexOf("="); + if (separatorIndex <= 0) continue; + const key = line.slice(0, separatorIndex).trim(); + if (!key || process.env[key]) continue; + const rawValue = line.slice(separatorIndex + 1).trim(); + if (!rawValue) continue; + process.env[key] = rawValue.replace(/^['"]|['"]$/g, ""); + } +} + +function normalizeBaseUrl(url) { + return url.replace(/\/+$/, ""); +} + +function normalizePathSegment(pathValue) { + return pathValue.replace(/^\/+|\/+$/g, ""); +} + +function resolveUpdateBaseUrl() { + const explicit = process.env.AGENTCOMPANY_UPDATE_URL?.trim(); + if (explicit) return normalizeBaseUrl(explicit); + + const publicBaseUrl = + process.env.AGENTCOMPANY_R2_PUBLIC_URL?.trim() || + process.env.R2_PUBLIC_BASE_URL?.trim(); + const prefix = process.env.AGENTCOMPANY_RELEASE_PREFIX?.trim() || "AC"; + + if (!publicBaseUrl) { + throw new Error("Missing AGENTCOMPANY_UPDATE_URL / R2 public URL while generating landing release links."); + } + + const normalizedBase = normalizeBaseUrl(publicBaseUrl); + const normalizedPrefix = normalizePathSegment(prefix); + return `${normalizedBase}/${normalizedPrefix}`; +} + +function ensureSingle(files, predicate, label) { + const matches = files.filter(predicate); + if (matches.length !== 1) { + throw new Error(`Expected exactly one ${label} artifact, found ${matches.length}: ${matches.join(", ")}`); + } + return matches[0]; +} + +function extractVersion(fileNames) { + const versions = new Set( + fileNames + .map((fileName) => fileName.match(/(\d+\.\d+\.\d+)/)?.[1] ?? null) + .filter(Boolean), + ); + + if (versions.size !== 1) { + throw new Error(`Expected exactly one release version across artifacts, found: ${[...versions].join(", ")}`); + } + + return [...versions][0]; +} + +function urlFor(baseUrl, fileName) { + return `${baseUrl}/${encodeURIComponent(fileName)}`; +} + +async function main() { + await loadEnvFile(join(rootDir, ".env.release.local")); + await loadEnvFile(join(rootDir, ".env.release")); + + if (!existsSync(localReleaseDir)) { + throw new Error(`Local release directory not found: ${localReleaseDir}`); + } + + const baseUrl = resolveUpdateBaseUrl(); + const macFiles = (await readdir(join(localReleaseDir, "mac"))).filter((name) => !name.startsWith(".")); + const winFiles = (await readdir(join(localReleaseDir, "win"))).filter((name) => !name.startsWith(".")); + const linuxFiles = (await readdir(join(localReleaseDir, "linux"))).filter((name) => !name.startsWith(".")); + + const macArm64Dmg = ensureSingle(macFiles, (name) => name.endsWith(".dmg") && name.includes("arm64"), "macOS Apple Silicon .dmg"); + const macIntelDmg = ensureSingle(macFiles, (name) => name.endsWith(".dmg") && !name.includes("arm64"), "macOS Intel .dmg"); + const windowsX64X86Installer = ensureSingle( + winFiles, + (name) => name.endsWith(".exe") && name.includes("Setup") && !name.includes("arm64"), + "Windows x64/x86 installer", + ); + const windowsArm64Installer = ensureSingle( + winFiles, + (name) => name.endsWith(".exe") && name.includes("Setup") && name.includes("arm64"), + "Windows arm64 installer", + ); + const linuxX64AppImage = ensureSingle( + linuxFiles, + (name) => name.endsWith(".AppImage") && !name.includes("arm64"), + "Linux x64 AppImage", + ); + const linuxArm64AppImage = ensureSingle( + linuxFiles, + (name) => name.endsWith(".AppImage") && name.includes("arm64"), + "Linux arm64 AppImage", + ); + const linuxX64Deb = ensureSingle( + linuxFiles, + (name) => name.endsWith(".deb") && name.includes("amd64"), + "Linux x64 .deb", + ); + const linuxArm64Deb = ensureSingle( + linuxFiles, + (name) => name.endsWith(".deb") && name.includes("arm64"), + "Linux arm64 .deb", + ); + const linuxX64Rpm = ensureSingle( + linuxFiles, + (name) => name.endsWith(".rpm") && (name.includes("x86_64") || name.includes("amd64")), + "Linux x64 .rpm", + ); + const linuxArm64Rpm = ensureSingle( + linuxFiles, + (name) => name.endsWith(".rpm") && (name.includes("aarch64") || name.includes("arm64")), + "Linux arm64 .rpm", + ); + + const version = extractVersion([ + macArm64Dmg, + macIntelDmg, + windowsX64X86Installer, + windowsArm64Installer, + linuxX64AppImage, + linuxArm64AppImage, + linuxX64Deb, + linuxArm64Deb, + linuxX64Rpm, + linuxArm64Rpm, + ]); + + const content = `export type PlatformDownloadAsset = { + id: string; + label: string; + href: string; + note: string; +}; + +export type PlatformDownload = { + id: "macos" | "windows" | "linux"; + title: string; + note: string; + assets: PlatformDownloadAsset[]; +}; + +export const releaseVersion = "${version}"; + +export const platformDownloads: PlatformDownload[] = [ + { + id: "macos", + title: "macOS", + note: "Signed + notarized DMG builds", + assets: [ + { + id: "macos-arm64-dmg", + label: "Apple Silicon DMG", + href: "${urlFor(baseUrl, macArm64Dmg)}", + note: "arm64", + }, + { + id: "macos-x64-dmg", + label: "Intel Mac DMG", + href: "${urlFor(baseUrl, macIntelDmg)}", + note: "x64", + }, + ], + }, + { + id: "windows", + title: "Windows", + note: "NSIS installer builds", + assets: [ + { + id: "windows-x64-x86-installer", + label: "Windows x64/x86 Installer", + href: "${urlFor(baseUrl, windowsX64X86Installer)}", + note: "x64 + ia32", + }, + { + id: "windows-arm64-installer", + label: "Windows ARM64 Installer", + href: "${urlFor(baseUrl, windowsArm64Installer)}", + note: "arm64", + }, + ], + }, + { + id: "linux", + title: "Linux", + note: "AppImage, DEB, and RPM builds", + assets: [ + { + id: "linux-x64-appimage", + label: "Linux x64 AppImage", + href: "${urlFor(baseUrl, linuxX64AppImage)}", + note: "x64", + }, + { + id: "linux-x64-deb", + label: "Linux x64 DEB", + href: "${urlFor(baseUrl, linuxX64Deb)}", + note: "x64", + }, + { + id: "linux-x64-rpm", + label: "Linux x64 RPM", + href: "${urlFor(baseUrl, linuxX64Rpm)}", + note: "x64", + }, + { + id: "linux-arm64-appimage", + label: "Linux ARM64 AppImage", + href: "${urlFor(baseUrl, linuxArm64AppImage)}", + note: "arm64", + }, + { + id: "linux-arm64-deb", + label: "Linux ARM64 DEB", + href: "${urlFor(baseUrl, linuxArm64Deb)}", + note: "arm64", + }, + { + id: "linux-arm64-rpm", + label: "Linux ARM64 RPM", + href: "${urlFor(baseUrl, linuxArm64Rpm)}", + note: "arm64", + }, + ], + }, +]; +`; + + await writeFile(outputPath, content, "utf8"); + console.log(`[generate-landing-release-links] wrote ${outputPath}`); + console.log(`[generate-landing-release-links] version ${version}`); +} + +await main(); diff --git a/scripts/release-local.mjs b/scripts/release-local.mjs new file mode 100644 index 00000000..ebf1d261 --- /dev/null +++ b/scripts/release-local.mjs @@ -0,0 +1,283 @@ +import { spawn } from "node:child_process"; +import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import os from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { prepareR2ReleaseHost, uploadReleaseFiles } from "./release-local/r2.mjs"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const rootDir = join(__dirname, ".."); +const buildReleaseDir = join(rootDir, "build", "release"); +const localReleaseDir = join(rootDir, "local-release"); +const pnpmVersion = "10.24.0"; +const dockerNodeModulesVolume = `${process.cwd().split("/").filter(Boolean).at(-1) ?? "agentcompany"}-node-modules`; + +function parseArgs(argv) { + const options = { + skipVerify: false, + upload: true, + platforms: ["mac", "win", "linux"], + }; + for (const arg of argv) { + if (arg === "--skip-verify") { + options.skipVerify = true; + continue; + } + if (arg === "--no-upload") { + options.upload = false; + continue; + } + if (arg.startsWith("--platforms=")) { + options.platforms = arg.slice("--platforms=".length) + .split(",") + .map((platform) => platform.trim()) + .filter(Boolean); + continue; + } + if (arg === "--help") { + printHelp(); + process.exit(0); + } + throw new Error(`Unknown argument: ${arg}`); + } + return options; +} + +function printHelp() { + console.log(`Usage: node scripts/release-local.mjs [options] + +Options: + --platforms=mac,win,linux Comma-separated platform list (default: all) + --skip-verify Skip pnpm verify before release builds + --no-upload Build artifacts locally without publishing + --help Show this help + +Environment loading: + - .env.release + - .env.release.local + - existing shell environment (takes precedence) +`); +} + +async function loadEnvFile(filePath) { + if (!existsSync(filePath)) return; + const content = await readFile(filePath, "utf8"); + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const separatorIndex = line.indexOf("="); + if (separatorIndex <= 0) continue; + const key = line.slice(0, separatorIndex).trim(); + if (!key || process.env[key]) continue; + const rawValue = line.slice(separatorIndex + 1).trim(); + if (!rawValue) continue; + process.env[key] = rawValue.replace(/^['"]|['"]$/g, ""); + } +} + +function hasDocker() { + return spawnSyncSuccess("docker", ["--version"]); +} + +function spawnSyncSuccess(command, args) { + return new Promise((resolve) => { + const child = spawn(command, args, { stdio: "ignore" }); + child.on("error", () => resolve(false)); + child.on("close", (code) => resolve(code === 0)); + }); +} + +function hasMacSigningCredential() { + return Boolean(process.env.CSC_LINK?.trim() || process.env.CSC_NAME?.trim()); +} + +function hasMacNotarizationCredential() { + return Boolean( + (process.env.APPLE_ID?.trim() && + process.env.APPLE_APP_SPECIFIC_PASSWORD?.trim() && + process.env.APPLE_TEAM_ID?.trim()) || + (process.env.APPLE_API_KEY?.trim() && + process.env.APPLE_API_KEY_ID?.trim() && + process.env.APPLE_API_ISSUER?.trim()), + ); +} + +async function validatePreflight(platforms, upload) { + if (platforms.includes("mac") && process.platform !== "darwin") { + throw new Error("Signed macOS release builds must run on macOS."); + } + if (platforms.includes("mac") && (!hasMacSigningCredential() || !hasMacNotarizationCredential())) { + throw new Error( + "Signed macOS release requires signing credentials (CSC_LINK or CSC_NAME) and notarization credentials " + + "(APPLE_ID/APPLE_APP_SPECIFIC_PASSWORD/APPLE_TEAM_ID or APPLE_API_KEY trio).", + ); + } + if ((platforms.includes("win") || platforms.includes("linux")) && !(await hasDocker())) { + throw new Error("Docker is required to build Windows/Linux locally from this macOS host."); + } + if (!upload) { + return; + } +} + +async function run(command, args, env = process.env) { + await new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: rootDir, + env, + stdio: "inherit", + shell: process.platform === "win32", + }); + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) { + resolve(); + return; + } + reject(new Error(`${command} ${args.join(" ")} failed with exit code ${code ?? 1}`)); + }); + }); +} + +async function stageRelease(platform) { + const destination = join(localReleaseDir, platform); + await rm(destination, { recursive: true, force: true }); + await mkdir(localReleaseDir, { recursive: true }); + await cp(buildReleaseDir, destination, { recursive: true }); + console.log(`[release-local] staged ${platform} artifacts in ${destination}`); +} + +function makeDockerEnv(platform) { + const env = { + AGENTCOMPANY_ELECTRON_PUBLISH: "never", + AGENTCOMPANY_UPDATE_URL: process.env.AGENTCOMPANY_UPDATE_URL ?? "", + AGENTCOMPANY_ELECTRON_DIST: "/root/.cache/electron", + }; + if (platform === "win") { + env.CSC_LINK = process.env.WIN_CSC_LINK ?? ""; + env.CSC_KEY_PASSWORD = process.env.WIN_CSC_KEY_PASSWORD ?? ""; + } + return env; +} + +function getDockerBuildCommands(platform) { + if (platform === "win") { + return [ + "node scripts/build-desktop.mjs --win nsis --x64 --ia32", + "node scripts/build-desktop.mjs --win nsis --arm64", + ]; + } + return [ + "node scripts/build-desktop.mjs --linux AppImage deb rpm --x64", + "node scripts/build-desktop.mjs --linux AppImage deb rpm --arm64", + ]; +} + +async function runDockerBuild(platform) { + const isWindows = platform === "win"; + const image = isWindows ? "electronuserland/builder:wine" : "electronuserland/builder"; + const env = makeDockerEnv(platform); + const cacheDir = join(os.homedir(), ".cache"); + const command = [ + "corepack enable", + `corepack prepare pnpm@${pnpmVersion} --activate`, + "pnpm install --frozen-lockfile", + "pnpm exec electron-vite build", + ...getDockerBuildCommands(platform), + ].join(" && "); + const dockerArgs = [ + "run", + "--rm", + "--platform", "linux/amd64", + "-v", `${rootDir}:/project`, + "-v", `${dockerNodeModulesVolume}:/project/node_modules`, + "-v", `${join(cacheDir, "electron")}:/root/.cache/electron`, + "-v", `${join(cacheDir, "electron-builder")}:/root/.cache/electron-builder`, + "-w", "/project", + ]; + for (const [key, value] of Object.entries(env)) { + dockerArgs.push("-e", `${key}=${value}`); + } + dockerArgs.push(image, "/bin/bash", "-lc", command); + await run("docker", dockerArgs); +} + +async function buildMac() { + const env = { + ...process.env, + AGENTCOMPANY_ELECTRON_PUBLISH: "never", + AGENTCOMPANY_ENABLE_LOCAL_DMG: "true", + CSC_IDENTITY_AUTO_DISCOVERY: "true", + }; + await run("pnpm", ["exec", "electron-vite", "build"], env); + await run("node", ["scripts/build-desktop.mjs", "--mac", "--arm64", "--x64"], env); + await stageRelease("mac"); +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + await loadEnvFile(join(rootDir, ".env.release.local")); + await loadEnvFile(join(rootDir, ".env.release")); + process.env.AGENTCOMPANY_ELECTRON_PUBLISH = "never"; + const r2 = options.upload ? await prepareR2ReleaseHost({ rootDir }) : null; + await validatePreflight(options.platforms, options.upload); + + console.log(`[release-local] update URL: ${process.env.AGENTCOMPANY_UPDATE_URL}`); + console.log(`[release-local] upload mode: ${options.upload ? "enabled" : "disabled"}`); + console.log(`[release-local] platforms: ${options.platforms.join(", ")}`); + + await rm(localReleaseDir, { recursive: true, force: true }); + if (!options.skipVerify) { + await run("pnpm", ["verify"]); + } + if (options.platforms.includes("mac")) { + await buildMac(); + if (r2) { + await uploadReleaseFiles({ + rootDir, + releaseDir: buildReleaseDir, + bucket: r2.bucket, + releasePrefix: r2.releasePrefix, + }); + } + } + if (options.platforms.includes("win")) { + await runDockerBuild("win"); + await stageRelease("win"); + if (r2) { + await uploadReleaseFiles({ + rootDir, + releaseDir: buildReleaseDir, + bucket: r2.bucket, + releasePrefix: r2.releasePrefix, + }); + } + } + if (options.platforms.includes("linux")) { + await runDockerBuild("linux"); + await stageRelease("linux"); + if (r2) { + await uploadReleaseFiles({ + rootDir, + releaseDir: buildReleaseDir, + bucket: r2.bucket, + releasePrefix: r2.releasePrefix, + }); + } + } + await mkdir(localReleaseDir, { recursive: true }); + await writeFile( + join(localReleaseDir, "manifest.json"), + JSON.stringify({ + generatedAt: new Date().toISOString(), + platforms: options.platforms, + uploadEnabled: options.upload, + updateUrl: process.env.AGENTCOMPANY_UPDATE_URL, + }, null, 2), + "utf8", + ); + console.log(`[release-local] complete. Local artifact staging: ${localReleaseDir}`); +} + +await main(); diff --git a/scripts/release-local/r2.mjs b/scripts/release-local/r2.mjs new file mode 100644 index 00000000..b6514894 --- /dev/null +++ b/scripts/release-local/r2.mjs @@ -0,0 +1,143 @@ +import { readdir } from "node:fs/promises"; +import { join } from "node:path"; +import { spawn } from "node:child_process"; + +const defaultBucket = "agentcompany-updates"; +const defaultPrefix = "AC"; +const ignoredReleaseFiles = new Set(["builder-debug.yml"]); + +function normalizeBaseUrl(url) { + return url.replace(/\/+$/, ""); +} + +function normalizePathSegment(pathValue) { + return pathValue.replace(/^\/+|\/+$/g, ""); +} + +function guessContentType(fileName) { + if (fileName.endsWith(".yml")) return "text/yaml; charset=utf-8"; + if (fileName.endsWith(".blockmap")) return "application/octet-stream"; + if (fileName.endsWith(".zip")) return "application/zip"; + if (fileName.endsWith(".dmg")) return "application/x-apple-diskimage"; + if (fileName.endsWith(".exe")) return "application/vnd.microsoft.portable-executable"; + if (fileName.endsWith(".AppImage")) return "application/octet-stream"; + if (fileName.endsWith(".deb")) return "application/vnd.debian.binary-package"; + if (fileName.endsWith(".rpm")) return "application/x-rpm"; + if (fileName.endsWith(".snap")) return "application/vnd.snap"; + return "application/octet-stream"; +} + +async function runWrangler(args, cwd, capture = false) { + return await new Promise((resolve, reject) => { + const child = spawn("npx", ["wrangler", ...args], { + cwd, + stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit", + shell: process.platform === "win32", + }); + let stdout = ""; + let stderr = ""; + if (capture) { + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + } + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) { + resolve({ stdout, stderr }); + return; + } + reject(new Error(stderr || stdout || `wrangler ${args.join(" ")} failed with exit code ${code ?? 1}`)); + }); + }); +} + +function resolveUpdateUrl(publicBaseUrl, releasePrefix) { + const baseUrl = normalizeBaseUrl(publicBaseUrl); + const prefix = normalizePathSegment(releasePrefix); + return prefix ? `${baseUrl}/${prefix}` : baseUrl; +} + +export async function prepareR2ReleaseHost({ rootDir }) { + const prefix = normalizePathSegment(process.env.AGENTCOMPANY_RELEASE_PREFIX?.trim() || defaultPrefix); + const whoami = JSON.parse((await runWrangler(["whoami", "--json"], rootDir, true)).stdout); + const accountId = process.env.R2_ACCOUNT_ID?.trim() + || process.env.AGENTCOMPANY_R2_ACCOUNT_ID?.trim() + || whoami.accounts?.[0]?.id; + if (!accountId) { + throw new Error("Unable to resolve Cloudflare account ID from wrangler."); + } + + const bucket = process.env.R2_BUCKET?.trim() + || process.env.AGENTCOMPANY_R2_BUCKET?.trim() + || defaultBucket; + + try { + await runWrangler(["r2", "bucket", "info", bucket, "--json"], rootDir, true); + } catch (error) { + if (!String(error).includes("does not exist")) { + throw error; + } + await runWrangler(["r2", "bucket", "create", bucket], rootDir, false); + } + + let publicBaseUrl = process.env.R2_PUBLIC_BASE_URL?.trim() + || process.env.AGENTCOMPANY_R2_PUBLIC_URL?.trim() + || ""; + + if (!publicBaseUrl) { + const devUrlState = await runWrangler(["r2", "bucket", "dev-url", "get", bucket], rootDir, true) + .then((result) => result.stdout) + .catch(() => ""); + if (!devUrlState.includes("https://")) { + await runWrangler(["r2", "bucket", "dev-url", "enable", bucket], rootDir, false); + } + const enabledOutput = (await runWrangler(["r2", "bucket", "dev-url", "get", bucket], rootDir, true)).stdout; + const match = enabledOutput.match(/https:\/\/[^\s']+\.r2\.dev/); + if (!match) { + throw new Error("Unable to resolve R2 public dev URL from wrangler."); + } + publicBaseUrl = match[0]; + } + + process.env.R2_ACCOUNT_ID = accountId; + process.env.AGENTCOMPANY_R2_ACCOUNT_ID = accountId; + process.env.R2_BUCKET = bucket; + process.env.AGENTCOMPANY_R2_BUCKET = bucket; + process.env.R2_PUBLIC_BASE_URL = publicBaseUrl; + process.env.AGENTCOMPANY_R2_PUBLIC_URL = publicBaseUrl; + process.env.AGENTCOMPANY_RELEASE_PREFIX = prefix; + process.env.AGENTCOMPANY_R2_PATH = prefix; + process.env.AGENTCOMPANY_UPDATE_URL = process.env.AGENTCOMPANY_UPDATE_URL?.trim() + || resolveUpdateUrl(publicBaseUrl, prefix); + + return { + accountId, + bucket, + publicBaseUrl, + releasePrefix: prefix, + updateUrl: process.env.AGENTCOMPANY_UPDATE_URL, + }; +} + +export async function uploadReleaseFiles({ rootDir, releaseDir, bucket, releasePrefix }) { + const files = (await readdir(releaseDir, { withFileTypes: true })) + .filter((entry) => entry.isFile() && !ignoredReleaseFiles.has(entry.name)) + .map((entry) => entry.name); + + for (const fileName of files) { + const objectPath = `${bucket}/${normalizePathSegment(releasePrefix)}/${fileName}`; + const filePath = join(releaseDir, fileName); + const args = [ + "r2", "object", "put", objectPath, + "--remote", + "--file", filePath, + "--content-type", guessContentType(fileName), + "--cache-control", fileName.endsWith(".yml") ? "no-cache" : "public, max-age=31536000, immutable", + ]; + await runWrangler(args, rootDir, false); + } +} diff --git a/scripts/run-vitest.mjs b/scripts/run-vitest.mjs new file mode 100644 index 00000000..083ec180 --- /dev/null +++ b/scripts/run-vitest.mjs @@ -0,0 +1,32 @@ +import { spawn } from "node:child_process"; +import { resolve } from "node:path"; + +const vitestEntrypoint = resolve(process.cwd(), "node_modules/vitest/vitest.mjs"); +const args = process.argv.slice(2); +const warningFlag = "--disable-warning=ExperimentalWarning"; +const env = { ...process.env }; +const existingNodeOptions = env.NODE_OPTIONS?.trim(); + +if (!existingNodeOptions) { + env.NODE_OPTIONS = warningFlag; +} else if (!existingNodeOptions.includes(warningFlag)) { + env.NODE_OPTIONS = `${existingNodeOptions} ${warningFlag}`; +} + +const child = spawn(process.execPath, ["--disable-warning=ExperimentalWarning", vitestEntrypoint, ...args], { + stdio: "inherit", + env, +}); + +child.on("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 1); +}); + +child.on("error", (error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/main/agent-permissions.ts b/src/main/agent-permissions.ts index 4f307fd1..dd70d934 100644 --- a/src/main/agent-permissions.ts +++ b/src/main/agent-permissions.ts @@ -9,6 +9,33 @@ function safeGetAgent(db: AppDatabase, agentId: string) { } } +const MANAGEMENT_KEYWORDS = [ + "chief", + "cto", + "cmo", + "coo", + "cfo", + "vp", + "vice president", + "head", + "director", + "manager", + "lead", +]; + +function hasManagementAuthority(agent: { role: string; title: string; department: string | null }) { + if (agent.department === "hr") { + return true; + } + + const text = `${agent.role} ${agent.title}`.toLowerCase(); + return MANAGEMENT_KEYWORDS.some((keyword) => text.includes(keyword)); +} + +function hasOrganizationPermission(db: AppDatabase, agentId: string) { + return db.hasAgentPermission(agentId, "organization_manage"); +} + function managesAgent(db: AppDatabase, managerId: string, targetAgentId: string): boolean { if (managerId === targetAgentId) { return true; @@ -42,7 +69,24 @@ export function canManageOrganization(db: AppDatabase, companyId: string, agentI if (!agent.reportsTo) { return true; } - return db.getDirectReports(agentId, companyId).length > 0 || agent.department === "hr"; + 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.getDirectReports(agentId, companyId).length > 0) { + return true; + } + return hasManagementAuthority(agent); } export function canAssignTask(db: AppDatabase, companyId: string, actorAgentId: string, targetAgentId: string | null): boolean { diff --git a/src/main/api-server.ts b/src/main/api-server.ts index a22f3702..9d0e172d 100644 --- a/src/main/api-server.ts +++ b/src/main/api-server.ts @@ -1,2324 +1,4 @@ -import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; -import log from "electron-log/main.js"; -import { z } from "zod"; -import { agentMessageInputSchema, approvalInputSchema, goalInputSchema, hireRequestSchema, projectInputSchema, taskInputSchema } from "@shared/contracts"; -import { getConnectorExecutionReadinessIssue } from "@shared/connector-policy"; -import { validateAgentToken, type TokenPayload } from "./agent-auth"; -import { canAssignTask, canControlAgent, canManageGoal, canManageOrganization, canManageProject, canUpdateTask } from "./agent-permissions"; -import { resolveApprovalDecision, shouldAutoApproveApproval } from "./approval-policy"; -import type { AppDatabase } from "./database"; -import { inferTaskType } from "./connector-matching"; -import { validateTaskCreation } from "./task-quality-gates"; -import { dispatchAgentMessage } from "./message-dispatch"; -import type { DesktopEvent, SectionId } from "@shared/types"; - -/** - * Decode an HTTP request body to a UTF-8 string, handling the common Windows - * encoding issue where Chinese/CJK characters arrive in GBK (CP936) instead - * of UTF-8. On Chinese Windows the system ANSI code page is GBK; tools like - * curl encode command-line arguments using that code page when stdout is piped - * (chcp 65001 does not help because there is no real console). We try strict - * UTF-8 first and, only if the bytes are invalid UTF-8, fall back to GBK. - */ -function decodeBodyToUtf8(raw: Buffer): string { - if (raw.length === 0) return ""; - try { - return new TextDecoder("utf-8", { fatal: true }).decode(raw); - } catch { - // Bytes are not valid UTF-8 — most likely GBK from Chinese Windows. - try { - return new TextDecoder("gbk").decode(raw); - } catch { - // Last resort: lossy UTF-8 (replaces invalid bytes with U+FFFD). - return raw.toString("utf8"); - } - } -} - -type RequestHandler = ( - req: IncomingMessage, - res: ServerResponse, - params: Record, - token: TokenPayload, - body: unknown, -) => void | Promise; - -interface Route { - method: string; - pattern: RegExp; - paramNames: string[]; - handler: RequestHandler; -} - -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(), -}); - -const checkoutSchema = z.object({ - agentId: z.string().uuid().optional(), -}); - -const approvalListQuerySchema = z.object({ - requestedByAgentId: z.string().uuid().optional(), - relatedTaskId: z.string().uuid().optional(), - status: z.string().optional(), -}); - -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; - onApprovalResolved: (approvalId: string, companyId: string, decision: string) => void; - onDocumentCreated: (companyId: string, authorAgentId: string | null, projectId: string | null) => void; - onAutoAssignTask: (taskId: string, companyId: string) => string | null; - emitEvent: (event: DesktopEvent) => void; - notify: (options: { title: string; body: string; urgency: "critical" | "informational"; navigation?: { section: SectionId; entityId?: string }; batchKey?: string }) => void; -} - -export class AgentApiServer { - private routes: Route[] = []; - private server: ReturnType | null = null; - private port = 0; - private db: AppDatabase; - private onDomainChanged: () => void; - private onWakeAgent: (agentId: string, companyId: string, trigger: string) => string | null; - private emitEvent: (event: DesktopEvent) => void; - private callbacks: ApiServerEventCallbacks; - private allowedOrigins: Set; - private onBrowserAction: ((actionId: string, companyId: string, socialAccountId: string, actionType: string, payload: Record) => void) | null = null; - - constructor( - db: AppDatabase, - onDomainChanged: () => void, - onWakeAgent: (agentId: string, companyId: string, trigger: string) => string | null, - callbacks?: Partial, - allowedOrigins: Iterable = ["app://-"], - ) { - this.db = db; - this.onDomainChanged = onDomainChanged; - this.onWakeAgent = onWakeAgent; - this.emitEvent = callbacks?.emitEvent ?? (() => {}); - this.allowedOrigins = new Set(Array.from(allowedOrigins).filter(Boolean)); - this.callbacks = { - onDomainChanged, - onWakeAgent, - onTaskCreated: callbacks?.onTaskCreated ?? (() => {}), - onTaskStatusChanged: callbacks?.onTaskStatusChanged ?? (() => {}), - onCommentPosted: callbacks?.onCommentPosted ?? (() => {}), - onApprovalResolved: callbacks?.onApprovalResolved ?? (() => {}), - onDocumentCreated: callbacks?.onDocumentCreated ?? (() => {}), - onAutoAssignTask: callbacks?.onAutoAssignTask ?? (() => null), - emitEvent: callbacks?.emitEvent ?? (() => {}), - notify: callbacks?.notify ?? (() => {}), - }; - this.registerRoutes(); - } - - setBrowserActionHandler(handler: (actionId: string, companyId: string, socialAccountId: string, actionType: string, payload: Record) => void) { - this.onBrowserAction = handler; - } - - getPort(): number { - return this.port; - } - - async start(): Promise { - return new Promise((resolve, reject) => { - this.server = createServer((req, res) => void this.handleRequest(req, res)); - this.server.listen(0, "127.0.0.1", () => { - const addr = this.server!.address(); - 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; - } - - private 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 async handleRequest(req: IncomingMessage, res: ServerResponse) { - const origin = req.headers.origin; - 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"); - } - 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 (req.method === "OPTIONS") { - 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 authHeader = req.headers.authorization; - if (!authHeader?.startsWith("Bearer ")) { - this.json(res, 401, { error: "Missing or invalid Authorization header" }); - return; - } - const token = validateAgentToken(authHeader.slice(7)); - if (!token) { - this.json(res, 401, { error: "Invalid or expired token" }); - return; - } - - const runHeader = req.headers["x-agent-company-run-id"]; - const claimedRunId = Array.isArray(runHeader) ? runHeader[0] : runHeader; - if (typeof claimedRunId !== "string" || claimedRunId.trim() !== token.runId) { - this.json(res, 401, { error: "Missing or mismatched X-Agent-Company-Run-Id header" }); - return; - } - - const runState = this.db.getRunAuthState(token.runId); - if (!runState || runState.companyId !== token.companyId || runState.agentId !== token.agentId) { - this.json(res, 403, { error: "Token references an unknown or mismatched run" }); - return; - } - - if (!["queued", "running"].includes(runState.status)) { - this.json(res, 401, { error: "Run token is no longer active" }); - return; - } - - for (const route of this.routes) { - if (route.method !== method) continue; - const match = pathname.match(route.pattern); - if (!match) continue; - - const params: Record = {}; - route.paramNames.forEach((name, i) => { - params[name] = match[i + 1]!; - }); - - let body: unknown = null; - if (["POST", "PATCH", "PUT"].includes(method)) { - 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 { - 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); - }); - } - - private json(res: ServerResponse, status: number, data: unknown) { - res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" }); - res.end(JSON.stringify(data)); - } - - private 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.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; - } - - private registerRoutes() { - this.addRoute("GET", "/api/agents/me", (_req, res, _params, token) => { - const agent = this.db.getAgent(token.agentId); - const chainOfCommand = this.db.getChainOfCommand(token.agentId); - const directReports = this.db.getDirectReports(token.agentId, token.companyId); - this.json(res, 200, { agent, chainOfCommand, directReports }); - }); - - this.addRoute("GET", "/api/companies/:companyId/issues", (req, res, params, token) => { - const companyId = params.companyId!; - if (companyId !== token.companyId) { - this.json(res, 403, { error: "Company mismatch" }); - return; - } - const url = new URL(req.url ?? "/", `http://127.0.0.1:${this.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 = this.db.listAgentTasks(assigneeAgentId, companyId, statuses); - this.json(res, 200, tasks); - } else { - const snapshot = this.db.listSnapshot(); - const tasks = snapshot.tasks.filter((t) => t.companyId === companyId && statuses.includes(t.status)); - this.json(res, 200, tasks); - } - }); - - this.addRoute("GET", "/api/issues/:issueId", (_req, res, params, token) => { - try { - const task = this.db.getTask(params.issueId!); - if (task.companyId !== token.companyId) { - this.json(res, 404, { error: "Task not found" }); - return; - } - this.json(res, 200, task); - } catch { - this.json(res, 404, { error: "Task not found" }); - } - }); - - this.addRoute("POST", "/api/issues/:issueId/checkout", (_req, res, params, token, body) => { - const parsedBody = checkoutSchema.safeParse(body); - if (!parsedBody.success) { - this.json(res, 400, { error: "Invalid checkout payload" }); - return; - } - const agentId = parsedBody.data.agentId ?? token.agentId; - if (agentId !== token.agentId) { - this.json(res, 403, { error: "Agents can only checkout work for themselves" }); - return; - } - let task; - try { - task = this.db.getTask(params.issueId!); - } catch { - this.json(res, 404, { error: "Task not found" }); - return; - } - if (task.companyId !== token.companyId) { - this.json(res, 404, { error: "Task not found" }); - return; - } - if (task.assigneeAgentId && !canUpdateTask(this.db, token.companyId, token.agentId, task)) { - this.json(res, 403, { error: "Current agent is not allowed to checkout this task" }); - return; - } - const previousStatus = task.status; - const success = this.db.checkoutTask(params.issueId!, token.runId); - if (!success) { - this.json(res, 409, { error: "Task cannot be checked out — already assigned or not in valid status" }); - return; - } - this.onDomainChanged(); - // Checkout changes status from todo/backlog to in_progress — use actual previous status - this.callbacks.onTaskStatusChanged(task.id, task.companyId, previousStatus, "in_progress"); - this.json(res, 200, { ok: true }); - }); - - this.addRoute("PATCH", "/api/issues/:issueId", (_req, res, params, token, body) => { - const parsedUpdate = taskUpdateSchema.safeParse(body); - if (!parsedUpdate.success) { - this.json(res, 400, { error: "Invalid task update payload" }); - return; - } - const update = parsedUpdate.data; - - try { - const task = this.db.getTask(params.issueId!); - if (task.companyId !== token.companyId) { - this.json(res, 404, { error: "Task not found" }); - return; - } - if (!canUpdateTask(this.db, token.companyId, token.agentId, task)) { - this.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(this.db, token.companyId, token.agentId, newAssignee ?? null)) { - this.json(res, 403, { error: "Current agent is not allowed to assign this task to the selected agent" }); - return; - } - - // Deliverable review gate: intercept agent trying to advance a flagged task to "done" - let deliverableReviewIntercepted = false; - if (update.status === "done" && task.requiresUserReview) { - try { - const importantTypes = ["prd","technical_spec","design_doc","project_brief","proposal","budget_proposal","architecture_decision","contract","sop"]; - const placeholders = importantTypes.map(() => "?").join(", "); - // Scope to documents authored by the task's agent, created during this task's lifetime - const docs = this.db.queryAll<{ id: string; type: string; title: string }>( - `select id, type, title from documents where company_id = ? and type in (${placeholders}) - and (author_agent_id = ? or created_at >= (select created_at from tasks where id = ?)) - order by created_at desc limit 10`, - task.companyId, ...importantTypes, task.assigneeAgentId ?? "", task.id, - ); - const reviewerAgent = this.db.getAgent(token.agentId); - const docTitles = docs.map((d) => d.title).join(", "); - this.db.requestApproval({ - companyId: task.companyId, - relatedTaskId: task.id, - requestedByAgentId: token.agentId, - type: "deliverable_review", - payloadSummary: `Review deliverable: ${docTitles || task.title}`.slice(0, 280), - impactSummary: `${reviewerAgent.name} reviewed and approved. Awaiting your final review.`.slice(0, 400), - payloadJson: JSON.stringify({ documentIds: docs.map((d) => d.id), documents: docs }), - }); - this.db.setTaskRequiresUserReview(task.id, false); - // Pause the producing agent's heartbeat until user reviews - if (task.assigneeAgentId) { - try { - const prodAgent = this.db.getAgent(task.assigneeAgentId); - if (prodAgent.heartbeatEnabled) { - this.db.setHeartbeat(task.assigneeAgentId, false, prodAgent.heartbeatIntervalSec || 120); - } - } catch { /* best effort */ } - } - this.db.addComment({ companyId: task.companyId, taskId: task.id, authorAgentId: null, authorName: "System", body: "Deliverable submitted for board review." }); - update.status = "in_review"; // Override: keep in review, don't advance to done - deliverableReviewIntercepted = true; - } catch { /* deliverable review interception is best-effort; fall through to normal flow */ } - } - - // 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)) { - this.json(res, 400, { error: `Invalid task status: ${update.status}` }); - return; - } - - const input: Record = { - id: task.id, - companyId: task.companyId, - 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: task.workspaceId, - }; - this.db.saveTask(input as Parameters[0]); - - // Apply status side-effect timestamps via advanceTaskStatus if status changed - if (update.status && update.status !== task.status) { - this.db.advanceTaskStatus(task.id, update.status); - } - - if (update.comment) { - const agent = this.db.getAgent(token.agentId); - this.db.addComment({ - companyId: task.companyId, - taskId: task.id, - authorAgentId: token.agentId, - authorName: agent.name, - body: update.comment, - }); - } - - this.db.addActivity({ - companyId: task.companyId, - actor: token.agentId, - action: `task.${update.status ? "status_changed" : "updated"}`, - entityType: "task", - entityId: task.id, - detail: update.comment ?? `Status → ${update.status ?? task.status}`, - }); - - this.onDomainChanged(); - - // Fire automation rules for status changes - const previousStatus = task.status; - const newStatus = update.status ?? task.status; - if (update.status && previousStatus !== newStatus) { - this.callbacks.onTaskStatusChanged(task.id, task.companyId, previousStatus, newStatus); - } - - if (update.status && ["done", "in_review"].includes(update.status)) { - if (task.parentId) { - try { - const parentTask = this.db.getTask(task.parentId); - if (parentTask.assigneeAgentId && parentTask.assigneeAgentId !== token.agentId) { - this.onWakeAgent(parentTask.assigneeAgentId, task.companyId, "subtask_completed"); - } - } catch { /* parent task may have been deleted */ } - } else { - const chain = this.db.getChainOfCommand(token.agentId); - if (chain.length > 0) { - this.onWakeAgent(chain[0].id, task.companyId, "subtask_completed"); - } - } - } - - if (update.status === "blocked") { - const chain = this.db.getChainOfCommand(token.agentId); - if (chain.length > 0) { - this.onWakeAgent(chain[0].id, task.companyId, "report_blocked"); - } - } - - if (update.status && ["in_progress", "todo"].includes(update.status) && - task.assigneeAgentId && task.assigneeAgentId !== token.agentId) { - this.onWakeAgent(task.assigneeAgentId, task.companyId, "assignment"); - } - - if (update.comment) { - // Fire comment automation rules - this.callbacks.onCommentPosted(task.id, task.companyId, token.agentId); - - const wokenForComment = new Set(); - if (task.assigneeAgentId && task.assigneeAgentId !== token.agentId) { - this.onWakeAgent(task.assigneeAgentId, task.companyId, "comment"); - wokenForComment.add(task.assigneeAgentId); - } - const chain = this.db.getChainOfCommand(token.agentId); - if (chain.length > 0 && chain[0].id !== token.agentId && !wokenForComment.has(chain[0].id)) { - this.onWakeAgent(chain[0].id, task.companyId, "comment"); - } - } - - // Wake new assignee when task is reassigned - if (newAssignee && newAssignee !== task.assigneeAgentId && newAssignee !== token.agentId) { - this.onWakeAgent(newAssignee, task.companyId, "assignment"); - } - - this.json(res, 200, { ok: true }); - } catch (error) { - if (error instanceof Error && error.message.includes("require an assignee")) { - this.json(res, 422, { error: error.message }); - return; - } - this.json(res, 404, { error: "Task not found" }); - } - }); - - this.addRoute("POST", "/api/companies/:companyId/issues", (_req, res, params, token, body) => { - const companyId = params.companyId!; - if (companyId !== token.companyId) { - this.json(res, 403, { error: "Company mismatch" }); - return; - } - const parsed = taskInputSchema.safeParse({ - companyId, - ...(typeof body === "object" && body ? body : {}), - }); - if (!parsed.success) { - this.json(res, 400, { error: "Invalid task payload" }); - return; - } - const input = parsed.data; - if (this.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(this.db, companyId, token.agentId, input.assigneeAgentId ?? null)) { - this.json(res, 403, { error: "Current agent is not allowed to assign this task to the selected agent" }); - return; - } - const isHeartbeat = input.title.startsWith("[Heartbeat]"); - const taskValidation = validateTaskCreation({ title: input.title, description: input.description ?? "" }, isHeartbeat); - if (!taskValidation.valid) { - this.json(res, 422, { error: taskValidation.errors.join("; ") }); - return; - } - - let id: string; - try { - id = this.db.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: input.workspaceId ?? null, - }); - } catch (error) { - this.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) { - try { - const assignee = this.db.getAgent(input.assigneeAgentId); - const inferredType = inferTaskType(assignee.department); - this.db.setTaskType(id, inferredType); - } catch { /* best effort -- agent may not exist or setTaskType may fail */ } - } - - this.db.addActivity({ - companyId, - actor: token.agentId, - action: "task.created", - entityType: "task", - entityId: id, - detail: input.title, - }); - - this.onDomainChanged(); - - // Fire the full task creation event chain (automation rules, auto-assign, etc.) - this.callbacks.onTaskCreated(id, companyId); - - // Also wake the assignee directly if specified - if (input.assigneeAgentId && input.assigneeAgentId !== token.agentId) { - this.onWakeAgent(input.assigneeAgentId, companyId, "assignment"); - } - - // If no assignee specified, auto-assign - if (!input.assigneeAgentId) { - const assignedId = this.callbacks.onAutoAssignTask(id, companyId); - if (assignedId && assignedId !== token.agentId) { - this.onWakeAgent(assignedId, companyId, "assignment"); - } - } - - this.json(res, 201, { id }); - }); - - this.addRoute("POST", "/api/companies/:companyId/projects", (_req, res, params, token, body) => { - const companyId = params.companyId!; - if (companyId !== token.companyId) { - this.json(res, 403, { error: "Company mismatch" }); - return; - } - const parsed = projectInputSchema.safeParse({ - companyId, - ...(typeof body === "object" && body ? body : {}), - }); - if (!parsed.success) { - this.json(res, 400, { error: "Invalid project payload" }); - return; - } - const input = parsed.data; - if (this.companyRefError(res, companyId, [ - { id: input.goalId, table: "goals", label: "goal" }, - { id: input.leadAgentId, table: "agents", label: "lead agent" }, - ])) { - return; - } - if (!canManageProject(this.db, companyId, token.agentId, input.leadAgentId ?? null)) { - this.json(res, 403, { error: "Current agent is not allowed to create or assign this project" }); - return; - } - - const id = this.db.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, - }); - - this.db.addActivity({ - companyId, - actor: token.agentId, - action: "project.created", - entityType: "project", - entityId: id, - detail: input.name, - }); - - this.onDomainChanged(); - if (input.leadAgentId && input.leadAgentId !== token.agentId) { - this.onWakeAgent(input.leadAgentId, companyId, "assignment"); - } - this.json(res, 201, { id }); - }); - - this.addRoute("PATCH", "/api/projects/:projectId", (_req, res, params, token, body) => { - const projectId = 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) { - this.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() }); - return; - } - - const snapshot = this.db.listSnapshot(); - const existing = snapshot.projects.find(p => p.id === projectId && p.companyId === token.companyId); - if (!existing) { - this.json(res, 404, { error: "Project not found" }); - return; - } - const nextLeadAgentId = parsed.data.leadAgentId ?? existing.leadAgentId ?? null; - if (!canManageProject(this.db, token.companyId, token.agentId, nextLeadAgentId)) { - this.json(res, 403, { error: "Current agent is not allowed to update this project" }); - return; - } - - try { - this.db.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), - }); - this.onDomainChanged(); - this.json(res, 200, { ok: true }); - } catch { - this.json(res, 500, { error: "Failed to update project" }); - } - }); - - this.addRoute("POST", "/api/companies/:companyId/goals", (_req, res, params, token, body) => { - const companyId = params.companyId!; - if (companyId !== token.companyId) { - this.json(res, 403, { error: "Company mismatch" }); - return; - } - const parsed = goalInputSchema.safeParse({ - companyId, - ...(typeof body === "object" && body ? body : {}), - }); - if (!parsed.success) { - this.json(res, 400, { error: "Invalid goal payload" }); - return; - } - const input = parsed.data; - if (this.companyRefError(res, companyId, [ - { id: input.parentId, table: "goals", label: "parent goal" }, - { id: input.ownerAgentId, table: "agents", label: "owner agent" }, - ])) { - return; - } - if (!canManageGoal(this.db, companyId, token.agentId, input.ownerAgentId ?? null)) { - this.json(res, 403, { error: "Current agent is not allowed to create this goal" }); - return; - } - - const id = this.db.saveGoal(input); - this.db.addActivity({ - companyId, - actor: token.agentId, - action: "goal.created", - entityType: "goal", - entityId: id, - detail: input.title, - }); - this.onDomainChanged(); - if (input.ownerAgentId && input.ownerAgentId !== token.agentId && input.status === "active") { - this.onWakeAgent(input.ownerAgentId, companyId, "goal_activated"); - } - this.json(res, 201, { id }); - }); - - this.addRoute("PATCH", "/api/goals/:goalId", (_req, res, params, token, body) => { - const goalId = 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) { - this.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() }); - return; - } - - const snapshot = this.db.listSnapshot(); - const existing = snapshot.goals.find(g => g.id === goalId && g.companyId === token.companyId); - if (!existing) { - this.json(res, 404, { error: "Goal not found" }); - return; - } - const nextOwnerAgentId = parsed.data.ownerAgentId ?? existing.ownerAgentId ?? null; - if (!canManageGoal(this.db, token.companyId, token.agentId, nextOwnerAgentId)) { - this.json(res, 403, { error: "Current agent is not allowed to update this goal" }); - return; - } - - try { - this.db.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, - }); - this.onDomainChanged(); - this.json(res, 200, { ok: true }); - } catch { - this.json(res, 500, { error: "Failed to update goal" }); - } - }); - - this.addRoute("POST", "/api/companies/:companyId/agents", (_req, res, params, token, body) => { - const companyId = params.companyId!; - if (companyId !== token.companyId) { - this.json(res, 403, { error: "Company mismatch" }); - return; - } - if (!canManageOrganization(this.db, companyId, token.agentId)) { - this.json(res, 403, { error: "Current agent is not allowed to request hires" }); - return; - } - const parsed = hireRequestSchema.safeParse({ - companyId, - requestedByAgentId: token.agentId, - ...(typeof body === "object" && body ? body : {}), - reportsTo: - typeof body === "object" && body && "reportsTo" in body - ? (body as { reportsTo?: string | null }).reportsTo - : token.agentId, - }); - if (!parsed.success) { - this.json(res, 400, { error: "Invalid hire payload" }); - return; - } - const input = parsed.data; - if (this.companyRefError(res, companyId, [ - { id: input.requestedByAgentId, table: "agents", label: "requesting agent" }, - { id: input.reportsTo, table: "agents", label: "manager" }, - { id: input.workspaceId, table: "workspaces", label: "workspace" }, - ])) { - return; - } - const connectorRecord = (() => { - try { - return this.db.getConnector(input.connectorId); - } catch { - return null; - } - })(); - if (!connectorRecord) { - this.json(res, 422, { error: "The selected connector does not exist." }); - return; - } - const connectorIssue = getConnectorExecutionReadinessIssue(connectorRecord); - if (connectorIssue) { - this.json(res, 422, { error: connectorIssue }); - return; - } - - // Prevent duplicate agents: reject if a non-terminated agent with the same name already exists - const existingAgents = this.db.listSnapshot().agents.filter( - (a) => a.companyId === companyId && a.status !== "terminated", - ); - const duplicateByName = existingAgents.find( - (a) => a.name.toLowerCase() === input.name.toLowerCase(), - ); - if (duplicateByName) { - this.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 = this.db.listSnapshot().approvals.filter( - (a) => a.companyId === companyId && a.type === "hire_agent" && a.state === "pending", - ); - for (const pending of pendingHires) { - try { - const payload = pending.payloadJson ? JSON.parse(pending.payloadJson) as Record : {}; - if (typeof payload.name === "string" && payload.name.toLowerCase() === input.name.toLowerCase()) { - this.json(res, 409, { error: `A hire request for "${input.name}" is already pending approval.` }); - return; - } - } catch { /* invalid payloadJson, skip */ } - } - - const managerLabel = input.reportsTo - ? (() => { - try { - return this.db.getAgent(input.reportsTo).name; - } catch { - return input.reportsTo; - } - })() - : "the company board"; - - let result: { agentId: string; approvalId: string }; - try { - result = this.db.createHireRequest({ - companyId, - requestedByAgentId: input.requestedByAgentId ?? token.agentId, - name: input.name, - role: input.role, - title: input.title, - department: input.department ?? null, - reportsTo: input.reportsTo ?? token.agentId, - 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({ - 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: input.reportsTo ?? token.agentId, - department: input.department ?? null, - title: input.title, - }, - }), - }); - } catch (error) { - this.json(res, 422, { error: error instanceof Error ? error.message : "Hire could not be created" }); - return; - } - - this.db.addActivity({ - companyId, - actor: token.agentId, - action: "agent.hire_requested", - entityType: "agent", - entityId: result.agentId, - detail: input.name, - }); - - let autoApproved = false; - if (shouldAutoApproveApproval(this.db, companyId, "hire_agent")) { - resolveApprovalDecision({ - db: this.db, - handleApprovalResolved: this.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`); - } - - this.onDomainChanged(); - this.json(res, 201, { ...result, autoApproved }); - }); - - this.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") { - this.json(res, 400, { error: "body is required" }); - return; - } - if (input.body.length > 4000) { - this.json(res, 400, { error: "Comment body must not exceed 4000 characters" }); - return; - } - try { - const task = this.db.getTask(params.issueId!); - if (task.companyId !== token.companyId) { - this.json(res, 404, { error: "Task not found" }); - return; - } - const agent = this.db.getAgent(token.agentId); - const comment = this.db.addComment({ - companyId: task.companyId, - taskId: task.id, - authorAgentId: token.agentId, - authorName: agent.name, - body: input.body, - }); - this.onDomainChanged(); - - // Fire comment automation rules - this.callbacks.onCommentPosted(task.id, task.companyId, token.agentId); - - const wokenForComment = new Set(); - if (task.assigneeAgentId && task.assigneeAgentId !== token.agentId) { - this.onWakeAgent(task.assigneeAgentId, task.companyId, "comment"); - wokenForComment.add(task.assigneeAgentId); - } - - const chain = this.db.getChainOfCommand(token.agentId); - if (chain.length > 0 && chain[0].id !== token.agentId && !wokenForComment.has(chain[0].id)) { - this.onWakeAgent(chain[0].id, task.companyId, "comment"); - wokenForComment.add(chain[0].id); - } - - // @mention detection: wake any mentioned agents not already woken - const mentionedIds = this.db.findMentionedAgentIds(task.companyId, input.body); - for (const mentionedId of mentionedIds) { - if (mentionedId !== token.agentId && !wokenForComment.has(mentionedId)) { - this.onWakeAgent(mentionedId, task.companyId, "comment_mentioned"); - wokenForComment.add(mentionedId); - } - } - - this.json(res, 201, comment); - } catch { - this.json(res, 404, { error: "Task not found" }); - } - }); - - this.addRoute("GET", "/api/issues/:issueId/comments", (_req, res, params, token) => { - try { - const task = this.db.getTask(params.issueId!); - if (task.companyId !== token.companyId) { - this.json(res, 404, { error: "Task not found" }); - return; - } - } catch { - this.json(res, 404, { error: "Task not found" }); - return; - } - const comments = this.db.listComments(params.issueId!); - this.json(res, 200, comments); - }); - - this.addRoute("POST", "/api/companies/:companyId/approvals", (_req, res, params, token, body) => { - const companyId = params.companyId!; - if (companyId !== token.companyId) { - this.json(res, 403, { error: "Company mismatch" }); - return; - } - const parsed = approvalInputSchema.safeParse({ - companyId, - ...(typeof body === "object" && body ? body : {}), - requestedByAgentId: token.agentId, - }); - if (!parsed.success) { - this.json(res, 400, { error: "Invalid approval payload" }); - return; - } - const input = parsed.data; - if (this.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; - } - - let id: string; - try { - id = this.db.requestApproval({ - companyId, - type: input.type, - relatedTaskId: input.relatedTaskId ?? null, - requestedByAgentId: token.agentId, - relatedAgentId: input.relatedAgentId ?? null, - payloadSummary: input.payloadSummary, - impactSummary: input.impactSummary, - payloadJson: input.payloadJson ?? null, - }); - } catch (error) { - this.json(res, 422, { error: error instanceof Error ? error.message : "Approval could not be created" }); - return; - } - - this.db.addActivity({ - companyId, - actor: token.agentId, - action: "approval.created", - entityType: "approval", - entityId: id, - detail: input.payloadSummary, - }); - - if (shouldAutoApproveApproval(this.db, companyId, input.type)) { - resolveApprovalDecision({ - db: this.db, - handleApprovalResolved: this.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 { - this.callbacks.notify({ - title: "Approval Required", - body: input.payloadSummary ?? `${input.type} approval pending`, - urgency: "critical", - navigation: { section: "approvals", entityId: id }, - batchKey: "pending_approval", - }); - } - - this.onDomainChanged(); - this.json(res, 201, { id }); - }); - - this.addRoute("GET", "/api/companies/:companyId/approvals", (req, res, params, token) => { - const companyId = params.companyId!; - if (companyId !== token.companyId) { - this.json(res, 403, { error: "Company mismatch" }); - return; - } - const url = new URL(req.url ?? "/", `http://127.0.0.1:${this.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) { - this.json(res, 400, { error: "Invalid approval query" }); - return; - } - const statuses = parsedQuery.data.status?.split(",").filter(Boolean) ?? null; - const snapshot = this.db.listSnapshot(); - const approvals = snapshot.approvals.filter((approval) => { - if (approval.companyId !== companyId) return false; - if (parsedQuery.data.requestedByAgentId && approval.requestedByAgentId !== parsedQuery.data.requestedByAgentId) return false; - if (parsedQuery.data.relatedTaskId && approval.relatedTaskId !== parsedQuery.data.relatedTaskId) return false; - if (statuses && !statuses.includes(approval.state)) return false; - return true; - }); - this.json(res, 200, approvals); - }); - - this.addRoute("GET", "/api/approvals/:approvalId", (_req, res, params, token) => { - const snapshot = this.db.listSnapshot(); - const approval = snapshot.approvals.find((a) => a.id === params.approvalId && a.companyId === token.companyId); - if (!approval) { - this.json(res, 404, { error: "Approval not found" }); - return; - } - this.json(res, 200, approval); - }); - - this.addRoute("PATCH", "/api/approvals/:approvalId", (_req, res) => { - this.json(res, 403, { error: "Approvals must be decided from the desktop board, not from an agent run token" }); - }); - - this.addRoute("GET", "/api/approvals/:approvalId/comments", (_req, res, params, token) => { - const approval = this.db.listSnapshot().approvals.find((a) => a.id === params.approvalId); - if (!approval) { this.json(res, 404, { error: "Approval not found" }); return; } - if (approval.companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); return; } - const comments = this.db.listApprovalComments(params.approvalId!, approval.companyId); - this.json(res, 200, comments); - }); - - this.addRoute("POST", "/api/approvals/:approvalId/comments", (_req, res, params, token, body) => { - const approval = this.db.listSnapshot().approvals.find((a) => a.id === params.approvalId); - if (!approval) { this.json(res, 404, { error: "Approval not found" }); return; } - if (approval.companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); return; } - const data = body as Record; - const commentBody = typeof data.body === "string" ? data.body.trim() : ""; - if (!commentBody) { this.json(res, 400, { error: "Comment body is required" }); return; } - const commentId = this.db.addApprovalComment({ - companyId: approval.companyId, - approvalId: approval.id, - authorAgentId: token.agentId ?? null, - body: commentBody, - }); - // Wake mentioned agents in comment - const mentionedIds = this.db.findMentionedAgentIds(approval.companyId, commentBody); - for (const mentionedId of mentionedIds) { - if (mentionedId !== token.agentId) { - this.onWakeAgent(mentionedId, approval.companyId, "approval_comment_mentioned"); - } - } - this.onDomainChanged(); - this.json(res, 201, { id: commentId }); - }); - - this.addRoute("POST", "/api/approvals/:approvalId/resubmit", (_req, res, params, token) => { - const approval = this.db.listSnapshot().approvals.find((a) => a.id === params.approvalId); - if (!approval) { this.json(res, 404, { error: "Approval not found" }); return; } - if (approval.companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); return; } - if (approval.state !== "revision_requested") { - this.json(res, 422, { error: "Only revision_requested approvals can be resubmitted" }); - return; - } - if (token.agentId !== approval.requestedByAgentId) { - this.json(res, 403, { error: "Only the requesting agent can resubmit this approval" }); - return; - } - this.db.resubmitApproval(approval.id, approval.companyId); - this.onDomainChanged(); - this.json(res, 200, { resubmitted: true }); - }); - - this.addRoute("GET", "/api/approvals/:approvalId/tasks", (_req, res, params, token) => { - const approval = this.db.listSnapshot().approvals.find((a) => a.id === params.approvalId); - if (!approval) { this.json(res, 404, { error: "Approval not found" }); return; } - if (approval.companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); return; } - const taskIds = this.db.listTasksForApproval(params.approvalId!); - this.json(res, 200, taskIds); - }); - - this.addRoute("POST", "/api/approvals/:approvalId/tasks", (_req, res, params, token, body) => { - const approval = this.db.listSnapshot().approvals.find((a) => a.id === params.approvalId); - if (!approval) { this.json(res, 404, { error: "Approval not found" }); return; } - if (approval.companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); return; } - const data = body as Record; - const taskIds = Array.isArray(data.taskIds) ? data.taskIds.filter((id): id is string => typeof id === "string") : []; - if (taskIds.length === 0) { this.json(res, 400, { error: "taskIds array is required" }); return; } - for (const taskId of taskIds) { - if (!this.db.belongsToCompany("tasks", taskId, token.companyId)) { - this.json(res, 400, { error: `Task ${taskId} does not belong to this company` }); return; - } - } - this.db.linkManyTasksToApproval(params.approvalId!, taskIds, token.companyId, token.agentId ?? null); - this.onDomainChanged(); - this.json(res, 200, { linked: taskIds.length }); - }); - - this.addRoute("GET", "/api/companies/:companyId/documents/:documentId/revisions", (_req, res, params, token) => { - if (params.companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); return; } - const documentId = params.documentId; - if (!documentId) { this.json(res, 400, { error: "Missing documentId" }); return; } - const revisions = this.db.listDocumentRevisions(documentId, token.companyId); - this.json(res, 200, revisions); - }); - - this.addRoute("GET", "/api/companies/:companyId/agents", (_req, res, params, token) => { - if (params.companyId !== token.companyId) { - this.json(res, 403, { error: "Company mismatch" }); - return; - } - const snapshot = this.db.listSnapshot(); - const agents = snapshot.agents.filter((a) => a.companyId === params.companyId); - this.json(res, 200, agents); - }); - - this.addRoute("GET", "/api/companies/:companyId/goals", (_req, res, params, token) => { - if (params.companyId !== token.companyId) { - this.json(res, 403, { error: "Company mismatch" }); - return; - } - const snapshot = this.db.listSnapshot(); - const goals = snapshot.goals.filter((g) => g.companyId === params.companyId); - this.json(res, 200, goals); - }); - - this.addRoute("GET", "/api/companies/:companyId/projects", (_req, res, params, token) => { - if (params.companyId !== token.companyId) { - this.json(res, 403, { error: "Company mismatch" }); - return; - } - const snapshot = this.db.listSnapshot(); - const projects = snapshot.projects.filter((p) => p.companyId === params.companyId); - this.json(res, 200, projects); - }); - - this.addRoute("GET", "/api/agents/me/standup", (_req, res, _params, token) => { - const snapshot = this.db.listSnapshot(); - const agent = snapshot.agents.find((a) => a.id === token.agentId); - if (!agent) { - this.json(res, 404, { error: "Agent not found" }); - return; - } - - const myTasks = snapshot.tasks.filter((t) => t.assigneeAgentId === token.agentId && t.companyId === token.companyId); - const directReports = snapshot.agents.filter((a) => a.reportsTo === token.agentId && a.companyId === token.companyId); - const reportIds = new Set(directReports.map((a) => a.id)); - const reportTasks = snapshot.tasks.filter((t) => t.assigneeAgentId && reportIds.has(t.assigneeAgentId) && t.companyId === token.companyId); - - 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 twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000; - const recentComments = snapshot.comments - .filter((c) => { - const isMyTask = myTasks.some((t) => t.id === c.taskId); - const isReportTask = reportTasks.some((t) => t.id === c.taskId); - return (isMyTask || isReportTask) && new Date(c.createdAt).getTime() > twoHoursAgo; - }) - .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) - .slice(0, 20); - - const myPendingApprovals = snapshot.approvals.filter( - (a) => a.requestedByAgentId === token.agentId && a.state === "pending" && a.companyId === token.companyId, - ); - const myResolvedApprovals = snapshot.approvals.filter( - (a) => a.requestedByAgentId === token.agentId && a.state !== "pending" && a.companyId === token.companyId, - ).slice(0, 5); - - this.json(res, 200, { - agent: { id: agent.id, name: agent.name, role: agent.role, status: agent.status }, - summary: { - tasksAwaitingReview: tasksAwaitingMyReview.length, - blockedReportTasks: blockedReportTasks.length, - myBlockedTasks: myBlockedTasks.length, - myInProgress: myInProgressTasks.length, - myTodo: myTodoTasks.length, - pendingApprovals: myPendingApprovals.length, - directReports: directReports.length, - }, - tasksAwaitingReview: tasksAwaitingMyReview.map((t) => ({ - id: t.id, - title: t.title, - assignee: snapshot.agents.find((a) => a.id === t.assigneeAgentId)?.name ?? "Unknown", - priority: t.priority, - })), - blockedTasks: [...blockedReportTasks, ...myBlockedTasks].map((t) => ({ - id: t.id, - title: t.title, - assignee: snapshot.agents.find((a) => a.id === t.assigneeAgentId)?.name ?? "Unknown", - priority: t.priority, - })), - recentComments: recentComments.map((c) => ({ - taskId: c.taskId, - taskTitle: snapshot.tasks.find((t) => t.id === c.taskId)?.title ?? "", - 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: directReports.map((r) => { - const rTasks = snapshot.tasks.filter((t) => t.assigneeAgentId === r.id && t.companyId === token.companyId); - return { - id: r.id, - name: r.name, - role: r.role, - status: r.status, - tasksInProgress: rTasks.filter((t) => t.status === "in_progress").length, - tasksInReview: rTasks.filter((t) => t.status === "in_review").length, - tasksBlocked: rTasks.filter((t) => t.status === "blocked").length, - tasksDone: rTasks.filter((t) => t.status === "done").length, - }; - }), - socialAccounts: snapshot.socialAccounts - .filter((a) => a.companyId === token.companyId) - .map((a) => ({ - id: a.id, - platform: a.platform, - accountName: a.accountName, - displayName: a.displayName, - status: a.status, - requireApproval: a.requireApproval, - })), - }); - }); - - this.addRoute("POST", "/api/companies/:companyId/browser/action", async (_req, res, params, token, body) => { - const companyId = params.companyId!; - if (companyId !== token.companyId) { - this.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) { - this.json(res, 400, { error: "Invalid browser action payload", details: parsed.error.issues }); - return; - } - - const input = parsed.data; - - const snapshot = this.db.listSnapshot(); - const account = snapshot.socialAccounts.find(a => a.id === input.socialAccountId && a.companyId === companyId); - if (!account) { - this.json(res, 404, { error: "Social account not found" }); - return; - } - - const postActions = ["post", "reply", "dm"]; - if (account.requireApproval && postActions.includes(input.actionType)) { - const approvalId = this.db.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.`, - }); - - const actionId = this.db.saveBrowserAction({ - companyId, - socialAccountId: input.socialAccountId, - agentId: token.agentId, - actionType: input.actionType, - payloadJson: JSON.stringify(input.payload), - status: "approval_required", - approvalId, - }); - - this.db.addActivity({ - companyId, - actor: token.agentId, - action: "browser.action_pending_approval", - entityType: "browser_action", - entityId: actionId, - detail: `${input.actionType} on ${account.platform} requires approval`, - }); - - this.onDomainChanged(); - this.json(res, 202, { id: actionId, status: "approval_required", approvalId, message: "Action requires Board approval before execution" }); - return; - } - - const actionId = this.db.saveBrowserAction({ - companyId, - socialAccountId: input.socialAccountId, - agentId: token.agentId, - actionType: input.actionType, - payloadJson: JSON.stringify(input.payload), - status: "queued", - }); - - this.db.addActivity({ - companyId, - actor: token.agentId, - action: "browser.action_queued", - entityType: "browser_action", - entityId: actionId, - detail: `${input.actionType} on ${account.platform}/@${account.accountName}`, - }); - - this.onDomainChanged(); - - if (this.onBrowserAction) { - this.onBrowserAction(actionId, companyId, input.socialAccountId, input.actionType, input.payload as Record); - } - - this.json(res, 201, { id: actionId, status: "queued", message: "Browser action queued for execution" }); - }); - - this.addRoute("GET", "/api/companies/:companyId/social-accounts", (_req, res, params, token) => { - if (params.companyId !== token.companyId) { - this.json(res, 403, { error: "Company mismatch" }); - return; - } - const accounts = this.db.listSocialAccounts(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, - })); - this.json(res, 200, sanitized); - }); - - 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(), - }); - - this.addRoute("POST", "/api/companies/:companyId/social/draft", async (_req, res, params, token, body) => { - const companyId = params.companyId!; - if (companyId !== token.companyId) { - this.json(res, 403, { error: "Company mismatch" }); - return; - } - - const parsed = socialDraftSchema.safeParse(body); - if (!parsed.success) { - this.json(res, 400, { error: "Invalid draft", details: parsed.error.issues }); - return; - } - - const account = this.db.getSocialAccount(parsed.data.socialAccountId, companyId); - if (!account) { - this.json(res, 404, { error: "Social account not found" }); - return; - } - - const contentPreview = parsed.data.content.slice(0, 120) + (parsed.data.content.length > 120 ? "..." : ""); - - const approvalId = this.db.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, - }), - }); - - this.onDomainChanged(); - this.json(res, 201, { approvalId, status: "pending_approval" }); - }); - - this.addRoute("GET", "/api/companies/:companyId/browser/actions", (req, res, params, token) => { - if (params.companyId !== token.companyId) { - this.json(res, 403, { error: "Company mismatch" }); - return; - } - const url = new URL(req.url ?? "/", `http://127.0.0.1:${this.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 = this.db.listBrowserActions(params.companyId!, { socialAccountId, status, limit }); - this.json(res, 200, actions); - }); - - this.addRoute("POST", "/api/companies/:companyId/documents", (_req, res, params, token, body) => { - if (params.companyId !== token.companyId) { - this.json(res, 403, { error: "Company mismatch" }); - return; - } - const data = body as Record; - const reviewerAgentId = data.reviewerAgentId ? String(data.reviewerAgentId) : null; - const projectId = data.projectId ? String(data.projectId) : null; - const goalId = data.goalId ? String(data.goalId) : null; - if (reviewerAgentId && !this.db.belongsToCompany("agents", reviewerAgentId, token.companyId)) { - this.json(res, 400, { error: "Reviewer agent does not belong to the current company" }); - return; - } - if (projectId && !this.db.belongsToCompany("projects", projectId, token.companyId)) { - this.json(res, 400, { error: "Project does not belong to the current company" }); - return; - } - if (goalId && !this.db.belongsToCompany("goals", goalId, token.companyId)) { - this.json(res, 400, { error: "Goal does not belong to the current company" }); - return; - } - const docType = String(data.type ?? "knowledge_article"); - const docTitle = String(data.title ?? "Untitled"); - const id = this.db.saveDocument({ - companyId: token.companyId, - type: docType, - title: docTitle, - content: String(data.content ?? ""), - authorAgentId: token.agentId, - reviewerAgentId, - projectId, - goalId, - status: String(data.status ?? "draft"), - tagsJson: data.tagsJson ? String(data.tagsJson) : "[]", - }); - this.onDomainChanged(); - // Fire document creation automation rules - this.callbacks.onDocumentCreated(token.companyId, token.agentId, projectId); - // Wake document reviewer if specified - if (reviewerAgentId) { - this.onWakeAgent(reviewerAgentId, token.companyId, "assignment"); - } - - // Deliverable review gate: check at document creation time (most reliable) - const IMPORTANT_DOC_TYPES = new Set(["prd","technical_spec","design_doc","project_brief","proposal","budget_proposal","architecture_decision","contract","sop"]); - if (IMPORTANT_DOC_TYPES.has(docType)) { - try { - const company = this.db.listSnapshot().companies.find((c) => c.id === token.companyId); - if (company?.reviewDeliverables) { - // Check if there's already a pending deliverable_review for this company - if (!this.db.hasPendingDeliverableReviews(token.companyId)) { - this.db.requestApproval({ - companyId: token.companyId, - relatedTaskId: null, - requestedByAgentId: token.agentId, - type: "deliverable_review", - payloadSummary: `Review deliverable: ${docTitle}`.slice(0, 280), - impactSummary: `New ${docType.replaceAll("_", " ")} created. Awaiting your review before workflow continues.`.slice(0, 400), - payloadJson: JSON.stringify({ documentIds: [id], documents: [{ id, type: docType, title: docTitle }] }), - }); - // Pause the producing agent's heartbeat - try { - const agent = this.db.getAgent(token.agentId); - if (agent.heartbeatEnabled) { - this.db.setHeartbeat(token.agentId, false, agent.heartbeatIntervalSec || 120); - } - } catch { /* best effort */ } - this.onDomainChanged(); - } - } - } catch { /* deliverable review gate is best-effort */ } - } - - this.json(res, 201, { id }); - }); - - this.addRoute("GET", "/api/companies/:companyId/documents", (req, res, params, token) => { - if (params.companyId !== token.companyId) { - this.json(res, 403, { error: "Company mismatch" }); - return; - } - const snapshot = this.db.listSnapshot(); - const docs = snapshot.documents.filter(d => d.companyId === token.companyId); - this.json(res, 200, docs); - }); - - this.addRoute("PATCH", "/api/companies/:companyId/documents/:documentId", (_req, res, params, token, body) => { - if (params.companyId !== token.companyId) { - this.json(res, 403, { error: "Company mismatch" }); - return; - } - const documentId = params.documentId; - if (!documentId) { - this.json(res, 400, { error: "Missing documentId" }); - return; - } - const snapshot = this.db.listSnapshot(); - const doc = snapshot.documents.find(d => d.id === documentId && d.companyId === token.companyId); - if (!doc) { - this.json(res, 404, { error: "Document not found in this company" }); - return; - } - const data = body as Record; - const VALID_STATUSES = new Set(["draft", "in_review", "approved", "archived", "superseded"]); - - // Conflict detection: if baseRevisionId provided, verify it matches current - if (typeof data.baseRevisionId === "string") { - const currentRevId = this.db.getDocumentLatestRevisionId(documentId); - if (currentRevId && data.baseRevisionId !== currentRevId) { - this.json(res, 409, { error: "Document was updated by someone else", currentRevisionId: currentRevId }); - return; - } - } - - if (data.status !== undefined) { - const newStatus = String(data.status); - if (!VALID_STATUSES.has(newStatus)) { - this.json(res, 400, { error: `Invalid status "${newStatus}". Must be one of: ${[...VALID_STATUSES].join(", ")}` }); - return; - } - this.db.updateDocumentStatus(documentId, token.companyId, newStatus); - } - - // Content update → create revision for audit trail - if (data.content !== undefined) { - const newContent = String(data.content); - this.db.createDocumentRevision({ - documentId, - companyId: token.companyId, - body: newContent, - changeSummary: typeof data.changeSummary === "string" ? data.changeSummary : null, - createdByAgentId: token.agentId ?? null, - }); - } - - // Field updates (title, reviewer, tags, etc.) - const reviewerAgentId = data.reviewerAgentId !== undefined - ? (data.reviewerAgentId ? String(data.reviewerAgentId) : null) - : doc.reviewerAgentId; - if (data.reviewerAgentId !== undefined && reviewerAgentId && !this.db.belongsToCompany("agents", reviewerAgentId, token.companyId)) { - this.json(res, 400, { error: "Reviewer agent does not belong to the current company" }); - return; - } - this.db.saveDocument({ - id: documentId, - companyId: token.companyId, - type: data.type ? String(data.type) : doc.type, - title: data.title ? String(data.title) : doc.title, - content: data.content !== undefined ? String(data.content) : doc.content, - authorAgentId: doc.authorAgentId, - reviewerAgentId, - projectId: doc.projectId, - goalId: doc.goalId, - status: data.status ? String(data.status) : doc.status, - tagsJson: data.tagsJson ? String(data.tagsJson) : doc.tagsJson, - }); - - this.onDomainChanged(); - this.json(res, 200, { updated: true, latestRevisionId: this.db.getDocumentLatestRevisionId(documentId) }); - }); - - // ── Work Products ── - - this.addRoute("GET", "/api/issues/:issueId/work-products", (_req, res, params, token) => { - try { - const task = this.db.getTask(params.issueId!); - if (task.companyId !== token.companyId) { this.json(res, 404, { error: "Task not found" }); return; } - const products = this.db.listWorkProductsForTask(task.id, task.companyId); - this.json(res, 200, products); - } catch { this.json(res, 404, { error: "Task not found" }); } - }); - - this.addRoute("POST", "/api/issues/:issueId/work-products", (_req, res, params, token, body) => { - try { - const task = this.db.getTask(params.issueId!); - if (task.companyId !== token.companyId) { this.json(res, 404, { error: "Task not found" }); return; } - const data = body as Record; - if (!data.type || !data.provider || !data.title) { - this.json(res, 400, { error: "type, provider, and title are required" }); return; - } - const id = this.db.createWorkProduct({ - companyId: task.companyId, - taskId: task.id, - projectId: typeof data.projectId === "string" ? data.projectId : task.projectId, - type: String(data.type), - provider: String(data.provider), - externalId: typeof data.externalId === "string" ? data.externalId : null, - title: String(data.title), - url: typeof data.url === "string" ? data.url : null, - status: typeof data.status === "string" ? data.status : "active", - reviewState: typeof data.reviewState === "string" ? data.reviewState : "none", - isPrimary: data.isPrimary === true, - summary: typeof data.summary === "string" ? data.summary : null, - metadataJson: data.metadata ? JSON.stringify(data.metadata) : null, - createdByRunId: token.runId ?? null, - }); - this.onDomainChanged(); - this.json(res, 201, { id }); - } catch { this.json(res, 404, { error: "Task not found" }); } - }); - - this.addRoute("PATCH", "/api/work-products/:workProductId", (_req, res, params, token, body) => { - const wpId = params.workProductId; - if (!wpId) { this.json(res, 400, { error: "Missing workProductId" }); return; } - const data = body as Record; - this.db.updateWorkProduct(wpId, token.companyId, data); - this.onDomainChanged(); - this.json(res, 200, { updated: true }); - }); - - this.addRoute("DELETE", "/api/work-products/:workProductId", (_req, res, params, token) => { - const wpId = params.workProductId; - if (!wpId) { this.json(res, 400, { error: "Missing workProductId" }); return; } - this.db.deleteWorkProduct(wpId, token.companyId); - this.onDomainChanged(); - this.json(res, 200, { deleted: true }); - }); - - // ── Task-Document Links ── - - this.addRoute("GET", "/api/issues/:issueId/documents", (_req, res, params, token) => { - try { - const task = this.db.getTask(params.issueId!); - if (task.companyId !== token.companyId) { this.json(res, 404, { error: "Task not found" }); return; } - const links = this.db.listDocumentsForTask(task.id, task.companyId); - this.json(res, 200, links); - } catch { this.json(res, 404, { error: "Task not found" }); } - }); - - this.addRoute("PUT", "/api/issues/:issueId/documents/:key", (_req, res, params, token, body) => { - try { - const task = this.db.getTask(params.issueId!); - if (task.companyId !== token.companyId) { this.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) { - this.json(res, 400, { error: "Invalid document key. Must be lowercase alphanumeric with _ or -, 1-64 chars." }); return; - } - const data = body as Record; - const documentId = typeof data.documentId === "string" ? data.documentId : null; - if (!documentId) { this.json(res, 400, { error: "documentId is required" }); return; } - if (!this.db.belongsToCompany("documents", documentId, token.companyId)) { - this.json(res, 400, { error: "Document does not belong to this company" }); return; - } - const linkId = this.db.linkDocumentToTask(task.id, documentId, task.companyId, key); - this.onDomainChanged(); - this.json(res, 200, { id: linkId, key }); - } catch { this.json(res, 404, { error: "Task not found" }); } - }); - - this.addRoute("DELETE", "/api/issues/:issueId/documents/:key", (_req, res, params, token) => { - try { - const task = this.db.getTask(params.issueId!); - if (task.companyId !== token.companyId) { this.json(res, 404, { error: "Task not found" }); return; } - const key = String(params.key ?? "").trim().toLowerCase(); - this.db.unlinkDocumentFromTask(task.id, key, task.companyId); - this.onDomainChanged(); - this.json(res, 200, { unlinked: true }); - } catch { this.json(res, 404, { error: "Task not found" }); } - }); - - this.addRoute("POST", "/api/companies/:companyId/meetings", (_req, res, params, token, body) => { - if (params.companyId !== token.companyId) { - this.json(res, 403, { error: "Company mismatch" }); - return; - } - const data = body as Record; - const participantAgentIds = data.participantAgentIds ? String(data.participantAgentIds) : "[]"; - try { - const parsedParticipants = JSON.parse(participantAgentIds) as string[]; - for (const agentId of parsedParticipants) { - if (typeof agentId !== "string" || !this.db.belongsToCompany("agents", agentId, token.companyId)) { - this.json(res, 400, { error: "Meeting participants must belong to the current company" }); - return; - } - } - } catch { - this.json(res, 400, { error: "participantAgentIds must be a JSON array of agent ids" }); - return; - } - const id = this.db.saveMeeting({ - companyId: token.companyId, - type: String(data.type ?? "department_sync"), - title: String(data.title ?? "Meeting"), - organizerAgentId: token.agentId, - participantAgentIds, - scheduledAt: String(data.scheduledAt ?? new Date().toISOString()), - durationMinutes: Number(data.durationMinutes ?? 30), - agendaJson: data.agendaJson ? String(data.agendaJson) : "[]", - status: "scheduled", - }); - this.onDomainChanged(); - this.json(res, 201, { id }); - }); - - this.addRoute("GET", "/api/companies/:companyId/meetings", (_req, res, params, token) => { - if (params.companyId !== token.companyId) { - this.json(res, 403, { error: "Company mismatch" }); - return; - } - const snapshot = this.db.listSnapshot(); - const meetings = snapshot.meetings.filter(m => m.companyId === token.companyId); - this.json(res, 200, meetings); - }); - - this.addRoute("PATCH", "/api/meetings/:meetingId", (_req, res, params, token, body) => { - const snapshot = this.db.listSnapshot(); - const existing = snapshot.meetings.find(m => m.id === params.meetingId && m.companyId === token.companyId); - if (!existing) { - this.json(res, 404, { error: "Meeting not found" }); - return; - } - const data = body as Record; - let actionItems: Array<{ title?: string; assigneeAgentId?: string; description?: string }> = []; - if (data.actionItemsJson) { - try { - actionItems = JSON.parse(String(data.actionItemsJson)) as Array<{ title?: string; assigneeAgentId?: string; description?: string }>; - } catch { - this.json(res, 400, { error: "actionItemsJson must be valid JSON" }); - return; - } - for (const item of actionItems) { - if (item.assigneeAgentId && !this.db.belongsToCompany("agents", item.assigneeAgentId, token.companyId)) { - this.json(res, 400, { error: "Meeting action item assignees must belong to the current company" }); - return; - } - } - } - this.db.saveMeeting({ - id: params.meetingId, - companyId: token.companyId, - type: data.type ? String(data.type) : "department_sync", - title: data.title ? String(data.title) : "Meeting", - notesJson: data.notesJson ? String(data.notesJson) : undefined, - decisionsJson: data.decisionsJson ? String(data.decisionsJson) : undefined, - actionItemsJson: data.actionItemsJson ? String(data.actionItemsJson) : undefined, - status: data.status ? String(data.status) : undefined, - scheduledAt: data.scheduledAt ? String(data.scheduledAt) : new Date().toISOString(), - }); - this.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}` }, - false, - ); - if (!meetingTaskValidation.valid) { - // Log but skip invalid meeting action items rather than failing the whole request - continue; - } - this.db.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", - }); - if (item.assigneeAgentId) { - this.onWakeAgent(item.assigneeAgentId, token.companyId, "assignment"); - } - } - } - } - this.json(res, 200, { updated: true }); - }); - - this.addRoute("POST", "/api/companies/:companyId/knowledge", (_req, res, params, token, body) => { - if (params.companyId !== token.companyId) { - this.json(res, 403, { error: "Company mismatch" }); - return; - } - const data = body as Record; - const id = this.db.saveKnowledgeEntry({ - companyId: token.companyId, - category: String(data.category ?? "lesson_learned"), - topic: String(data.topic ?? ""), - content: String(data.content ?? ""), - authorAgentId: token.agentId, - importance: String(data.importance ?? "medium"), - tagsJson: data.tagsJson ? String(data.tagsJson) : "[]", - referencedEntityType: data.referencedEntityType ? String(data.referencedEntityType) : null, - referencedEntityId: data.referencedEntityId ? String(data.referencedEntityId) : null, - }); - this.onDomainChanged(); - this.json(res, 201, { id }); - }); - - this.addRoute("GET", "/api/companies/:companyId/knowledge", (_req, res, params, token) => { - if (params.companyId !== token.companyId) { - this.json(res, 403, { error: "Company mismatch" }); - return; - } - const snapshot = this.db.listSnapshot(); - const entries = snapshot.knowledgeBase.filter(k => k.companyId === token.companyId); - this.json(res, 200, entries); - }); - - this.addRoute("POST", "/api/companies/:companyId/messages", (_req, res, params, token, body) => { - const companyId = params.companyId!; - if (companyId !== token.companyId) { - this.json(res, 403, { error: "Company mismatch" }); - return; - } - - const parsed = agentMessageInputSchema.safeParse({ - ...(body as Record), - companyId, - fromAgentId: token.agentId, - }); - if (!parsed.success) { - this.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() }); - return; - } - - const dispatchResult = dispatchAgentMessage( - { - db: this.db, - emitEvent: this.emitEvent, - publishDomainChanged: this.onDomainChanged, - wakeAgentIfPossible: (agentId, companyId, trigger) => { - if (!agentId) return null; - return this.onWakeAgent(agentId, companyId, trigger); - }, - }, - parsed.data, - "message", - ); - if (!dispatchResult.ok) { - this.json(res, 400, { error: dispatchResult.error.message, code: dispatchResult.error.code }); - return; - } - - this.json(res, 201, { id: dispatchResult.data.id }); - }); - - this.addRoute("GET", "/api/companies/:companyId/messages", (req, res, params, token) => { - const companyId = params.companyId!; - if (companyId !== token.companyId) { - this.json(res, 403, { error: "Company mismatch" }); - return; - } - - const url = new URL(req.url ?? "/", `http://127.0.0.1:${this.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)) { - this.json(res, 400, { error: `Invalid channel filter: ${channelParam}` }); - return; - } - - const messages = this.db.listAgentMessages(companyId, { - agentId: token.agentId, - channel: channelParam ?? undefined, - channelTargetId: channelTargetId && channelTargetId.length > 0 ? channelTargetId : undefined, - unreadOnly, - limit, - }); - this.json(res, 200, messages); - }); - - this.addRoute("POST", "/api/companies/:companyId/messages/:messageId/read", (_req, res, params, token) => { - const companyId = params.companyId!; - if (companyId !== token.companyId) { - this.json(res, 403, { error: "Company mismatch" }); - return; - } - const message = this.db.getAgentMessage(params.messageId!, companyId); - if (!message) { - this.json(res, 404, { error: "Message not found" }); - return; - } - const visibleToAgent = this.db.listAgentMessages(companyId, { - agentId: token.agentId, - messageId: message.id, - limit: 1, - }).some((entry) => entry.id === message.id); - if (!visibleToAgent) { - this.json(res, 403, { error: "This message is not visible to the current agent" }); - return; - } - this.db.markAgentMessageRead(params.messageId!, companyId, token.agentId); - this.onDomainChanged(); - this.json(res, 200, { ok: true }); - }); - - this.addRoute("GET", "/api/companies/:companyId/sprints", (_req, res, params, token) => { - const companyId = params.companyId!; - if (companyId !== token.companyId) { - this.json(res, 403, { error: "Company mismatch" }); - return; - } - const snapshot = this.db.listSnapshot(); - const sprints = snapshot.sprints.filter(s => s.companyId === companyId); - this.json(res, 200, sprints); - }); - - this.addRoute("POST", "/api/companies/:companyId/sprints", (_req, res, params, token, body) => { - const companyId = params.companyId!; - if (companyId !== token.companyId) { - this.json(res, 403, { error: "Company mismatch" }); - return; - } - const data = body as Record; - try { - const id = this.db.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), - }); - this.onDomainChanged(); - this.json(res, 201, { id }); - } catch { - this.json(res, 400, { error: "Invalid sprint data" }); - } - }); - - this.addRoute("GET", "/api/agents/:agentId/metrics", (_req, res, params, token) => { - const snapshot = this.db.listSnapshot(); - const agent = snapshot.agents.find((entry) => entry.id === params.agentId && entry.companyId === token.companyId); - if (!agent) { - this.json(res, 404, { error: "Agent not found" }); - return; - } - const companyMetrics = this.db.getCompanyMetrics(token.companyId, 30); - const agentMetric = companyMetrics.agentMetrics.find((metric) => metric.agentId === params.agentId); - if (!agentMetric) { - this.json(res, 404, { error: "Metrics not found" }); - return; - } - this.json(res, 200, agentMetric); - }); - - this.addRoute("GET", "/api/agents/me/metrics", (_req, res, _params, token) => { - const companyMetrics = this.db.getCompanyMetrics(token.companyId, 30); - const agentMetric = companyMetrics.agentMetrics.find(m => m.agentId === token.agentId); - if (!agentMetric) { - this.json(res, 404, { error: "Metrics not found" }); - return; - } - this.json(res, 200, agentMetric); - }); - - this.addRoute("GET", "/api/agents/:agentId", (_req, res, params, token) => { - const snapshot = this.db.listSnapshot(); - const agent = snapshot.agents.find(a => a.id === params.agentId && a.companyId === token.companyId); - if (!agent) { - this.json(res, 404, { error: "Agent not found" }); - return; - } - const directReports = snapshot.agents.filter(a => a.reportsTo === agent.id && a.companyId === token.companyId); - const manager = agent.reportsTo ? snapshot.agents.find(a => a.id === agent.reportsTo) : null; - this.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 })), - }); - }); - - this.addRoute("POST", "/api/agents/:agentId/wake", (_req, res, params, token, body) => { - const targetAgentId = params.agentId!; - const targetAgent = (() => { - try { - return this.db.getAgent(targetAgentId); - } catch { - return null; - } - })(); - if (!targetAgent || targetAgent.companyId !== token.companyId) { - this.json(res, 404, { error: "Agent not found" }); - return; - } - if (!canControlAgent(this.db, token.companyId, token.agentId, targetAgentId)) { - this.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 = this.db.hasActiveRun({ companyId: token.companyId, agentId: targetAgentId }); - const runId = this.onWakeAgent(targetAgentId, token.companyId, reason); - if (runId) { - this.json(res, 200, { ok: true, runId, message: `Wake started for agent ${targetAgentId}` }); - return; - } - if (alreadyActive) { - this.json(res, 202, { - ok: true, - queued: true, - message: `Agent ${targetAgentId} is already running. Wake will be retried after the active run completes.`, - }); - return; - } - this.json(res, 409, { ok: false, error: "Agent could not be woken right now" }); - }); - - this.addRoute("PATCH", "/api/agents/:agentId/heartbeat", (_req, res, params, token) => { - const targetAgentId = params.agentId!; - const snapshot = this.db.listSnapshot(); - const targetAgent = snapshot.agents.find( - (a) => a.id === targetAgentId && a.companyId === token.companyId, - ); - if (!targetAgent) { - this.json(res, 404, { error: "Agent not found or not in the same company" }); - return; - } - if (!canControlAgent(this.db, token.companyId, token.agentId, targetAgentId)) { - this.json(res, 403, { error: "Current agent is not allowed to trigger heartbeat for this agent" }); - return; - } - const alreadyActive = this.db.hasActiveRun({ companyId: token.companyId, agentId: targetAgentId }); - const runId = this.onWakeAgent(targetAgentId, token.companyId, "peer_request"); - if (runId) { - this.json(res, 200, { ok: true, runId, message: `Heartbeat triggered for agent ${targetAgentId}` }); - return; - } - if (alreadyActive) { - this.json(res, 202, { - ok: true, - queued: true, - message: `Agent ${targetAgentId} is already running. Heartbeat will retry after the current run.`, - }); - return; - } - this.json(res, 409, { ok: false, error: "Heartbeat could not be started for this agent" }); - }); - - this.addRoute("GET", "/api/companies/:companyId/dashboard", (_req, res, params, token) => { - const companyId = params.companyId!; - if (companyId !== token.companyId) { - this.json(res, 403, { error: "Company mismatch" }); - return; - } - const snapshot = this.db.listSnapshot(); - const agents = snapshot.agents.filter((a) => a.companyId === companyId); - const tasks = snapshot.tasks.filter((t) => t.companyId === companyId); - const recentActivity = snapshot.activity - .filter((a) => a.companyId === companyId) - .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) - .slice(0, 20); - - const tasksByStatus: Record = {}; - for (const t of tasks) { - tasksByStatus[t.status] = (tasksByStatus[t.status] ?? 0) + 1; - } - - this.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, - })), - }); - }); - - // ── Skill REST API endpoints ────────────────────────────────────── - - // List all skills for a company (summary, no markdown) - this.addRoute("GET", "/api/companies/:companyId/skills", (_req, res, params, token) => { - const companyId = params.companyId!; - if (companyId !== token.companyId) { - this.json(res, 403, { error: "Company mismatch" }); - return; - } - const skills = this.db.listCompanySkills(companyId); - this.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, - }))); - }); - - // Get skill detail including full markdown - this.addRoute("GET", "/api/skills/:skillId", (_req, res, params, token) => { - const skill = this.db.getCompanySkill(token.companyId, params.skillId!); - if (!skill) { - this.json(res, 404, { error: "Skill not found" }); - return; - } - this.json(res, 200, skill); - }); - - // Assign a skill to an agent - this.addRoute("POST", "/api/agents/:agentId/skills", (_req, res, params, token, body) => { - const agentId = params.agentId!; - const parsed = z.object({ skillId: z.string().uuid() }).safeParse(body); - if (!parsed.success) { - this.json(res, 400, { error: "Invalid body: skillId (uuid) required" }); - return; - } - // Verify agent belongs to company - const snapshot = this.db.listSnapshot(); - const agent = snapshot.agents.find((a) => a.id === agentId && a.companyId === token.companyId); - if (!agent) { - this.json(res, 404, { error: "Agent not found in your company" }); - return; - } - // Verify skill belongs to company - const skill = this.db.getCompanySkill(token.companyId, parsed.data.skillId); - if (!skill) { - this.json(res, 404, { error: "Skill not found in your company" }); - return; - } - this.db.assignSkillToAgent(agentId, parsed.data.skillId); - this.onDomainChanged(); - this.json(res, 200, { ok: true, message: `Skill "${skill.name}" assigned to agent ${agent.name}` }); - }); - - // Remove a skill from an agent - this.addRoute("DELETE", "/api/agents/:agentId/skills/:skillId", (_req, res, params, token) => { - const agentId = params.agentId!; - const skillId = params.skillId!; - // Verify agent belongs to company - const snapshot = this.db.listSnapshot(); - const agent = snapshot.agents.find((a) => a.id === agentId && a.companyId === token.companyId); - if (!agent) { - this.json(res, 404, { error: "Agent not found in your company" }); - return; - } - this.db.removeSkillFromAgent(agentId, skillId); - this.onDomainChanged(); - this.json(res, 200, { ok: true, message: `Skill removed from agent ${agent.name}` }); - }); - - // List skills assigned to an agent - this.addRoute("GET", "/api/agents/:agentId/skills", (_req, res, params, token) => { - const agentId = params.agentId!; - // Verify agent belongs to company - const snapshot = this.db.listSnapshot(); - const agent = snapshot.agents.find((a) => a.id === agentId && a.companyId === token.companyId); - if (!agent) { - this.json(res, 404, { error: "Agent not found in your company" }); - return; - } - const skillIds = this.db.listAgentSkillAssignments(agentId); - const skills = skillIds.map((sid) => this.db.getCompanySkill(token.companyId, sid)).filter(Boolean); - this.json(res, 200, skills.map((s) => ({ - id: s!.id, - name: s!.name, - slug: s!.slug, - description: s!.description, - sourceType: s!.sourceType, - trustLevel: s!.trustLevel, - }))); - }); - } -} +// 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"; diff --git a/src/main/api-server/index.ts b/src/main/api-server/index.ts new file mode 100644 index 00000000..3d7d989a --- /dev/null +++ b/src/main/api-server/index.ts @@ -0,0 +1,38 @@ +import { AgentApiServer, type ApiServerEventCallbacks } from "./server-core"; +import { registerAgentRoutes } from "./routes-agents"; +import { registerAgentControlRoutes } from "./routes-agent-control"; +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 { 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, + onDomainChanged: () => void, + onWakeAgent: (agentId: string, companyId: string, trigger: string) => string | null, + callbacks?: Partial, + allowedOrigins?: Iterable, +): AgentApiServer { + const server = new AgentApiServer(db, onDomainChanged, onWakeAgent, callbacks, allowedOrigins); + registerAgentRoutes(server); + registerAgentControlRoutes(server); + registerTaskRoutes(server); + registerWorkGraphRoutes(server); + registerMessageRoutes(server); + registerApprovalRoutes(server); + registerFileRoutes(server); + registerMiscRoutes(server); + registerRunRoutes(server); + return server; +} diff --git a/src/main/api-server/routes-agent-control.ts b/src/main/api-server/routes-agent-control.ts new file mode 100644 index 00000000..ce6246cc --- /dev/null +++ b/src/main/api-server/routes-agent-control.ts @@ -0,0 +1,136 @@ +import { z } from "zod"; +import { canControlAgent } from "../agent-permissions"; +import 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 = params.agentId!; + const targetAgent = (() => { + try { + return db.getAgent(targetAgentId); + } catch { + 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.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 = params.agentId!; + const snapshot = db.listSnapshot(); + const targetAgent = snapshot.agents.find( + (a) => a.id === targetAgentId && a.companyId === token.companyId, + ); + if (!targetAgent) { + 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.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 = params.agentId!; + const snapshot = db.listSnapshot(); + const agent = snapshot.agents.find((a) => a.id === agentId && a.companyId === token.companyId); + if (!agent) { + server.json(res, 404, { error: "Agent not found in your company" }); + return; + } + const skillIds = db.listAgentSkillAssignments(agentId); + const skills = skillIds.map((sid) => db.getCompanySkill(token.companyId, sid)).filter(Boolean); + 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 = 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; + } + const snapshot = db.listSnapshot(); + const agent = snapshot.agents.find((a) => a.id === agentId && a.companyId === token.companyId); + if (!agent) { + server.json(res, 404, { error: "Agent not found in your company" }); + return; + } + const skill = db.getCompanySkill(token.companyId, parsed.data.skillId); + if (!skill) { + server.json(res, 404, { error: "Skill not found in your company" }); + return; + } + db.assignSkillToAgent(agentId, parsed.data.skillId); + 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 = params.agentId!; + const skillId = params.skillId!; + const snapshot = db.listSnapshot(); + const agent = snapshot.agents.find((a) => a.id === agentId && a.companyId === token.companyId); + if (!agent) { + server.json(res, 404, { error: "Agent not found in your company" }); + return; + } + db.removeSkillFromAgent(agentId, skillId); + onDomainChanged(); + server.json(res, 200, { ok: true, message: `Skill removed from agent ${agent.name}` }); + }); +} diff --git a/src/main/api-server/routes-agents.ts b/src/main/api-server/routes-agents.ts new file mode 100644 index 00000000..d5cb59f4 --- /dev/null +++ b/src/main/api-server/routes-agents.ts @@ -0,0 +1,334 @@ +import log from "electron-log/main.js"; +import { hireRequestSchema } from "@shared/contracts"; +import { getConnectorExecutionReadinessIssue } from "@shared/connector-policy"; +import { canManageOrganization, canRequestHire } from "../agent-permissions"; +import { resolveApprovalDecision, shouldAutoApproveApproval } from "../approval-policy"; +import type { AgentApiServer } from "./server-core"; + +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.getAgent(token.agentId); + const chainOfCommand = db.getChainOfCommand(token.agentId); + const directReports = db.getDirectReports(token.agentId, token.companyId); + server.json(res, 200, { agent, chainOfCommand, directReports }); + }); + + server.addRoute("GET", "/api/agents/me/standup", (_req, res, _params, token) => { + const snapshot = db.listSnapshot(); + const agent = snapshot.agents.find((a) => a.id === token.agentId); + if (!agent) { + server.json(res, 404, { error: "Agent not found" }); + return; + } + + const myTasks = snapshot.tasks.filter((t) => t.assigneeAgentId === token.agentId && t.companyId === token.companyId); + const directReports = snapshot.agents.filter((a) => a.reportsTo === token.agentId && a.companyId === token.companyId); + const reportIds = new Set(directReports.map((a) => a.id)); + const reportTasks = snapshot.tasks.filter((t) => t.assigneeAgentId && reportIds.has(t.assigneeAgentId) && t.companyId === token.companyId); + + 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 twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000; + const recentComments = snapshot.comments + .filter((c) => { + const isMyTask = myTasks.some((t) => t.id === c.taskId); + const isReportTask = reportTasks.some((t) => t.id === c.taskId); + return (isMyTask || isReportTask) && new Date(c.createdAt).getTime() > twoHoursAgo; + }) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + .slice(0, 20); + + const myPendingApprovals = snapshot.approvals.filter( + (a) => a.requestedByAgentId === token.agentId && a.state === "pending" && a.companyId === token.companyId, + ); + const myResolvedApprovals = snapshot.approvals.filter( + (a) => a.requestedByAgentId === token.agentId && a.state !== "pending" && a.companyId === token.companyId, + ).slice(0, 5); + + server.json(res, 200, { + agent: { id: agent.id, name: agent.name, role: agent.role, status: agent.status }, + summary: { + tasksAwaitingReview: tasksAwaitingMyReview.length, + blockedReportTasks: blockedReportTasks.length, + myBlockedTasks: myBlockedTasks.length, + myInProgress: myInProgressTasks.length, + myTodo: myTodoTasks.length, + pendingApprovals: myPendingApprovals.length, + directReports: directReports.length, + }, + tasksAwaitingReview: tasksAwaitingMyReview.map((t) => ({ + id: t.id, + title: t.title, + assignee: snapshot.agents.find((a) => a.id === t.assigneeAgentId)?.name ?? "Unknown", + priority: t.priority, + })), + blockedTasks: [...blockedReportTasks, ...myBlockedTasks].map((t) => ({ + id: t.id, + title: t.title, + assignee: snapshot.agents.find((a) => a.id === t.assigneeAgentId)?.name ?? "Unknown", + priority: t.priority, + })), + recentComments: recentComments.map((c) => ({ + taskId: c.taskId, + taskTitle: snapshot.tasks.find((t) => t.id === c.taskId)?.title ?? "", + 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: directReports.map((r) => { + const rTasks = snapshot.tasks.filter((t) => t.assigneeAgentId === r.id && t.companyId === token.companyId); + return { + id: r.id, + name: r.name, + role: r.role, + status: r.status, + tasksInProgress: rTasks.filter((t) => t.status === "in_progress").length, + tasksInReview: rTasks.filter((t) => t.status === "in_review").length, + tasksBlocked: rTasks.filter((t) => t.status === "blocked").length, + tasksDone: rTasks.filter((t) => t.status === "done").length, + }; + }), + socialAccounts: snapshot.socialAccounts + .filter((a) => a.companyId === token.companyId) + .map((a) => ({ + id: a.id, + platform: a.platform, + accountName: a.accountName, + displayName: a.displayName, + status: a.status, + requireApproval: a.requireApproval, + })), + }); + }); + + server.addRoute("GET", "/api/agents/me/metrics", (_req, res, _params, token) => { + const companyMetrics = db.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 snapshot = db.listSnapshot(); + const agent = snapshot.agents.find((entry) => entry.id === params.agentId && entry.companyId === token.companyId); + if (!agent) { + server.json(res, 404, { error: "Agent not found" }); + return; + } + const companyMetrics = db.getCompanyMetrics(token.companyId, 30); + const agentMetric = companyMetrics.agentMetrics.find((metric) => metric.agentId === params.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) => { + const snapshot = db.listSnapshot(); + const agent = snapshot.agents.find(a => a.id === params.agentId && a.companyId === token.companyId); + if (!agent) { + server.json(res, 404, { error: "Agent not found" }); + return; + } + const directReports = snapshot.agents.filter(a => a.reportsTo === agent.id && a.companyId === token.companyId); + const manager = agent.reportsTo ? snapshot.agents.find(a => a.id === agent.reportsTo) : 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 snapshot = db.listSnapshot(); + const agents = snapshot.agents.filter((a) => a.companyId === params.companyId); + server.json(res, 200, agents); + }); + + server.addRoute("POST", "/api/companies/:companyId/agents", (_req, res, params, token, body) => { + const companyId = 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; + } + const parsed = hireRequestSchema.safeParse({ + companyId, + requestedByAgentId: token.agentId, + ...(typeof body === "object" && body ? body : {}), + reportsTo: + typeof body === "object" && body && "reportsTo" in body + ? (body as { reportsTo?: string | null }).reportsTo + : token.agentId, + }); + if (!parsed.success) { + server.json(res, 400, { error: "Invalid hire payload" }); + return; + } + const input = parsed.data; + if (server.companyRefError(res, companyId, [ + { id: input.requestedByAgentId, table: "agents", label: "requesting agent" }, + { id: input.reportsTo, table: "agents", label: "manager" }, + { id: input.workspaceId, table: "workspaces", label: "workspace" }, + ])) { + return; + } + if ( + input.reportsTo && + input.reportsTo !== token.agentId && + !canManageOrganization(db, companyId, token.agentId) && + !db.getChainOfCommand(input.reportsTo).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.getConnector(input.connectorId); + } catch { + 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.listSnapshot().agents.filter( + (a) => a.companyId === companyId && a.status !== "terminated", + ); + 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.listSnapshot().approvals.filter( + (a) => a.companyId === companyId && a.type === "hire_agent" && a.state === "pending", + ); + for (const pending of pendingHires) { + try { + const payload = pending.payloadJson ? JSON.parse(pending.payloadJson) as Record : {}; + 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 { /* invalid payloadJson, skip */ } + } + + const managerLabel = input.reportsTo + ? (() => { + try { + return db.getAgent(input.reportsTo).name; + } catch { + return input.reportsTo; + } + })() + : "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: input.reportsTo ?? token.agentId, + 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({ + 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: input.reportsTo ?? token.agentId, + 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.addActivity({ + companyId, + actor: token.agentId, + action: "agent.hire_requested", + entityType: "agent", + entityId: result.agentId, + detail: input.name, + }); + + let autoApproved = false; + if (shouldAutoApproveApproval(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 }); + }); + +} diff --git a/src/main/api-server/routes-approvals.ts b/src/main/api-server/routes-approvals.ts new file mode 100644 index 00000000..eca1cd70 --- /dev/null +++ b/src/main/api-server/routes-approvals.ts @@ -0,0 +1,212 @@ +import { z } from "zod"; +import log from "electron-log/main.js"; +import { approvalInputSchema } from "@shared/contracts"; +import { resolveApprovalDecision, shouldAutoApproveApproval } from "../approval-policy"; +import type { AgentApiServer } from "./server-core"; + +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_; + + server.addRoute("POST", "/api/companies/:companyId/approvals", (_req, res, params, token, body) => { + const companyId = 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 (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; + } + + let id: string; + try { + id = db.requestApproval({ + companyId, + type: input.type, + relatedTaskId: input.relatedTaskId ?? null, + requestedByAgentId: token.agentId, + relatedAgentId: input.relatedAgentId ?? null, + payloadSummary: input.payloadSummary, + impactSummary: input.impactSummary, + payloadJson: input.payloadJson ?? null, + }); + } catch (error) { + server.json(res, 422, { error: error instanceof Error ? error.message : "Approval could not be created" }); + return; + } + + db.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 (shouldAutoApproveApproval(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 = 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 snapshot = db.listSnapshot(); + const approvals = snapshot.approvals.filter((approval) => { + if (approval.companyId !== companyId) return false; + if (parsedQuery.data.requestedByAgentId && approval.requestedByAgentId !== parsedQuery.data.requestedByAgentId) return false; + if (parsedQuery.data.relatedTaskId && approval.relatedTaskId !== parsedQuery.data.relatedTaskId) return false; + if (statuses && !statuses.includes(approval.state)) return false; + return true; + }); + server.json(res, 200, approvals); + }); + + server.addRoute("GET", "/api/approvals/:approvalId", (_req, res, params, token) => { + const snapshot = db.listSnapshot(); + const approval = snapshot.approvals.find((a) => a.id === params.approvalId && a.companyId === token.companyId); + if (!approval) { + 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 approval = db.listSnapshot().approvals.find((a) => a.id === 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 comments = db.listApprovalComments(params.approvalId!, approval.companyId); + server.json(res, 200, comments); + }); + + server.addRoute("POST", "/api/approvals/:approvalId/comments", (_req, res, params, token, body) => { + const approval = db.listSnapshot().approvals.find((a) => a.id === 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; + const commentBody = typeof data.body === "string" ? data.body.trim() : ""; + if (!commentBody) { server.json(res, 400, { error: "Comment body is required" }); return; } + const commentId = db.addApprovalComment({ + companyId: approval.companyId, + approvalId: approval.id, + authorAgentId: token.agentId ?? null, + body: commentBody, + }); + // Wake mentioned agents in comment + const mentionedIds = db.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.listSnapshot().approvals.find((a) => a.id === 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.resubmitApproval(approval.id, approval.companyId); + onDomainChanged(); + server.json(res, 200, { resubmitted: true }); + }); + + server.addRoute("GET", "/api/approvals/:approvalId/tasks", (_req, res, params, token) => { + const approval = db.listSnapshot().approvals.find((a) => a.id === 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 taskIds = db.listTasksForApproval(params.approvalId!); + server.json(res, 200, taskIds); + }); + + server.addRoute("POST", "/api/approvals/:approvalId/tasks", (_req, res, params, token, body) => { + const approval = db.listSnapshot().approvals.find((a) => a.id === 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; + 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.belongsToCompany("tasks", taskId, token.companyId)) { + server.json(res, 400, { error: `Task ${taskId} does not belong to this company` }); return; + } + } + db.linkManyTasksToApproval(params.approvalId!, taskIds, token.companyId, token.agentId ?? null); + onDomainChanged(); + server.json(res, 200, { linked: taskIds.length }); + }); +} diff --git a/src/main/api-server/routes-files.ts b/src/main/api-server/routes-files.ts new file mode 100644 index 00000000..5108e91b --- /dev/null +++ b/src/main/api-server/routes-files.ts @@ -0,0 +1,296 @@ +import type { AgentApiServer } from "./server-core"; + +export function registerFileRoutes(server: AgentApiServer) { + const db = server.db_; + const callbacks = server.callbacks_; + const onDomainChanged = server.onDomainChanged_; + const onWakeAgent = server.onWakeAgent_; + + server.addRoute("POST", "/api/companies/:companyId/documents", (_req, res, params, token, body) => { + if (params.companyId !== token.companyId) { + server.json(res, 403, { error: "Company mismatch" }); + return; + } + const data = body as Record; + const reviewerAgentId = data.reviewerAgentId ? String(data.reviewerAgentId) : null; + const projectId = data.projectId ? String(data.projectId) : null; + const goalId = data.goalId ? String(data.goalId) : null; + if (reviewerAgentId && !db.belongsToCompany("agents", reviewerAgentId, token.companyId)) { + server.json(res, 400, { error: "Reviewer agent does not belong to the current company" }); + return; + } + if (projectId && !db.belongsToCompany("projects", projectId, token.companyId)) { + server.json(res, 400, { error: "Project does not belong to the current company" }); + return; + } + if (goalId && !db.belongsToCompany("goals", goalId, token.companyId)) { + server.json(res, 400, { error: "Goal does not belong to the current company" }); + return; + } + const docType = String(data.type ?? "knowledge_article"); + const docTitle = String(data.title ?? "Untitled"); + const id = db.saveDocument({ + companyId: token.companyId, + type: docType, + title: docTitle, + content: String(data.content ?? ""), + authorAgentId: token.agentId, + reviewerAgentId, + projectId, + goalId, + status: String(data.status ?? "draft"), + tagsJson: data.tagsJson ? String(data.tagsJson) : "[]", + sourceRunId: token.runId ?? null, + }); + onDomainChanged(); + // Derive taskId from the agent's current run so thread integration + // and checkpoint engine can bind the document to its originating task. + let taskId: string | null = null; + if (token.runId) { + try { + const run = db.getRun(token.runId); + if (run.companyId === token.companyId) { + taskId = run.taskId; + } + } catch { /* run may have finished or been deleted */ } + } + // Fire document creation automation rules + callbacks.onDocumentCreated(token.companyId, token.agentId, projectId, id, docTitle, docType, taskId); + // Wake document reviewer if specified + if (reviewerAgentId) { + onWakeAgent(reviewerAgentId, token.companyId, "assignment"); + } + + // Deliverable review gate: check at document creation time (most reliable). + // In ai_cofounder mode, checkpoint-engine handles document reviews via the + // onDocumentCreated callback, so skip the direct approval here to avoid duplicates. + const IMPORTANT_DOC_TYPES = new Set(["prd","technical_spec","design_doc","project_brief","proposal","budget_proposal","architecture_decision","contract","sop"]); + if (IMPORTANT_DOC_TYPES.has(docType)) { + try { + const company = db.listSnapshot().companies.find((c) => c.id === token.companyId); + const isCofounderMode = db.getCompanyExecutionMode(token.companyId) === "ai_cofounder"; + + if (company?.reviewDeliverables && !isCofounderMode) { + if (!db.hasPendingDeliverableReviews(token.companyId)) { + db.requestApproval({ + companyId: token.companyId, + relatedTaskId: taskId, + requestedByAgentId: token.agentId, + type: "deliverable_review", + payloadSummary: `Review deliverable: ${docTitle}`.slice(0, 280), + impactSummary: `New ${docType.replaceAll("_", " ")} created. Awaiting your review before workflow continues.`.slice(0, 400), + payloadJson: JSON.stringify({ documentIds: [id], documents: [{ id, type: docType, title: docTitle }] }), + }); + try { + const agent = db.getAgent(token.agentId); + if (agent.heartbeatEnabled) { + db.setHeartbeat(token.agentId, false, agent.heartbeatIntervalSec || 120); + } + } catch { /* best effort */ } + onDomainChanged(); + } + } + } catch { /* deliverable review gate is best-effort */ } + } + + server.json(res, 201, { id }); + }); + + server.addRoute("GET", "/api/companies/:companyId/documents", (_req, res, params, token) => { + if (params.companyId !== token.companyId) { + server.json(res, 403, { error: "Company mismatch" }); + return; + } + const snapshot = db.listSnapshot(); + const docs = snapshot.documents.filter(d => d.companyId === token.companyId); + server.json(res, 200, docs); + }); + + server.addRoute("PATCH", "/api/companies/:companyId/documents/:documentId", (_req, res, params, token, body) => { + if (params.companyId !== token.companyId) { + server.json(res, 403, { error: "Company mismatch" }); + return; + } + const documentId = params.documentId; + if (!documentId) { + server.json(res, 400, { error: "Missing documentId" }); + return; + } + const snapshot = db.listSnapshot(); + const doc = snapshot.documents.find(d => d.id === documentId && d.companyId === token.companyId); + if (!doc) { + server.json(res, 404, { error: "Document not found in this company" }); + return; + } + const data = body as Record; + const VALID_STATUSES = new Set(["draft", "in_review", "approved", "archived", "superseded"]); + const reviewerAgentId = data.reviewerAgentId !== undefined + ? (data.reviewerAgentId ? String(data.reviewerAgentId) : null) + : doc.reviewerAgentId; + if (data.reviewerAgentId !== undefined && reviewerAgentId && !db.belongsToCompany("agents", reviewerAgentId, token.companyId)) { + server.json(res, 400, { error: "Reviewer agent does not belong to the current company" }); + return; + } + + // Conflict detection: if baseRevisionId provided, verify it matches current + if (typeof data.baseRevisionId === "string") { + const currentRevId = db.getDocumentLatestRevisionId(documentId); + if (currentRevId && data.baseRevisionId !== currentRevId) { + server.json(res, 409, { error: "Document was updated by someone else", currentRevisionId: currentRevId }); + return; + } + } + + if (data.status !== undefined) { + const newStatus = String(data.status); + if (!VALID_STATUSES.has(newStatus)) { + server.json(res, 400, { error: `Invalid status "${newStatus}". Must be one of: ${[...VALID_STATUSES].join(", ")}` }); + return; + } + } + + // Content update → create revision for audit trail + let updatedVersion = doc.version; + if (data.content !== undefined) { + const newContent = String(data.content); + const revision = db.createDocumentRevision({ + documentId, + companyId: token.companyId, + body: newContent, + changeSummary: typeof data.changeSummary === "string" ? data.changeSummary : null, + createdByAgentId: token.agentId ?? null, + }); + updatedVersion = revision.revisionNumber; + } + + // Field updates (title, reviewer, tags, etc.) + if (data.status !== undefined) { + db.updateDocumentStatus(documentId, token.companyId, String(data.status)); + } + db.saveDocument({ + id: documentId, + companyId: token.companyId, + type: data.type ? String(data.type) : doc.type, + title: data.title ? String(data.title) : doc.title, + content: data.content !== undefined ? String(data.content) : doc.content, + authorAgentId: doc.authorAgentId, + reviewerAgentId, + projectId: doc.projectId, + goalId: doc.goalId, + version: updatedVersion, + status: data.status ? String(data.status) : doc.status, + tagsJson: data.tagsJson ? String(data.tagsJson) : doc.tagsJson, + sourceRunId: data.content !== undefined ? (token.runId ?? null) : null, + }); + + onDomainChanged(); + server.json(res, 200, { updated: true, latestRevisionId: db.getDocumentLatestRevisionId(documentId) }); + }); + + server.addRoute("GET", "/api/companies/:companyId/documents/:documentId/revisions", (_req, res, params, token) => { + if (params.companyId !== token.companyId) { server.json(res, 403, { error: "Company mismatch" }); return; } + const documentId = params.documentId; + if (!documentId) { server.json(res, 400, { error: "Missing documentId" }); return; } + const revisions = db.listDocumentRevisions(documentId, token.companyId); + server.json(res, 200, revisions); + }); + + // ── Work Products ── + + server.addRoute("GET", "/api/issues/:issueId/work-products", (_req, res, params, token) => { + try { + const task = db.getTask(params.issueId!); + if (task.companyId !== token.companyId) { server.json(res, 404, { error: "Task not found" }); return; } + const products = db.listWorkProductsForTask(task.id, task.companyId); + server.json(res, 200, products); + } catch { server.json(res, 404, { error: "Task not found" }); } + }); + + server.addRoute("POST", "/api/issues/:issueId/work-products", (_req, res, params, token, body) => { + try { + const task = db.getTask(params.issueId!); + if (task.companyId !== token.companyId) { server.json(res, 404, { error: "Task not found" }); return; } + const data = body as Record; + if (!data.type || !data.provider || !data.title) { + server.json(res, 400, { error: "type, provider, and title are required" }); return; + } + const id = db.createWorkProduct({ + companyId: task.companyId, + taskId: task.id, + projectId: typeof data.projectId === "string" ? data.projectId : task.projectId, + type: String(data.type), + provider: String(data.provider), + externalId: typeof data.externalId === "string" ? data.externalId : null, + title: String(data.title), + url: typeof data.url === "string" ? data.url : null, + status: typeof data.status === "string" ? data.status : "active", + reviewState: typeof data.reviewState === "string" ? data.reviewState : "none", + isPrimary: data.isPrimary === true, + summary: typeof data.summary === "string" ? data.summary : null, + metadataJson: data.metadata ? JSON.stringify(data.metadata) : null, + createdByRunId: token.runId ?? null, + }); + onDomainChanged(); + server.json(res, 201, { id }); + } catch { server.json(res, 404, { error: "Task not found" }); } + }); + + server.addRoute("PATCH", "/api/work-products/:workProductId", (_req, res, params, token, body) => { + const wpId = params.workProductId; + if (!wpId) { server.json(res, 400, { error: "Missing workProductId" }); return; } + const data = body as Record; + db.updateWorkProduct(wpId, token.companyId, data); + onDomainChanged(); + server.json(res, 200, { updated: true }); + }); + + server.addRoute("DELETE", "/api/work-products/:workProductId", (_req, res, params, token) => { + const wpId = params.workProductId; + if (!wpId) { server.json(res, 400, { error: "Missing workProductId" }); return; } + db.deleteWorkProduct(wpId, token.companyId); + onDomainChanged(); + server.json(res, 200, { deleted: true }); + }); + + // ── Task-Document Links ── + + server.addRoute("GET", "/api/issues/:issueId/documents", (_req, res, params, token) => { + try { + const task = db.getTask(params.issueId!); + if (task.companyId !== token.companyId) { server.json(res, 404, { error: "Task not found" }); return; } + const links = db.listDocumentsForTask(task.id, task.companyId); + server.json(res, 200, links); + } catch { server.json(res, 404, { error: "Task not found" }); } + }); + + server.addRoute("PUT", "/api/issues/:issueId/documents/:key", (_req, res, params, token, body) => { + try { + const task = db.getTask(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 data = body as Record; + const documentId = typeof data.documentId === "string" ? data.documentId : null; + if (!documentId) { server.json(res, 400, { error: "documentId is required" }); return; } + if (!db.belongsToCompany("documents", documentId, token.companyId)) { + server.json(res, 400, { error: "Document does not belong to this company" }); return; + } + const linkId = db.linkDocumentToTask(task.id, documentId, task.companyId, key); + onDomainChanged(); + server.json(res, 200, { id: linkId, key }); + } catch { server.json(res, 404, { error: "Task not found" }); } + }); + + server.addRoute("DELETE", "/api/issues/:issueId/documents/:key", (_req, res, params, token) => { + try { + const task = db.getTask(params.issueId!); + if (task.companyId !== token.companyId) { server.json(res, 404, { error: "Task not found" }); return; } + const key = String(params.key ?? "").trim().toLowerCase(); + db.unlinkDocumentFromTask(task.id, key, task.companyId); + onDomainChanged(); + server.json(res, 200, { unlinked: true }); + } catch { server.json(res, 404, { error: "Task not found" }); } + }); +} diff --git a/src/main/api-server/routes-messages.ts b/src/main/api-server/routes-messages.ts new file mode 100644 index 00000000..7b394656 --- /dev/null +++ b/src/main/api-server/routes-messages.ts @@ -0,0 +1,175 @@ +import { agentMessageInputSchema } from "@shared/contracts"; +import { dispatchAgentMessage } from "../message-dispatch"; +import 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.getTask(params.issueId!); + if (task.companyId !== token.companyId) { + server.json(res, 404, { error: "Task not found" }); + return; + } + const agent = db.getAgent(token.agentId); + const comment = db.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(); + if (task.assigneeAgentId && task.assigneeAgentId !== token.agentId) { + onWakeAgent(task.assigneeAgentId, task.companyId, "comment"); + wokenForComment.add(task.assigneeAgentId); + } + + const chain = db.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.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 { + server.json(res, 404, { error: "Task not found" }); + } + }); + + server.addRoute("GET", "/api/issues/:issueId/comments", (_req, res, params, token) => { + try { + const task = db.getTask(params.issueId!); + if (task.companyId !== token.companyId) { + server.json(res, 404, { error: "Task not found" }); + return; + } + } catch { + server.json(res, 404, { error: "Task not found" }); + return; + } + const comments = db.listComments(params.issueId!); + server.json(res, 200, comments); + }); + + server.addRoute("POST", "/api/companies/:companyId/messages", (_req, res, params, token, body) => { + const companyId = params.companyId!; + if (companyId !== token.companyId) { + server.json(res, 403, { error: "Company mismatch" }); + return; + } + + const parsed = agentMessageInputSchema.safeParse({ + ...(body as Record), + 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); + }, + 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 = 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.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 = params.companyId!; + if (companyId !== token.companyId) { + server.json(res, 403, { error: "Company mismatch" }); + return; + } + const message = db.getAgentMessage(params.messageId!, companyId); + if (!message) { + server.json(res, 404, { error: "Message not found" }); + return; + } + const visibleToAgent = db.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.markAgentMessageRead(params.messageId!, companyId, token.agentId); + onDomainChanged(); + server.json(res, 200, { ok: true }); + }); +} diff --git a/src/main/api-server/routes-misc.ts b/src/main/api-server/routes-misc.ts new file mode 100644 index 00000000..cd89b471 --- /dev/null +++ b/src/main/api-server/routes-misc.ts @@ -0,0 +1,349 @@ +import { z } from "zod"; +import { validateTaskCreation } from "../task-quality-gates"; +import type { AgentApiServer } from "./server-core"; + +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(), +}); + +export function registerMiscRoutes(server: AgentApiServer) { + const db = server.db_; + const onDomainChanged = server.onDomainChanged_; + const onWakeAgent = server.onWakeAgent_; + + server.addRoute("POST", "/api/companies/:companyId/browser/action", async (_req, res, params, token, body) => { + const companyId = 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 snapshot = db.listSnapshot(); + const account = snapshot.socialAccounts.find(a => a.id === input.socialAccountId && a.companyId === 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.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.`, + }); + + const actionId = db.saveBrowserAction({ + companyId, + socialAccountId: input.socialAccountId, + agentId: token.agentId, + actionType: input.actionType, + payloadJson: JSON.stringify(input.payload), + status: "approval_required", + approvalId, + }); + + db.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; + } + + const actionId = db.saveBrowserAction({ + companyId, + socialAccountId: input.socialAccountId, + agentId: token.agentId, + actionType: input.actionType, + payloadJson: JSON.stringify(input.payload), + status: "queued", + }); + + db.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) { + onBrowserAction(actionId, companyId, input.socialAccountId, input.actionType, input.payload as Record); + } + + 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.listSocialAccounts(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 = 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.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.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.listBrowserActions(params.companyId!, { socialAccountId, status, limit }); + server.json(res, 200, actions); + }); + + 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 data = body as Record; + const participantAgentIds = data.participantAgentIds ? String(data.participantAgentIds) : "[]"; + try { + const parsedParticipants = JSON.parse(participantAgentIds) as string[]; + for (const agentId of parsedParticipants) { + if (typeof agentId !== "string" || !db.belongsToCompany("agents", agentId, token.companyId)) { + server.json(res, 400, { error: "Meeting participants must belong to the current company" }); + return; + } + } + } catch { + server.json(res, 400, { error: "participantAgentIds must be a JSON array of agent ids" }); + return; + } + const id = db.saveMeeting({ + companyId: token.companyId, + type: String(data.type ?? "department_sync"), + title: String(data.title ?? "Meeting"), + organizerAgentId: token.agentId, + participantAgentIds, + scheduledAt: String(data.scheduledAt ?? new Date().toISOString()), + durationMinutes: Number(data.durationMinutes ?? 30), + agendaJson: data.agendaJson ? String(data.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 snapshot = db.listSnapshot(); + const meetings = snapshot.meetings.filter(m => m.companyId === token.companyId); + server.json(res, 200, meetings); + }); + + server.addRoute("PATCH", "/api/meetings/:meetingId", (_req, res, params, token, body) => { + const snapshot = db.listSnapshot(); + const existing = snapshot.meetings.find(m => m.id === params.meetingId && m.companyId === token.companyId); + if (!existing) { + server.json(res, 404, { error: "Meeting not found" }); + return; + } + const data = body as Record; + let actionItems: Array<{ title?: string; assigneeAgentId?: string; description?: string }> = []; + if (data.actionItemsJson) { + try { + actionItems = JSON.parse(String(data.actionItemsJson)) as Array<{ title?: string; assigneeAgentId?: string; description?: string }>; + } catch { + server.json(res, 400, { error: "actionItemsJson must be valid JSON" }); + return; + } + for (const item of actionItems) { + if (item.assigneeAgentId && !db.belongsToCompany("agents", item.assigneeAgentId, token.companyId)) { + server.json(res, 400, { error: "Meeting action item assignees must belong to the current company" }); + return; + } + } + } + db.saveMeeting({ + id: params.meetingId, + companyId: token.companyId, + type: data.type ? String(data.type) : "department_sync", + title: data.title ? String(data.title) : "Meeting", + notesJson: data.notesJson ? String(data.notesJson) : undefined, + decisionsJson: data.decisionsJson ? String(data.decisionsJson) : undefined, + actionItemsJson: data.actionItemsJson ? String(data.actionItemsJson) : undefined, + status: data.status ? String(data.status) : undefined, + scheduledAt: data.scheduledAt ? String(data.scheduledAt) : new Date().toISOString(), + }); + 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}` }, + false, + ); + if (!meetingTaskValidation.valid) { + // Log but skip invalid meeting action items rather than failing the whole request + continue; + } + db.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", + }); + if (item.assigneeAgentId) { + onWakeAgent(item.assigneeAgentId, token.companyId, "assignment"); + } + } + } + } + server.json(res, 200, { updated: true }); + }); + + 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 data = body as Record; + const id = db.saveKnowledgeEntry({ + companyId: token.companyId, + category: String(data.category ?? "lesson_learned"), + topic: String(data.topic ?? ""), + content: String(data.content ?? ""), + authorAgentId: token.agentId, + importance: String(data.importance ?? "medium"), + tagsJson: data.tagsJson ? String(data.tagsJson) : "[]", + referencedEntityType: data.referencedEntityType ? String(data.referencedEntityType) : null, + referencedEntityId: data.referencedEntityId ? String(data.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 snapshot = db.listSnapshot(); + const entries = snapshot.knowledgeBase.filter(k => k.companyId === token.companyId); + server.json(res, 200, entries); + }); + + // ── Skill REST API endpoints ────────────────────────────────────── + + server.addRoute("GET", "/api/companies/:companyId/skills", (_req, res, params, token) => { + const companyId = params.companyId!; + if (companyId !== token.companyId) { + server.json(res, 403, { error: "Company mismatch" }); + return; + } + const skills = db.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.getCompanySkill(token.companyId, params.skillId!); + if (!skill) { + server.json(res, 404, { error: "Skill not found" }); + return; + } + server.json(res, 200, skill); + }); +} diff --git a/src/main/api-server/routes-runs.ts b/src/main/api-server/routes-runs.ts new file mode 100644 index 00000000..a79b7c98 --- /dev/null +++ b/src/main/api-server/routes-runs.ts @@ -0,0 +1,92 @@ +import type { TokenPayload } from "../agent-auth"; +import type { AgentApiServer } from "./server-core"; + +const DEFAULT_EVENT_LIMIT = 500; +const MAX_EVENT_LIMIT = 5_000; +const EVENT_POLL_INTERVAL_MS = 500; +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_.getRunAuthState(runId); + if (!runState || runState.companyId !== token.companyId || runState.agentId !== token.agentId) { + return null; + } + return runState; +} + +function writeSse(res: Parameters[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 = 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_.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 = 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 = () => { + const events = server.db_ + .listRunEvents(runId, token.companyId, { offset: 0, limit: MAX_EVENT_LIMIT }) + .filter((event) => event.seq > cursor); + for (const event of events) { + writeSse(res, "run-event", event); + cursor = event.seq; + } + }; + + 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); + }); +} diff --git a/src/main/api-server/routes-tasks.ts b/src/main/api-server/routes-tasks.ts new file mode 100644 index 00000000..0cc70e9b --- /dev/null +++ b/src/main/api-server/routes-tasks.ts @@ -0,0 +1,370 @@ +import { z } from "zod"; +import { taskInputSchema } from "@shared/contracts"; +import { canAssignTask, canUpdateTask } from "../agent-permissions"; +import { inferTaskType } from "../connector-matching"; +import { validateTaskCreation } from "../task-quality-gates"; +import 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(), +}); + +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 = 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.listAgentTasks(assigneeAgentId, companyId, statuses); + server.json(res, 200, tasks); + } else { + const snapshot = db.listSnapshot(); + const tasks = snapshot.tasks.filter((t) => t.companyId === companyId && statuses.includes(t.status)); + server.json(res, 200, tasks); + } + }); + + server.addRoute("GET", "/api/issues/:issueId", (_req, res, params, token) => { + try { + const task = db.getTask(params.issueId!); + if (task.companyId !== token.companyId) { + server.json(res, 404, { error: "Task not found" }); + return; + } + server.json(res, 200, task); + } catch { + 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.getTask(params.issueId!); + } catch { + 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.checkoutTask(params.issueId!, 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.getTask(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; + } + + // Deliverable review gate: intercept agent trying to advance a flagged task to "done". + // This MUST succeed — silently skipping review would let unreviewed work advance. + if (update.status === "done" && task.requiresUserReview) { + const importantTypes = ["prd","technical_spec","design_doc","project_brief","proposal","budget_proposal","architecture_decision","contract","sop"]; + const placeholders = importantTypes.map(() => "?").join(", "); + const docs = db.queryAll<{ id: string; type: string; title: string }>( + `select id, type, title from documents where company_id = ? and type in (${placeholders}) + and (author_agent_id = ? or created_at >= (select created_at from tasks where id = ?)) + order by created_at desc limit 10`, + task.companyId, ...importantTypes, task.assigneeAgentId ?? "", task.id, + ); + const reviewerAgent = db.getAgent(token.agentId); + const docTitles = docs.map((d) => d.title).join(", "); + db.requestApproval({ + companyId: task.companyId, + relatedTaskId: task.id, + requestedByAgentId: token.agentId, + type: "deliverable_review", + payloadSummary: `Review deliverable: ${docTitles || task.title}`.slice(0, 280), + impactSummary: `${reviewerAgent.name} reviewed and approved. Awaiting your final review.`.slice(0, 400), + payloadJson: JSON.stringify({ documentIds: docs.map((d) => d.id), documents: docs }), + }); + db.setTaskRequiresUserReview(task.id, false); + if (task.assigneeAgentId) { + const prodAgent = db.getAgent(task.assigneeAgentId); + if (prodAgent.heartbeatEnabled) { + db.setHeartbeat(task.assigneeAgentId, false, prodAgent.heartbeatIntervalSec || 120); + } + } + db.addComment({ companyId: task.companyId, taskId: task.id, authorAgentId: null, authorName: "System", body: "Deliverable submitted for board review." }); + update.status = "in_review"; + } + + // 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; + } + + // Apply status side-effect timestamps FIRST via advanceTaskStatus + // so started_at / completed_at / cancelled_at are set correctly. + // advanceTaskStatus checks previousStatus !== newStatus internally, + // so it must run before saveTask writes the new status. + if (update.status && update.status !== task.status) { + db.advanceTaskStatus(task.id, update.status); + } + + const input: Record = { + id: task.id, + companyId: task.companyId, + 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: task.workspaceId, + }; + db.saveTask(input as Parameters[0]); + + if (update.comment) { + const agent = db.getAgent(token.agentId); + db.addComment({ + companyId: task.companyId, + taskId: task.id, + authorAgentId: token.agentId, + authorName: agent.name, + body: update.comment, + }); + } + + db.addActivity({ + companyId: task.companyId, + actor: token.agentId, + action: `task.${update.status ? "status_changed" : "updated"}`, + entityType: "task", + entityId: task.id, + detail: update.comment ?? `Status → ${update.status ?? task.status}`, + }); + + onDomainChanged(); + + // Fire automation rules for status changes + const previousStatus = task.status; + const newStatus = update.status ?? task.status; + if (update.status && previousStatus !== newStatus) { + callbacks.onTaskStatusChanged(task.id, task.companyId, previousStatus, newStatus); + } + + if (update.status && ["done", "in_review"].includes(update.status)) { + if (task.parentId) { + try { + const parentTask = db.getTask(task.parentId); + if (parentTask.assigneeAgentId && parentTask.assigneeAgentId !== token.agentId) { + onWakeAgent(parentTask.assigneeAgentId, task.companyId, "subtask_completed"); + } + } catch { /* parent task may have been deleted */ } + } else { + const chain = db.getChainOfCommand(token.agentId); + if (chain.length > 0) { + onWakeAgent(chain[0].id, task.companyId, "subtask_completed"); + } + } + } + + if (update.status === "blocked") { + const chain = db.getChainOfCommand(token.agentId); + if (chain.length > 0) { + onWakeAgent(chain[0].id, task.companyId, "report_blocked"); + } + } + + if (update.status && ["in_progress", "todo"].includes(update.status) && + task.assigneeAgentId && task.assigneeAgentId !== token.agentId) { + onWakeAgent(task.assigneeAgentId, task.companyId, "assignment"); + } + + if (update.comment) { + // Fire comment automation rules + callbacks.onCommentPosted(task.id, task.companyId, token.agentId); + + const wokenForComment = new Set(); + if (task.assigneeAgentId && task.assigneeAgentId !== token.agentId) { + onWakeAgent(task.assigneeAgentId, task.companyId, "comment"); + wokenForComment.add(task.assigneeAgentId); + } + const chain = db.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 (newAssignee && newAssignee !== task.assigneeAgentId && newAssignee !== token.agentId) { + onWakeAgent(newAssignee, 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; + } + 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 = 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 isHeartbeat = input.title.startsWith("[Heartbeat]"); + const taskValidation = validateTaskCreation({ title: input.title, description: input.description ?? "" }, isHeartbeat); + if (!taskValidation.valid) { + server.json(res, 422, { error: taskValidation.errors.join("; ") }); + return; + } + + let id: string; + try { + id = db.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: input.workspaceId ?? null, + }); + } 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.getAgent(input.assigneeAgentId); + const inferredType = inferTaskType(assignee.department); + db.setTaskType(id, inferredType); + } + + db.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 }); + }); + +} diff --git a/src/main/api-server/routes-work-graph.ts b/src/main/api-server/routes-work-graph.ts new file mode 100644 index 00000000..15fc8083 --- /dev/null +++ b/src/main/api-server/routes-work-graph.ts @@ -0,0 +1,289 @@ +import { z } from "zod"; +import { goalInputSchema, projectInputSchema } from "@shared/contracts"; +import { canManageGoal, canManageProject } from "../agent-permissions"; +import 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 = 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.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.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 = 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 snapshot = db.listSnapshot(); + const existing = snapshot.projects.find(p => p.id === projectId && p.companyId === 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.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 { + server.json(res, 500, { error: "Failed to update project" }); + } + }); + + server.addRoute("POST", "/api/companies/:companyId/goals", (_req, res, params, token, body) => { + const companyId = 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.saveGoal(input); + db.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 = 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 snapshot = db.listSnapshot(); + const existing = snapshot.goals.find(g => g.id === goalId && g.companyId === 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.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 { + 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 snapshot = db.listSnapshot(); + const goals = snapshot.goals.filter((g) => g.companyId === 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 snapshot = db.listSnapshot(); + const projects = snapshot.projects.filter((p) => p.companyId === params.companyId); + server.json(res, 200, projects); + }); + + server.addRoute("GET", "/api/companies/:companyId/sprints", (_req, res, params, token) => { + const companyId = params.companyId!; + if (companyId !== token.companyId) { + server.json(res, 403, { error: "Company mismatch" }); + return; + } + const snapshot = db.listSnapshot(); + const sprints = snapshot.sprints.filter(s => s.companyId === companyId); + server.json(res, 200, sprints); + }); + + server.addRoute("POST", "/api/companies/:companyId/sprints", (_req, res, params, token, body) => { + const companyId = params.companyId!; + if (companyId !== token.companyId) { + server.json(res, 403, { error: "Company mismatch" }); + return; + } + const data = body as Record; + try { + const id = db.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 { + server.json(res, 400, { error: "Invalid sprint data" }); + } + }); + + server.addRoute("GET", "/api/companies/:companyId/dashboard", (_req, res, params, token) => { + const companyId = params.companyId!; + if (companyId !== token.companyId) { + server.json(res, 403, { error: "Company mismatch" }); + return; + } + const snapshot = db.listSnapshot(); + const agents = snapshot.agents.filter((a) => a.companyId === companyId); + const tasks = snapshot.tasks.filter((t) => t.companyId === companyId); + const recentActivity = snapshot.activity + .filter((a) => a.companyId === companyId) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + .slice(0, 20); + + const tasksByStatus: Record = {}; + 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, + })), + }); + }); +} diff --git a/src/main/api-server/server-core.ts b/src/main/api-server/server-core.ts new file mode 100644 index 00000000..9237fdd6 --- /dev/null +++ b/src/main/api-server/server-core.ts @@ -0,0 +1,276 @@ +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 { DesktopEvent, SectionId } from "@shared/types"; + +/** + * Decode an HTTP request body to a UTF-8 string, handling the common Windows + * encoding issue where Chinese/CJK characters arrive in GBK (CP936) instead + * of UTF-8. On Chinese Windows the system ANSI code page is GBK; tools like + * curl encode command-line arguments using that code page when stdout is piped + * (chcp 65001 does not help because there is no real console). We try strict + * UTF-8 first and, only if the bytes are invalid UTF-8, fall back to GBK. + */ +export function decodeBodyToUtf8(raw: Buffer): string { + if (raw.length === 0) return ""; + try { + return new TextDecoder("utf-8", { fatal: true }).decode(raw); + } catch { + // Bytes are not valid UTF-8 — most likely GBK from Chinese Windows. + try { + return new TextDecoder("gbk").decode(raw); + } catch { + // Last resort: lossy UTF-8 (replaces invalid bytes with U+FFFD). + return raw.toString("utf8"); + } + } +} + +export type RequestHandler = ( + req: IncomingMessage, + res: ServerResponse, + params: Record, + token: TokenPayload, + body: unknown, +) => void | Promise; + +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; + onDocumentCreated: (companyId: string, authorAgentId: string | null, projectId: string | null, documentId: string, documentTitle: string, documentType: string, taskId: string | null) => void; + onAutoAssignTask: (taskId: string, companyId: string) => string | null; + onHumanMessage: (companyId: string, toAgentId?: string | null, threadId?: string | null, waitStateId?: string | null) => void; + emitEvent: (event: DesktopEvent) => void; + notify: (options: { title: string; body: string; urgency: "critical" | "informational"; navigation?: { section: SectionId; entityId?: string }; batchKey?: string }) => void; +} + +export class AgentApiServer { + private routes: Route[] = []; + private server: ReturnType | 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; + private onBrowserActionCb: ((actionId: string, companyId: string, socialAccountId: string, actionType: string, payload: Record) => void) | null = null; + + constructor( + db: AppDatabase, + onDomainChanged: () => void, + onWakeAgent: (agentId: string, companyId: string, trigger: string) => string | null, + callbacks?: Partial, + allowedOrigins: Iterable = ["app://-"], + ) { + this.db = db; + this.onDomainChangedCb = onDomainChanged; + this.onWakeAgentCb = onWakeAgent; + this.emitEventCb = callbacks?.emitEvent ?? (() => {}); + this.allowedOrigins = new Set(Array.from(allowedOrigins).filter(Boolean)); + this.callbacks = { + onDomainChanged, + onWakeAgent, + onTaskCreated: callbacks?.onTaskCreated ?? (() => {}), + onTaskStatusChanged: callbacks?.onTaskStatusChanged ?? (() => {}), + onCommentPosted: callbacks?.onCommentPosted ?? (() => {}), + onApprovalCreated: callbacks?.onApprovalCreated ?? (() => {}), + onApprovalResolved: callbacks?.onApprovalResolved ?? (() => {}), + onDocumentCreated: callbacks?.onDocumentCreated ?? (() => {}), + onAutoAssignTask: callbacks?.onAutoAssignTask ?? (() => null), + onHumanMessage: callbacks?.onHumanMessage ?? (() => {}), + emitEvent: callbacks?.emitEvent ?? (() => {}), + notify: callbacks?.notify ?? (() => {}), + }; + } + + setBrowserActionHandler(handler: (actionId: string, companyId: string, socialAccountId: string, actionType: string, payload: Record) => void) { + this.onBrowserActionCb = handler; + } + + getPort(): number { + return this.port; + } + + async start(): Promise { + return new Promise((resolve, reject) => { + this.server = createServer((req, res) => void this.handleRequest(req, res)); + this.server.listen(0, "127.0.0.1", () => { + const addr = this.server!.address(); + 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 async handleRequest(req: IncomingMessage, res: ServerResponse) { + const origin = req.headers.origin; + 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"); + } + 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 (req.method === "OPTIONS") { + 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 authHeader = req.headers.authorization; + if (!authHeader?.startsWith("Bearer ")) { + this.json(res, 401, { error: "Missing or invalid Authorization header" }); + return; + } + const token = validateAgentToken(authHeader.slice(7)); + if (!token) { + this.json(res, 401, { error: "Invalid or expired token" }); + return; + } + + const runHeader = req.headers["x-agent-company-run-id"]; + const claimedRunId = Array.isArray(runHeader) ? runHeader[0] : runHeader; + if (typeof claimedRunId !== "string" || claimedRunId.trim() !== token.runId) { + this.json(res, 401, { error: "Missing or mismatched X-Agent-Company-Run-Id header" }); + return; + } + + const runState = this.db.getRunAuthState(token.runId); + if (!runState || runState.companyId !== token.companyId || runState.agentId !== token.agentId) { + this.json(res, 403, { error: "Token references an unknown or mismatched run" }); + return; + } + + if (!["queued", "running"].includes(runState.status)) { + this.json(res, 401, { error: "Run token is no longer active" }); + return; + } + + for (const route of this.routes) { + if (route.method !== method) continue; + const match = pathname.match(route.pattern); + if (!match) continue; + + const params: Record = {}; + route.paramNames.forEach((name, i) => { + params[name] = match[i + 1]!; + }); + + let body: unknown = null; + if (["POST", "PATCH", "PUT"].includes(method)) { + 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 { + 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.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, socialAccountId: string, actionType: string, payload: Record) => void) | null { + return this.onBrowserActionCb; + } + get port_(): number { return this.port; } +} diff --git a/src/main/approval-policy.ts b/src/main/approval-policy.ts index d25f3ec0..773c4b0c 100644 --- a/src/main/approval-policy.ts +++ b/src/main/approval-policy.ts @@ -1,29 +1,12 @@ import type { AppDatabase } from "./database"; - -const AUTO_APPROVAL_TYPES = new Set(["hire_agent", "approve_ceo_strategy"]); +import { shouldAutoApprove } from "./execution-policy"; export function shouldAutoApproveApproval( db: AppDatabase, companyId: string, approvalType: string, ): boolean { - if (!AUTO_APPROVAL_TYPES.has(approvalType)) { - return false; - } - - const company = db - .listSnapshot() - .companies - .find((c) => c.id === companyId); - - if (!company?.autoApproveHires) return false; - - // When reviewDeliverables is ON, CEO strategy needs human review — don't auto-approve - if (approvalType === "approve_ceo_strategy" && company.reviewDeliverables) { - return false; - } - - return true; + return shouldAutoApprove(db, companyId, approvalType); } export function resolveApprovalDecision({ diff --git a/src/main/automation-service.ts b/src/main/automation-service.ts index a0d4e7db..131aa8e1 100644 --- a/src/main/automation-service.ts +++ b/src/main/automation-service.ts @@ -1,1574 +1,2 @@ -import type { AppDatabase } from "./database"; -import type { - AgentRecord, - AutomationAction, - AutomationTrigger, - DesktopEvent, - RunRecord, - RunStatus, -} from "@shared/types"; -import { dispatchAgentMessage } from "./message-dispatch"; -import { validateTaskCreation } from "./task-quality-gates"; -import type { NotificationOptions } from "./notification-service"; -import { notifyHireApproved } from "./hire-hook"; - -/** Document types considered important enough to gate behind user review when reviewDeliverables is enabled. */ -const IMPORTANT_DOCUMENT_TYPES = new Set([ - "prd", "technical_spec", "design_doc", "project_brief", "proposal", - "budget_proposal", "architecture_decision", "contract", "sop", -]); - -const WORKFLOW_ACTIONS: ReadonlySet = new Set([ - "assign_task", - "create_task", - "notify_agent", - "send_message", - "trigger_heartbeat", - "create_approval", - "update_task_status", - "escalate_to_manager", - "cross_department_notify", - "schedule_meeting", - "create_document", - "auto_approve", - "reassign_task", - "run_task", -]); - -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; -} - -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; - dispatchBrowserAction: (actionId: string, companyId: string) => Promise; - isQuitting: () => boolean; -} - -const DEPARTMENT_SKILL_KEYWORDS: Record = { - engineering: ["code", "engineer", "develop", "program", "software", "api", "backend", "frontend", "full-stack", "fullstack", "devops", "infrastructure", "system", "architect", "debug", "test", "ci", "cd", "deploy", "docker", "kubernetes", "database", "sql", "git", "typescript", "javascript", "python", "rust", "go", "java", "react", "node", "css", "html", "web"], - product: ["product", "feature", "requirement", "roadmap", "user story", "ux", "ui", "design", "prototype", "wireframe", "specification", "prd"], - marketing: ["market", "content", "seo", "social media", "brand", "campaign", "analytics", "growth", "copywrite", "blog", "newsletter", "email"], - sales: ["sales", "crm", "lead", "pipeline", "deal", "revenue", "prospect", "outbound", "cold"], - hr: ["hr", "human resource", "hiring", "recruit", "onboard", "performance", "culture", "talent", "people"], - finance: ["finance", "budget", "accounting", "cost", "revenue", "profit", "spend", "invoice", "tax", "payroll"], - design: ["design", "ui", "ux", "visual", "figma", "sketch", "prototype", "illustration", "typography", "layout", "color", "icon"], - research: ["research", "analysis", "data", "study", "survey", "experiment", "insight", "benchmark", "report"], - operations: ["operations", "process", "workflow", "efficiency", "supply chain", "logistics", "coordination"], - customer_support: ["support", "customer", "ticket", "helpdesk", "troubleshoot", "faq", "documentation"], - legal: ["legal", "compliance", "contract", "regulation", "policy", "privacy", "ip", "intellectual property"], - executive: ["strategy", "leadership", "management", "executive", "ceo", "cto", "coo", "cfo", "cmo", "vp"], -}; - -function parseDocumentIdsFromPayload(payloadJson: string | null): string[] { - if (!payloadJson) return []; - try { - const parsed = JSON.parse(payloadJson) as Record; - const ids = parsed.documentIds; - if (Array.isArray(ids)) return ids.filter((id): id is string => typeof id === "string"); - } catch { /* invalid JSON */ } - return []; -} - -function localizeNotification(locale: string | null | undefined, en: string, zh: string): string { - return locale === "zh" ? zh : en; -} - -function scoreSkillAgentMatch( - skill: { name: string; description: string | null; markdown: string }, - agent: { role: string; department: string | null; title: string | null; capabilities: string }, -): number { - const agentText = [ - agent.role, - agent.department ?? "", - agent.title ?? "", - agent.capabilities, - ].join(" ").toLowerCase(); - - const skillText = [ - skill.name, - skill.description ?? "", - skill.markdown.slice(0, 500), - ].join(" ").toLowerCase(); - - let score = 0; - - const dept = agent.department ?? ""; - const deptKeywords = DEPARTMENT_SKILL_KEYWORDS[dept] ?? []; - for (const keyword of deptKeywords) { - if (skillText.includes(keyword)) { - score += 2; - } - } - - const agentWords = agentText - .replace(/[^a-z0-9\s-]/g, " ") - .split(/\s+/) - .filter((w) => w.length >= 3); - for (const word of agentWords) { - if (skillText.includes(word)) { - score += 1; - } - } - - // Executives need awareness of all domains - const executiveRoles = ["ceo", "cto", "coo", "cfo", "cmo", "vp", "chief", "director", "head", "lead"]; - const roleLower = agent.role.toLowerCase(); - if (executiveRoles.some((r) => roleLower.includes(r))) { - score += 1; - } - - return score; -} - -export function autoAssignSkillsToAgent( - db: AppDatabase, - logger: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void }, - companyId: string, - agentId: string, -): number { - const agent = db.getAgent(agentId); - if (!agent || agent.companyId !== companyId) return 0; - - const allSkills = db.listCompanySkills(companyId); - if (allSkills.length === 0) return 0; - - const existingAssignments = new Set(db.listAgentSkillAssignments(agentId)); - - // Always assign basic-for-all builtin skills regardless of score - let assigned = 0; - for (const skill of allSkills) { - if (existingAssignments.has(skill.id)) continue; - try { - const meta = skill.metadata ? (typeof skill.metadata === "string" ? JSON.parse(skill.metadata) : skill.metadata) : {}; - if (meta.builtinCategory === "basic-for-all") { - db.assignSkillToAgent(agentId, skill.id); - existingAssignments.add(skill.id); - assigned++; - logger.info(`[skill-autoassign] Assigned basic skill "${skill.name}" to ${agent.name}`); - } - } catch { /* skip malformed metadata */ } - } - - const scored = allSkills - .filter((skill) => !existingAssignments.has(skill.id)) - .map((skill) => ({ - skill, - score: scoreSkillAgentMatch(skill, agent), - })) - .sort((a, b) => b.score - a.score); - - const MIN_SCORE = 3; - for (const { skill, score } of scored) { - if (score < MIN_SCORE) continue; - try { - db.assignSkillToAgent(agentId, skill.id); - assigned++; - logger.info( - `[skill-autoassign] Assigned "${skill.name}" to ${agent.name} (${agent.role}) — score ${score}`, - ); - } catch (err) { - logger.warn( - `[skill-autoassign] Failed to assign "${skill.name}" to ${agent.name}: ${err instanceof Error ? err.message : String(err)}`, - ); - } - } - - if (assigned === 0) { - logger.info( - `[skill-autoassign] No matching skills for ${agent.name} (${agent.role}); agent will run without skills until manually assigned`, - ); - } - - return assigned; -} - -interface RunFinishedEvent { - status: RunStatus; - summary: string | null; - errorMessage: string | null; - costUsd: number | null; -} - -export function createAutomationService({ - db, - logger, - emitEvent, - publishDomainChanged, - notify, - wakeAgentIfPossible, - dispatchBrowserAction, - isQuitting, -}: AutomationDependencies) { - const activeAutomationExecutions = new Set(); - const activeWorkflowTriggerExecutions = new Set(); - const pendingScheduledTimers = new Set>(); - - function normalizeWorkflowSteps(raw: unknown): WorkflowStepState[] { - if (!Array.isArray(raw)) { - throw new Error("Workflow steps must be a JSON array."); - } - return raw.map((entry, index) => { - const step = (entry ?? {}) as Record; - const rawAction = typeof step.action === "string" ? step.action : "send_message"; - if (!WORKFLOW_ACTIONS.has(rawAction as AutomationAction)) { - throw new Error(`Workflow step ${index + 1} has unsupported action "${rawAction}".`); - } - return { - id: typeof step.id === "string" && step.id ? step.id : `step-${index + 1}`, - name: typeof step.name === "string" && step.name ? step.name : `Step ${index + 1}`, - action: rawAction as AutomationAction, - configJson: typeof step.configJson === "string" ? step.configJson : "{}", - assigneeDepartment: typeof step.assigneeDepartment === "string" ? step.assigneeDepartment : null, - assigneeAgentId: typeof step.assigneeAgentId === "string" ? step.assigneeAgentId : null, - requiresApproval: step.requiresApproval === true, - timeoutSec: typeof step.timeoutSec === "number" ? step.timeoutSec : 0, - status: ["pending", "running", "completed", "failed", "skipped"].includes(String(step.status)) - ? (String(step.status) as WorkflowStepState["status"]) - : "pending", - }; - }); - } - - function resolveWorkflowStepActor(step: WorkflowStepState, companyId: string): AgentRecord | null { - if (step.assigneeAgentId) { - const agent = db.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.getIdleAgentsForDepartment(companyId, step.assigneeDepartment); - if (idleAgents.length > 0) { - return db.getAgent(idleAgents[0]!.id); - } - } - - const snapshot = db.listSnapshot(); - return snapshot.agents.find((agent) => agent.companyId === companyId && !agent.reportsTo && agent.status !== "terminated") ?? null; - } - - function buildWorkflowActionContext(companyId: string, step: WorkflowStepState, actor: AgentRecord | null) { - let config: Record = {}; - try { - config = JSON.parse(step.configJson) as Record; - } 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, - }; - } - - function matchesEventConfig(configJson: string, sourceDepartment: string | null, ctx: AutomationEventContext): boolean { - if (sourceDepartment && ctx.department && sourceDepartment !== ctx.department) { - return false; - } - try { - const conditions = JSON.parse(configJson) as Record; - 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.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 { - // Entity missing — treat as condition not applicable, continue matching. - } - } - if (typeof conditions.budgetThresholdPct === "number" && ctx.agentId) { - try { - const agent = db.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 { - // Agent missing — treat as condition not applicable, continue matching. - } - } - 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; - } catch { - return false; - } - } - - function matchesConditions( - rule: { conditionsJson: string; sourceDepartment: string | null }, - ctx: AutomationEventContext, - ) { - return matchesEventConfig(rule.conditionsJson, rule.sourceDepartment, ctx); - } - - function executeAutomationAction( - rule: { - id?: string; - name?: string; - action: string; - actionConfigJson: string; - targetDepartment: string | null; - trigger?: string; - }, - ctx: AutomationEventContext, - ) { - const config = (() => { - try { - return JSON.parse(rule.actionConfigJson) as Record; - } catch { - return {}; - } - })(); - - switch (rule.action as AutomationAction) { - case "assign_task": { - if (ctx.taskId) { - const assignedId = db.autoAssignTask(ctx.taskId, ctx.companyId); - if (assignedId) { - wakeAgentIfPossible(assignedId, ctx.companyId, "assignment"); - } - } else if (ctx.agentId) { - const snapshot = db.listSnapshot(); - const unassigned = snapshot.tasks - .filter((task) => task.companyId === ctx.companyId && !task.assigneeAgentId && ["backlog", "todo"].includes(task.status)) - .sort((a, b) => { - const priorityScore = { critical: 4, high: 3, medium: 2, low: 1 } as Record; - return (priorityScore[b.priority] ?? 0) - (priorityScore[a.priority] ?? 0); - }); - if (unassigned.length > 0) { - const task = unassigned[0]!; - db.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, - priority: task.priority, - status: "todo", - }); - wakeAgentIfPossible(ctx.agentId, ctx.companyId, "assignment"); - logger.info(`[automation] Auto-assigned task "${task.title}" to idle agent ${ctx.agentId}`); - } - } - break; - } - case "create_task": { - const title = (config.title as string) ?? "[Auto] Task from automation rule"; - const description = (config.description as string) ?? ""; - const priority = (config.priority as string) ?? "medium"; - const assigneeAgentId = (config.assigneeAgentId as string) ?? null; - const isHeartbeat = title.startsWith("[Heartbeat]"); - const taskValidation = validateTaskCreation({ title, description }, isHeartbeat); - if (!taskValidation.valid) { - logger.warn(`[automation] Skipping create_task — validation failed: ${taskValidation.errors.join("; ")}`); - break; - } - const taskId = db.saveTask({ - companyId: ctx.companyId, - projectId: ctx.projectId ?? (config.projectId as string) ?? null, - title, - description, - assigneeAgentId, - priority, - status: assigneeAgentId ? "todo" : "backlog", - }); - handleTaskCreated(taskId, ctx.companyId); - logger.info(`[automation] Created task: ${title} (${taskId})`); - break; - } - case "notify_agent": - case "send_message": { - const toAgentId = (config.toAgentId as string) ?? ctx.agentId ?? null; - const fromAgentId = (config.fromAgentId as string) ?? ctx.agentId ?? toAgentId; - if (!fromAgentId) break; - const defaultChannel = toAgentId ? "direct" : "company"; - const requestedChannel = (config.channel as string) ?? defaultChannel; - const channel = ["direct", "department", "company", "project", "incident"].includes(requestedChannel) - ? requestedChannel - : defaultChannel; - let channelTargetId: string | null = (config.channelTargetId as string) ?? (config.department as string) ?? ctx.department ?? null; - if (channel === "department" && !channelTargetId && fromAgentId) { - try { - const sender = db.getAgent(fromAgentId); - channelTargetId = sender.department ?? null; - } catch { - channelTargetId = null; - } - } - const dispatchResult = dispatchAgentMessage( - { - db, - emitEvent, - publishDomainChanged, - wakeAgentIfPossible, - }, - { - companyId: ctx.companyId, - fromAgentId, - toAgentId: channel === "direct" ? toAgentId : null, - channel: channel as "direct" | "department" | "company" | "project" | "incident", - channelTargetId, - subject: (config.subject as string) ?? "Automation notification", - body: (config.body as string) ?? `Automation rule triggered for task ${ctx.taskId ?? "n/a"}`, - priority: (config.priority as "urgent" | "normal" | "low") ?? "normal", - }, - "message", - ); - if (!dispatchResult.ok) { - logger.warn( - `[automation] Failed to dispatch message for rule ${rule.name} (${rule.id}): ${dispatchResult.error.code} ${dispatchResult.error.message}`, - ); - } - break; - } - case "trigger_heartbeat": { - let targetAgentId = (config.agentId as string) ?? null; - if (!targetAgentId && ctx.agentId) { - const triggerType = rule.trigger ?? ""; - if (triggerType === "run_completed" || triggerType === "run_failed") { - const manager = db.getManagerForAgent(ctx.agentId); - targetAgentId = manager?.id ?? ctx.agentId; - } else { - targetAgentId = ctx.agentId; - } - } - if (targetAgentId) { - // Derive a descriptive wake trigger from the automation context - const triggerType = rule.trigger ?? ""; - const wakeTrigger = - triggerType === "task_status_changed" && ctx.previousStatus === "in_review" ? "comment" - : triggerType === "run_completed" ? "subtask_completed" - : triggerType === "run_failed" ? "report_failed" - : triggerType === "hire_approved" ? "assignment" - : triggerType === "agent_idle" ? "timer" - : "manual"; - wakeAgentIfPossible(targetAgentId, ctx.companyId, wakeTrigger); - } - break; - } - case "create_approval": { - const approvalType = (config.approvalType as string) ?? "document_review"; - db.requestApproval({ - companyId: ctx.companyId, - relatedTaskId: ctx.taskId ?? null, - requestedByAgentId: ctx.agentId ?? null, - type: approvalType, - payloadSummary: (config.summary as string) ?? "Auto-generated approval request", - impactSummary: (config.impact as string) ?? "Triggered by automation rule", - }); - break; - } - case "update_task_status": { - if (!ctx.taskId) break; - const newStatus = (config.status as string) ?? "todo"; - const previousTask = db.getTask(ctx.taskId); - db.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); - } - break; - } - case "escalate_to_manager": { - const targetAgentId = ctx.agentId; - if (!targetAgentId) break; - let agentName = "an agent"; - try { - agentName = db.getAgent(targetAgentId).name; - } catch { - // Fall back to the generic label when the agent was deleted mid-flight. - } - let taskTitle = ctx.taskId ?? "unknown"; - try { - if (ctx.taskId) taskTitle = db.getTask(ctx.taskId).title; - } catch { - // Keep the fallback title when the task no longer exists. - } - const manager = db.getManagerForAgent(targetAgentId); - const escalationBody = (config.body as string) ?? `Task "${taskTitle}" needs your attention. ${agentName} has flagged this as requiring manager intervention.`; - // Pick the most descriptive wake trigger based on the originating event - const wakeTrigger = ctx.newStatus === "in_review" - ? "subtask_completed" - : ctx.newStatus === "blocked" - ? "report_blocked" - : rule.trigger === "run_completed" - ? "subtask_completed" - : rule.trigger === "run_failed" - ? "report_failed" - : "report_blocked"; - 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}`, - ); - } - } else { - // No manager found — agent is likely the CEO or top-level. - // Post as company-wide message so the board and other leaders see it. - const escalationResult = dispatchAgentMessage( - { - db, - emitEvent, - publishDomainChanged, - wakeAgentIfPossible, - }, - { - companyId: ctx.companyId, - fromAgentId: targetAgentId, - toAgentId: null, - channel: "company", - subject: `Escalation: ${taskTitle}`, - body: escalationBody, - priority: "urgent", - }, - wakeTrigger, - ); - if (!escalationResult.ok) { - logger.warn( - `[automation] Failed to escalate "${taskTitle}" to company channel: ${escalationResult.error.code} ${escalationResult.error.message}`, - ); - } - } - break; - } - case "cross_department_notify": { - const targetDepartment = rule.targetDepartment ?? (config.department as string); - if (!targetDepartment) break; - const departmentAgents = db.getIdleAgentsForDepartment(ctx.companyId, targetDepartment); - const senderAgentId = ctx.agentId ?? departmentAgents[0]?.id; - if (senderAgentId) { - const dispatchResult = dispatchAgentMessage( - { - db, - emitEvent, - publishDomainChanged, - wakeAgentIfPossible, - }, - { - companyId: ctx.companyId, - fromAgentId: senderAgentId, - toAgentId: null, - channel: "department", - channelTargetId: targetDepartment, - subject: (config.subject as string) ?? "Cross-department notification", - body: (config.body as string) ?? "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}`, - ); - } - } - break; - } - case "schedule_meeting": { - const meetingType = (config.meetingType as string) ?? "department_sync"; - const title = (config.title as string) ?? "Auto-scheduled meeting"; - db.saveMeeting({ - companyId: ctx.companyId, - type: meetingType, - title, - organizerAgentId: ctx.agentId ?? null, - participantAgentIds: (config.participantAgentIds as string) ?? "[]", - scheduledAt: new Date(Date.now() + 3600_000).toISOString(), - durationMinutes: (config.durationMinutes as number) ?? 30, - }); - break; - } - case "create_document": { - const documentType = (config.documentType as string) ?? "status_report"; - const title = (config.title as string) ?? "Auto-generated document"; - db.saveDocument({ - companyId: ctx.companyId, - type: documentType, - title, - content: (config.content as string) ?? "", - authorAgentId: ctx.agentId ?? null, - projectId: ctx.projectId ?? null, - }); - break; - } - case "auto_approve": { - const snapshot = db.listSnapshot(); - const pendingApprovals = snapshot.approvals.filter( - (approval) => approval.companyId === ctx.companyId && approval.state === "pending", - ); - for (const approval of pendingApprovals.slice(0, 5)) { - db.decideApproval({ - companyId: ctx.companyId, - approvalId: approval.id, - state: "approved", - decisionNote: "Auto-approved by automation rule.", - }); - handleApprovalResolved(approval.id, ctx.companyId, "approved"); - } - break; - } - case "reassign_task": { - if (!ctx.taskId) break; - const newAssignee = db.autoAssignTask(ctx.taskId, ctx.companyId); - if (newAssignee) { - wakeAgentIfPossible(newAssignee, ctx.companyId, "assignment"); - } - break; - } - case "run_task": { - const targetTaskId = (config.taskId as string) ?? ctx.taskId; - if (!targetTaskId) break; - const task = db.getTask(targetTaskId); - if (!task.assigneeAgentId) break; - wakeAgentIfPossible(task.assigneeAgentId, ctx.companyId, "assignment"); - break; - } - default: - throw new Error(`Unknown automation action: ${rule.action}`); - } - } - - function evaluateAutomationRules(trigger: string, ctx: AutomationEventContext) { - try { - const rules = db.listActiveAutomationRules(ctx.companyId, trigger); - for (const rule of rules) { - if (!matchesConditions(rule, ctx)) continue; - const executionKey = [ - rule.id, - trigger, - ctx.companyId, - ctx.taskId ?? "-", - ctx.agentId ?? "-", - ctx.runId ?? "-", - ctx.previousStatus ?? "-", - ctx.newStatus ?? "-", - ].join(":"); - if (activeAutomationExecutions.has(executionKey)) { - logger.warn(`[automation] Skipping re-entrant execution for rule "${rule.name}" (${executionKey})`); - continue; - } - activeAutomationExecutions.add(executionKey); - try { - executeAutomationAction(rule, ctx); - db.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.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.listTriggerableWorkflows(ctx.companyId, trigger); - for (const workflow of workflows) { - if (!matchesEventConfig(workflow.triggerConfigJson, null, ctx)) continue; - const executionKey = [ - workflow.id, - trigger, - ctx.companyId, - ctx.taskId ?? "-", - ctx.agentId ?? "-", - ctx.runId ?? "-", - ctx.previousStatus ?? "-", - ctx.newStatus ?? "-", - ].join(":"); - 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); - } - - async function runWorkflowPipeline(workflowId: string, companyId: string) { - const workflow = db.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.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.updateWorkflowExecution({ - id: workflowId, - companyId, - status: "active", - currentStepIndex: index, - stepsJson: JSON.stringify(steps), - lastRunAt: new Date().toISOString(), - }); - publishDomainChanged(); - - const actor = resolveWorkflowStepActor(step, companyId); - const { config, ctx } = buildWorkflowActionContext(companyId, step, actor); - - if (step.requiresApproval) { - const approvalId = db.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.updateWorkflowExecution({ - id: workflowId, - companyId, - status: "paused", - currentStepIndex: index, - stepsJson: JSON.stringify(steps), - lastRunAt: new Date().toISOString(), - }); - db.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({ - action: step.action, - actionConfigJson: JSON.stringify(config), - targetDepartment: step.assigneeDepartment, - trigger: "workflow_step", - }, ctx); - step.status = "completed"; - db.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.updateWorkflowExecution({ - id: workflowId, - companyId, - status: completionStatus, - currentStepIndex: index + 1, - stepsJson: JSON.stringify(steps), - lastRunAt: new Date().toISOString(), - }); - publishDomainChanged(); - } catch (error) { - step.status = "failed"; - db.updateWorkflowExecution({ - id: workflowId, - companyId, - status: "failed", - currentStepIndex: index, - stepsJson: JSON.stringify(steps), - lastRunAt: new Date().toISOString(), - }); - db.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; - } - } - } - - function checkRunProducedDeliverable(runId: string, companyId: string, taskId: string): boolean { - // Check for knowledge entries created during this run - try { - const recentKnowledge = db.queryAll<{ id: string }>( - "select id from knowledge_entries where company_id = ? and created_at >= (select started_at from runs where id = ?) limit 1", - companyId, runId, - ); - if (recentKnowledge.length > 0) return true; - } catch { /* table may not exist */ } - - // Check for documents created during this run - try { - const recentDocs = db.queryAll<{ id: string }>( - "select id from documents where company_id = ? and created_at >= (select started_at from runs where id = ?) limit 1", - companyId, runId, - ); - if (recentDocs.length > 0) return true; - } catch { /* table may not exist */ } - - // Check for substantial comments on the task (agent may have written findings as comments) - try { - const recentComments = db.queryAll<{ body: string }>( - "select body from comments where task_id = ? and created_at >= (select started_at from runs where id = ?) and length(body) > 100", - taskId, runId, - ); - if (recentComments.length > 0) return true; - } catch { /* table may not exist */ } - - return false; - } - - /** Returns important documents created during this run (matching IMPORTANT_DOCUMENT_TYPES). */ - function findImportantDocsForRun(runId: string, companyId: string): Array<{ id: string; type: string; title: string }> { - try { - const placeholders = [...IMPORTANT_DOCUMENT_TYPES].map(() => "?").join(", "); - return db.queryAll<{ id: string; type: string; title: string }>( - `select id, type, title from documents where company_id = ? and type in (${placeholders}) and created_at >= (select started_at from runs where id = ?)`, - companyId, ...IMPORTANT_DOCUMENT_TYPES, runId, - ); - } catch { return []; } - } - - /** Creates a deliverable_review approval and fires OS notification. */ - function createDeliverableReviewApproval( - companyId: string, - taskId: string, - agentId: string, - docs: Array<{ id: string; type: string; title: string }>, - reviewerNote?: string, - ): string { - const docTitles = docs.map((d) => d.title).join(", "); - const approvalId = db.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.setTaskRequiresUserReview(taskId, false); - // Pause the producing agent's heartbeat until the user reviews - try { - const agent = db.getAgent(agentId); - if (agent.heartbeatEnabled) { - db.setHeartbeat(agentId, false, agent.heartbeatIntervalSec || 120); - logger.info(`[deliverable-review] Paused heartbeat for ${agent.name} — awaiting user review.`); - } - } catch { /* best effort */ } - try { - notify({ - title: "Deliverable Ready for Review", - body: `${docTitles}`.slice(0, 200), - urgency: "informational", - navigation: { section: "approvals", entityId: approvalId }, - batchKey: "deliverable_review", - }); - } catch { /* best effort */ } - publishDomainChanged(); - return approvalId; - } - - function processRunCompletion(run: RunRecord, event: RunFinishedEvent) { - if (isQuitting()) return; - let task: ReturnType | null = null; - try { - task = db.getTask(run.taskId); - } catch { - 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") { - // Check if non-code task produced a deliverable before auto-advancing - const taskType = ((task as unknown as Record).task_type as string) ?? "general"; - const needsDeliverable = taskType === "research" || taskType === "content"; - - if (needsDeliverable) { - const hasDeliverable = checkRunProducedDeliverable(run.id, run.companyId, task.id); - if (!hasDeliverable) { - // Don't auto-advance -- flag for manager review - db.advanceTaskStatus(task.id, "in_progress", - `${taskType} task run completed but no deliverable artifact was detected. Flagging for manager review.`); - handleTaskStatusChange(task.id, run.companyId, "in_progress", "in_progress"); - // Wake manager for review - try { - const agent = db.getAgent(run.agentId); - const chain = db.getChainOfCommand(agent.id); - if (chain.length > 0) { - wakeAgentIfPossible(chain[0].id, run.companyId, "review_needed"); - } - } catch { /* best effort */ } - publishDomainChanged(); - // Skip the normal in_review advance - } else { - db.advanceTaskStatus(task.id, "in_review", "Run completed with deliverable. Awaiting review."); - handleTaskStatusChange(task.id, run.companyId, "in_progress", "in_review"); - effectiveStatus = "in_review"; - // Check if deliverable review gate should activate - try { - const company = db.listSnapshot().companies.find((c) => c.id === run.companyId); - if (company?.reviewDeliverables) { - const importantDocs = findImportantDocsForRun(run.id, run.companyId); - if (importantDocs.length > 0) { - db.setTaskRequiresUserReview(task.id, true); - const agent = db.getAgent(run.agentId); - // Pause heartbeat immediately — agent should not run while deliverable awaits review - if (agent.heartbeatEnabled) { - db.setHeartbeat(agent.id, false, agent.heartbeatIntervalSec || 120); - logger.info(`[deliverable-review] Paused heartbeat for ${agent.name} — deliverable flagged for review.`); - } - // If agent has no manager (is CEO), create approval immediately - const chain = db.getChainOfCommand(agent.id); - if (chain.length === 0) { - createDeliverableReviewApproval(run.companyId, task.id, run.agentId, importantDocs); - } - } - } - } catch { /* deliverable review gate is best-effort */ } - publishDomainChanged(); - } - } else { - // Code/management/general tasks advance normally - db.advanceTaskStatus(task.id, "in_review", "Run completed successfully. Awaiting review."); - handleTaskStatusChange(task.id, run.companyId, "in_progress", "in_review"); - effectiveStatus = "in_review"; - // Check if deliverable review gate should activate for code/general tasks too - try { - const company = db.listSnapshot().companies.find((c) => c.id === run.companyId); - if (company?.reviewDeliverables) { - const importantDocs = findImportantDocsForRun(run.id, run.companyId); - if (importantDocs.length > 0) { - db.setTaskRequiresUserReview(task.id, true); - const agent = db.getAgent(run.agentId); - if (agent.heartbeatEnabled) { - db.setHeartbeat(agent.id, false, agent.heartbeatIntervalSec || 120); - logger.info(`[deliverable-review] Paused heartbeat for ${agent.name} — deliverable flagged for review.`); - } - const chain = db.getChainOfCommand(agent.id); - if (chain.length === 0) { - createDeliverableReviewApproval(run.companyId, task.id, run.agentId, importantDocs); - } - } - } - } catch { /* deliverable review gate is best-effort */ } - publishDomainChanged(); - } - } - - if (isSuccess && task.title.startsWith("[Heartbeat]") && effectiveStatus !== "done") { - db.advanceTaskStatus(task.id, "done", "Heartbeat check completed."); - handleTaskStatusChange(task.id, run.companyId, effectiveStatus, "done"); - } - - const automationContext: AutomationEventContext = { - companyId: run.companyId, - taskId: run.taskId, - agentId: run.agentId, - runId: run.id, - costUsd: event.costUsd ?? undefined, - }; - try { - const agent = db.getAgent(run.agentId); - automationContext.department = agent.department ?? undefined; - } catch { - // Agent can disappear after a run completes; keep the context without department. - } - - if (isSuccess) { - dispatchAutomation("run_completed", automationContext); - if (task.parentId) { - try { - const siblings = db.listChildTasks(task.parentId, run.companyId); - const allSiblingsDoneOrReview = siblings.every((entry) => entry.status === "done" || entry.status === "in_review"); - if (allSiblingsDoneOrReview) { - const parent = db.getTask(task.parentId); - if (parent.assigneeAgentId) { - wakeAgentIfPossible(parent.assigneeAgentId, run.companyId, "subtask_completed"); - } else { - const assignedId = db.autoAssignTask(parent.id, run.companyId); - if (assignedId) { - wakeAgentIfPossible(assignedId, run.companyId, "subtask_completed"); - } - } - } - } catch { - // Parent task may have been deleted or reassigned during cascade evaluation. - } - } - } - - if (isFailed) { - dispatchAutomation("run_failed", automationContext); - } - - if (event.costUsd && event.costUsd > 0) { - try { - const agent = db.getAgent(run.agentId); - if (agent.budgetMonthlyUsd > 0) { - const utilization = agent.spentMonthlyUsd / agent.budgetMonthlyUsd; - if (utilization >= 0.8) { - dispatchAutomation("budget_threshold", { - ...automationContext, - agentId: agent.id, - }); - // Fire OS notification for budget threshold warning - const pct = Math.round(utilization * 100); - const budgetLocale = db.listSnapshot().locale; - notify({ - title: localizeNotification(budgetLocale, "Budget Warning", "预算警告"), - body: localizeNotification(budgetLocale, `${agent.name} has used ${pct}% of monthly budget`, `${agent.name} 已使用月度预算的 ${pct}%`), - urgency: "informational", - navigation: { section: "costs" }, - batchKey: "budget_alerts", - }); - if (utilization >= 1.0) { - // Fire OS notification for budget exhaustion (agent auto-paused by database) - notify({ - title: localizeNotification(budgetLocale, "Budget Exhausted", "预算耗尽"), - body: localizeNotification(budgetLocale, `${agent.name} has reached 100% of monthly budget and is paused`, `${agent.name} 已达到月度预算上限并已暂停`), - urgency: "critical", - navigation: { section: "costs" }, - }); - } - } - } - } catch { - // Budget follow-up is best-effort; do not fail run completion if the agent disappeared. - } - } - } - - function handleApprovalResolved(approvalId: string, companyId: string, decision: string) { - if (isQuitting()) return; - const snapshot = db.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) { - try { - const agent = db.getAgent(approval.relatedAgentId); - if (agent.status === "idle") { - db.addActivity({ - companyId, - actor: "system", - action: "agent.hired", - entityType: "agent", - entityId: agent.id, - detail: `${agent.name} hire approved and activated.`, - }); - - const skillsAssigned = autoAssignSkillsToAgent(db, logger, companyId, agent.id); - if (skillsAssigned > 0) { - db.addActivity({ - companyId, - actor: "system", - action: "skills.auto_assigned", - entityType: "agent", - entityId: agent.id, - detail: `Auto-assigned ${skillsAssigned} skill(s) to ${agent.name} based on role/department matching.`, - }); - } - - dispatchAutomation("hire_approved", { - companyId, - agentId: agent.id, - }); - wakeAgentIfPossible(agent.id, companyId, "assignment"); - - const ceo = db.getCeoForCompany(companyId); - if (ceo && ceo.id !== agent.id) { - wakeAgentIfPossible(ceo.id, companyId, "assignment"); - } - - publishDomainChanged(); - const hireLocale = db.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: "orgchart", 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(() => {}); - } - } catch { - // Hire follow-up is best-effort; approval state is already committed in storage. - } - } - - if (approval.type === "deliverable_review") { - try { - // Update linked document statuses from payloadJson - const documentIds = parseDocumentIdsFromPayload(approval.payloadJson); - const docStatus = decision === "approved" ? "approved" : decision === "rejected" ? "draft" : "in_review"; - for (const docId of documentIds) { - db.updateDocumentStatus(docId, companyId, docStatus); - } - - if (approval.relatedTaskId) { - const task = db.getTask(approval.relatedTaskId); - if (task.assigneeAgentId) { - try { - const agent = db.getAgent(task.assigneeAgentId); - if (!agent.heartbeatEnabled) { - db.setHeartbeat(task.assigneeAgentId, true, agent.heartbeatIntervalSec || 120); - logger.info(`[deliverable-review] Resumed heartbeat for ${agent.name} after user ${decision}.`); - } - } catch { /* agent lookup non-fatal */ } - } - if (decision === "approved") { - db.advanceTaskStatus(task.id, "done", "Board approved deliverable."); - handleTaskStatusChange(task.id, companyId, task.status, "done"); - db.addActivity({ companyId, actor: "board", action: "deliverable.approved", entityType: "task", entityId: task.id, detail: approval.decisionNote || "Deliverable approved by board." }); - } else if (decision === "rejected") { - db.advanceTaskStatus(task.id, "in_progress", `Board rejected deliverable: ${approval.decisionNote || "No reason given."}`); - handleTaskStatusChange(task.id, companyId, task.status, "in_progress"); - if (task.assigneeAgentId) { - wakeAgentIfPossible(task.assigneeAgentId, companyId, "revision_needed"); - } - } else if (decision === "revision_requested") { - if (task.assigneeAgentId) { - wakeAgentIfPossible(task.assigneeAgentId, companyId, "revision_needed"); - } - } - } else if (approval.requestedByAgentId) { - // Document-only review (no task) — resume the producing agent - try { - const agent = db.getAgent(approval.requestedByAgentId); - if (!agent.heartbeatEnabled) { - db.setHeartbeat(approval.requestedByAgentId, true, agent.heartbeatIntervalSec || 120); - logger.info(`[deliverable-review] Resumed heartbeat for ${agent.name} after user ${decision}.`); - } - } catch { /* agent lookup non-fatal */ } - if (decision !== "approved") { - wakeAgentIfPossible(approval.requestedByAgentId, companyId, "revision_needed"); - } - } - - publishDomainChanged(); - } catch { - // Deliverable review follow-up is non-fatal — approval state is already committed. - } - } - - // 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.resolveBudgetIncident(payload.policyId, companyId); - db.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.getBrowserActionByApprovalId(approvalId, companyId); - if (browserAction) { - if (decision === "approved") { - db.updateBrowserAction(browserAction.id, { - status: "queued", - resultSummary: "Approval granted. Dispatching browser action.", - errorMessage: null, - }); - publishDomainChanged(); - void dispatchBrowserAction(browserAction.id, companyId).catch((error) => { - logger.error(`[browser] Failed to dispatch approved browser action ${browserAction.id}: ${error instanceof Error ? error.message : String(error)}`); - }); - } else if (decision === "rejected") { - db.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.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.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(); - void dispatchBrowserAction(actionId, approval.companyId).catch((error) => { - logger.error(`[automation] Failed to dispatch approved social post ${actionId}: ${error instanceof Error ? error.message : String(error)}`); - }); - }; - - if (draft.scheduledAt) { - const delay = new Date(draft.scheduledAt).getTime() - Date.now(); - if (delay > 0) { - const timer = setTimeout(() => { - pendingScheduledTimers.delete(timer); - if (isQuitting()) return; - dispatchSocialAction(); - }, delay); - pendingScheduledTimers.add(timer); - } else { - dispatchSocialAction(); - } - } else { - dispatchSocialAction(); - } - } catch (err) { - logger.error("[automation] Failed to dispatch approved social post:", err); - } - } - - if (approval.payloadJson) { - try { - const payload = JSON.parse(approval.payloadJson) as { workflowId?: string; stepId?: string }; - if (payload.workflowId && payload.stepId) { - const workflow = db.getWorkflowRecord(payload.workflowId, companyId); - if (workflow) { - const steps = normalizeWorkflowSteps(JSON.parse(workflow.stepsJson || "[]")); - const stepIndex = steps.findIndex((step) => step.id === payload.stepId); - if (stepIndex >= 0) { - if (decision === "approved") { - steps[stepIndex]!.status = "pending"; - db.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.updateWorkflowExecution({ - id: workflow.id, - companyId, - status: decision === "revision_requested" ? "paused" : "failed", - currentStepIndex: stepIndex, - stepsJson: JSON.stringify(steps), - lastRunAt: workflow.lastRunAt, - }); - publishDomainChanged(); - } - } - } - } - } catch { - // Non-workflow approvals may use payloadJson for unrelated metadata. - } - } - - if (approval.requestedByAgentId) { - wakeAgentIfPossible(approval.requestedByAgentId, companyId, "approval_resolved"); - } - } - - function handleTaskStatusChange(taskId: string, companyId: string, previousStatus: string, newStatus: string) { - if (isQuitting()) return; - if (previousStatus === newStatus) return; - - let task: ReturnType | null = null; - try { - task = db.getTask(taskId); - } catch { - return; - } - if (!task) return; - - db.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.getAgent(task.assigneeAgentId); - context.department = agent.department ?? undefined; - } - } catch { - // Agent may have been deleted after the task update; keep the transition without department context. - } - - dispatchAutomation("task_status_changed", context); - - if (newStatus === "blocked") { - dispatchAutomation("task_blocked", context); - if (task.assigneeAgentId) { - const manager = db.getManagerForAgent(task.assigneeAgentId); - if (manager) { - wakeAgentIfPossible(manager.id, companyId, "report_blocked"); - } - } - } - - if (newStatus === "done" && task.parentId) { - try { - const siblings = db.listChildTasks(task.parentId, companyId); - const allDone = siblings.every((entry) => entry.id === taskId ? true : entry.status === "done"); - if (allDone) { - const parent = db.getTask(task.parentId); - if (parent.assigneeAgentId) { - wakeAgentIfPossible(parent.assigneeAgentId, companyId, "subtask_completed"); - } else { - const assignedId = db.autoAssignTask(parent.id, companyId); - if (assignedId) { - wakeAgentIfPossible(assignedId, companyId, "subtask_completed"); - } - } - } - } catch { - // Parent task may have been deleted or reassigned during completion cascade evaluation. - } - } - } - - function handleTaskCreated(taskId: string, companyId: string) { - if (isQuitting()) return; - let task: ReturnType | null = null; - try { - task = db.getTask(taskId); - } catch { - return; - } - if (!task) return; - - dispatchAutomation("task_created", { - companyId, - taskId, - agentId: task.assigneeAgentId ?? undefined, - }); - - if (!task.assigneeAgentId) { - const assignedId = db.autoAssignTask(taskId, companyId); - if (assignedId) { - wakeAgentIfPossible(assignedId, companyId, "assignment"); - } - } else { - wakeAgentIfPossible(task.assigneeAgentId, companyId, "assignment"); - } - } - - function destroy() { - for (const timer of pendingScheduledTimers) { - clearTimeout(timer); - } - pendingScheduledTimers.clear(); - } - - return { - runWorkflowPipeline, - dispatchAutomation, - processRunCompletion, - handleApprovalResolved, - handleTaskStatusChange, - handleTaskCreated, - destroy, - }; -} +export { createAutomationService, autoAssignSkillsToAgent } from "./automation/index"; +export type { AutomationEventContext } from "./automation/index"; diff --git a/src/main/automation/automation-actions.ts b/src/main/automation/automation-actions.ts new file mode 100644 index 00000000..6d1d9e35 --- /dev/null +++ b/src/main/automation/automation-actions.ts @@ -0,0 +1,357 @@ +import type { AppDatabase } from "../database"; +import type { AutomationAction, DesktopEvent } from "@shared/types"; +import { dispatchAgentMessage } from "../message-dispatch"; +import { validateTaskCreation } from "../task-quality-gates"; +import type { AutomationEventContext } from "./types"; + +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 function executeAutomationAction( + deps: ActionDeps, + callbacks: ActionCallbacks, + rule: { + id?: string; + name?: string; + action: string; + actionConfigJson: string; + targetDepartment: string | null; + trigger?: string; + }, + ctx: AutomationEventContext, +): void { + const { db, logger, emitEvent, publishDomainChanged, wakeAgentIfPossible } = deps; + const { handleTaskCreated, handleApprovalResolved, handleTaskStatusChange } = callbacks; + + const config = (() => { + try { + return JSON.parse(rule.actionConfigJson) as Record; + } catch { + return {}; + } + })(); + + switch (rule.action as AutomationAction) { + case "assign_task": { + if (ctx.taskId) { + const assignedId = db.autoAssignTask(ctx.taskId, ctx.companyId); + if (assignedId) { + wakeAgentIfPossible(assignedId, ctx.companyId, "assignment"); + } + } else if (ctx.agentId) { + const snapshot = db.listSnapshot(); + const unassigned = snapshot.tasks + .filter((task) => task.companyId === ctx.companyId && !task.assigneeAgentId && ["backlog", "todo"].includes(task.status)) + .sort((a, b) => { + const priorityScore = { critical: 4, high: 3, medium: 2, low: 1 } as Record; + return (priorityScore[b.priority] ?? 0) - (priorityScore[a.priority] ?? 0); + }); + if (unassigned.length > 0) { + const task = unassigned[0]!; + db.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, + priority: task.priority, + status: "todo", + }); + wakeAgentIfPossible(ctx.agentId, ctx.companyId, "assignment"); + logger.info(`[automation] Auto-assigned task "${task.title}" to idle agent ${ctx.agentId}`); + } + } + break; + } + case "create_task": { + const title = (config.title as string) ?? "[Auto] Task from automation rule"; + const description = (config.description as string) ?? ""; + const priority = (config.priority as string) ?? "medium"; + const assigneeAgentId = (config.assigneeAgentId as string) ?? null; + const isHeartbeat = title.startsWith("[Heartbeat]"); + const taskValidation = validateTaskCreation({ title, description }, isHeartbeat); + if (!taskValidation.valid) { + logger.warn(`[automation] Skipping create_task — validation failed: ${taskValidation.errors.join("; ")}`); + break; + } + const taskId = db.saveTask({ + companyId: ctx.companyId, + projectId: ctx.projectId ?? (config.projectId as string) ?? null, + title, + description, + assigneeAgentId, + priority, + status: assigneeAgentId ? "todo" : "backlog", + }); + handleTaskCreated(taskId, ctx.companyId); + logger.info(`[automation] Created task: ${title} (${taskId})`); + break; + } + case "notify_agent": + case "send_message": { + const toAgentId = (config.toAgentId as string) ?? ctx.agentId ?? null; + const fromAgentId = (config.fromAgentId as string) ?? ctx.agentId ?? toAgentId; + if (!fromAgentId) break; + const defaultChannel = toAgentId ? "direct" : "company"; + const requestedChannel = (config.channel as string) ?? defaultChannel; + const channel = ["direct", "department", "company", "project", "incident"].includes(requestedChannel) + ? requestedChannel + : defaultChannel; + let channelTargetId: string | null = (config.channelTargetId as string) ?? (config.department as string) ?? ctx.department ?? null; + if (channel === "department" && !channelTargetId && fromAgentId) { + try { + const sender = db.getAgent(fromAgentId); + channelTargetId = sender.department ?? null; + } catch { + channelTargetId = null; + } + } + const dispatchResult = dispatchAgentMessage( + { db, emitEvent, publishDomainChanged, wakeAgentIfPossible }, + { + companyId: ctx.companyId, + fromAgentId, + toAgentId: channel === "direct" ? toAgentId : null, + channel: channel as "direct" | "department" | "company" | "project" | "incident", + channelTargetId, + subject: (config.subject as string) ?? "Automation notification", + body: (config.body as string) ?? `Automation rule triggered for task ${ctx.taskId ?? "n/a"}`, + priority: (config.priority as "urgent" | "normal" | "low") ?? "normal", + }, + "message", + ); + if (!dispatchResult.ok) { + logger.warn( + `[automation] Failed to dispatch message for rule ${rule.name} (${rule.id}): ${dispatchResult.error.code} ${dispatchResult.error.message}`, + ); + } + break; + } + case "trigger_heartbeat": { + let targetAgentId = (config.agentId as string) ?? null; + if (!targetAgentId && ctx.agentId) { + const triggerType = rule.trigger ?? ""; + if (triggerType === "run_completed" || triggerType === "run_failed") { + const manager = db.getManagerForAgent(ctx.agentId); + targetAgentId = manager?.id ?? ctx.agentId; + } else { + targetAgentId = ctx.agentId; + } + } + if (targetAgentId) { + // Derive a descriptive wake trigger from the automation context + const triggerType = rule.trigger ?? ""; + const wakeTrigger = + triggerType === "task_status_changed" && ctx.previousStatus === "in_review" ? "comment" + : triggerType === "run_completed" ? "subtask_completed" + : triggerType === "run_failed" ? "report_failed" + : triggerType === "hire_approved" ? "assignment" + : triggerType === "agent_idle" ? "timer" + : "manual"; + wakeAgentIfPossible(targetAgentId, ctx.companyId, wakeTrigger); + } + break; + } + case "create_approval": { + const approvalType = (config.approvalType as string) ?? "document_review"; + db.requestApproval({ + companyId: ctx.companyId, + relatedTaskId: ctx.taskId ?? null, + requestedByAgentId: ctx.agentId ?? null, + type: approvalType, + payloadSummary: (config.summary as string) ?? "Auto-generated approval request", + impactSummary: (config.impact as string) ?? "Triggered by automation rule", + }); + break; + } + case "update_task_status": { + if (!ctx.taskId) break; + const newStatus = (config.status as string) ?? "todo"; + const previousTask = db.getTask(ctx.taskId); + db.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); + } + break; + } + case "escalate_to_manager": { + const targetAgentId = ctx.agentId; + if (!targetAgentId) break; + let agentName = "an agent"; + try { + agentName = db.getAgent(targetAgentId).name; + } catch { + // Fall back to the generic label when the agent was deleted mid-flight. + } + let taskTitle = ctx.taskId ?? "unknown"; + try { + if (ctx.taskId) taskTitle = db.getTask(ctx.taskId).title; + } catch { + // Keep the fallback title when the task no longer exists. + } + const manager = db.getManagerForAgent(targetAgentId); + const escalationBody = (config.body as string) ?? `Task "${taskTitle}" needs your attention. ${agentName} has flagged this as requiring manager intervention.`; + // Pick the most descriptive wake trigger based on the originating event + const wakeTrigger = ctx.newStatus === "in_review" + ? "subtask_completed" + : ctx.newStatus === "blocked" + ? "report_blocked" + : rule.trigger === "run_completed" + ? "subtask_completed" + : rule.trigger === "run_failed" + ? "report_failed" + : "report_blocked"; + 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}`, + ); + } + } else { + // No manager found — agent is likely the CEO or top-level. + // Post as company-wide message so the board and other leaders see it. + const escalationResult = dispatchAgentMessage( + { db, emitEvent, publishDomainChanged, wakeAgentIfPossible }, + { + companyId: ctx.companyId, + fromAgentId: targetAgentId, + toAgentId: null, + channel: "company", + subject: `Escalation: ${taskTitle}`, + body: escalationBody, + priority: "urgent", + }, + wakeTrigger, + ); + if (!escalationResult.ok) { + logger.warn( + `[automation] Failed to escalate "${taskTitle}" to company channel: ${escalationResult.error.code} ${escalationResult.error.message}`, + ); + } + } + break; + } + case "cross_department_notify": { + const targetDepartment = rule.targetDepartment ?? (config.department as string); + if (!targetDepartment) break; + const departmentAgents = db.getIdleAgentsForDepartment(ctx.companyId, targetDepartment); + const senderAgentId = ctx.agentId ?? departmentAgents[0]?.id; + if (senderAgentId) { + const dispatchResult = dispatchAgentMessage( + { db, emitEvent, publishDomainChanged, wakeAgentIfPossible }, + { + companyId: ctx.companyId, + fromAgentId: senderAgentId, + toAgentId: null, + channel: "department", + channelTargetId: targetDepartment, + subject: (config.subject as string) ?? "Cross-department notification", + body: (config.body as string) ?? "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}`, + ); + } + } + break; + } + case "schedule_meeting": { + const meetingType = (config.meetingType as string) ?? "department_sync"; + const title = (config.title as string) ?? "Auto-scheduled meeting"; + db.saveMeeting({ + companyId: ctx.companyId, + type: meetingType, + title, + organizerAgentId: ctx.agentId ?? null, + participantAgentIds: (config.participantAgentIds as string) ?? "[]", + scheduledAt: new Date(Date.now() + 3600_000).toISOString(), + durationMinutes: (config.durationMinutes as number) ?? 30, + }); + break; + } + case "create_document": { + const documentType = (config.documentType as string) ?? "status_report"; + const title = (config.title as string) ?? "Auto-generated document"; + db.saveDocument({ + companyId: ctx.companyId, + type: documentType, + title, + content: (config.content as string) ?? "", + authorAgentId: ctx.agentId ?? null, + projectId: ctx.projectId ?? null, + }); + break; + } + case "auto_approve": { + const snapshot = db.listSnapshot(); + const pendingApprovals = snapshot.approvals.filter( + (approval) => approval.companyId === ctx.companyId && approval.state === "pending", + ); + for (const approval of pendingApprovals.slice(0, 5)) { + db.decideApproval({ + companyId: ctx.companyId, + approvalId: approval.id, + state: "approved", + decisionNote: "Auto-approved by automation rule.", + }); + handleApprovalResolved(approval.id, ctx.companyId, "approved"); + } + break; + } + case "reassign_task": { + if (!ctx.taskId) break; + const newAssignee = db.autoAssignTask(ctx.taskId, ctx.companyId); + if (newAssignee) { + wakeAgentIfPossible(newAssignee, ctx.companyId, "assignment"); + } + break; + } + case "run_task": { + const targetTaskId = (config.taskId as string) ?? ctx.taskId; + if (!targetTaskId) break; + const task = db.getTask(targetTaskId); + if (!task.assigneeAgentId) break; + wakeAgentIfPossible(task.assigneeAgentId, ctx.companyId, "assignment"); + break; + } + default: + throw new Error(`Unknown automation action: ${rule.action}`); + } +} diff --git a/src/main/automation/automation-approval-handler.ts b/src/main/automation/automation-approval-handler.ts new file mode 100644 index 00000000..d22af82c --- /dev/null +++ b/src/main/automation/automation-approval-handler.ts @@ -0,0 +1,374 @@ +import type { AppDatabase } from "../database"; +import type { NotificationOptions } from "../notification-service"; +import { extractApprovalDocumentIds } from "@shared/approval-documents"; +import { notifyHireApproved } from "../hire-hook"; +import { autoAssignSkillsToAgent } from "./automation-skills"; +import type { AutomationEventContext } from "./types"; +import { normalizeWorkflowSteps } from "./automation-workflow"; + +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.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.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; + dispatchBrowserAction: (actionId: string, companyId: string) => Promise; + 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; + trackTimer: (timer: ReturnType) => 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, dispatchBrowserAction } = deps; + const { dispatchAutomation, handleTaskStatusChange, runWorkflowPipeline, trackTimer } = callbacks; + + const snapshot = db.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) { + try { + const agent = db.getAgent(approval.relatedAgentId); + + if (agent.status === "idle") { + db.addActivity({ + companyId, + actor: "system", + action: "agent.hired", + entityType: "agent", + entityId: agent.id, + detail: `${agent.name} hire approved and activated.`, + }); + + const skillsAssigned = autoAssignSkillsToAgent(db, logger, companyId, agent.id); + if (skillsAssigned > 0) { + db.addActivity({ + companyId, + actor: "system", + action: "skills.auto_assigned", + entityType: "agent", + entityId: agent.id, + detail: `Auto-assigned ${skillsAssigned} skill(s) to ${agent.name} based on role/department matching.`, + }); + } + + dispatchAutomation("hire_approved", { + companyId, + agentId: agent.id, + }); + wakeAgentIfPossible(agent.id, companyId, "assignment"); + + const ceo = db.getCeoForCompany(companyId); + if (ceo && ceo.id !== agent.id) { + wakeAgentIfPossible(ceo.id, companyId, "assignment"); + } + + publishDomainChanged(); + const hireLocale = db.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: "orgchart", 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(() => {}); + } + } catch { + // Hire follow-up is best-effort; approval state is already committed in storage. + } + } + + if (approval.type === "deliverable_review") { + try { + // Update linked document statuses from payloadJson + const documentIds = extractApprovalDocumentIds(approval.payloadJson); + const docStatus = decision === "approved" + ? "approved" + : decision === "rejected" || decision === "revision_requested" + ? "draft" + : "in_review"; + for (const docId of documentIds) { + db.updateDocumentStatus(docId, companyId, docStatus); + } + + if (approval.relatedTaskId) { + const task = db.getTask(approval.relatedTaskId); + if (task.assigneeAgentId) { + try { + const agent = db.getAgent(task.assigneeAgentId); + if (!agent.heartbeatEnabled) { + db.setHeartbeat(task.assigneeAgentId, true, agent.heartbeatIntervalSec || 120); + logger.info(`[deliverable-review] Resumed heartbeat for ${agent.name} after user ${decision}.`); + } + } catch { /* agent lookup non-fatal */ } + } + if (decision === "approved") { + db.advanceTaskStatus(task.id, "done", "Board approved deliverable."); + handleTaskStatusChange(task.id, companyId, task.status, "done"); + db.addActivity({ companyId, actor: "board", action: "deliverable.approved", entityType: "task", entityId: task.id, detail: approval.decisionNote || "Deliverable approved by board." }); + } else if (decision === "rejected") { + recordReviewFeedbackKnowledge(db, approval, decision); + db.advanceTaskStatus(task.id, "in_progress", `Board rejected deliverable: ${approval.decisionNote || "No reason given."}`); + handleTaskStatusChange(task.id, companyId, task.status, "in_progress"); + if (task.assigneeAgentId) { + wakeAgentIfPossible(task.assigneeAgentId, companyId, "revision_needed"); + } + } else if (decision === "revision_requested") { + db.advanceTaskStatus(task.id, "in_progress", `Board requested revisions: ${approval.decisionNote || "No reason given."}`); + handleTaskStatusChange(task.id, companyId, task.status, "in_progress"); + recordReviewFeedbackKnowledge(db, approval, decision); + if (task.assigneeAgentId) { + wakeAgentIfPossible(task.assigneeAgentId, companyId, "revision_needed"); + } + } + } else if (approval.requestedByAgentId) { + // Document-only review (no task) — resume the producing agent + try { + const agent = db.getAgent(approval.requestedByAgentId); + if (!agent.heartbeatEnabled) { + db.setHeartbeat(approval.requestedByAgentId, true, agent.heartbeatIntervalSec || 120); + logger.info(`[deliverable-review] Resumed heartbeat for ${agent.name} after user ${decision}.`); + } + } catch { /* agent lookup non-fatal */ } + if (decision !== "approved") { + wakeAgentIfPossible(approval.requestedByAgentId, companyId, "revision_needed"); + } + } + + publishDomainChanged(); + } catch { + // Deliverable review follow-up is non-fatal — approval state is already committed. + } + } + + if (approval.type === "draft_review" || approval.type === "strategy_review") { + try { + const documentIds = extractApprovalDocumentIds(approval.payloadJson); + const docStatus = decision === "approved" ? "approved" : "draft"; + for (const docId of documentIds) { + db.updateDocumentStatus(docId, companyId, docStatus); + } + publishDomainChanged(); + } catch { + // Document checkpoint follow-up is non-fatal — approval state is already committed. + } + } + + // 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.resolveBudgetIncident(payload.policyId, companyId); + db.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.getBrowserActionByApprovalId(approvalId, companyId); + if (browserAction) { + if (decision === "approved") { + db.updateBrowserAction(browserAction.id, { + status: "queued", + resultSummary: "Approval granted. Dispatching browser action.", + errorMessage: null, + }); + publishDomainChanged(); + void dispatchBrowserAction(browserAction.id, companyId).catch((error) => { + logger.error(`[browser] Failed to dispatch approved browser action ${browserAction.id}: ${error instanceof Error ? error.message : String(error)}`); + }); + } else if (decision === "rejected") { + db.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.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.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(); + void dispatchBrowserAction(actionId, approval.companyId).catch((error) => { + logger.error(`[automation] Failed to dispatch approved social post ${actionId}: ${error instanceof Error ? error.message : String(error)}`); + }); + }; + + 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); + } + } + + if (approval.payloadJson) { + try { + const payload = JSON.parse(approval.payloadJson) as { workflowId?: string; stepId?: string }; + if (payload.workflowId && payload.stepId) { + const workflow = db.getWorkflowRecord(payload.workflowId, companyId); + if (workflow) { + const steps = normalizeWorkflowSteps(JSON.parse(workflow.stepsJson || "[]")); + const stepIndex = steps.findIndex((step) => step.id === payload.stepId); + if (stepIndex >= 0) { + if (decision === "approved") { + steps[stepIndex]!.status = "pending"; + db.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.updateWorkflowExecution({ + id: workflow.id, + companyId, + status: decision === "revision_requested" ? "paused" : "failed", + currentStepIndex: stepIndex, + stepsJson: JSON.stringify(steps), + lastRunAt: workflow.lastRunAt, + }); + publishDomainChanged(); + } + } + } + } + } catch { + // Non-workflow approvals may use payloadJson for unrelated metadata. + } + } + + if (approval.requestedByAgentId) { + wakeAgentIfPossible(approval.requestedByAgentId, companyId, "approval_resolved"); + } +} diff --git a/src/main/automation/automation-conditions.ts b/src/main/automation/automation-conditions.ts new file mode 100644 index 00000000..c7ce40f0 --- /dev/null +++ b/src/main/automation/automation-conditions.ts @@ -0,0 +1,54 @@ +import type { AppDatabase } from "../database"; +import type { AutomationEventContext } from "./types"; + +export function matchesEventConfig( + db: AppDatabase, + configJson: string, + sourceDepartment: string | null, + ctx: AutomationEventContext, +): boolean { + if (sourceDepartment && ctx.department && sourceDepartment !== ctx.department) { + return false; + } + try { + const conditions = JSON.parse(configJson) as Record; + 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.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 { + // Entity missing — treat as condition not applicable, continue matching. + } + } + if (typeof conditions.budgetThresholdPct === "number" && ctx.agentId) { + try { + const agent = db.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 { + // Agent missing — treat as condition not applicable, continue matching. + } + } + 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; + } catch { + return false; + } +} + +export function matchesConditions( + db: AppDatabase, + rule: { conditionsJson: string; sourceDepartment: string | null }, + ctx: AutomationEventContext, +): boolean { + return matchesEventConfig(db, rule.conditionsJson, rule.sourceDepartment, ctx); +} diff --git a/src/main/automation/automation-handlers.ts b/src/main/automation/automation-handlers.ts new file mode 100644 index 00000000..69060383 --- /dev/null +++ b/src/main/automation/automation-handlers.ts @@ -0,0 +1,136 @@ +import type { AppDatabase } from "../database"; +import type { NotificationOptions } from "../notification-service"; +import type { AutomationEventContext } from "./types"; + +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; + dispatchBrowserAction: (actionId: string, companyId: string) => Promise; + 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; +} + +export function handleTaskStatusChange( + deps: HandlerDeps, + callbacks: Pick, + taskId: string, + companyId: string, + previousStatus: string, + newStatus: string, +): void { + if (deps.isQuitting()) return; + if (previousStatus === newStatus) return; + const { db, wakeAgentIfPossible } = deps; + const { dispatchAutomation } = callbacks; + + let task: ReturnType | null = null; + try { + task = db.getTask(taskId); + } catch { + return; + } + if (!task) return; + + db.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.getAgent(task.assigneeAgentId); + context.department = agent.department ?? undefined; + } + } catch { + // Agent may have been deleted after the task update; keep the transition without department context. + } + + dispatchAutomation("task_status_changed", context); + + if (newStatus === "blocked") { + dispatchAutomation("task_blocked", context); + if (task.assigneeAgentId) { + const manager = db.getManagerForAgent(task.assigneeAgentId); + if (manager) { + wakeAgentIfPossible(manager.id, companyId, "report_blocked"); + } + } + } + + if (newStatus === "done" && task.parentId) { + try { + const siblings = db.listChildTasks(task.parentId, companyId); + const allDone = siblings.every((entry) => entry.id === taskId ? true : entry.status === "done"); + if (allDone) { + const parent = db.getTask(task.parentId); + if (parent.assigneeAgentId) { + wakeAgentIfPossible(parent.assigneeAgentId, companyId, "subtask_completed"); + } else { + const assignedId = db.autoAssignTask(parent.id, companyId); + if (assignedId) { + wakeAgentIfPossible(assignedId, companyId, "subtask_completed"); + } + } + } + } catch { + // Parent task may have been deleted or reassigned during completion cascade evaluation. + } + } +} + +export function handleTaskCreated( + deps: HandlerDeps, + callbacks: Pick, + taskId: string, + companyId: string, +): void { + if (deps.isQuitting()) return; + const { db, wakeAgentIfPossible } = deps; + const { dispatchAutomation } = callbacks; + + let task: ReturnType | null = null; + try { + task = db.getTask(taskId); + } catch { + return; + } + if (!task) return; + + dispatchAutomation("task_created", { + companyId, + taskId, + agentId: task.assigneeAgentId ?? undefined, + }); + + if (!task.assigneeAgentId) { + const assignedId = db.autoAssignTask(taskId, companyId); + if (assignedId) { + wakeAgentIfPossible(assignedId, companyId, "assignment"); + } + } else { + wakeAgentIfPossible(task.assigneeAgentId, companyId, "assignment"); + } +} diff --git a/src/main/automation/automation-run-completion.ts b/src/main/automation/automation-run-completion.ts new file mode 100644 index 00000000..5ad57d82 --- /dev/null +++ b/src/main/automation/automation-run-completion.ts @@ -0,0 +1,292 @@ +import type { AppDatabase } from "../database"; +import type { RunRecord } 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; +} + +function checkRunProducedDeliverable(db: AppDatabase, runId: string, companyId: string, taskId: string): boolean { + // Prefer causal binding via source_run_id (set when document/knowledge + // is created during a run). Falls back to time-window for legacy data + // that predates the source_run_id column. + const causalKnowledge = db.queryAll<{ id: string }>( + "select id from knowledge_base where company_id = ? and source_run_id = ? limit 1", + companyId, runId, + ); + if (causalKnowledge.length > 0) return true; + + const causalDocs = db.queryAll<{ id: string }>( + "select id from documents where company_id = ? and source_run_id = ? limit 1", + companyId, runId, + ); + if (causalDocs.length > 0) return true; + + // Time-window fallback: check for legacy records without source_run_id + const legacyKnowledge = db.queryAll<{ id: string }>( + "select id from knowledge_base where company_id = ? and source_run_id is null and created_at >= (select started_at from runs where id = ?) limit 1", + companyId, runId, + ); + if (legacyKnowledge.length > 0) return true; + + const legacyDocs = db.queryAll<{ id: string }>( + "select id from documents where company_id = ? and source_run_id is null and created_at >= (select started_at from runs where id = ?) limit 1", + companyId, runId, + ); + if (legacyDocs.length > 0) return true; + + const recentComments = db.queryAll<{ body: string }>( + "select body from task_comments where task_id = ? and created_at >= (select started_at from runs where id = ?) and length(body) > 100", + taskId, runId, + ); + if (recentComments.length > 0) return true; + + return false; +} + +/** 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.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.setTaskRequiresUserReview(taskId, false); + const agent = db.getAgent(agentId); + if (agent.heartbeatEnabled) { + db.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; +} + +export function processRunCompletion( + deps: RunCompletionDeps, + callbacks: RunCompletionCallbacks, + run: RunRecord, + event: RunFinishedEvent, + isQuitting: () => boolean, +): void { + if (isQuitting()) return; + const { db, logger, publishDomainChanged, notify, wakeAgentIfPossible } = deps; + const { dispatchAutomation, handleTaskStatusChange } = callbacks; + + let task: ReturnType | null = null; + try { + task = db.getTask(run.taskId); + } catch { + 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") { + // Check if non-code task produced a deliverable before auto-advancing + const taskType = ((task as unknown as Record).task_type as string) ?? "general"; + const needsDeliverable = taskType === "research" || taskType === "content"; + + if (needsDeliverable) { + const hasDeliverable = checkRunProducedDeliverable(db, run.id, run.companyId, task.id); + if (!hasDeliverable) { + // Don't auto-advance -- flag for manager review + db.advanceTaskStatus(task.id, "in_progress", + `${taskType} task run completed but no deliverable artifact was detected. Flagging for manager review.`); + handleTaskStatusChange(task.id, run.companyId, "in_progress", "in_progress"); + // Wake manager for review + try { + const agent = db.getAgent(run.agentId); + const chain = db.getChainOfCommand(agent.id); + if (chain.length > 0) { + wakeAgentIfPossible(chain[0].id, run.companyId, "review_needed"); + } + } catch { /* best effort */ } + publishDomainChanged(); + // Skip the normal in_review advance + } else { + db.advanceTaskStatus(task.id, "in_review", "Run completed with deliverable. Awaiting review."); + handleTaskStatusChange(task.id, run.companyId, "in_progress", "in_review"); + effectiveStatus = "in_review"; + const company = db.listSnapshot().companies.find((c) => c.id === run.companyId); + if (company?.reviewDeliverables) { + const importantDocs = findImportantDocsForRun(db, run.id, run.companyId); + if (importantDocs.length > 0) { + db.setTaskRequiresUserReview(task.id, true); + const agent = db.getAgent(run.agentId); + if (agent.heartbeatEnabled) { + db.setHeartbeat(agent.id, false, agent.heartbeatIntervalSec || 120); + logger.info(`[deliverable-review] Paused heartbeat for ${agent.name} — deliverable flagged for review.`); + } + const chain = db.getChainOfCommand(agent.id); + if (chain.length === 0) { + createDeliverableReviewApproval(deps, run.companyId, task.id, run.agentId, importantDocs); + } + } + } + publishDomainChanged(); + } + } else { + // Code/management/general tasks advance normally + db.advanceTaskStatus(task.id, "in_review", "Run completed successfully. Awaiting review."); + handleTaskStatusChange(task.id, run.companyId, "in_progress", "in_review"); + effectiveStatus = "in_review"; + const company = db.listSnapshot().companies.find((c) => c.id === run.companyId); + if (company?.reviewDeliverables) { + const importantDocs = findImportantDocsForRun(db, run.id, run.companyId); + if (importantDocs.length > 0) { + db.setTaskRequiresUserReview(task.id, true); + const agent = db.getAgent(run.agentId); + if (agent.heartbeatEnabled) { + db.setHeartbeat(agent.id, false, agent.heartbeatIntervalSec || 120); + logger.info(`[deliverable-review] Paused heartbeat for ${agent.name} — deliverable flagged for review.`); + } + const chain = db.getChainOfCommand(agent.id); + if (chain.length === 0) { + createDeliverableReviewApproval(deps, run.companyId, task.id, run.agentId, importantDocs); + } + } + } + publishDomainChanged(); + } + } + + if (isSuccess && task.title.startsWith("[Heartbeat]") && effectiveStatus !== "done") { + db.advanceTaskStatus(task.id, "done", "Heartbeat check completed."); + handleTaskStatusChange(task.id, run.companyId, effectiveStatus, "done"); + } + + const automationContext: AutomationEventContext = { + companyId: run.companyId, + taskId: run.taskId, + agentId: run.agentId, + runId: run.id, + costUsd: event.costUsd ?? undefined, + }; + try { + const agent = db.getAgent(run.agentId); + automationContext.department = agent.department ?? undefined; + } catch { + // Agent can disappear after a run completes; keep the context without department. + } + + if (isSuccess) { + dispatchAutomation("run_completed", automationContext); + if (task.parentId) { + try { + const siblings = db.listChildTasks(task.parentId, run.companyId); + const allSiblingsDoneOrReview = siblings.every((entry) => entry.status === "done" || entry.status === "in_review"); + if (allSiblingsDoneOrReview) { + const parent = db.getTask(task.parentId); + if (parent.assigneeAgentId) { + wakeAgentIfPossible(parent.assigneeAgentId, run.companyId, "subtask_completed"); + } else { + const assignedId = db.autoAssignTask(parent.id, run.companyId); + if (assignedId) { + wakeAgentIfPossible(assignedId, run.companyId, "subtask_completed"); + } + } + } + } catch { + // Parent task may have been deleted or reassigned during cascade evaluation. + } + } + } + + if (isFailed) { + dispatchAutomation("run_failed", automationContext); + } + + if (event.costUsd && event.costUsd > 0) { + try { + const agent = db.getAgent(run.agentId); + if (agent.budgetMonthlyUsd > 0) { + const utilization = agent.spentMonthlyUsd / agent.budgetMonthlyUsd; + if (utilization >= 0.8) { + dispatchAutomation("budget_threshold", { + ...automationContext, + agentId: agent.id, + }); + // Fire OS notification for budget threshold warning + const pct = Math.round(utilization * 100); + const budgetLocale = db.listSnapshot().locale; + notify({ + title: localizeNotification(budgetLocale, "Budget Warning", "预算警告"), + body: localizeNotification(budgetLocale, `${agent.name} has used ${pct}% of monthly budget`, `${agent.name} 已使用月度预算的 ${pct}%`), + urgency: "informational", + navigation: { section: "costs" }, + batchKey: "budget_alerts", + }); + if (utilization >= 1.0) { + // Fire OS notification for budget exhaustion (agent auto-paused by database) + notify({ + title: localizeNotification(budgetLocale, "Budget Exhausted", "预算耗尽"), + body: localizeNotification(budgetLocale, `${agent.name} has reached 100% of monthly budget and is paused`, `${agent.name} 已达到月度预算上限并已暂停`), + urgency: "critical", + navigation: { section: "costs" }, + }); + } + } + } + } catch { + // Budget follow-up is best-effort; do not fail run completion if the agent disappeared. + } + } +} diff --git a/src/main/automation/automation-skills.ts b/src/main/automation/automation-skills.ts new file mode 100644 index 00000000..d2b51772 --- /dev/null +++ b/src/main/automation/automation-skills.ts @@ -0,0 +1,125 @@ +import type { AppDatabase } from "../database"; + +const DEPARTMENT_SKILL_KEYWORDS: Record = { + engineering: ["code", "engineer", "develop", "program", "software", "api", "backend", "frontend", "full-stack", "fullstack", "devops", "infrastructure", "system", "architect", "debug", "test", "ci", "cd", "deploy", "docker", "kubernetes", "database", "sql", "git", "typescript", "javascript", "python", "rust", "go", "java", "react", "node", "css", "html", "web"], + product: ["product", "feature", "requirement", "roadmap", "user story", "ux", "ui", "design", "prototype", "wireframe", "specification", "prd"], + marketing: ["market", "content", "seo", "social media", "brand", "campaign", "analytics", "growth", "copywrite", "blog", "newsletter", "email"], + sales: ["sales", "crm", "lead", "pipeline", "deal", "revenue", "prospect", "outbound", "cold"], + hr: ["hr", "human resource", "hiring", "recruit", "onboard", "performance", "culture", "talent", "people"], + finance: ["finance", "budget", "accounting", "cost", "revenue", "profit", "spend", "invoice", "tax", "payroll"], + design: ["design", "ui", "ux", "visual", "figma", "sketch", "prototype", "illustration", "typography", "layout", "color", "icon"], + research: ["research", "analysis", "data", "study", "survey", "experiment", "insight", "benchmark", "report"], + operations: ["operations", "process", "workflow", "efficiency", "supply chain", "logistics", "coordination"], + customer_support: ["support", "customer", "ticket", "helpdesk", "troubleshoot", "faq", "documentation"], + legal: ["legal", "compliance", "contract", "regulation", "policy", "privacy", "ip", "intellectual property"], + executive: ["strategy", "leadership", "management", "executive", "ceo", "cto", "coo", "cfo", "cmo", "vp"], +}; + +function scoreSkillAgentMatch( + skill: { name: string; description: string | null; markdown: string }, + agent: { role: string; department: string | null; title: string | null; capabilities: string }, +): number { + const agentText = [ + agent.role, + agent.department ?? "", + agent.title ?? "", + agent.capabilities, + ].join(" ").toLowerCase(); + + const skillText = [ + skill.name, + skill.description ?? "", + skill.markdown.slice(0, 500), + ].join(" ").toLowerCase(); + + let score = 0; + + const dept = agent.department ?? ""; + const deptKeywords = DEPARTMENT_SKILL_KEYWORDS[dept] ?? []; + for (const keyword of deptKeywords) { + if (skillText.includes(keyword)) { + score += 2; + } + } + + const agentWords = agentText + .replace(/[^a-z0-9\s-]/g, " ") + .split(/\s+/) + .filter((w) => w.length >= 3); + for (const word of agentWords) { + if (skillText.includes(word)) { + score += 1; + } + } + + // Executives need awareness of all domains + const executiveRoles = ["ceo", "cto", "coo", "cfo", "cmo", "vp", "chief", "director", "head", "lead"]; + const roleLower = agent.role.toLowerCase(); + if (executiveRoles.some((r) => roleLower.includes(r))) { + score += 1; + } + + return score; +} + +export function autoAssignSkillsToAgent( + db: AppDatabase, + logger: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void }, + companyId: string, + agentId: string, +): number { + const agent = db.getAgent(agentId); + if (!agent || agent.companyId !== companyId) return 0; + + const allSkills = db.listCompanySkills(companyId); + if (allSkills.length === 0) return 0; + + const existingAssignments = new Set(db.listAgentSkillAssignments(agentId)); + + // Always assign basic-for-all builtin skills regardless of score + let assigned = 0; + for (const skill of allSkills) { + if (existingAssignments.has(skill.id)) continue; + try { + const meta = skill.metadata ? (typeof skill.metadata === "string" ? JSON.parse(skill.metadata) : skill.metadata) : {}; + if (meta.builtinCategory === "basic-for-all") { + db.assignSkillToAgent(agentId, skill.id); + existingAssignments.add(skill.id); + assigned++; + logger.info(`[skill-autoassign] Assigned basic skill "${skill.name}" to ${agent.name}`); + } + } catch { /* skip malformed metadata */ } + } + + const scored = allSkills + .filter((skill) => !existingAssignments.has(skill.id)) + .map((skill) => ({ + skill, + score: scoreSkillAgentMatch(skill, agent), + })) + .sort((a, b) => b.score - a.score); + + const MIN_SCORE = 3; + for (const { skill, score } of scored) { + if (score < MIN_SCORE) continue; + try { + db.assignSkillToAgent(agentId, skill.id); + assigned++; + logger.info( + `[skill-autoassign] Assigned "${skill.name}" to ${agent.name} (${agent.role}) — score ${score}`, + ); + } catch (err) { + logger.warn( + `[skill-autoassign] Failed to assign "${skill.name}" to ${agent.name}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + if (assigned === 0) { + logger.info( + `[skill-autoassign] No matching skills for ${agent.name} (${agent.role}); agent will run without skills until manually assigned`, + ); + } + + return assigned; +} diff --git a/src/main/automation/automation-workflow.ts b/src/main/automation/automation-workflow.ts new file mode 100644 index 00000000..061320ae --- /dev/null +++ b/src/main/automation/automation-workflow.ts @@ -0,0 +1,234 @@ +import type { AppDatabase } from "../database"; +import type { AgentRecord } from "@shared/types"; +import { WORKFLOW_ACTIONS, type WorkflowStepState, type AutomationEventContext } from "./types"; +import type { ActionDeps, ActionCallbacks } from "./automation-actions"; +import { executeAutomationAction } from "./automation-actions"; + +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[] { + if (!Array.isArray(raw)) { + throw new Error("Workflow steps must be a JSON array."); + } + return raw.map((entry, index) => { + const step = (entry ?? {}) as Record; + const rawAction = typeof step.action === "string" ? step.action : "send_message"; + if (!WORKFLOW_ACTIONS.has(rawAction as WorkflowStepState["action"])) { + throw new Error(`Workflow step ${index + 1} has unsupported action "${rawAction}".`); + } + return { + id: typeof step.id === "string" && step.id ? step.id : `step-${index + 1}`, + name: typeof step.name === "string" && step.name ? step.name : `Step ${index + 1}`, + action: rawAction as WorkflowStepState["action"], + configJson: typeof step.configJson === "string" ? step.configJson : "{}", + assigneeDepartment: typeof step.assigneeDepartment === "string" ? step.assigneeDepartment : null, + assigneeAgentId: typeof step.assigneeAgentId === "string" ? step.assigneeAgentId : null, + requiresApproval: step.requiresApproval === true, + timeoutSec: typeof step.timeoutSec === "number" ? step.timeoutSec : 0, + status: ["pending", "running", "completed", "failed", "skipped"].includes(String(step.status)) + ? (String(step.status) as WorkflowStepState["status"]) + : "pending", + }; + }); +} + +export function resolveWorkflowStepActor( + db: AppDatabase, + step: WorkflowStepState, + companyId: string, +): AgentRecord | null { + if (step.assigneeAgentId) { + const agent = db.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.getIdleAgentsForDepartment(companyId, step.assigneeDepartment); + if (idleAgents.length > 0) { + return db.getAgent(idleAgents[0]!.id); + } + } + + const snapshot = db.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; ctx: AutomationEventContext } { + let config: Record = {}; + try { + config = JSON.parse(step.configJson) as Record; + } 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 { + const { db, publishDomainChanged } = deps; + + const workflow = db.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.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.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.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.updateWorkflowExecution({ + id: workflowId, + companyId, + status: "paused", + currentStepIndex: index, + stepsJson: JSON.stringify(steps), + lastRunAt: new Date().toISOString(), + }); + db.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.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.updateWorkflowExecution({ + id: workflowId, + companyId, + status: completionStatus, + currentStepIndex: index + 1, + stepsJson: JSON.stringify(steps), + lastRunAt: new Date().toISOString(), + }); + publishDomainChanged(); + } catch (error) { + step.status = "failed"; + db.updateWorkflowExecution({ + id: workflowId, + companyId, + status: "failed", + currentStepIndex: index, + stepsJson: JSON.stringify(steps), + lastRunAt: new Date().toISOString(), + }); + db.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; + } + } +} diff --git a/src/main/automation/index.ts b/src/main/automation/index.ts new file mode 100644 index 00000000..004c271d --- /dev/null +++ b/src/main/automation/index.ts @@ -0,0 +1,225 @@ +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 { autoAssignSkillsToAgent } from "./automation-skills"; + +export function createAutomationService({ + db, + logger, + emitEvent, + publishDomainChanged, + notify, + wakeAgentIfPossible, + dispatchBrowserAction, + isQuitting, +}: AutomationDependencies) { + const activeAutomationExecutions = new Set(); + const activeWorkflowTriggerExecutions = new Set(); + const pendingScheduledTimers = new Set>(); + + const actionDeps: ActionDeps = { + db, + logger, + emitEvent, + publishDomainChanged, + wakeAgentIfPossible, + }; + + const workflowDeps: WorkflowDeps = { + db, + logger, + publishDomainChanged, + }; + + const handlerDeps: HandlerDeps = { + db, + logger, + publishDomainChanged, + notify, + wakeAgentIfPossible, + dispatchBrowserAction, + isQuitting, + }; + + const approvalDeps: ApprovalHandlerDeps = { + db, + logger, + publishDomainChanged, + notify, + wakeAgentIfPossible, + dispatchBrowserAction, + isQuitting, + }; + + const runCompletionDeps: RunCompletionDeps = { + db, + logger, + publishDomainChanged, + notify, + wakeAgentIfPossible, + }; + + // Forward-declared so callbacks can reference each other + function _runWorkflowPipeline(workflowId: string, companyId: string): Promise { + 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.listActiveAutomationRules(ctx.companyId, trigger); + for (const rule of rules) { + if (!matchesConditions(db, rule, ctx)) continue; + const executionKey = [ + rule.id, + trigger, + ctx.companyId, + ctx.taskId ?? "-", + ctx.agentId ?? "-", + ctx.runId ?? "-", + ctx.previousStatus ?? "-", + ctx.newStatus ?? "-", + ].join(":"); + 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.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.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.listTriggerableWorkflows(ctx.companyId, trigger); + for (const workflow of workflows) { + if (!matchesEventConfig(db, workflow.triggerConfigJson, null, ctx)) continue; + const executionKey = [ + workflow.id, + trigger, + ctx.companyId, + ctx.taskId ?? "-", + ctx.agentId ?? "-", + ctx.runId ?? "-", + ctx.previousStatus ?? "-", + ctx.newStatus ?? "-", + ].join(":"); + 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, + }; +} diff --git a/src/main/automation/types.ts b/src/main/automation/types.ts new file mode 100644 index 00000000..1af2243d --- /dev/null +++ b/src/main/automation/types.ts @@ -0,0 +1,76 @@ +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 const WORKFLOW_ACTIONS: ReadonlySet = new Set([ + "assign_task", + "create_task", + "notify_agent", + "send_message", + "trigger_heartbeat", + "create_approval", + "update_task_status", + "escalate_to_manager", + "cross_department_notify", + "schedule_meeting", + "create_document", + "auto_approve", + "reassign_task", + "run_task", +]); + +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; + dispatchBrowserAction: (actionId: string, companyId: string) => Promise; + isQuitting: () => boolean; +} + +export interface RunFinishedEvent { + status: RunStatus; + summary: string | null; + errorMessage: string | null; + costUsd: number | null; +} diff --git a/src/main/bootstrap-application.ts b/src/main/bootstrap-application.ts index 584c855f..cb3cd85b 100644 --- a/src/main/bootstrap-application.ts +++ b/src/main/bootstrap-application.ts @@ -8,6 +8,9 @@ import { createServiceGraph } from "./service-graph"; import { createIpcSupport } from "./ipc-support"; import { createNotificationService } from "./notification-service"; import type { NotificationOptions } from "./notification-service"; +import { createThreadIntegration } from "./thread-integration"; +import { createCheckpointEngine } from "./checkpoint-engine"; +import { createRunHousekeeping } from "./run-housekeeping"; import type { DesktopEvent, SectionId } from "@shared/types"; interface BootstrapApplicationDependencies { @@ -29,9 +32,11 @@ interface BootstrapApplicationDependencies { onTaskCreated: (taskId: string, companyId: string) => void; onTaskStatusChanged: (taskId: string, companyId: string, prev: string, next: 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; - onDocumentCreated: (companyId: string, authorAgentId: string | null, projectId: string | null) => void; + onDocumentCreated: (companyId: string, authorAgentId: string | null, projectId: string | null, documentId: string, documentTitle: string, documentType: string, taskId: string | null) => void; onAutoAssignTask: (taskId: string, companyId: string) => string | null; + onHumanMessage: (companyId: string, toAgentId?: string | null, threadId?: string | null, waitStateId?: string | null) => void; emitEvent: (event: DesktopEvent) => void; notify: (options: { title: string; body: string; urgency: "critical" | "informational"; navigation?: { section: SectionId; entityId?: string }; batchKey?: string }) => void; }, allowedOrigins: string[]) => AgentApiServer; @@ -43,6 +48,7 @@ interface BootstrapApplicationDependencies { apiServer: AgentApiServer; serviceGraph: ReturnType; ipcSupport: ReturnType; + threadIntegration: ReturnType; }) => void; } @@ -112,6 +118,22 @@ export async function bootstrapApplication({ ipcSupport?.publishDomainChanged(); }; + const threadIntegration = createThreadIntegration({ + db, + logger: log, + emitEvent, + publishDomainChanged, + notify: (options) => notify({ ...options, urgency: "informational" }), + }); + const checkpointEngine = createCheckpointEngine({ + db, + logger: log, + threadIntegration, + publishDomainChanged, + notify, + }); + const runHousekeeping = createRunHousekeeping({ db, logger: log, threadIntegration }); + serviceGraph = createServiceGraph({ db, apiServerRef, @@ -130,6 +152,11 @@ export async function bootstrapApplication({ notify, createRunToken, 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, }); ipcSupport = createIpcSupport({ isDev, @@ -153,16 +180,26 @@ export async function bootstrapApplication({ const automation = serviceGraph.getAutomationService(); if (!automation) throw new Error("Automation service is not initialized."); automation.handleTaskCreated(taskId, companyId); + threadIntegration.onTaskCreated(taskId, companyId); }; const handleTaskStatusChange = (taskId: string, companyId: string, prev: string, next: string) => { const automation = serviceGraph.getAutomationService(); if (!automation) throw new Error("Automation service is not initialized."); automation.handleTaskStatusChange(taskId, companyId, prev, next); + threadIntegration.onTaskStatusChanged(taskId, companyId, prev, next); + // In co-founder mode, create plan_review checkpoint when task starts + if (prev === "todo" && next === "in_progress") { + try { + const task = db.getTask(taskId); + checkpointEngine.onTaskStarted(taskId, companyId, task.assigneeAgentId); + } catch { /* task may not exist */ } + } }; const handleApprovalResolved = (approvalId: string, companyId: string, decision: string) => { const automation = serviceGraph.getAutomationService(); if (!automation) throw new Error("Automation service is not initialized."); automation.handleApprovalResolved(approvalId, companyId, decision); + threadIntegration.onApprovalResolved(approvalId, companyId, decision); }; const dispatchAutomation = (trigger: string, ctx: Record) => { const automation = serviceGraph.getAutomationService(); @@ -170,6 +207,10 @@ export async function bootstrapApplication({ automation.dispatchAutomation(trigger, ctx as any); }; + const handleApprovalCreated = (approvalId: string, companyId: string, taskId: string | null, approvalType: string, summary: string) => { + threadIntegration.onApprovalCreated(approvalId, companyId, taskId, approvalType, summary); + }; + const apiServer = createApiServer(db, { onDomainChanged: publishDomainChanged, onWakeAgent: (agentId, companyId, trigger) => wakeAgentIfPossible(agentId, companyId, trigger), @@ -178,11 +219,18 @@ export async function bootstrapApplication({ onCommentPosted: (taskId, companyId, authorAgentId) => { dispatchAutomation("comment_posted", { companyId, taskId, agentId: authorAgentId ?? undefined }); }, + onApprovalCreated: handleApprovalCreated, onApprovalResolved: handleApprovalResolved, - onDocumentCreated: (companyId, authorAgentId, projectId) => { + onDocumentCreated: (companyId, authorAgentId, projectId, documentId, documentTitle, documentType, taskId) => { dispatchAutomation("document_created", { companyId, agentId: authorAgentId ?? undefined, projectId: projectId ?? undefined }); + threadIntegration.onDocumentCreated(companyId, authorAgentId, taskId, documentTitle); + checkpointEngine.onDocumentDrafted(companyId, documentId, documentTitle, documentType, authorAgentId, taskId); }, onAutoAssignTask: (taskId, companyId) => db.autoAssignTask(taskId, companyId), + onHumanMessage: (companyId, toAgentId, threadId, waitStateId) => { + threadIntegration.onHumanMessage(companyId, toAgentId, threadId, waitStateId); + publishDomainChanged(); + }, emitEvent, notify, }, [isDev && devServerUrl ? new URL(devServerUrl).origin : null, "app://-"].filter((origin): origin is string => Boolean(origin))); @@ -194,6 +242,7 @@ export async function bootstrapApplication({ apiServer, serviceGraph, ipcSupport, + threadIntegration, }); if (apiServer && serviceGraph.getBrowserActionService()) { @@ -337,5 +386,6 @@ export async function bootstrapApplication({ apiServer, serviceGraph, ipcSupport, + threadIntegration, }; } diff --git a/src/main/browser-manager.ts b/src/main/browser-manager.ts index f78becc8..7a0ddb6f 100644 --- a/src/main/browser-manager.ts +++ b/src/main/browser-manager.ts @@ -1,104 +1,12 @@ -import { BrowserWindow, session, type NativeImage } from "electron"; -import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { BrowserWindow, session } from "electron"; +import { existsSync, mkdirSync } from "node:fs"; import { rm } 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"; -export interface BrowserAction { - id: string; - socialAccountId: string; - platform: string; - actionType: string; - payload: Record; -} - -export interface BrowserActionResult { - success: boolean; - cancelled: boolean; - summary: string; - screenshotPath: string | null; - error: string | null; -} - -interface QueuedAction { - action: BrowserAction; - resolve: (result: BrowserActionResult) => void; - reject: (error: Error) => void; -} - -interface PlatformRule { - loginUrl: string; - authCheckUrl: string; - allowedHosts: string[]; - cookieOrigins: string[]; - unauthenticatedUrlMarkers: string[]; -} - -const PLATFORM_RULES: Record = { - 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"], - 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"], - unauthenticatedUrlMarkers: ["/login", "/checkpoint"], - }, - instagram: { - loginUrl: "https://www.instagram.com/accounts/login/", - authCheckUrl: "https://www.instagram.com/", - allowedHosts: ["instagram.com"], - cookieOrigins: ["https://www.instagram.com"], - unauthenticatedUrlMarkers: ["/accounts/login"], - }, - facebook: { - loginUrl: "https://www.facebook.com/login", - authCheckUrl: "https://www.facebook.com/", - allowedHosts: ["facebook.com"], - cookieOrigins: ["https://www.facebook.com"], - unauthenticatedUrlMarkers: ["/login"], - }, - reddit: { - loginUrl: "https://www.reddit.com/login", - authCheckUrl: "https://www.reddit.com/", - allowedHosts: ["reddit.com"], - cookieOrigins: ["https://www.reddit.com"], - 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"], - unauthenticatedUrlMarkers: ["/signin", "/ServiceLogin"], - }, - tiktok: { - loginUrl: "https://www.tiktok.com/login", - authCheckUrl: "https://www.tiktok.com/foryou", - allowedHosts: ["tiktok.com"], - cookieOrigins: ["https://www.tiktok.com"], - unauthenticatedUrlMarkers: ["/login"], - }, - bluesky: { - loginUrl: "https://bsky.app", - authCheckUrl: "https://bsky.app/", - allowedHosts: ["bsky.app"], - cookieOrigins: ["https://bsky.app"], - unauthenticatedUrlMarkers: ["/login"], - }, - other: { - loginUrl: "https://google.com", - authCheckUrl: "https://google.com", - allowedHosts: ["google.com"], - cookieOrigins: ["https://google.com"], - unauthenticatedUrlMarkers: ["/login"], - }, -}; +export type { BrowserAction, BrowserActionResult } from "./browser/browser-types"; export class BrowserManager { private screenshotDir: string; @@ -166,10 +74,11 @@ export class BrowserManager { return { success: true, cancelled: false, summary: "Login window already open", screenshotPath: null, error: null }; } - const rules = this.getPlatformRules(platform); + const rules = getPlatformRules(platform); const url = loginUrl || rules.loginUrl; const ses = this.getSession(socialAccountId); - this.assertAllowedUrl(platform, url, "login"); + + assertAllowedUrl(platform, url, "login"); log.info(`[browser] Opening login window for ${platform} → ${url}`); @@ -186,7 +95,7 @@ export class BrowserManager { }); this.loginWindows.set(socialAccountId, win); - this.attachWindowSecurity(win, platform); + attachWindowSecurity(win, platform); win.setMenuBarVisibility(false); @@ -263,7 +172,13 @@ export class BrowserManager { while (queue.length > 0) { const item = queue.shift()!; try { - const result = await this.runAction(item.action); + const result = await runAction( + item.action, + this.screenshotDir, + this.getSession(item.action.socialAccountId), + this.cancelledActionIds, + this.activeActionWindows, + ); item.resolve(result); } catch (error) { item.reject(error instanceof Error ? error : new Error(String(error))); @@ -272,392 +187,9 @@ export class BrowserManager { this.processing.delete(socialAccountId); } - private async runAction(action: BrowserAction): Promise { - const ses = this.getSession(action.socialAccountId); - const payload = action.payload; - const screenshotPath = join(this.screenshotDir, `${action.id}.png`); - const platform = action.platform; - - 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: ses, - nodeIntegration: false, - contextIsolation: true, - sandbox: true, - }, - }); - this.activeActionWindows.set(action.id, win); - this.attachWindowSecurity(win, platform); - win.setMenuBarVisibility(false); - - try { - let summary = "Action completed"; - const assertNotCancelled = () => { - if (this.cancelledActionIds.has(action.id)) { - throw new Error("BROWSER_ACTION_CANCELLED"); - } - }; - - switch (action.actionType) { - case "navigate": { - assertNotCancelled(); - const url = String(payload.url || "https://google.com"); - this.assertAllowedUrl(platform, url, "navigate"); - await win.loadURL(url); - await this.waitForLoad(win); - assertNotCancelled(); - const title = await win.webContents.executeJavaScript("document.title"); - summary = `Navigated to ${title}`; - break; - } - - case "post": { - assertNotCancelled(); - const content = String(payload.content || ""); - const postUrl = this.getComposeUrl(platform); - await win.loadURL(postUrl); - await this.waitForLoad(win); - await this.delay(2000); - assertNotCancelled(); - - if (platform === "twitter") { - // Use clipboard-based approach since Twitter's React editor ignores execCommand - await win.webContents.executeJavaScript(` - (async () => { - const editor = document.querySelector('[data-testid="tweetTextarea_0"], [role="textbox"][contenteditable="true"]'); - if (!editor) return; - editor.focus(); - // Write content to clipboard and paste it — this triggers React's input handlers - const dt = new DataTransfer(); - dt.setData('text/plain', ${JSON.stringify(content)}); - const pasteEvent = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }); - editor.dispatchEvent(pasteEvent); - // Fallback: if paste didn't work, set innerHTML and dispatch input - await new Promise(r => setTimeout(r, 500)); - if (!editor.textContent || editor.textContent.trim().length === 0) { - editor.textContent = ${JSON.stringify(content)}; - editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: ${JSON.stringify(content)} })); - } - })() - `); - await this.delay(1500); - await win.webContents.executeJavaScript(` - (() => { - const btn = document.querySelector('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]'); - if (btn && !btn.disabled) btn.click(); - })() - `); - await this.delay(3000); - } else if (platform === "linkedin") { - await win.webContents.executeJavaScript(` - (() => { - const btn = document.querySelector('button.share-box-feed-entry__trigger, button.artdeco-button--muted'); - if (btn) btn.click(); - })() - `); - await this.delay(2000); - await win.webContents.executeJavaScript(` - (async () => { - const editor = document.querySelector('[role="textbox"][contenteditable="true"], .ql-editor[contenteditable="true"]'); - if (editor) { - editor.focus(); - const dt = new DataTransfer(); - dt.setData('text/plain', ${JSON.stringify(content)}); - const pasteEvent = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }); - editor.dispatchEvent(pasteEvent); - await new Promise(r => setTimeout(r, 500)); - if (!editor.textContent || editor.textContent.trim().length === 0) { - editor.textContent = ${JSON.stringify(content)}; - editor.dispatchEvent(new InputEvent('input', { bubbles: true })); - } - } - })() - `); - await this.delay(1500); - await win.webContents.executeJavaScript(` - (() => { - const btn = document.querySelector('button.share-actions__primary-action, button.artdeco-button--primary[type="submit"]'); - if (btn && !btn.disabled) btn.click(); - })() - `); - await this.delay(3000); - } - summary = `Posted to ${platform}: "${content.slice(0, 50)}..."`; - break; - } - - case "reply": { - assertNotCancelled(); - const targetUrl = String(payload.targetUrl || ""); - this.assertAllowedUrl(platform, targetUrl, "reply"); - const replyContent = String(payload.content || ""); - await win.loadURL(targetUrl); - await this.waitForLoad(win); - await this.delay(2000); - assertNotCancelled(); - - await win.webContents.executeJavaScript(` - (async () => { - const editor = document.querySelector('[data-testid="tweetTextarea_0"], [role="textbox"][contenteditable="true"]'); - if (editor) { - editor.focus(); - const dt = new DataTransfer(); - dt.setData('text/plain', ${JSON.stringify(replyContent)}); - const pasteEvent = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }); - editor.dispatchEvent(pasteEvent); - await new Promise(r => setTimeout(r, 500)); - if (!editor.textContent || editor.textContent.trim().length === 0) { - editor.textContent = ${JSON.stringify(replyContent)}; - editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: ${JSON.stringify(replyContent)} })); - } - } - })() - `); - await this.delay(1500); - await win.webContents.executeJavaScript(` - (() => { - const btn = document.querySelector('[data-testid="tweetButton"], [data-testid="tweetButtonInline"], button[type="submit"]'); - if (btn && !btn.disabled) btn.click(); - })() - `); - await this.delay(3000); - summary = `Replied to ${targetUrl.slice(0, 50)}`; - break; - } - - case "like": { - assertNotCancelled(); - const targetUrl = String(payload.targetUrl || ""); - this.assertAllowedUrl(platform, targetUrl, "like"); - await win.loadURL(targetUrl); - await this.waitForLoad(win); - await this.delay(2000); - assertNotCancelled(); - await win.webContents.executeJavaScript(` - (() => { - const btn = document.querySelector('[data-testid="like"], button[aria-label*="Like"]'); - if (btn) btn.click(); - })() - `); - await this.delay(2000); - summary = `Liked post at ${targetUrl.slice(0, 50)}`; - break; - } - - case "follow": { - assertNotCancelled(); - const targetUrl = String(payload.targetUrl || payload.profileUrl || ""); - this.assertAllowedUrl(platform, targetUrl, "follow"); - await win.loadURL(targetUrl); - await this.waitForLoad(win); - await this.delay(2000); - assertNotCancelled(); - await win.webContents.executeJavaScript(` - (() => { - const btn = document.querySelector('[data-testid*="follow"], button[aria-label*="Follow"]'); - if (btn && !btn.textContent.includes('Following')) btn.click(); - })() - `); - await this.delay(2000); - summary = `Followed user at ${targetUrl.slice(0, 50)}`; - break; - } - - case "browse_feed": { - assertNotCancelled(); - const feedUrl = String(payload.url || this.getFeedUrl(platform)); - this.assertAllowedUrl(platform, feedUrl, "browse_feed"); - const maxPosts = Number(payload.maxPosts || 10); - await win.loadURL(feedUrl); - await this.waitForLoad(win); - await this.delay(3000); - assertNotCancelled(); - - for (let i = 0; i < 3; i++) { - await win.webContents.executeJavaScript("window.scrollBy(0, window.innerHeight)"); - await this.delay(1500); - } - - const posts: string[] = await win.webContents.executeJavaScript(` - (() => { - const articles = document.querySelectorAll('article, [data-testid="tweet"], .feed-shared-update-v2'); - return Array.from(articles).slice(0, ${maxPosts}).map(el => el.innerText.slice(0, 300)); - })() - `); - summary = `Browsed feed — found ${posts.length} posts`; - break; - } - - case "search": { - assertNotCancelled(); - const query = String(payload.query || ""); - const searchUrl = this.getSearchUrl(platform, query); - this.assertAllowedUrl(platform, searchUrl, "search"); - await win.loadURL(searchUrl); - await this.waitForLoad(win); - await this.delay(3000); - assertNotCancelled(); - - const results: string[] = await win.webContents.executeJavaScript(` - (() => { - const items = document.querySelectorAll('article, [data-testid="tweet"], .search-result'); - return Array.from(items).slice(0, 10).map(el => el.innerText.slice(0, 200)); - })() - `); - summary = `Search "${query.slice(0, 30)}" — ${results.length} results`; - break; - } - - case "screenshot": { - assertNotCancelled(); - const url = String(payload.url || "about:blank"); - if (url !== "about:blank") { - this.assertAllowedUrl(platform, url, "screenshot"); - await win.loadURL(url); - await this.waitForLoad(win); - await this.delay(2000); - } - assertNotCancelled(); - const title = await win.webContents.executeJavaScript("document.title"); - summary = `Screenshot captured: ${title}`; - break; - } - - default: - throw new Error(`Unsupported browser action type: ${action.actionType}`); - } - - try { - const image: NativeImage = await win.webContents.capturePage(); - if (!image.isEmpty()) { - writeFileSync(screenshotPath, image.toPNG()); - } - } catch (err) { - log.warn(`[browser] Screenshot failed: ${err}`); - } - - await this.delay(1000); - if (!win.isDestroyed()) win.close(); - - return { - success: true, - cancelled: false, - summary, - screenshotPath: existsSync(screenshotPath) ? screenshotPath : null, - error: null, - }; - } catch (error) { - const wasCancelled = this.cancelledActionIds.has(action.id) || (error instanceof Error && error.message === "BROWSER_ACTION_CANCELLED"); - try { - const image: NativeImage = await win.webContents.capturePage(); - if (!image.isEmpty()) writeFileSync(screenshotPath, image.toPNG()); - } catch { /* ignore */ } - - if (!win.isDestroyed()) win.close(); - - const msg = wasCancelled ? "Action cancelled." : error instanceof Error ? error.message : String(error); - if (!wasCancelled) { - log.error(`[browser] Action ${action.actionType} failed: ${msg}`); - } - return { - success: false, - cancelled: wasCancelled, - summary: wasCancelled ? "Action cancelled" : "Action failed", - screenshotPath: existsSync(screenshotPath) ? screenshotPath : null, - error: msg, - }; - } finally { - this.activeActionWindows.delete(action.id); - this.cancelledActionIds.delete(action.id); - } - } - - private delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); - } - - private waitForLoad(win: BrowserWindow, timeoutMs = 15000): Promise { - return new Promise((resolve) => { - if (win.webContents.isLoading()) { - const timer = setTimeout(resolve, timeoutMs); - win.webContents.once("did-finish-load", () => { - clearTimeout(timer); - resolve(); - }); - } else { - resolve(); - } - }); - } - - private getPlatformRules(platform: string): PlatformRule { - return PLATFORM_RULES[platform] ?? PLATFORM_RULES.other; - } - - private isAllowedNavigation(platform: string, rawUrl: string) { - try { - this.assertAllowedUrl(platform, rawUrl, "navigate"); - return true; - } catch { - return false; - } - } - - private attachWindowSecurity(win: BrowserWindow, platform: string) { - win.webContents.setWindowOpenHandler(({ url }) => { - if (!this.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 (this.isAllowedNavigation(platform, navigationUrl)) { - return; - } - event.preventDefault(); - log.warn(`[browser] Blocked navigation outside allowlist for ${platform}: ${navigationUrl}`); - }); - win.webContents.on("will-redirect", (event, navigationUrl) => { - if (this.isAllowedNavigation(platform, navigationUrl)) { - return; - } - event.preventDefault(); - log.warn(`[browser] Blocked redirect outside allowlist for ${platform}: ${navigationUrl}`); - }); - } - - private assertAllowedUrl(platform: string, rawUrl: string, actionType: string) { - const allowedHosts = this.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.`); - } - } - private async verifyLoginSession(socialAccountId: string, platform: string): Promise { const ses = this.getSession(socialAccountId); - const rules = this.getPlatformRules(platform); + const rules = getPlatformRules(platform); const cookieSets = await Promise.all( rules.cookieOrigins.map(async (origin) => { try { @@ -684,7 +216,7 @@ export class BrowserManager { try { await verificationWindow.loadURL(rules.authCheckUrl); - await this.waitForLoad(verificationWindow, 10000); + await waitForLoad(verificationWindow, 10000); const finalUrl = verificationWindow.webContents.getURL(); const finalUrlLower = finalUrl.toLowerCase(); const bouncedToLogin = rules.unauthenticatedUrlMarkers.some((marker) => finalUrlLower.includes(marker.toLowerCase())); @@ -699,44 +231,6 @@ export class BrowserManager { } } - private getComposeUrl(platform: string): string { - const urls: Record = { - 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/", - }; - return urls[platform] || this.getPlatformRules(platform).loginUrl; - } - - private getFeedUrl(platform: string): string { - const urls: Record = { - 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/", - }; - return urls[platform] || this.getPlatformRules(platform).loginUrl; - } - - private getSearchUrl(platform: string, query: string): string { - const q = encodeURIComponent(query); - const urls: Record = { - 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}`, - }; - return urls[platform] || `https://google.com/search?q=${q}`; - } - shutdown() { for (const [, win] of this.loginWindows) { if (!win.isDestroyed()) win.close(); diff --git a/src/main/browser/browser-action-executor.ts b/src/main/browser/browser-action-executor.ts new file mode 100644 index 00000000..5f8f1bb8 --- /dev/null +++ b/src/main/browser/browser-action-executor.ts @@ -0,0 +1,335 @@ +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, getSearchUrl } from "./browser-url-helpers"; + +export { getPlatformRules, assertAllowedUrl, attachWindowSecurity } from "./browser-url-helpers"; + +export function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export function waitForLoad(win: BrowserWindow, timeoutMs = 15000): Promise { + return new Promise((resolve) => { + if (win.webContents.isLoading()) { + const timer = setTimeout(resolve, timeoutMs); + win.webContents.once("did-finish-load", () => { + clearTimeout(timer); + resolve(); + }); + } else { + resolve(); + } + }); +} + +export async function runAction( + action: BrowserAction, + screenshotDir: string, + session: Electron.Session, + cancelledActionIds: Set, + activeActionWindows: Map, +): Promise { + const payload = action.payload; + const screenshotPath = `${screenshotDir}/${action.id}.png`; + const platform = action.platform; + + 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); + + try { + let summary = "Action completed"; + const assertNotCancelled = () => { + if (cancelledActionIds.has(action.id)) { + throw new Error("BROWSER_ACTION_CANCELLED"); + } + }; + + switch (action.actionType) { + case "navigate": { + assertNotCancelled(); + const url = String(payload.url || "https://google.com"); + assertAllowedUrl(platform, url, "navigate"); + await win.loadURL(url); + await waitForLoad(win); + assertNotCancelled(); + const title = await win.webContents.executeJavaScript("document.title"); + summary = `Navigated to ${title}`; + break; + } + + case "post": { + assertNotCancelled(); + const content = String(payload.content || ""); + const postUrl = getComposeUrl(platform); + await win.loadURL(postUrl); + await waitForLoad(win); + await delay(2000); + assertNotCancelled(); + + if (platform === "twitter") { + // Use clipboard-based approach since Twitter's React editor ignores execCommand + await win.webContents.executeJavaScript(` + (async () => { + const editor = document.querySelector('[data-testid="tweetTextarea_0"], [role="textbox"][contenteditable="true"]'); + if (!editor) return; + editor.focus(); + const dt = new DataTransfer(); + dt.setData('text/plain', ${JSON.stringify(content)}); + const pasteEvent = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }); + editor.dispatchEvent(pasteEvent); + await new Promise(r => setTimeout(r, 500)); + if (!editor.textContent || editor.textContent.trim().length === 0) { + editor.textContent = ${JSON.stringify(content)}; + editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: ${JSON.stringify(content)} })); + } + })() + `); + await delay(1500); + await win.webContents.executeJavaScript(` + (() => { + const btn = document.querySelector('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]'); + if (btn && !btn.disabled) btn.click(); + })() + `); + await delay(3000); + } else if (platform === "linkedin") { + await win.webContents.executeJavaScript(` + (() => { + const btn = document.querySelector('button.share-box-feed-entry__trigger, button.artdeco-button--muted'); + if (btn) btn.click(); + })() + `); + await delay(2000); + await win.webContents.executeJavaScript(` + (async () => { + const editor = document.querySelector('[role="textbox"][contenteditable="true"], .ql-editor[contenteditable="true"]'); + if (editor) { + editor.focus(); + const dt = new DataTransfer(); + dt.setData('text/plain', ${JSON.stringify(content)}); + const pasteEvent = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }); + editor.dispatchEvent(pasteEvent); + await new Promise(r => setTimeout(r, 500)); + if (!editor.textContent || editor.textContent.trim().length === 0) { + editor.textContent = ${JSON.stringify(content)}; + editor.dispatchEvent(new InputEvent('input', { bubbles: true })); + } + } + })() + `); + await delay(1500); + await win.webContents.executeJavaScript(` + (() => { + const btn = document.querySelector('button.share-actions__primary-action, button.artdeco-button--primary[type="submit"]'); + if (btn && !btn.disabled) btn.click(); + })() + `); + await delay(3000); + } + summary = `Posted to ${platform}: "${content.slice(0, 50)}..."`; + break; + } + + case "reply": { + assertNotCancelled(); + const targetUrl = String(payload.targetUrl || ""); + assertAllowedUrl(platform, targetUrl, "reply"); + const replyContent = String(payload.content || ""); + await win.loadURL(targetUrl); + await waitForLoad(win); + await delay(2000); + assertNotCancelled(); + + await win.webContents.executeJavaScript(` + (async () => { + const editor = document.querySelector('[data-testid="tweetTextarea_0"], [role="textbox"][contenteditable="true"]'); + if (editor) { + editor.focus(); + const dt = new DataTransfer(); + dt.setData('text/plain', ${JSON.stringify(replyContent)}); + const pasteEvent = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }); + editor.dispatchEvent(pasteEvent); + await new Promise(r => setTimeout(r, 500)); + if (!editor.textContent || editor.textContent.trim().length === 0) { + editor.textContent = ${JSON.stringify(replyContent)}; + editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: ${JSON.stringify(replyContent)} })); + } + } + })() + `); + await delay(1500); + await win.webContents.executeJavaScript(` + (() => { + const btn = document.querySelector('[data-testid="tweetButton"], [data-testid="tweetButtonInline"], button[type="submit"]'); + if (btn && !btn.disabled) btn.click(); + })() + `); + await delay(3000); + summary = `Replied to ${targetUrl.slice(0, 50)}`; + break; + } + + case "like": { + assertNotCancelled(); + const targetUrl = String(payload.targetUrl || ""); + assertAllowedUrl(platform, targetUrl, "like"); + await win.loadURL(targetUrl); + await waitForLoad(win); + await delay(2000); + assertNotCancelled(); + await win.webContents.executeJavaScript(` + (() => { + const btn = document.querySelector('[data-testid="like"], button[aria-label*="Like"]'); + if (btn) btn.click(); + })() + `); + await delay(2000); + summary = `Liked post at ${targetUrl.slice(0, 50)}`; + break; + } + + case "follow": { + assertNotCancelled(); + const targetUrl = String(payload.targetUrl || payload.profileUrl || ""); + assertAllowedUrl(platform, targetUrl, "follow"); + await win.loadURL(targetUrl); + await waitForLoad(win); + await delay(2000); + assertNotCancelled(); + await win.webContents.executeJavaScript(` + (() => { + const btn = document.querySelector('[data-testid*="follow"], button[aria-label*="Follow"]'); + if (btn && !btn.textContent.includes('Following')) btn.click(); + })() + `); + await delay(2000); + summary = `Followed user at ${targetUrl.slice(0, 50)}`; + break; + } + + case "browse_feed": { + assertNotCancelled(); + const feedUrl = String(payload.url || getFeedUrl(platform)); + assertAllowedUrl(platform, feedUrl, "browse_feed"); + const maxPosts = Number(payload.maxPosts || 10); + await win.loadURL(feedUrl); + await waitForLoad(win); + await delay(3000); + assertNotCancelled(); + + for (let i = 0; i < 3; i++) { + await win.webContents.executeJavaScript("window.scrollBy(0, window.innerHeight)"); + await delay(1500); + } + + const posts: string[] = await win.webContents.executeJavaScript(` + (() => { + const articles = document.querySelectorAll('article, [data-testid="tweet"], .feed-shared-update-v2'); + return Array.from(articles).slice(0, ${maxPosts}).map(el => el.innerText.slice(0, 300)); + })() + `); + summary = `Browsed feed — found ${posts.length} posts`; + break; + } + + case "search": { + assertNotCancelled(); + const query = String(payload.query || ""); + const searchUrl = getSearchUrl(platform, query); + assertAllowedUrl(platform, searchUrl, "search"); + await win.loadURL(searchUrl); + await waitForLoad(win); + await delay(3000); + assertNotCancelled(); + + const results: string[] = await win.webContents.executeJavaScript(` + (() => { + const items = document.querySelectorAll('article, [data-testid="tweet"], .search-result'); + return Array.from(items).slice(0, 10).map(el => el.innerText.slice(0, 200)); + })() + `); + summary = `Search "${query.slice(0, 30)}" — ${results.length} results`; + break; + } + + case "screenshot": { + assertNotCancelled(); + const url = String(payload.url || "about:blank"); + if (url !== "about:blank") { + assertAllowedUrl(platform, url, "screenshot"); + await win.loadURL(url); + await waitForLoad(win); + await delay(2000); + } + assertNotCancelled(); + const title = await win.webContents.executeJavaScript("document.title"); + summary = `Screenshot captured: ${title}`; + break; + } + + default: + throw new Error(`Unsupported browser action type: ${action.actionType}`); + } + + try { + const image: NativeImage = await win.webContents.capturePage(); + if (!image.isEmpty()) { + writeFileSync(screenshotPath, image.toPNG()); + } + } catch (err) { + log.warn(`[browser] Screenshot failed: ${err}`); + } + + await delay(1000); + if (!win.isDestroyed()) win.close(); + + return { + success: true, + cancelled: false, + summary, + screenshotPath: existsSync(screenshotPath) ? screenshotPath : null, + error: null, + }; + } catch (error) { + const wasCancelled = cancelledActionIds.has(action.id) || (error instanceof Error && error.message === "BROWSER_ACTION_CANCELLED"); + try { + const image: NativeImage = await win.webContents.capturePage(); + if (!image.isEmpty()) writeFileSync(screenshotPath, image.toPNG()); + } catch { /* ignore */ } + + if (!win.isDestroyed()) win.close(); + + const msg = wasCancelled ? "Action cancelled." : error instanceof Error ? error.message : String(error); + if (!wasCancelled) { + log.error(`[browser] Action ${action.actionType} failed: ${msg}`); + } + return { + success: false, + cancelled: wasCancelled, + summary: wasCancelled ? "Action cancelled" : "Action failed", + screenshotPath: existsSync(screenshotPath) ? screenshotPath : null, + error: msg, + }; + } finally { + activeActionWindows.delete(action.id); + cancelledActionIds.delete(action.id); + } +} diff --git a/src/main/browser/browser-types.ts b/src/main/browser/browser-types.ts new file mode 100644 index 00000000..169df1b8 --- /dev/null +++ b/src/main/browser/browser-types.ts @@ -0,0 +1,95 @@ +export interface BrowserAction { + id: string; + socialAccountId: string; + platform: string; + actionType: string; + payload: Record; +} + +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[]; + unauthenticatedUrlMarkers: string[]; +} + +export const PLATFORM_RULES: Record = { + 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"], + 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"], + unauthenticatedUrlMarkers: ["/login", "/checkpoint"], + }, + instagram: { + loginUrl: "https://www.instagram.com/accounts/login/", + authCheckUrl: "https://www.instagram.com/", + allowedHosts: ["instagram.com"], + cookieOrigins: ["https://www.instagram.com"], + unauthenticatedUrlMarkers: ["/accounts/login"], + }, + facebook: { + loginUrl: "https://www.facebook.com/login", + authCheckUrl: "https://www.facebook.com/", + allowedHosts: ["facebook.com"], + cookieOrigins: ["https://www.facebook.com"], + unauthenticatedUrlMarkers: ["/login"], + }, + reddit: { + loginUrl: "https://www.reddit.com/login", + authCheckUrl: "https://www.reddit.com/", + allowedHosts: ["reddit.com"], + cookieOrigins: ["https://www.reddit.com"], + 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"], + unauthenticatedUrlMarkers: ["/signin", "/ServiceLogin"], + }, + tiktok: { + loginUrl: "https://www.tiktok.com/login", + authCheckUrl: "https://www.tiktok.com/foryou", + allowedHosts: ["tiktok.com"], + cookieOrigins: ["https://www.tiktok.com"], + unauthenticatedUrlMarkers: ["/login"], + }, + bluesky: { + loginUrl: "https://bsky.app", + authCheckUrl: "https://bsky.app/", + allowedHosts: ["bsky.app"], + cookieOrigins: ["https://bsky.app"], + unauthenticatedUrlMarkers: ["/login"], + }, + other: { + loginUrl: "https://google.com", + authCheckUrl: "https://google.com", + allowedHosts: ["google.com"], + cookieOrigins: ["https://google.com"], + unauthenticatedUrlMarkers: ["/login"], + }, +}; diff --git a/src/main/browser/browser-url-helpers.ts b/src/main/browser/browser-url-helpers.ts new file mode 100644 index 00000000..07ce60ed --- /dev/null +++ b/src/main/browser/browser-url-helpers.ts @@ -0,0 +1,100 @@ +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 = { + 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/", + }; + return urls[platform] || getPlatformRules(platform).loginUrl; +} + +export function getFeedUrl(platform: string): string { + const urls: Record = { + 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/", + }; + return urls[platform] || getPlatformRules(platform).loginUrl; +} + +export function getSearchUrl(platform: string, query: string): string { + const q = encodeURIComponent(query); + const urls: Record = { + 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}`, + }; + return urls[platform] || `https://google.com/search?q=${q}`; +} diff --git a/src/main/budget-enforcement-service.ts b/src/main/budget-enforcement-service.ts index b087c411..08731c43 100644 --- a/src/main/budget-enforcement-service.ts +++ b/src/main/budget-enforcement-service.ts @@ -27,7 +27,7 @@ export interface BudgetGateResult { function getScopeLabel(db: AppDatabase, policy: BudgetPolicyRecord): string { if (policy.scopeType === "agent") { try { - const agents = db.loadSnapshot(null).agents; + const agents = db.listSnapshot({ companyId: null }).agents; const agent = agents.find((a) => a.id === policy.scopeId); return agent ? `Agent "${agent.name}"` : `Agent ${policy.scopeId.slice(0, 8)}`; } catch { @@ -36,7 +36,7 @@ function getScopeLabel(db: AppDatabase, policy: BudgetPolicyRecord): string { } if (policy.scopeType === "project") { try { - const projects = db.loadSnapshot(null).projects; + const projects = db.listSnapshot({ companyId: null }).projects; const project = projects.find((p) => p.id === policy.scopeId); return project ? `Project "${project.name}"` : `Project ${policy.scopeId.slice(0, 8)}`; } catch { diff --git a/src/main/checkpoint-engine.ts b/src/main/checkpoint-engine.ts new file mode 100644 index 00000000..22400b42 --- /dev/null +++ b/src/main/checkpoint-engine.ts @@ -0,0 +1,164 @@ +/** + * 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"; + +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.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 non-code tasks + * so the user can review the task plan before execution begins. + */ + 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.getTask(taskId); + const taskType = db.queryAll<{ taskType: string }>( + "SELECT task_type as taskType FROM tasks WHERE id = ? LIMIT 1", + taskId, + )[0]?.taskType; + + // Only create plan checkpoints for non-code task types + if (!taskType || !CHECKPOINT_TASK_TYPES.has(taskType)) return null; + + const approvalId = db.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) { + logger.error(`[checkpoint] onTaskStarted failed: ${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.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) { + logger.error(`[checkpoint] onDocumentDrafted failed: ${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 + const pendingApprovals = db.queryAll<{ id: string }>( + `SELECT a.id FROM approvals a + JOIN tasks t ON a.related_task_id = t.id + WHERE a.company_id = ? AND a.state = 'pending' + AND t.assignee_agent_id = ? + AND a.type IN ('plan_review','scope_review','draft_review','strategy_review','deliverable_review','exception_review') + LIMIT 1`, + companyId, agentId, + ); + return pendingApprovals.length > 0; + } catch (err) { + logger.error(`[checkpoint] shouldPauseHeartbeat failed: ${err instanceof Error ? err.message : String(err)}`); + return false; + } + }, + + /** + * Returns execution mode for a company. Convenience accessor. + */ + getExecutionMode(companyId: string): ExecutionMode { + return getCompanyExecutionMode(db, companyId); + }, + }; +} + +export type CheckpointEngineService = ReturnType; diff --git a/src/main/connector-auth-service.ts b/src/main/connector-auth-service.ts index 731d4291..ac5c489b 100644 --- a/src/main/connector-auth-service.ts +++ b/src/main/connector-auth-service.ts @@ -1,5 +1,6 @@ import { spawn, spawnSync } from "node:child_process"; import { homedir } from "node:os"; +import { normalizeCommandValue } from "@agentcompany/adapter-utils/server-utils"; import type { ConnectorId } from "@shared/types"; function quoteShell(value: string) { @@ -10,11 +11,18 @@ function escapeAppleScript(value: string) { return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); } +function quoteCmd(value: string) { + const normalized = normalizeCommandValue(value); + if (!normalized.length) return '""'; + const escaped = normalized.replace(/"/g, '""'); + return /[\s"&<>|^()]/.test(escaped) ? `"${escaped}"` : escaped; +} + /* ------------------------------------------------------------------ */ /* Shared terminal spawning — used by both auth and generic commands */ /* ------------------------------------------------------------------ */ -function spawnTerminalWithScript(script: string, title: string, cwd: string): boolean { +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], { @@ -70,23 +78,27 @@ function spawnTerminalWithScript(script: string, title: string, cwd: string): bo /* ------------------------------------------------------------------ */ function connectorAuthSpec(connectorId: ConnectorId, command: string) { + const normalizedCommand = normalizeCommandValue(command); switch (connectorId) { case "codex_local": return { title: "Codex Login", - shellCommand: `${quoteShell(command)} login`, + shellCommand: `${quoteShell(normalizedCommand)} login`, + windowsCommand: `${quoteCmd(normalizedCommand)} login`, intro: "AgentCompany is opening the Codex login flow.", }; case "claude_local": return { title: "Claude Code Login", - shellCommand: `${quoteShell(command)} auth login`, + shellCommand: `${quoteShell(normalizedCommand)} auth login`, + windowsCommand: `${quoteCmd(normalizedCommand)} auth login`, intro: "AgentCompany is opening the Claude Code login flow.", }; case "gemini_local": return { title: "Gemini CLI Login", - shellCommand: `${quoteShell(command)}`, + shellCommand: `${quoteShell(normalizedCommand)}`, + windowsCommand: quoteCmd(normalizedCommand), intro: "AgentCompany is opening Gemini CLI. If authentication is required, use /auth in the Gemini session.", }; } @@ -95,6 +107,7 @@ function connectorAuthSpec(connectorId: ConnectorId, command: string) { export async function openConnectorAuthTerminal(connectorId: ConnectorId, command: string, cwd?: string) { const targetCwd = cwd || process.env.HOME || homedir() || process.cwd(); const spec = connectorAuthSpec(connectorId, command); + if (!spec) return false; if (process.platform === "win32") { // Windows: build a CMD-compatible script directly @@ -103,7 +116,7 @@ export async function openConnectorAuthTerminal(connectorId: ConnectorId, comman `echo ${spec.intro}`, `echo.`, `cd /d "${targetCwd}"`, - spec.shellCommand.replace(/'/g, ""), // Remove shell quotes for CMD + spec.windowsCommand, `echo.`, `echo You can close this terminal after the login flow completes.`, `pause`, diff --git a/src/main/connector-definitions.ts b/src/main/connector-definitions.ts new file mode 100644 index 00000000..c8843378 --- /dev/null +++ b/src/main/connector-definitions.ts @@ -0,0 +1,353 @@ +import type { ServerAdapterModule } from "@agentcompany/adapter-utils"; +import { agentConfigurationDoc as claudeDoc, models as claudeModels } from "@agentcompany/adapter-claude-local"; +import { execute as claudeExecute, sessionCodec as claudeSessionCodec, testEnvironment as claudeTestEnvironment } from "@agentcompany/adapter-claude-local/server"; +import { + agentConfigurationDoc as codexDoc, + DEFAULT_CODEX_LOCAL_MODEL, + models as codexModels, +} from "@agentcompany/adapter-codex-local"; +import { execute as codexExecute, sessionCodec as codexSessionCodec, testEnvironment as codexTestEnvironment } from "@agentcompany/adapter-codex-local/server"; +import { agentConfigurationDoc as geminiDoc, DEFAULT_GEMINI_LOCAL_MODEL, models as geminiModels } from "@agentcompany/adapter-gemini-local"; +import { execute as geminiExecute, sessionCodec as geminiSessionCodec, testEnvironment as geminiTestEnvironment } from "@agentcompany/adapter-gemini-local/server"; +import type { CapabilityMatrix, ConnectorId } from "@shared/types"; + +export interface ConnectorDefinition { + id: ConnectorId; + label: string; + description: string; + defaultCommand: string; + defaultModel: string | null; + notes: string; + capabilityMatrix: CapabilityMatrix; + configurationDoc: string; + module: ServerAdapterModule; + listModels?: () => Promise>; +} + +export const CONNECTORS: ConnectorDefinition[] = [ + { + id: "codex_local", + label: "Codex", + description: "Local Codex CLI with session resume, structured event parsing, and model selection.", + defaultCommand: "codex", + defaultModel: DEFAULT_CODEX_LOCAL_MODEL, + notes: "", + capabilityMatrix: { + sessionResume: true, + gracefulCancel: true, + modelDiscovery: false, + structuredTranscript: true, + costAttribution: false, + }, + configurationDoc: codexDoc, + module: { + type: "codex_local", + execute: codexExecute, + testEnvironment: codexTestEnvironment, + sessionCodec: codexSessionCodec, + nativeContextManagement: true, + models: codexModels, + }, + listModels: async () => codexModels, + }, + { + id: "claude_local", + label: "Claude Code", + description: "Local Claude Code CLI with session persistence and structured result parsing.", + defaultCommand: "claude", + defaultModel: claudeModels[0]?.id ?? null, + notes: "", + capabilityMatrix: { + sessionResume: true, + gracefulCancel: true, + modelDiscovery: false, + structuredTranscript: true, + costAttribution: true, + }, + configurationDoc: claudeDoc, + module: { + type: "claude_local", + execute: claudeExecute, + testEnvironment: claudeTestEnvironment, + sessionCodec: claudeSessionCodec, + nativeContextManagement: true, + models: claudeModels, + }, + listModels: async () => claudeModels, + }, + { + id: "claude_compatible", + label: "Claude Code Compatible API", + description: "Claude Code CLI connected to a third-party API provider (e.g. DeepSeek, OpenRouter, Ollama).", + defaultCommand: "claude", + defaultModel: null, + notes: "", + capabilityMatrix: { + sessionResume: true, + gracefulCancel: true, + modelDiscovery: false, + structuredTranscript: true, + costAttribution: false, + }, + configurationDoc: claudeDoc, + module: { + type: "claude_local", + execute: claudeExecute, + testEnvironment: claudeTestEnvironment, + sessionCodec: claudeSessionCodec, + nativeContextManagement: true, + models: claudeModels, + }, + listModels: async () => claudeModels, + }, + { + id: "claude_compat_2", + label: "Claude Code Compatible API (2)", + description: "Second Claude Code CLI compatible API slot for a third-party provider.", + defaultCommand: "claude", + defaultModel: null, + notes: "", + capabilityMatrix: { + sessionResume: true, + gracefulCancel: true, + modelDiscovery: false, + structuredTranscript: true, + costAttribution: false, + }, + configurationDoc: claudeDoc, + module: { + type: "claude_local", + execute: claudeExecute, + testEnvironment: claudeTestEnvironment, + sessionCodec: claudeSessionCodec, + nativeContextManagement: true, + models: claudeModels, + }, + listModels: async () => claudeModels, + }, + { + id: "claude_compat_3", + label: "Claude Code Compatible API (3)", + description: "Third Claude Code CLI compatible API slot for a third-party provider.", + defaultCommand: "claude", + defaultModel: null, + notes: "", + capabilityMatrix: { + sessionResume: true, + gracefulCancel: true, + modelDiscovery: false, + structuredTranscript: true, + costAttribution: false, + }, + configurationDoc: claudeDoc, + module: { + type: "claude_local", + execute: claudeExecute, + testEnvironment: claudeTestEnvironment, + sessionCodec: claudeSessionCodec, + nativeContextManagement: true, + models: claudeModels, + }, + listModels: async () => claudeModels, + }, + { + id: "claude_compat_4", + label: "Claude Code Compatible API (4)", + description: "Claude Code CLI compatible API slot 4 for a third-party provider.", + defaultCommand: "claude", + defaultModel: null, + notes: "", + capabilityMatrix: { + sessionResume: true, + gracefulCancel: true, + modelDiscovery: false, + structuredTranscript: true, + costAttribution: false, + }, + configurationDoc: claudeDoc, + module: { + type: "claude_local", + execute: claudeExecute, + testEnvironment: claudeTestEnvironment, + sessionCodec: claudeSessionCodec, + nativeContextManagement: true, + models: claudeModels, + }, + listModels: async () => claudeModels, + }, + { + id: "claude_compat_5", + label: "Claude Code Compatible API (5)", + description: "Claude Code CLI compatible API slot 5 for a third-party provider.", + defaultCommand: "claude", + defaultModel: null, + notes: "", + capabilityMatrix: { + sessionResume: true, + gracefulCancel: true, + modelDiscovery: false, + structuredTranscript: true, + costAttribution: false, + }, + configurationDoc: claudeDoc, + module: { + type: "claude_local", + execute: claudeExecute, + testEnvironment: claudeTestEnvironment, + sessionCodec: claudeSessionCodec, + nativeContextManagement: true, + models: claudeModels, + }, + listModels: async () => claudeModels, + }, + { + id: "claude_compat_6", + label: "Claude Code Compatible API (6)", + description: "Claude Code CLI compatible API slot 6 for a third-party provider.", + defaultCommand: "claude", + defaultModel: null, + notes: "", + capabilityMatrix: { + sessionResume: true, + gracefulCancel: true, + modelDiscovery: false, + structuredTranscript: true, + costAttribution: false, + }, + configurationDoc: claudeDoc, + module: { + type: "claude_local", + execute: claudeExecute, + testEnvironment: claudeTestEnvironment, + sessionCodec: claudeSessionCodec, + nativeContextManagement: true, + models: claudeModels, + }, + listModels: async () => claudeModels, + }, + { + id: "claude_compat_7", + label: "Claude Code Compatible API (7)", + description: "Claude Code CLI compatible API slot 7 for a third-party provider.", + defaultCommand: "claude", + defaultModel: null, + notes: "", + capabilityMatrix: { + sessionResume: true, + gracefulCancel: true, + modelDiscovery: false, + structuredTranscript: true, + costAttribution: false, + }, + configurationDoc: claudeDoc, + module: { + type: "claude_local", + execute: claudeExecute, + testEnvironment: claudeTestEnvironment, + sessionCodec: claudeSessionCodec, + nativeContextManagement: true, + models: claudeModels, + }, + listModels: async () => claudeModels, + }, + { + id: "claude_compat_8", + label: "Claude Code Compatible API (8)", + description: "Claude Code CLI compatible API slot 8 for a third-party provider.", + defaultCommand: "claude", + defaultModel: null, + notes: "", + capabilityMatrix: { + sessionResume: true, + gracefulCancel: true, + modelDiscovery: false, + structuredTranscript: true, + costAttribution: false, + }, + configurationDoc: claudeDoc, + module: { + type: "claude_local", + execute: claudeExecute, + testEnvironment: claudeTestEnvironment, + sessionCodec: claudeSessionCodec, + nativeContextManagement: true, + models: claudeModels, + }, + listModels: async () => claudeModels, + }, + { + id: "claude_compat_9", + label: "Claude Code Compatible API (9)", + description: "Claude Code CLI compatible API slot 9 for a third-party provider.", + defaultCommand: "claude", + defaultModel: null, + notes: "", + capabilityMatrix: { + sessionResume: true, + gracefulCancel: true, + modelDiscovery: false, + structuredTranscript: true, + costAttribution: false, + }, + configurationDoc: claudeDoc, + module: { + type: "claude_local", + execute: claudeExecute, + testEnvironment: claudeTestEnvironment, + sessionCodec: claudeSessionCodec, + nativeContextManagement: true, + models: claudeModels, + }, + listModels: async () => claudeModels, + }, + { + id: "claude_compat_10", + label: "Claude Code Compatible API (10)", + description: "Claude Code CLI compatible API slot 10 for a third-party provider.", + defaultCommand: "claude", + defaultModel: null, + notes: "", + capabilityMatrix: { + sessionResume: true, + gracefulCancel: true, + modelDiscovery: false, + structuredTranscript: true, + costAttribution: false, + }, + configurationDoc: claudeDoc, + module: { + type: "claude_local", + execute: claudeExecute, + testEnvironment: claudeTestEnvironment, + sessionCodec: claudeSessionCodec, + nativeContextManagement: true, + models: claudeModels, + }, + listModels: async () => claudeModels, + }, + { + id: "gemini_local", + label: "Gemini CLI", + description: "Local Gemini CLI with resume support and JSON event parsing.", + defaultCommand: "gemini", + defaultModel: DEFAULT_GEMINI_LOCAL_MODEL, + notes: "", + capabilityMatrix: { + sessionResume: true, + gracefulCancel: true, + modelDiscovery: false, + structuredTranscript: true, + costAttribution: true, + }, + configurationDoc: geminiDoc, + module: { + type: "gemini_local", + execute: geminiExecute, + testEnvironment: geminiTestEnvironment, + sessionCodec: geminiSessionCodec, + nativeContextManagement: false, + models: geminiModels, + }, + listModels: async () => geminiModels, + }, +]; diff --git a/src/main/connector-health-service.ts b/src/main/connector-health-service.ts index f24d7af6..ae137b6c 100644 --- a/src/main/connector-health-service.ts +++ b/src/main/connector-health-service.ts @@ -1,5 +1,6 @@ import { spawn } from "node:child_process"; import { homedir } from "node:os"; +import { normalizeCommandValue } from "@agentcompany/adapter-utils/server-utils"; import type { AppDatabase } from "./database"; import { getConnectorDefinition } from "./connectors"; import { buildSanitizedRuntimeEnv } from "./runtime-env"; @@ -36,9 +37,10 @@ export function createConnectorHealthService({ function parseVersion(command: string): Promise { return new Promise((resolve) => { let resolved = false; - const child = spawn(command, ["--version"], { + const normalizedCommand = normalizeCommandValue(command); + const child = spawn(normalizedCommand, ["--version"], { env: buildSanitizedRuntimeEnv(process.env, {}), - shell: process.platform === "win32", + shell: false, stdio: ["ignore", "pipe", "pipe"], }); let buffer = ""; @@ -167,19 +169,12 @@ export function createConnectorHealthService({ const errorMessage = error instanceof Error ? error.message : String(error); _logger.error(`[connector] Health check failed for ${id}: ${errorMessage}`); const isNotInstalled = /command.*not found|not executable|ENOENT/i.test(errorMessage); - const isTimeout = /timed out/i.test(errorMessage); - // If the command exists on PATH but the probe failed (timeout, unexpected output, etc.), - // still treat it as ready — the user configured it and it's resolvable. - const status = isNotInstalled - ? "not_installed" - : isTimeout - ? "ready" - : "error"; + const status = isNotInstalled ? "not_installed" : "error"; db.updateConnectorHealth({ id, status, authState: isNotInstalled ? "error" : "unknown", - version: isTimeout ? connector.version : null, + version: null, lastError: errorMessage, }); } diff --git a/src/main/connector-matching.ts b/src/main/connector-matching.ts index 63ace854..654c8889 100644 --- a/src/main/connector-matching.ts +++ b/src/main/connector-matching.ts @@ -2,6 +2,8 @@ import type { ConnectorId, ConnectorStatus, TaskType } from "@shared/types"; interface ConnectorMatchInput { taskType: TaskType; + agentRole?: string; + agentDepartment?: string; availableConnectors: Array<{ id: ConnectorId; status: ConnectorStatus; diff --git a/src/main/connector-model-policy.ts b/src/main/connector-model-policy.ts new file mode 100644 index 00000000..ae14f536 --- /dev/null +++ b/src/main/connector-model-policy.ts @@ -0,0 +1,111 @@ +import type { ConnectorId } from "@shared/types"; +import { getConnectorDefinition } from "./connectors"; + +interface ConnectorModelPolicyDeps { + getConnector: (id: ConnectorId) => { id: ConnectorId; label?: string | null; model: string | null }; + getDisabledModels: (connectorId: string) => Array<{ id: string; label: string }>; +} + +export interface ConnectorModelPolicyResult { + connectorId: ConnectorId; + connectorLabel: string; + enabledModels: Array<{ id: string; label: string }>; + defaultModel: string | null; +} + +export function parseCompatConnectorModelCatalog(model: string | null | undefined): string[] { + return uniqueModels( + (model ?? "") + .split(",") + .map((entry) => ({ id: entry.trim(), label: entry.trim(), enabled: true })) + .filter((entry) => entry.id.length > 0), + ).map((entry) => entry.id); +} + +function uniqueModels(models: Array<{ id: string; label: string }>) { + const byId = new Map(); + for (const model of models) { + const id = model.id.trim(); + if (!id) continue; + if (!byId.has(id)) { + byId.set(id, { id, label: model.label || id }); + } + } + return Array.from(byId.values()); +} + +export function getConnectorModelPolicy( + deps: ConnectorModelPolicyDeps, + connectorId: ConnectorId, +): ConnectorModelPolicyResult { + const connector = deps.getConnector(connectorId); + const definition = getConnectorDefinition(connectorId); + const disabledModels = new Set(deps.getDisabledModels(connectorId).map((model) => model.id)); + + const catalog = connectorId.startsWith("claude_compat") + ? parseCompatConnectorModelCatalog(connector.model).map((model) => ({ id: model, label: model })) + : (definition.module.models ?? []).map((model) => ({ id: model.id, label: model.label })); + + const enabledModels = uniqueModels(catalog).filter((model) => !disabledModels.has(model.id)); + + return { + connectorId, + connectorLabel: connector.label?.trim() || definition.label, + enabledModels, + defaultModel: connectorId.startsWith("claude_compat") + ? null + : (connector.model?.trim() || definition.defaultModel), + }; +} + +export function validateConnectorModelSelection( + deps: ConnectorModelPolicyDeps, + connectorId: ConnectorId, + model: string | null | undefined, +): { ok: true; effectiveModel: string | null } | { ok: false; error: string } { + const policy = getConnectorModelPolicy(deps, connectorId); + const selectedModel = model?.trim() || null; + + if (policy.enabledModels.length === 0) { + return { + ok: false, + error: `${policy.connectorLabel} has no enabled models available.`, + }; + } + + if (selectedModel) { + if (!policy.enabledModels.some((entry) => entry.id === selectedModel)) { + return { + ok: false, + error: `${policy.connectorLabel} does not allow model "${selectedModel}".`, + }; + } + return { ok: true, effectiveModel: selectedModel }; + } + + if (policy.defaultModel && policy.enabledModels.some((entry) => entry.id === policy.defaultModel)) { + return { ok: true, effectiveModel: policy.defaultModel }; + } + + return { ok: true, effectiveModel: policy.enabledModels[0]?.id ?? null }; +} + +export function validateConnectorConfigurationModel( + connectorId: ConnectorId, + model: string | null | undefined, +): { ok: true } | { ok: false; error: string } { + if (!connectorId.startsWith("claude_compat")) { + return { ok: true }; + } + + if (model == null) { + return { ok: true }; + } + + const catalog = parseCompatConnectorModelCatalog(model); + if (catalog.length === 0) { + return { ok: false, error: "Compatible connectors require at least one model in the configured model catalog." }; + } + + return { ok: true }; +} diff --git a/src/main/connectors.ts b/src/main/connectors.ts index 917abf5a..197987f2 100644 --- a/src/main/connectors.ts +++ b/src/main/connectors.ts @@ -1,15 +1,6 @@ -import type { ServerAdapterModule } from "@agentcompany/adapter-utils"; -import { agentConfigurationDoc as claudeDoc, models as claudeModels } from "@agentcompany/adapter-claude-local"; -import { execute as claudeExecute, sessionCodec as claudeSessionCodec, testEnvironment as claudeTestEnvironment } from "@agentcompany/adapter-claude-local/server"; -import { - agentConfigurationDoc as codexDoc, - DEFAULT_CODEX_LOCAL_MODEL, - models as codexModels, -} from "@agentcompany/adapter-codex-local"; -import { execute as codexExecute, sessionCodec as codexSessionCodec, testEnvironment as codexTestEnvironment } from "@agentcompany/adapter-codex-local/server"; -import { agentConfigurationDoc as geminiDoc, DEFAULT_GEMINI_LOCAL_MODEL, models as geminiModels } from "@agentcompany/adapter-gemini-local"; -import { execute as geminiExecute, sessionCodec as geminiSessionCodec, testEnvironment as geminiTestEnvironment } from "@agentcompany/adapter-gemini-local/server"; -import type { CapabilityMatrix, ConnectorId, ConnectorRecord } from "@shared/types"; +import type { ConnectorRecord } from "@shared/types"; +import { CONNECTORS } from "./connector-definitions"; +export type { ConnectorDefinition } from "./connector-definitions"; const RESERVED_ENV_BINDING_KEYS = new Set([ "PATH", @@ -23,348 +14,7 @@ const RESERVED_ENV_BINDING_KEYS = new Set([ "PWD", ]); -interface ConnectorDefinition { - id: ConnectorId; - label: string; - description: string; - defaultCommand: string; - defaultModel: string | null; - notes: string; - capabilityMatrix: CapabilityMatrix; - configurationDoc: string; - module: ServerAdapterModule; - listModels?: () => Promise>; -} - -const CONNECTORS: ConnectorDefinition[] = [ - { - id: "codex_local", - label: "Codex", - description: "Local Codex CLI with session resume, structured event parsing, and model selection.", - defaultCommand: "codex", - defaultModel: DEFAULT_CODEX_LOCAL_MODEL, - notes: "", - capabilityMatrix: { - sessionResume: true, - gracefulCancel: true, - modelDiscovery: false, - structuredTranscript: true, - costAttribution: false, - }, - configurationDoc: codexDoc, - module: { - type: "codex_local", - execute: codexExecute, - testEnvironment: codexTestEnvironment, - sessionCodec: codexSessionCodec, - nativeContextManagement: true, - models: codexModels, - }, - listModels: async () => codexModels, - }, - { - id: "claude_local", - label: "Claude Code", - description: "Local Claude Code CLI with session persistence and structured result parsing.", - defaultCommand: "claude", - defaultModel: claudeModels[0]?.id ?? null, - notes: "", - capabilityMatrix: { - sessionResume: true, - gracefulCancel: true, - modelDiscovery: false, - structuredTranscript: true, - costAttribution: true, - }, - configurationDoc: claudeDoc, - module: { - type: "claude_local", - execute: claudeExecute, - testEnvironment: claudeTestEnvironment, - sessionCodec: claudeSessionCodec, - nativeContextManagement: true, - models: claudeModels, - }, - listModels: async () => claudeModels, - }, - { - id: "claude_compatible", - label: "Claude Code Compatible API", - description: "Claude Code CLI connected to a third-party API provider (e.g. DeepSeek, OpenRouter, Ollama).", - defaultCommand: "claude", - defaultModel: null, - notes: "", - capabilityMatrix: { - sessionResume: true, - gracefulCancel: true, - modelDiscovery: false, - structuredTranscript: true, - costAttribution: false, - }, - configurationDoc: claudeDoc, - module: { - type: "claude_local", - execute: claudeExecute, - testEnvironment: claudeTestEnvironment, - sessionCodec: claudeSessionCodec, - nativeContextManagement: true, - models: claudeModels, - }, - listModels: async () => claudeModels, - }, - { - id: "claude_compat_2", - label: "Claude Code Compatible API (2)", - description: "Second Claude Code CLI compatible API slot for a third-party provider.", - defaultCommand: "claude", - defaultModel: null, - notes: "", - capabilityMatrix: { - sessionResume: true, - gracefulCancel: true, - modelDiscovery: false, - structuredTranscript: true, - costAttribution: false, - }, - configurationDoc: claudeDoc, - module: { - type: "claude_local", - execute: claudeExecute, - testEnvironment: claudeTestEnvironment, - sessionCodec: claudeSessionCodec, - nativeContextManagement: true, - models: claudeModels, - }, - listModels: async () => claudeModels, - }, - { - id: "claude_compat_3", - label: "Claude Code Compatible API (3)", - description: "Third Claude Code CLI compatible API slot for a third-party provider.", - defaultCommand: "claude", - defaultModel: null, - notes: "", - capabilityMatrix: { - sessionResume: true, - gracefulCancel: true, - modelDiscovery: false, - structuredTranscript: true, - costAttribution: false, - }, - configurationDoc: claudeDoc, - module: { - type: "claude_local", - execute: claudeExecute, - testEnvironment: claudeTestEnvironment, - sessionCodec: claudeSessionCodec, - nativeContextManagement: true, - models: claudeModels, - }, - listModels: async () => claudeModels, - }, - { - id: "claude_compat_4", - label: "Claude Code Compatible API (4)", - description: "Claude Code CLI compatible API slot 4 for a third-party provider.", - defaultCommand: "claude", - defaultModel: null, - notes: "", - capabilityMatrix: { - sessionResume: true, - gracefulCancel: true, - modelDiscovery: false, - structuredTranscript: true, - costAttribution: false, - }, - configurationDoc: claudeDoc, - module: { - type: "claude_local", - execute: claudeExecute, - testEnvironment: claudeTestEnvironment, - sessionCodec: claudeSessionCodec, - nativeContextManagement: true, - models: claudeModels, - }, - listModels: async () => claudeModels, - }, - { - id: "claude_compat_5", - label: "Claude Code Compatible API (5)", - description: "Claude Code CLI compatible API slot 5 for a third-party provider.", - defaultCommand: "claude", - defaultModel: null, - notes: "", - capabilityMatrix: { - sessionResume: true, - gracefulCancel: true, - modelDiscovery: false, - structuredTranscript: true, - costAttribution: false, - }, - configurationDoc: claudeDoc, - module: { - type: "claude_local", - execute: claudeExecute, - testEnvironment: claudeTestEnvironment, - sessionCodec: claudeSessionCodec, - nativeContextManagement: true, - models: claudeModels, - }, - listModels: async () => claudeModels, - }, - { - id: "claude_compat_6", - label: "Claude Code Compatible API (6)", - description: "Claude Code CLI compatible API slot 6 for a third-party provider.", - defaultCommand: "claude", - defaultModel: null, - notes: "", - capabilityMatrix: { - sessionResume: true, - gracefulCancel: true, - modelDiscovery: false, - structuredTranscript: true, - costAttribution: false, - }, - configurationDoc: claudeDoc, - module: { - type: "claude_local", - execute: claudeExecute, - testEnvironment: claudeTestEnvironment, - sessionCodec: claudeSessionCodec, - nativeContextManagement: true, - models: claudeModels, - }, - listModels: async () => claudeModels, - }, - { - id: "claude_compat_7", - label: "Claude Code Compatible API (7)", - description: "Claude Code CLI compatible API slot 7 for a third-party provider.", - defaultCommand: "claude", - defaultModel: null, - notes: "", - capabilityMatrix: { - sessionResume: true, - gracefulCancel: true, - modelDiscovery: false, - structuredTranscript: true, - costAttribution: false, - }, - configurationDoc: claudeDoc, - module: { - type: "claude_local", - execute: claudeExecute, - testEnvironment: claudeTestEnvironment, - sessionCodec: claudeSessionCodec, - nativeContextManagement: true, - models: claudeModels, - }, - listModels: async () => claudeModels, - }, - { - id: "claude_compat_8", - label: "Claude Code Compatible API (8)", - description: "Claude Code CLI compatible API slot 8 for a third-party provider.", - defaultCommand: "claude", - defaultModel: null, - notes: "", - capabilityMatrix: { - sessionResume: true, - gracefulCancel: true, - modelDiscovery: false, - structuredTranscript: true, - costAttribution: false, - }, - configurationDoc: claudeDoc, - module: { - type: "claude_local", - execute: claudeExecute, - testEnvironment: claudeTestEnvironment, - sessionCodec: claudeSessionCodec, - nativeContextManagement: true, - models: claudeModels, - }, - listModels: async () => claudeModels, - }, - { - id: "claude_compat_9", - label: "Claude Code Compatible API (9)", - description: "Claude Code CLI compatible API slot 9 for a third-party provider.", - defaultCommand: "claude", - defaultModel: null, - notes: "", - capabilityMatrix: { - sessionResume: true, - gracefulCancel: true, - modelDiscovery: false, - structuredTranscript: true, - costAttribution: false, - }, - configurationDoc: claudeDoc, - module: { - type: "claude_local", - execute: claudeExecute, - testEnvironment: claudeTestEnvironment, - sessionCodec: claudeSessionCodec, - nativeContextManagement: true, - models: claudeModels, - }, - listModels: async () => claudeModels, - }, - { - id: "claude_compat_10", - label: "Claude Code Compatible API (10)", - description: "Claude Code CLI compatible API slot 10 for a third-party provider.", - defaultCommand: "claude", - defaultModel: null, - notes: "", - capabilityMatrix: { - sessionResume: true, - gracefulCancel: true, - modelDiscovery: false, - structuredTranscript: true, - costAttribution: false, - }, - configurationDoc: claudeDoc, - module: { - type: "claude_local", - execute: claudeExecute, - testEnvironment: claudeTestEnvironment, - sessionCodec: claudeSessionCodec, - nativeContextManagement: true, - models: claudeModels, - }, - listModels: async () => claudeModels, - }, - { - id: "gemini_local", - label: "Gemini CLI", - description: "Local Gemini CLI with resume support and JSON event parsing.", - defaultCommand: "gemini", - defaultModel: DEFAULT_GEMINI_LOCAL_MODEL, - notes: "", - capabilityMatrix: { - sessionResume: true, - gracefulCancel: true, - modelDiscovery: false, - structuredTranscript: true, - costAttribution: true, - }, - configurationDoc: geminiDoc, - module: { - type: "gemini_local", - execute: geminiExecute, - testEnvironment: geminiTestEnvironment, - sessionCodec: geminiSessionCodec, - nativeContextManagement: false, - models: geminiModels, - }, - listModels: async () => geminiModels, - }, -]; - -export function getConnectorDefinition(id: ConnectorId): ConnectorDefinition { +export function getConnectorDefinition(id: string) { const definition = CONNECTORS.find((entry) => entry.id === id); if (!definition) { throw new Error(`Unknown connector: ${id}`); diff --git a/src/main/database-base.ts b/src/main/database-base.ts new file mode 100644 index 00000000..8e9ecdb9 --- /dev/null +++ b/src/main/database-base.ts @@ -0,0 +1,400 @@ +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { DatabaseSync, type SQLInputValue } from "node:sqlite"; +import { buildThreadMethods } from "./database/threads"; +import { buildCompanyMethods } from "./database/company"; +import { buildAgentMethods } from "./database/agents"; +import { buildTaskMethods } from "./database/tasks"; +import { buildMessageMethods } from "./database/messages"; +import { buildRunMethods } from "./database/runs"; +import { buildSnapshotMethods } from "./database/snapshot"; +import { buildSettingsMethods } from "./database/settings"; +import { buildConnectorMethods } from "./database/connectors-crud"; +import { buildWorkspaceMethods } from "./database/workspaces"; +import { buildApprovalMethods } from "./database/approvals"; +import { buildSocialMethods } from "./database/social"; +import { buildActivityMethods } from "./database/activity"; +import { buildSkillMethods } from "./database/skills"; +import { buildBudgetMethods } from "./database/budget"; +import { buildEntityCrudMethods } from "./database/entity-crud"; +import { buildDocumentMethods } from "./database/documents"; +import { buildAutomationMethods } from "./database/automation"; +import { buildKnowledgeMethods } from "./database/knowledge"; +import { buildWakeupMethods } from "./database/wakeup"; +import { buildBackupMethods } from "./database/backup"; +import { initializeDatabase } from "./database/schema"; +import { SecretVault } from "./vault"; +import type { AgentStatus, ConnectorId, ProfileSnapshot, RunStatus } from "@shared/types"; + +function nowIso() { + return new Date().toISOString(); +} +export const STALE_LOCK_TIMEOUT_MS = 15 * 60 * 1000; + +export class AppDatabaseBase { + readonly profileDir: string; + readonly dbPath: string; + readonly logsDir: string; + readonly artifactsDir: string; + readonly backupsDir: string; + readonly vault: SecretVault; + protected db: DatabaseSync; + + protected readonly threads: ReturnType; + protected readonly company: ReturnType; + protected readonly agents: ReturnType; + protected readonly tasks: ReturnType; + protected readonly messages: ReturnType; + protected readonly runs: ReturnType; + protected readonly snap: ReturnType; + protected readonly settings: ReturnType; + protected readonly connectorsCrud: ReturnType; + protected readonly workspacesMod: ReturnType; + protected readonly approvalsMod: ReturnType; + protected readonly social: ReturnType; + protected readonly activityMod: ReturnType; + protected readonly skills: ReturnType; + protected readonly budget: ReturnType; + protected readonly entityCrud: ReturnType; + protected readonly documentsMod: ReturnType; + protected readonly automationMod: ReturnType; + protected readonly knowledgeMod: ReturnType; + protected readonly wakeup: ReturnType; + protected readonly backupMod: ReturnType; + + constructor(rootDir: string) { + this.profileDir = rootDir; + this.dbPath = join(rootDir, "profile.sqlite"); + this.logsDir = join(rootDir, "logs"); + this.artifactsDir = join(rootDir, "artifacts"); + this.backupsDir = join(rootDir, "backups"); + mkdirSync(rootDir, { recursive: true }); + mkdirSync(this.logsDir, { recursive: true }); + mkdirSync(this.artifactsDir, { recursive: true }); + mkdirSync(this.backupsDir, { recursive: true }); + this.vault = new SecretVault(rootDir); + this.db = this.openConnection(); + + const getDb = () => this.db; + const ra = (sql: string, ...params: SQLInputValue[]) => this.readAll(sql, ...params); + const tx = (op: () => T) => this.transaction(op); + + this.activityMod = buildActivityMethods(getDb, ra); + this.settings = buildSettingsMethods(getDb, ra, { + profileDir: this.profileDir, logsDir: this.logsDir, + artifactsDir: this.artifactsDir, backupsDir: this.backupsDir, + }); + this.connectorsCrud = buildConnectorMethods(getDb, ra); + this.snap = buildSnapshotMethods(getDb, ra, { + getCurrentCompanyId: () => this.settings.getCurrentCompanyId(), + getSetting: (key) => this.getSetting(key), + readConnectors: () => this.connectorsCrud.readConnectors(), + profileDir: this.profileDir, + vaultBackendLabel: () => this.vault.getBackendLabel(), + }); + this.threads = buildThreadMethods(getDb, ra); + this.company = buildCompanyMethods(getDb, ra, tx, { + getSetting: (key) => this.getSetting(key), + setSetting: (key, value) => this.setSetting(key, value), + listSnapshot: (options) => this.listSnapshot(options), + addComment: (input) => this.addComment(input), + }); + this.agents = buildAgentMethods(getDb, ra, tx, { + getAgent: (id) => this.getAgent(id), + wouldCreateAgentCycle: (agentId, reportsTo) => this.wouldCreateAgentCycle(agentId, reportsTo), + recordConfigRevision: (input) => this.recordConfigRevision(input), + cancelRunForDeletion: (runId, taskId, workspaceId, summary) => this.runs.cancelRunForDeletion(runId, taskId, workspaceId, summary), + addActivity: (input) => this.addActivity(input), + }); + this.tasks = buildTaskMethods(getDb, ra, tx, { + addComment: (input) => this.addComment(input), + addActivity: (input) => this.addActivity(input), + }); + this.messages = buildMessageMethods(getDb, ra, { + getAgent: (id) => this.getAgent(id), + addActivity: (input) => this.addActivity(input), + }); + this.runs = buildRunMethods(getDb, ra, tx, { logsDir: this.logsDir }); + this.workspacesMod = buildWorkspaceMethods(getDb, ra, tx); + this.approvalsMod = buildApprovalMethods(getDb, ra, tx, { + getAgent: (id) => this.getAgent(id), + }); + this.social = buildSocialMethods(getDb, ra, tx); + this.skills = buildSkillMethods(getDb, ra); + this.budget = buildBudgetMethods(getDb, ra, tx); + this.entityCrud = buildEntityCrudMethods(getDb, ra, tx, { + addActivity: (input) => this.addActivity(input), + }); + this.documentsMod = buildDocumentMethods(getDb, ra, tx); + this.automationMod = buildAutomationMethods(getDb, ra, { + addActivity: (input) => this.addActivity(input), + }); + this.knowledgeMod = buildKnowledgeMethods(getDb, ra); + this.wakeup = buildWakeupMethods(getDb, ra, tx); + this.backupMod = buildBackupMethods({ + getDb, setDb: (db) => { this.db = db; }, + openConnection: () => this.openConnection(), + init: () => this.init(), + close: () => this.close(), + profileDir: this.profileDir, dbPath: this.dbPath, + logsDir: this.logsDir, artifactsDir: this.artifactsDir, + backupsDir: this.backupsDir, + }); + } + + // ─── Lifecycle ───────────────────────────────────────────────────────────── + + init() { + const ra = (sql: string, ...params: SQLInputValue[]) => this.readAll(sql, ...params); + const tx = (op: () => T) => this.transaction(op); + initializeDatabase(this.db, ra, tx); + this.connectorsCrud.seedConnectors(); + this._lastInterruptedCount = this.interruptStaleRuns(); + this.settings.purgeExpiredRunLogs(); + this.wakeup.purgeExpiredWakeupRequests(); + } + + close() { this.db.close(); } + + // ─── Settings ────────────────────────────────────────────────────────────── + + getCurrentCompanyId() { return this.settings.getCurrentCompanyId(); } + setCurrentCompany(companyId: string) { this.settings.setCurrentCompany(companyId); } + hasCompany(companyId: string) { return this.settings.hasCompany(companyId); } + belongsToCompany(table: "workspaces" | "agents" | "goals" | "projects" | "tasks" | "approvals" | "runs" | "secrets" | "meetings" | "documents" | "knowledge_base" | "sprints" | "automation_rules" | "workflow_pipelines", id: string, companyId: string) { return this.settings.belongsToCompany(table, id, companyId); } + updateSettings(input: { theme?: "system" | "light" | "dark"; locale?: "en" | "zh"; updatesEnabled?: boolean; logRetentionDays?: number }) { this.settings.updateSettings(input); } + getLogRetentionDays() { return this.settings.getLogRetentionDays(); } + purgeExpiredRunLogs() { return this.settings.purgeExpiredRunLogs(); } + canOpenPath(targetPath: string) { return this.settings.canOpenPath(targetPath); } + getDisabledModels(connectorId: string) { return this.settings.getDisabledModels(connectorId); } + setDisabledModels(connectorId: string, models: Array<{ id: string; label: string }>) { this.settings.setDisabledModels(connectorId, models); } + getLastBudgetResetMonth(companyId: string) { return this.settings.getLastBudgetResetMonth(companyId); } + setLastBudgetResetMonth(companyId: string, month: string) { this.settings.setLastBudgetResetMonth(companyId, month); } + + // ─── Snapshot ────────────────────────────────────────────────────────────── + + listSnapshot(options: { companyId?: string | null } = {}): ProfileSnapshot { return this.snap.listSnapshot(options); } + listRendererSnapshot(): ProfileSnapshot { return this.snap.listRendererSnapshot(); } + + // ─── Company ─────────────────────────────────────────────────────────────── + + saveCompany(input: { id?: string | null; name: string; description: string; status: string; autoApproveHires?: boolean; reviewDeliverables?: boolean; executionMode?: string }) { return this.company.saveCompany(input); } + deleteCompany(id: string) { return this.company.deleteCompany(id); } + getCompanyStatus(companyId: string) { return this.company.getCompanyStatus(companyId); } + getCompanyBasic(companyId: string) { return this.company.getCompanyBasic(companyId); } + getCompanyExecutionMode(companyId: string) { return this.company.getCompanyExecutionMode(companyId); } + getCompanyApprovalSettings(companyId: string) { return this.company.getCompanyApprovalSettings(companyId); } + listActiveCompanyIds() { return this.company.listActiveCompanyIds(); } + getSidebarBadges(companyId: string) { return this.company.getSidebarBadges(companyId); } + getInbox(companyId: string) { return this.company.getInbox(companyId); } + getCompanyMetrics(companyId: string, days: number = 30) { return this.company.getCompanyMetrics(companyId, days); } + getStandupReport(companyId: string) { return this.company.getStandupReport(companyId); } + + // ─── Connectors ──────────────────────────────────────────────────────────── + + saveConnector(input: { id: ConnectorId; command: string; model: string | null; envBindingText: string; notes: string }) { this.connectorsCrud.saveConnector(input); } + updateConnectorHealth(input: { id: ConnectorId; status: string; authState: string; version: string | null; lastError: string | null }) { this.connectorsCrud.updateConnectorHealth(input); } + getConnector(id: ConnectorId) { return this.connectorsCrud.getConnector(id); } + + // ─── Agents ──────────────────────────────────────────────────────────────── + + saveAgent(input: { id?: string | null; companyId: string; name: string; role: string; title: string; department?: string | null; status: AgentStatus; reportsTo?: string | null; connectorId: ConnectorId; workspaceId?: string | null; model?: string | null; metadataJson?: string; capabilities: string; budgetMonthlyUsd: number }) { return this.agents.saveAgent(input); } + deleteAgent(id: string, companyId: string) { return this.agents.deleteAgent(id, companyId); } + getAgent(id: string) { return this.entityCrud.getAgent(id); } + setHeartbeat(agentId: string, enabled: boolean, intervalSec: number) { return this.agents.setHeartbeat(agentId, enabled, intervalSec); } + markHeartbeatAt(agentId: string) { return this.agents.markHeartbeatAt(agentId); } + resetMonthlyBudgets(companyId: string) { return this.agents.resetMonthlyBudgets(companyId); } + listHeartbeatEligibleAgents(companyId: string) { return this.agents.listHeartbeatEligibleAgents(companyId); } + getChainOfCommand(agentId: string) { return this.agents.getChainOfCommand(agentId); } + getDirectReports(agentId: string, companyId: string) { return this.agents.getDirectReports(agentId, companyId); } + getCeoForCompany(companyId: string) { return this.agents.getCeoForCompany(companyId); } + listActiveAgentsForCompany(companyId: string) { return this.agents.listActiveAgentsForCompany(companyId); } + hasAgentAssignedTasks(agentId: string, companyId: string) { return this.agents.hasAgentAssignedTasks(agentId, companyId); } + updateAgentStatus(agentId: string, status: string) { return this.agents.updateAgentStatus(agentId, status); } + isAgentPendingApproval(agentId: string) { return this.agents.isAgentPendingApproval(agentId); } + findMentionedAgentIds(companyId: string, text: string) { return this.agents.findMentionedAgentIds(companyId, text); } + getIdleAgentsForDepartment(companyId: string, department: string) { return this.agents.getIdleAgentsForDepartment(companyId, department); } + getManagerForAgent(agentId: string) { return this.agents.getManagerForAgent(agentId); } + getAgentPermissions(agentId: string) { return this.agents.getAgentPermissions(agentId); } + setAgentPermission(agentId: string, companyId: string, permissionKey: string, enabled: boolean) { return this.agents.setAgentPermission(agentId, companyId, permissionKey, enabled); } + hasAgentPermission(agentId: string, permissionKey: string) { return this.agents.hasAgentPermission(agentId, permissionKey); } + getRuntimeState(agentId: string) { return this.agents.getRuntimeState(agentId); } + setRuntimeState(agentId: string, companyId: string, state: Record) { return this.agents.setRuntimeState(agentId, companyId, state); } + getHeartbeatPolicy(agentId: string) { return this.agents.getHeartbeatPolicy(agentId); } + setHeartbeatPolicy(agentId: string, companyId: string, policy: { enabled?: boolean; intervalSec?: number; cooldownSec?: number; maxConcurrentRuns?: number; wakeOnDemand?: boolean }) { return this.agents.setHeartbeatPolicy(agentId, companyId, policy); } + countActiveRunsForAgent(agentId: string, companyId: string) { return this.agents.countActiveRunsForAgent(agentId, companyId); } + + // ─── Tasks ───────────────────────────────────────────────────────────────── + + saveTask(input: { id?: string | null; companyId: string; projectId?: string | null; goalId?: string | null; parentId?: string | null; title: string; description: string; assigneeAgentId?: string | null; workspaceId?: string | null; priority: string; status: string }) { return this.tasks.saveTask(input); } + getTask(id: string) { return this.entityCrud.getTask(id); } + deleteTask(id: string, companyId: string) { return this.tasks.deleteTask(id, companyId); } + updateTaskSession(taskId: string, sessionJson: Record | null, sessionDisplayId: string | null) { return this.tasks.updateTaskSession(taskId, sessionJson, sessionDisplayId); } + getTaskSession(taskId: string) { return this.tasks.getTaskSession(taskId); } + getActiveTaskSession(taskId: string) { return this.tasks.getActiveTaskSession(taskId); } + incrementSessionUsage(sessionId: string, inputTokens: number, outputTokens: number, runId: string) { return this.tasks.incrementSessionUsage(sessionId, inputTokens, outputTokens, runId); } + archiveSession(sessionId: string, reason: string, handoffMarkdown: string) { return this.tasks.archiveSession(sessionId, reason, handoffMarkdown); } + createNewSession(input: { taskId: string; companyId: string; agentId: string; adapterType: string; sessionParamsJson: string | null; sessionDisplayId: string | null }) { return this.tasks.createNewSession(input); } + getRecentRunsForTask(taskId: string, limit: number) { return this.tasks.getRecentRunsForTask(taskId, limit); } + getRecentActivityForTask(taskId: string, limit: number) { return this.tasks.getRecentActivityForTask(taskId, limit); } + checkoutTask(taskId: string, runId: string) { return this.tasks.checkoutTask(taskId, runId); } + releaseTaskLock(taskId: string, runId: string) { return this.tasks.releaseTaskLock(taskId, runId); } + renewTaskLease(taskId: string, runId: string) { return this.tasks.renewTaskLease(taskId, runId); } + listAgentTasks(agentId: string, companyId: string, statuses: string[]) { return this.tasks.listAgentTasks(agentId, companyId, statuses); } + findReusableHeartbeatTask(agentId: string, companyId: string) { return this.tasks.findReusableHeartbeatTask(agentId, companyId); } + cleanupDuplicateHeartbeatTasks(companyId: string) { return this.tasks.cleanupDuplicateHeartbeatTasks(companyId); } + listReviewTasksForManager(managerId: string, companyId: string) { return this.tasks.listReviewTasksForManager(managerId, companyId); } + countActiveTasksForCompany(companyId: string) { return this.tasks.countActiveTasksForCompany(companyId); } + listChildTasks(parentId: string, companyId: string) { return this.tasks.listChildTasks(parentId, companyId); } + advanceTaskStatus(taskId: string, newStatus: string, comment?: string) { return this.tasks.advanceTaskStatus(taskId, newStatus, comment); } + setTaskRequiresUserReview(taskId: string, value: boolean) { return this.tasks.setTaskRequiresUserReview(taskId, value); } + hasPendingDeliverableReviews(companyId: string) { return this.tasks.hasPendingDeliverableReviews(companyId); } + hasAgentPendingDeliverableReview(agentId: string, companyId: string) { return this.tasks.hasAgentPendingDeliverableReview(agentId, companyId); } + hasAgentTaskAwaitingReview(agentId: string, companyId: string) { return this.tasks.hasAgentTaskAwaitingReview(agentId, companyId); } + autoAssignTask(taskId: string, companyId: string) { return this.tasks.autoAssignTask(taskId, companyId); } + getTaskRetryCount(taskId: string) { return this.tasks.getTaskRetryCount(taskId); } + incrementTaskRetryCount(taskId: string) { return this.tasks.incrementTaskRetryCount(taskId); } + resetTaskRetryCount(taskId: string) { return this.tasks.resetTaskRetryCount(taskId); } + retryInterruptedTask(taskId: string, _agentId: string, _companyId: string) { return this.tasks.retryInterruptedTask(taskId, _agentId, _companyId); } + dismissInterruptedTask(taskId: string) { return this.tasks.dismissInterruptedTask(taskId); } + markInterruptedTaskFailed(taskId: string) { return this.tasks.markInterruptedTaskFailed(taskId); } + setTaskType(taskId: string, taskType: string) { return this.tasks.setTaskType(taskId, taskType); } + + // ─── Runs ────────────────────────────────────────────────────────────────── + + getRun(id: string) { return this.runs.getRun(id); } + getRunAuthState(runId: string) { return this.runs.getRunAuthState(runId); } + createRun(input: { id?: string; companyId: string; taskId: string; agentId: string; workspaceId: string | null; connectorId: ConnectorId }) { return this.runs.createRun(input); } + appendRunLog(runId: string, stream: "stdout" | "stderr", chunk: string) { return this.runs.appendRunLog(runId, stream, chunk); } + readRunLog(runId: string) { return this.runs.readRunLog(runId); } + readRunLogChunk(runId: string, offset = 0, limit = 65_536) { return this.runs.readRunLogChunk(runId, offset, limit); } + markRunStatus(runId: string, status: RunStatus, message: string) { return this.runs.markRunStatus(runId, status, message); } + finishRun(input: { runId: string; status: RunStatus; summary: string | null; errorMessage: string | null; exitCode: number | null; signal: string | null; model: string | null; sessionDisplayId: string | null; costUsd: number | null; unattributedCost: boolean }) { return this.runs.finishRun(input); } + hasActiveRun(filter: { companyId: string; taskId?: string; agentId?: string }) { return this.runs.hasActiveRun(filter); } + listQueuedRuns() { return this.runs.listQueuedRuns(); } + getInterruptedRunsForRecovery() { return this.runs.getInterruptedRunsForRecovery(); } + appendRunEvent(input: { companyId: string; runId: string; agentId: string; eventType: string; stream?: string | null; level?: string | null; message?: string | null; payload?: Record | null }) { return this.runs.appendRunEvent(input); } + listRunEvents(runId: string, companyId: string, opts?: { offset?: number; limit?: number }) { return this.runs.listRunEvents(runId, companyId, opts); } + updateRunActor(runId: string, actorType: string, actorId: string | null) { return this.runs.updateRunActor(runId, actorType, actorId); } + + // ─── Approvals ───────────────────────────────────────────────────────────── + + requestApproval(input: { companyId: string; relatedTaskId?: string | null; requestedByAgentId?: string | null; relatedAgentId?: string | null; type: string; payloadSummary: string; impactSummary: string; payloadJson?: string | null }) { return this.approvalsMod.requestApproval(input); } + decideApproval(input: { companyId?: string | null; approvalId: string; state: string; decisionNote: string }) { return this.approvalsMod.decideApproval(input); } + getApprovalRecord(id: string) { return this.approvalsMod.getApprovalRecord(id); } + deleteApproval(id: string, companyId: string) { this.approvalsMod.deleteApproval(id, companyId); } + getBlockingApproval(companyId: string, agentId: string, taskId: string | null) { return this.approvalsMod.getBlockingApproval(companyId, agentId, taskId); } + resubmitApproval(approvalId: string, companyId: string) { this.approvalsMod.resubmitApproval(approvalId, companyId); } + addApprovalComment(input: { companyId: string; approvalId: string; authorAgentId?: string | null; authorUserId?: string | null; body: string }) { return this.approvalsMod.addApprovalComment(input); } + listApprovalComments(approvalId: string, companyId: string) { return this.approvalsMod.listApprovalComments(approvalId, companyId); } + linkTaskToApproval(taskId: string, approvalId: string, companyId: string, linkedByAgentId?: string | null) { this.approvalsMod.linkTaskToApproval(taskId, approvalId, companyId, linkedByAgentId); } + unlinkTaskFromApproval(taskId: string, approvalId: string) { this.approvalsMod.unlinkTaskFromApproval(taskId, approvalId); } + listApprovalsForTask(taskId: string) { return this.approvalsMod.listApprovalsForTask(taskId); } + listTasksForApproval(approvalId: string) { return this.approvalsMod.listTasksForApproval(approvalId); } + linkManyTasksToApproval(approvalId: string, taskIds: string[], companyId: string, linkedByAgentId?: string | null) { this.approvalsMod.linkManyTasksToApproval(approvalId, taskIds, companyId, linkedByAgentId); } + + // ─── Workspaces ──────────────────────────────────────────────────────────── + + saveWorkspace(input: { id?: string | null; companyId: string; name: string; localPath: string; repoUrl: string; repoRef: string; projectId?: string | null; isPrimary: boolean }) { return this.workspacesMod.saveWorkspace(input); } + getWorkspace(id: string | null | undefined) { return this.workspacesMod.getWorkspace(id); } + getProjectPrimaryWorkspace(projectId: string | null | undefined, companyId: string) { return this.workspacesMod.getProjectPrimaryWorkspace(projectId, companyId); } + listWorkspaceLocalPaths(companyId: string) { return this.workspacesMod.listWorkspaceLocalPaths(companyId); } + getAnyCompanyWorkspace(companyId: string) { return this.workspacesMod.getAnyCompanyWorkspace(companyId); } + deleteWorkspace(id: string, companyId: string) { this.workspacesMod.deleteWorkspace(id, companyId); } + acquireWorkspaceLock(workspaceId: string, runId: string) { return this.workspacesMod.acquireWorkspaceLock(workspaceId, runId); } + claimWorkspaceLock(workspaceId: string, runId: string) { return this.workspacesMod.claimWorkspaceLock(workspaceId, runId); } + releaseWorkspaceLock(workspaceId: string, runId: string) { this.workspacesMod.releaseWorkspaceLock(workspaceId, runId); } + releaseAllWorkspaceLocks() { this.workspacesMod.releaseAllWorkspaceLocks(); } + releaseStaleWorkspaceLocks() { return this.workspacesMod.releaseStaleWorkspaceLocks(); } + isWorkspaceLocked(workspaceId: string) { return this.workspacesMod.isWorkspaceLocked(workspaceId); } + + // ─── Internal infrastructure ─────────────────────────────────────────────── + protected _lastInterruptedCount = 0; + get lastInterruptedCount(): number { return this._lastInterruptedCount; } + + interruptStaleRuns(): number { + const ts = nowIso(); + let interruptedCount = 0; + this.transaction(() => { + const result = this.db + .prepare("update runs set status = 'interrupted', error_message = 'AgentCompany restarted before the run could report completion.', finished_at = ?, updated_at = ? where status = 'running'") + .run(ts, ts); + interruptedCount = Number(result.changes); + if (interruptedCount > 0) { + this.db.prepare( + `update tasks set status = 'blocked', updated_at = ? where id in (select task_id from runs where status = 'interrupted' and finished_at = ?) and status in ('in_progress', 'todo')`, + ).run(ts, ts); + } + this.db.prepare("update tasks set active_run_id = null where active_run_id in (select id from runs where status = 'interrupted')").run(); + this.db.prepare("update agents set status = 'idle', updated_at = ? where status = 'running'").run(ts); + this.releaseAllWorkspaceLocks(); + }); + return interruptedCount; + } + + recoverStaleLocks(): Array<{ taskId: string; companyId: string; title: string }> { + const cutoff = new Date(Date.now() - STALE_LOCK_TIMEOUT_MS).toISOString(); + const stale = this.readAll<{ id: string; companyId: string; title: string }>( + `SELECT id, company_id AS companyId, title FROM tasks WHERE execution_locked_at IS NOT NULL AND execution_locked_at < ? AND status = 'in_progress' AND checkout_run_id NOT IN (SELECT id FROM runs WHERE status = 'running')`, + cutoff, + ); + const ts = nowIso(); + for (const task of stale) { + this.db.prepare(`UPDATE tasks SET checkout_run_id = NULL, execution_locked_at = NULL, status = 'todo', updated_at = ? WHERE id = ?`).run(ts, task.id); + } + return stale.map((t) => ({ taskId: t.id, companyId: t.companyId, title: t.title })); + } + + protected incrementActiveSprintVelocity(companyId: string, delta: number) { + this.entityCrud.incrementActiveSprintVelocity(companyId, delta); + } + + protected getSetting(key: string): string | null { + const row = this.db.prepare("select value from settings where key = ?").get(key) as { value: string } | undefined; + return row?.value ?? null; + } + + protected setSetting(key: string, value: string) { + this.db.prepare("insert into settings (key, value) values (?, ?) on conflict(key) do update set value = excluded.value").run(key, value); + } + + protected readAll(sql: string, ...params: SQLInputValue[]): T[] { + return this.db.prepare(sql).all(...params) as T[]; + } + + protected wouldCreateAgentCycle(agentId: string, reportsTo: string | null) { + if (!reportsTo) return false; + if (reportsTo === agentId) return true; + const visited = new Set([agentId]); + let currentId: string | null = reportsTo; + while (currentId) { + if (visited.has(currentId)) return true; + visited.add(currentId); + const current = this.db.prepare("select reports_to as reportsTo from agents where id = ?").get(currentId) as { reportsTo: string | null } | undefined; + currentId = current?.reportsTo ?? null; + } + return false; + } + + protected openConnection(): DatabaseSync { + return new DatabaseSync(this.dbPath, { timeout: 5000 }); + } + + protected transaction(operation: () => T): T { + this.db.exec("BEGIN IMMEDIATE"); + try { + const result = operation(); + this.db.exec("COMMIT"); + return result; + } catch (error) { + this.db.exec("ROLLBACK"); + throw error; + } + } + + // ─── Methods used by subclass constructor callbacks ──────────────────────── + + addActivity(input: { companyId: string; actor: string; action: string; entityType: string; entityId: string; detail: string }) { this.activityMod.addActivity(input); } + addComment(input: { companyId: string; taskId: string; authorAgentId: string | null; authorName: string; body: string }) { return this.activityMod.addComment(input); } + recordConfigRevision(input: { companyId: string; agentId: string; changedKeys: string[]; configBefore: Record; configAfter: Record; source?: string; rolledBackFromRevisionId?: string | null; createdByAgentId?: string | null; createdByUserId?: string | null }) { return this.documentsMod.recordConfigRevision(input); } +} diff --git a/src/main/database.ts b/src/main/database.ts index ae00260b..6008d538 100644 --- a/src/main/database.ts +++ b/src/main/database.ts @@ -1,5453 +1,166 @@ -import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, realpathSync, statSync, unlinkSync, writeFileSync } from "node:fs"; -import { appendFile, copyFile, cp, rm } from "node:fs/promises"; -import { extname, join, resolve, sep } from "node:path"; -import { randomUUID } from "node:crypto"; -import log from "electron-log/main.js"; -import { DatabaseSync, backup as backupDatabase, type SQLInputValue } from "node:sqlite"; -import { getConnectorDefinition, buildDefaultConnectorRows, listInvalidEnvBindingKeys, parseEnvBindingText } from "./connectors"; -import { SecretVault } from "./vault"; -import type { SkillRecord, FileInventoryEntry, SkillSourceType, TrustLevel } from "./skill-types.js"; -import type { - ActivityRecord, - AgentMessageRecord, - AgentMetrics, - AgentRecord, - ApprovalRecord, - BrowserActionRecord, - BudgetIncidentRecord, - BudgetPolicyRecord, - CommentRecord, - CompanyMetrics, - CompanyRecord, - ConnectorId, - ConnectorRecord, - CostRecord, - GoalRecord, - InboxItem, - ProfileSnapshot, - ProjectRecord, - RunLogChunk, - RunRecord, - RunStatus, - SecretRecord, - SocialAccountRecord, - StandupReport, - TaskRecord, - TaskSessionRecord, - WorkflowPipelineRecord, - WorkspaceRecord, -} from "@shared/types"; +import type { SQLInputValue } from "node:sqlite"; +import { AppDatabaseBase } from "./database-base"; +import { listInvalidEnvBindingKeys, parseEnvBindingText } from "./connectors"; +import type { ConnectorId, WorkflowPipelineRecord } from "@shared/types"; +import type { buildThreadMethods } from "./database/threads"; -type SqliteDatabase = DatabaseSync; -type SqliteRow = Record; -const BOARD_READER_ID = "__board__"; +export { STALE_LOCK_TIMEOUT_MS } from "./database-base"; -function nowIso() { - return new Date().toISOString(); -} - -function parseJson(value: string | null | undefined, fallback: T): T { - if (!value) return fallback; - try { - return JSON.parse(value) as T; - } catch { - return fallback; - } -} - -function ensureId(id: string | null | undefined) { - return id && id.length > 0 ? id : randomUUID(); -} - -function normalizeOptionalId(id: string | null | undefined) { - return id && id.length > 0 ? id : null; -} - -function toBoolean(value: unknown) { - return value === true || value === 1 || value === "1"; -} - -function isWithinOrEqual(targetPath: string, rootPath: string) { - const resolvedTarget = resolve(targetPath); - const resolvedRoot = resolve(rootPath); - return resolvedTarget === resolvedRoot || resolvedTarget.startsWith(`${resolvedRoot}${sep}`); -} - -function safeRealpath(targetPath: string) { - try { - return realpathSync(targetPath); - } catch { - return resolve(targetPath); - } -} - -/** - * Find the last complete UTF-8 character boundary in a buffer. - * Returns the number of bytes up to (and including) the last complete character. - * This prevents corrupting multi-byte CJK characters when slicing at chunk edges. - */ -function findUtf8Boundary(buffer: Buffer, bytesRead: number): number { - if (bytesRead === 0) return 0; - // Walk backwards past continuation bytes (10xxxxxx) - let pos = bytesRead - 1; - while (pos >= 0 && (buffer[pos] & 0xc0) === 0x80) { - pos--; - } - if (pos < 0) return bytesRead; - const lead = buffer[pos]; - let charLen: number; - if ((lead & 0x80) === 0) charLen = 1; - else if ((lead & 0xe0) === 0xc0) charLen = 2; - else if ((lead & 0xf0) === 0xe0) charLen = 3; - else if ((lead & 0xf8) === 0xf0) charLen = 4; - else return bytesRead; - // If the character is complete within the buffer, keep all bytes - if (pos + charLen <= bytesRead) return bytesRead; - // Otherwise truncate before the incomplete character - return pos; -} - -function taskPriorityOrderSql(columnName: string) { - return `case ${columnName} - when 'critical' then 4 - when 'high' then 3 - when 'medium' then 2 - when 'low' then 1 - else 0 - end`; -} - -export const STALE_LOCK_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes (D-01) - -export class AppDatabase { - readonly profileDir: string; - readonly dbPath: string; - readonly logsDir: string; - readonly artifactsDir: string; - readonly backupsDir: string; - readonly vault: SecretVault; - private db: SqliteDatabase; - - constructor(rootDir: string) { - this.profileDir = rootDir; - this.dbPath = join(rootDir, "profile.sqlite"); - this.logsDir = join(rootDir, "logs"); - this.artifactsDir = join(rootDir, "artifacts"); - this.backupsDir = join(rootDir, "backups"); - mkdirSync(rootDir, { recursive: true }); - mkdirSync(this.logsDir, { recursive: true }); - mkdirSync(this.artifactsDir, { recursive: true }); - mkdirSync(this.backupsDir, { recursive: true }); - this.vault = new SecretVault(rootDir); - this.db = this.openConnection(); - } - - init() { - this.db.exec("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;"); - this.db.exec(` - create table if not exists settings ( - key text primary key, - value text not null - ); - create table if not exists companies ( - id text primary key, - name text not null, - description text not null, - status text not null, - created_at text not null, - updated_at text not null - ); - create table if not exists connectors ( - id text primary key, - label text not null, - description text not null, - status text not null, - command text not null, - version text, - auth_state text not null, - last_checked_at text, - last_error text, - model text, - env_binding_text text not null, - notes text not null, - updated_at text - ); - create table if not exists workspaces ( - id text primary key, - company_id text not null, - name text not null, - local_path text not null, - repo_url text not null, - repo_ref text not null, - project_id text, - state text not null, - is_primary integer not null default 0, - created_at text not null, - updated_at text not null - ); - create table if not exists agents ( - id text primary key, - company_id text not null, - name text not null, - role text not null, - title text not null, - status text not null, - reports_to text, - connector_id text not null, - workspace_id text, - model text, - capabilities text not null, - budget_monthly_usd real not null default 0, - spent_monthly_usd real not null default 0, - metadata_json text not null default '{}', - created_at text not null, - updated_at text not null - ); - create table if not exists goals ( - id text primary key, - company_id text not null, - title text not null, - description text not null, - parent_id text, - owner_agent_id text, - status text not null, - created_at text not null, - updated_at text not null - ); - create table if not exists projects ( - id text primary key, - company_id text not null, - goal_id text, - name text not null, - description text not null, - lead_agent_id text, - status text not null, - target_date text, - created_at text not null, - updated_at text not null - ); - create table if not exists tasks ( - id text primary key, - company_id text not null, - project_id text, - goal_id text, - parent_id text, - title text not null, - description text not null, - assignee_agent_id text, - workspace_id text, - status text not null, - priority text not null, - active_run_id text, - session_json text, - session_display_id text, - cost_usd real not null default 0, - created_at text not null, - updated_at text not null - ); - create table if not exists approvals ( - id text primary key, - company_id text not null, - related_task_id text, - requested_by_agent_id text, - type text not null, - payload_summary text not null, - impact_summary text not null, - payload_json text, - decision_note text not null default '', - state text not null, - created_at text not null, - updated_at text not null - ); - create table if not exists runs ( - id text primary key, - company_id text not null, - task_id text not null, - agent_id text not null, - workspace_id text, - connector_id text not null, - status text not null, - summary text not null, - error_message text, - exit_code integer, - signal text, - model text, - session_display_id text, - cost_usd real, - unattributed_cost integer not null default 0, - started_at text, - finished_at text, - log_path text, - created_at text not null, - updated_at text not null - ); - create table if not exists cost_entries ( - id text primary key, - company_id text not null, - run_id text not null, - task_id text, - agent_id text, - connector_id text not null, - amount_usd real, - unattributed integer not null default 0, - created_at text not null - ); - create table if not exists activity ( - id text primary key, - company_id text not null, - actor text not null, - action text not null, - entity_type text not null, - entity_id text not null, - detail text not null, - created_at text not null - ); - create table if not exists secrets ( - id text primary key, - company_id text not null, - name text not null, - description text not null, - backend text not null, - ciphertext blob not null, - updated_at text not null, - created_at text not null, - unique(company_id, name) - ); - `); - - this.db.exec(` - create index if not exists idx_workspaces_company on workspaces(company_id); - create index if not exists idx_agents_company on agents(company_id); - create index if not exists idx_goals_company on goals(company_id); - create index if not exists idx_projects_company on projects(company_id); - create index if not exists idx_tasks_company on tasks(company_id); - create index if not exists idx_tasks_assignee on tasks(assignee_agent_id); - create index if not exists idx_tasks_project on tasks(project_id); - create index if not exists idx_approvals_company on approvals(company_id); - create index if not exists idx_runs_company on runs(company_id); - create index if not exists idx_runs_task on runs(task_id); - create index if not exists idx_runs_agent on runs(agent_id); - create index if not exists idx_cost_entries_company on cost_entries(company_id); - create index if not exists idx_cost_entries_run on cost_entries(run_id); - create index if not exists idx_activity_company on activity(company_id); - create index if not exists idx_tasks_company_status on tasks(company_id, status); - create index if not exists idx_agents_company_status on agents(company_id, status); - create index if not exists idx_agents_reports_to on agents(reports_to); - create index if not exists idx_runs_company_status on runs(company_id, status); - create index if not exists idx_approvals_company_state on approvals(company_id, state); - `); - - this.db.exec(` - create table if not exists pending_wakes ( - id text primary key, - company_id text not null, - agent_id text not null, - trigger_reason text not null, - created_at text not null - ); - create index if not exists idx_pending_wakes_agent on pending_wakes(agent_id); - `); - - this.db.exec(` - CREATE TABLE IF NOT EXISTS wakeup_requests ( - id TEXT PRIMARY KEY, - company_id TEXT NOT NULL, - agent_id TEXT NOT NULL, - source TEXT NOT NULL, - trigger_detail TEXT, - reason TEXT, - payload TEXT, - status TEXT NOT NULL DEFAULT 'queued', - coalesced_count INTEGER NOT NULL DEFAULT 0, - requested_by_actor_type TEXT, - requested_by_actor_id TEXT, - idempotency_key TEXT, - run_id TEXT, - requested_at TEXT NOT NULL, - claimed_at TEXT, - finished_at TEXT, - error TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_wakeup_requests_agent ON wakeup_requests(agent_id, company_id, status); - `); - - this.migrateSecretsTable(); - this.ensureOptionalColumns(); - - this.db.exec(`create index if not exists idx_secrets_company on secrets(company_id);`); - - this.runMigrations(); - - // Create indexes that depend on columns added by migrations (e.g., department) - try { - this.db.exec(` - create index if not exists idx_agents_department on agents(company_id, department); - create index if not exists idx_runs_company_created_at on runs(company_id, created_at desc); - create index if not exists idx_cost_entries_company_created_at on cost_entries(company_id, created_at desc); - create index if not exists idx_activity_company_created_at on activity(company_id, created_at desc); - create index if not exists idx_agent_messages_company_created_at on agent_messages(company_id, created_at desc); - `); - } catch { /* column may not exist if migrations failed */ } - - this.seedSettings(); - this.seedConnectors(); - this._lastInterruptedCount = this.interruptStaleRuns(); - this.purgeExpiredRunLogs(); - this.purgeExpiredWakeupRequests(); - } - - close() { - this.db.close(); - } - - getCurrentCompanyId() { - return this.getSetting("current_company_id"); - } - - setCurrentCompany(companyId: string) { - this.setSetting("current_company_id", companyId); - } - - hasCompany(companyId: string) { - const row = this.db.prepare("select id from companies where id = ?").get(companyId) as { id: string } | undefined; - return Boolean(row); - } - - belongsToCompany(table: "workspaces" | "agents" | "goals" | "projects" | "tasks" | "approvals" | "runs" | "secrets" | "meetings" | "documents" | "knowledge_base" | "sprints" | "automation_rules" | "workflow_pipelines", id: string, companyId: string) { - const queries: Record = { - workspaces: "select 1 as present from workspaces where id = ? and company_id = ?", - agents: "select 1 as present from agents where id = ? and company_id = ?", - goals: "select 1 as present from goals where id = ? and company_id = ?", - projects: "select 1 as present from projects where id = ? and company_id = ?", - tasks: "select 1 as present from tasks where id = ? and company_id = ?", - approvals: "select 1 as present from approvals where id = ? and company_id = ?", - runs: "select 1 as present from runs where id = ? and company_id = ?", - secrets: "select 1 as present from secrets where id = ? and company_id = ?", - meetings: "select 1 as present from meetings where id = ? and company_id = ?", - documents: "select 1 as present from documents where id = ? and company_id = ?", - knowledge_base: "select 1 as present from knowledge_base where id = ? and company_id = ?", - sprints: "select 1 as present from sprints where id = ? and company_id = ?", - automation_rules: "select 1 as present from automation_rules where id = ? and company_id = ?", - workflow_pipelines: "select 1 as present from workflow_pipelines where id = ? and company_id = ?", - }; - const sql = queries[table]; - if (!sql) throw new Error(`Unknown table: ${table}`); - const row = this.db.prepare(sql).get(id, companyId) as { present: number } | undefined; - return Boolean(row?.present); - } - - hasActiveRun(filter: { companyId: string; taskId?: string; agentId?: string }) { - const whereClauses = ["company_id = ?", "status in ('queued', 'running')"]; - const params: SQLInputValue[] = [filter.companyId]; - if (filter.taskId) { - whereClauses.push("task_id = ?"); - params.push(filter.taskId); - } - if (filter.agentId) { - whereClauses.push("agent_id = ?"); - params.push(filter.agentId); - } - const row = this.db.prepare( - `select 1 as present from runs where ${whereClauses.join(" and ")} limit 1`, - ).get(...params) as { present: number } | undefined; - return Boolean(row?.present); - } - - canOpenPath(targetPath: string) { - if (!targetPath || !existsSync(targetPath)) { - return false; - } - - const managedRoots = [this.profileDir, this.logsDir, this.artifactsDir, this.backupsDir]; - const sensitiveProfileExtensions = new Set([".sqlite", ".db", ".key"]); - const forbiddenExecutableExtensions = new Set([".app", ".exe", ".bat", ".cmd", ".sh", ".command", ".desktop", ".msi", ".com", ".scr"]); - const resolvedTarget = safeRealpath(targetPath); - const targetStats = lstatSync(targetPath); - const targetExtension = extname(resolvedTarget).toLowerCase(); - if ( - managedRoots.some((rootPath) => isWithinOrEqual(resolvedTarget, rootPath)) && - sensitiveProfileExtensions.has(targetExtension) - ) { - return false; - } - if (!targetStats.isDirectory() && forbiddenExecutableExtensions.has(targetExtension)) { - return false; - } - if (managedRoots.some((rootPath) => isWithinOrEqual(resolvedTarget, safeRealpath(rootPath)))) { - return true; - } - - const workspaces = this.readAll<{ localPath: string }>("select local_path as localPath from workspaces"); - if (workspaces.some((workspace) => isWithinOrEqual(resolvedTarget, safeRealpath(workspace.localPath)))) { - return true; - } - - const runLogs = this.readAll<{ logPath: string | null }>("select log_path as logPath from runs where log_path is not null"); - return runLogs.some((run) => run.logPath ? safeRealpath(run.logPath) === resolvedTarget : false); - } - - updateSettings(input: { theme?: "system" | "light" | "dark"; locale?: "en" | "zh"; updatesEnabled?: boolean; logRetentionDays?: number }) { - if (input.theme) { - this.setSetting("theme", input.theme); - } - if (input.locale) { - this.setSetting("locale", input.locale); - } - if (typeof input.updatesEnabled === "boolean") { - this.setSetting("updates_enabled", input.updatesEnabled ? "true" : "false"); - } - if (typeof input.logRetentionDays === "number") { - this.setSetting("log_retention_days", String(input.logRetentionDays)); - } - } - - getLogRetentionDays(): number { - const raw = this.getSetting("log_retention_days"); - const parsed = raw ? parseInt(raw, 10) : NaN; - return Number.isFinite(parsed) && parsed >= 1 ? parsed : 90; // default 90 days - } - - purgeExpiredRunLogs(): number { - const retentionDays = this.getLogRetentionDays(); - const cutoff = new Date(Date.now() - retentionDays * 86_400_000).toISOString(); - const expiredRuns = this.readAll<{ id: string; logPath: string | null }>( - "select id, log_path as logPath from runs where finished_at is not null and finished_at < ? and log_path is not null", - cutoff, - ); - let purged = 0; - for (const run of expiredRuns) { - if (run.logPath) { - try { - unlinkSync(run.logPath); - } catch { - // Log files may already be gone; continue clearing DB references. - } - } - this.db - .prepare("update runs set log_path = null, updated_at = ? where id = ?") - .run(nowIso(), run.id); - purged++; - } - return purged; - } - - listSnapshot(options: { companyId?: string | null } = {}): ProfileSnapshot { - const companyId = options.companyId ?? null; - const companyWhere = companyId ? " where company_id = ?" : ""; - const companyArgs: SQLInputValue[] = companyId ? [companyId] : []; - const boardReadReceiptParams: SQLInputValue[] = companyId ? [BOARD_READER_ID, companyId] : [BOARD_READER_ID]; - const connectors = this.readConnectors(); - const theme = (this.getSetting("theme") as "system" | "light" | "dark" | null) ?? "system"; - const locale = (this.getSetting("locale") as "en" | "zh" | null) ?? "en"; - const boardReadReceiptMap = new Map( - this.readAll<{ messageId: string; readAt: string }>( - `select reads.message_id as messageId, reads.read_at as readAt - from agent_message_reads reads - join agent_messages messages on messages.id = reads.message_id - where reads.agent_id = ?${companyId ? " and messages.company_id = ?" : ""}`, - ...boardReadReceiptParams, - ).map((row) => [row.messageId, row.readAt]), - ); - return { - currentCompanyId: this.getCurrentCompanyId(), - profilePath: this.profileDir, - updatesEnabled: this.getSetting("updates_enabled") !== "false", - theme, - locale, - backend: { - secretBackend: this.vault.getBackendLabel(), - }, - companies: this.readAll( - "select id, name, description, status, auto_approve_hires as autoApproveHires, review_deliverables as reviewDeliverables, created_at as createdAt, updated_at as updatedAt from companies order by created_at asc", - ).map((record) => ({ ...record, autoApproveHires: toBoolean(record.autoApproveHires), reviewDeliverables: toBoolean(record.reviewDeliverables) })) as CompanyRecord[], - connectors, - workspaces: this.readAll( - `select id, company_id as companyId, name, local_path as localPath, repo_url as repoUrl, repo_ref as repoRef, project_id as projectId, state, is_primary as isPrimary, created_at as createdAt, updated_at as updatedAt from workspaces${companyWhere} order by created_at desc`, - ...companyArgs, - ).map((record) => ({ ...record, isPrimary: toBoolean(record.isPrimary) })) as WorkspaceRecord[], - agents: this.readAll( - `select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, created_at as createdAt, updated_at as updatedAt from agents${companyWhere} order by created_at asc`, - ...companyArgs, - ).map((record) => ({ ...record, heartbeatEnabled: toBoolean(record.heartbeatEnabled) })) as AgentRecord[], - goals: this.readAll( - `select id, company_id as companyId, title, description, parent_id as parentId, owner_agent_id as ownerAgentId, status, created_at as createdAt, updated_at as updatedAt from goals${companyWhere} order by created_at asc`, - ...companyArgs, - ), - projects: this.readAll( - `select id, company_id as companyId, goal_id as goalId, name, description, lead_agent_id as leadAgentId, status, target_date as targetDate, created_at as createdAt, updated_at as updatedAt from projects${companyWhere} order by created_at desc`, - ...companyArgs, - ), - tasks: this.readAll( - `select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, requires_user_review as requiresUserReview, started_at as startedAt, completed_at as completedAt, cancelled_at as cancelledAt, created_at as createdAt, updated_at as updatedAt from tasks${companyWhere} order by created_at desc`, - ...companyArgs, - ).map((record) => ({ ...record, requiresUserReview: toBoolean(record.requiresUserReview), startedAt: record.startedAt ?? null, completedAt: record.completedAt ?? null, cancelledAt: record.cancelledAt ?? null })) as TaskRecord[], - approvals: this.readAll( - `select id, company_id as companyId, related_task_id as relatedTaskId, related_agent_id as relatedAgentId, requested_by_agent_id as requestedByAgentId, type, payload_summary as payloadSummary, impact_summary as impactSummary, payload_json as payloadJson, decision_note as decisionNote, state, created_at as createdAt, updated_at as updatedAt from approvals${companyWhere} order by created_at desc`, - ...companyArgs, - ), - runs: this.readAll( - `select id, company_id as companyId, task_id as taskId, agent_id as agentId, workspace_id as workspaceId, connector_id as connectorId, status, summary, error_message as errorMessage, exit_code as exitCode, signal, model, session_display_id as sessionDisplayId, cost_usd as costUsd, unattributed_cost as unattributedCost, started_at as startedAt, finished_at as finishedAt, log_path as logPath, created_at as createdAt, updated_at as updatedAt from runs${companyWhere} order by created_at desc${companyId ? "" : " limit 500"}`, - ...companyArgs, - ).map((record) => ({ ...record, unattributedCost: Boolean(record.unattributedCost) })) as RunRecord[], - costs: this.readAll( - `select id, company_id as companyId, run_id as runId, task_id as taskId, agent_id as agentId, connector_id as connectorId, amount_usd as amountUsd, unattributed, created_at as createdAt from cost_entries${companyWhere} order by created_at desc${companyId ? "" : " limit 500"}`, - ...companyArgs, - ).map((entry) => ({ ...entry, unattributed: Boolean(entry.unattributed) })) as CostRecord[], - activity: this.readAll( - `select id, company_id as companyId, actor, action, entity_type as entityType, entity_id as entityId, detail, created_at as createdAt from activity${companyWhere} order by created_at desc${companyId ? "" : " limit 250"}`, - ...companyArgs, - ), - secrets: this.readAll( - `select id, company_id as companyId, name, description, backend, updated_at as updatedAt from secrets${companyWhere} order by updated_at desc`, - ...companyArgs, - ), - comments: this.readAll( - `select id, company_id as companyId, task_id as taskId, author_agent_id as authorAgentId, author_name as authorName, body, created_at as createdAt from task_comments${companyWhere} order by created_at asc`, - ...companyArgs, - ), - socialAccounts: this.readAll( - `select id, company_id, platform, account_name, display_name, profile_url, credential_secret_id, status, require_approval, metadata_json, last_used_at, created_at, updated_at from social_accounts${companyWhere} order by created_at desc`, - ...companyArgs, - ).map((r) => ({ - id: r.id, - companyId: r.company_id, - platform: r.platform, - accountName: r.account_name, - displayName: r.display_name, - profileUrl: r.profile_url, - credentialSecretId: r.credential_secret_id, - status: r.status, - requireApproval: toBoolean(r.require_approval), - metadataJson: r.metadata_json, - lastUsedAt: r.last_used_at, - createdAt: r.created_at, - updatedAt: r.updated_at, - })) as SocialAccountRecord[], - meetings: this.readAll( - `select id, company_id, type, title, organizer_agent_id, participant_agent_ids, scheduled_at, duration_minutes, agenda_json, notes_json, decisions_json, action_items_json, status, created_at, updated_at from meetings${companyWhere} order by scheduled_at desc`, - ...companyArgs, - ).map((r) => ({ - id: r.id, companyId: r.company_id, type: r.type, title: r.title, organizerAgentId: r.organizer_agent_id, - participantAgentIds: r.participant_agent_ids, scheduledAt: r.scheduled_at, durationMinutes: r.duration_minutes, - agendaJson: r.agenda_json, notesJson: r.notes_json, decisionsJson: r.decisions_json, - actionItemsJson: r.action_items_json, status: r.status, createdAt: r.created_at, updatedAt: r.updated_at, - })), - documents: this.readAll( - `select id, company_id, type, title, content, author_agent_id, reviewer_agent_id, project_id, goal_id, parent_doc_id, version, status, tags_json, latest_revision_id, created_at, updated_at from documents${companyWhere} order by updated_at desc`, - ...companyArgs, - ).map((r) => ({ - id: r.id, companyId: r.company_id, type: r.type, title: r.title, content: r.content, - authorAgentId: r.author_agent_id, reviewerAgentId: r.reviewer_agent_id, projectId: r.project_id, - goalId: r.goal_id, parentDocId: r.parent_doc_id, version: r.version, status: r.status, - tagsJson: r.tags_json, latestRevisionId: r.latest_revision_id ?? null, createdAt: r.created_at, updatedAt: r.updated_at, - })), - knowledgeBase: this.readAll( - `select id, company_id, category, topic, content, author_agent_id, importance, tags_json, referenced_entity_type, referenced_entity_id, created_at, updated_at from knowledge_base${companyWhere} order by updated_at desc`, - ...companyArgs, - ).map((r) => ({ - id: r.id, companyId: r.company_id, category: r.category, topic: r.topic, content: r.content, - authorAgentId: r.author_agent_id, importance: r.importance, tagsJson: r.tags_json, - referencedEntityType: r.referenced_entity_type, referencedEntityId: r.referenced_entity_id, - createdAt: r.created_at, updatedAt: r.updated_at, - })), - sprints: this.readAll( - `select id, company_id, name, goal, start_date, end_date, status, retrospective_notes, velocity_points, completed_points, created_at, updated_at from sprints${companyWhere} order by start_date desc`, - ...companyArgs, - ).map((r) => ({ - id: r.id, companyId: r.company_id, name: r.name, goal: r.goal, - startDate: r.start_date, endDate: r.end_date, status: r.status, - retrospectiveNotes: r.retrospective_notes, velocityPoints: r.velocity_points, - completedPoints: r.completed_points, createdAt: r.created_at, updatedAt: r.updated_at, - })), - automationRules: this.readAll( - `select id, company_id, name, description, trigger, conditions_json, action, action_config_json, source_department, target_department, priority, status, execution_count, last_executed_at, created_at, updated_at from automation_rules${companyWhere} order by priority desc, created_at desc`, - ...companyArgs, - ).map((r) => ({ - id: r.id, companyId: r.company_id, name: r.name, description: r.description, - trigger: r.trigger, conditionsJson: r.conditions_json, action: r.action, - actionConfigJson: r.action_config_json, sourceDepartment: r.source_department, - targetDepartment: r.target_department, priority: r.priority, status: r.status, - executionCount: r.execution_count, lastExecutedAt: r.last_executed_at, - createdAt: r.created_at, updatedAt: r.updated_at, - })), - agentMessages: this.readAll( - `select id, company_id, from_agent_id, to_agent_id, channel, channel_target_id, subject, body, priority, read_at, parent_message_id, attachments_json, created_at from agent_messages${companyWhere} order by created_at desc${companyId ? "" : " limit 500"}`, - ...companyArgs, - ).map((r) => ({ - id: r.id, companyId: r.company_id, fromAgentId: r.from_agent_id, - toAgentId: r.to_agent_id, channel: r.channel, channelTargetId: r.channel_target_id, - subject: r.subject, body: r.body, priority: r.priority, readAt: boardReadReceiptMap.get(r.id) ?? null, - parentMessageId: r.parent_message_id, attachmentsJson: r.attachments_json, - createdAt: r.created_at, - })), - workflows: this.readAll( - `select id, company_id, name, description, steps_json, trigger_type, trigger_config_json, status, current_step_index, run_count, last_run_at, created_at, updated_at from workflow_pipelines${companyWhere} order by created_at desc`, - ...companyArgs, - ).map((r) => ({ - id: r.id, companyId: r.company_id, name: r.name, description: r.description, - stepsJson: r.steps_json, triggerType: r.trigger_type, triggerConfigJson: r.trigger_config_json, - status: r.status, currentStepIndex: r.current_step_index, runCount: r.run_count, - lastRunAt: r.last_run_at, createdAt: r.created_at, updatedAt: r.updated_at, - })), - }; - } - - listRendererSnapshot(): ProfileSnapshot { - const currentCompanyId = this.getCurrentCompanyId(); - if (!currentCompanyId) { - return this.listSnapshot(); - } - const snapshot = this.listSnapshot({ companyId: currentCompanyId }); - const companyId = snapshot.currentCompanyId; - if (!companyId) { - return snapshot; - } - return snapshot; - } - - saveCompany(input: { id?: string | null; name: string; description: string; status: string; autoApproveHires?: boolean; reviewDeliverables?: boolean }) { - const id = ensureId(input.id ?? null); - const ts = nowIso(); - const autoApprove = input.autoApproveHires ? 1 : 0; - const reviewDeliverables = input.reviewDeliverables ? 1 : 0; - const existing = this.db.prepare("select id from companies where id = ?").get(id) as { id: string } | undefined; - if (existing) { - this.db - .prepare("update companies set name = ?, description = ?, status = ?, auto_approve_hires = ?, review_deliverables = ?, updated_at = ? where id = ?") - .run(input.name, input.description, input.status, autoApprove, reviewDeliverables, ts, id); - } else { - this.db - .prepare("insert into companies (id, name, description, status, auto_approve_hires, review_deliverables, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?)") - .run(id, input.name, input.description, input.status, autoApprove, reviewDeliverables, ts, ts); - if (!this.getCurrentCompanyId()) { - this.setCurrentCompany(id); - } - } - return id; - } - - saveConnector(input: { - id: ConnectorId; - command: string; - model: string | null; - envBindingText: string; - notes: string; - }) { - this.db - .prepare( - "update connectors set command = ?, model = ?, env_binding_text = ?, notes = ?, updated_at = ? where id = ?", - ) - .run(input.command, input.model, input.envBindingText, input.notes, nowIso(), input.id); - } - - updateConnectorHealth(input: { - id: ConnectorId; - status: string; - authState: string; - version: string | null; - lastError: string | null; - }) { - this.db - .prepare( - "update connectors set status = ?, auth_state = ?, version = ?, last_checked_at = ?, last_error = ?, updated_at = ? where id = ?", - ) - .run(input.status, input.authState, input.version, nowIso(), input.lastError, nowIso(), input.id); - } - - saveWorkspace(input: { - id?: string | null; - companyId: string; - name: string; - localPath: string; - repoUrl: string; - repoRef: string; - projectId?: string | null; - isPrimary: boolean; - }) { - const id = ensureId(input.id ?? null); - const ts = nowIso(); - if (input.isPrimary) { - this.db.prepare("update workspaces set is_primary = 0 where company_id = ?").run(input.companyId); - } - const existing = this.db.prepare("select id from workspaces where id = ? and company_id = ?").get(id, input.companyId) as { id: string } | undefined; - if (existing) { - this.db - .prepare( - "update workspaces set name = ?, local_path = ?, repo_url = ?, repo_ref = ?, project_id = ?, is_primary = ?, updated_at = ? where id = ? and company_id = ?", - ) - .run(input.name, input.localPath, input.repoUrl, input.repoRef, normalizeOptionalId(input.projectId), input.isPrimary ? 1 : 0, ts, id, input.companyId); - } else { - this.db - .prepare( - "insert into workspaces (id, company_id, name, local_path, repo_url, repo_ref, project_id, state, is_primary, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, 'ready', ?, ?, ?)", - ) - .run(id, input.companyId, input.name, input.localPath, input.repoUrl, input.repoRef, normalizeOptionalId(input.projectId), input.isPrimary ? 1 : 0, ts, ts); - } - return id; - } - - saveAgent(input: { - id?: string | null; - companyId: string; - name: string; - role: string; - title: string; - department?: string | null; - status: AgentRecord["status"]; - reportsTo?: string | null; - connectorId: ConnectorId; - workspaceId?: string | null; - model?: string | null; - metadataJson?: string; - capabilities: string; - budgetMonthlyUsd: number; - }) { - const id = ensureId(input.id ?? null); - const ts = nowIso(); - const metadataJson = input.metadataJson ?? "{}"; - const department = input.department ?? null; - const reportsTo = normalizeOptionalId(input.reportsTo); - if (this.wouldCreateAgentCycle(id, reportsTo)) { - throw new Error("Agent reporting relationship would create a cycle."); - } - const existing = this.db.prepare("select id from agents where id = ? and company_id = ?").get(id, input.companyId) as { id: string } | undefined; - if (existing) { - // Snapshot before-config for revision tracking - const beforeAgent = this.db.prepare( - "SELECT name, role, title, department, capabilities, connector_id, model, budget_monthly_usd, metadata_json FROM agents WHERE id = ? AND company_id = ?" - ).get(id, input.companyId) as Record | undefined; - this.db - .prepare( - "update agents set name = ?, role = ?, title = ?, department = ?, status = ?, reports_to = ?, connector_id = ?, workspace_id = ?, model = ?, metadata_json = ?, capabilities = ?, budget_monthly_usd = ?, updated_at = ? where id = ? and company_id = ?", - ) - .run( - input.name, - input.role, - input.title, - department, - input.status, - reportsTo, - input.connectorId, - normalizeOptionalId(input.workspaceId), - normalizeOptionalId(input.model), - metadataJson, - input.capabilities, - input.budgetMonthlyUsd, - ts, - id, - input.companyId, - ); - // Record config revision if fields changed - if (beforeAgent) { - const afterConfig = { name: input.name, role: input.role, title: input.title, department, capabilities: input.capabilities, connectorId: input.connectorId, model: normalizeOptionalId(input.model), budgetMonthlyUsd: input.budgetMonthlyUsd, metadataJson }; - const beforeConfig = { name: beforeAgent.name, role: beforeAgent.role, title: beforeAgent.title, department: beforeAgent.department, capabilities: beforeAgent.capabilities, connectorId: beforeAgent.connector_id, model: beforeAgent.model, budgetMonthlyUsd: beforeAgent.budget_monthly_usd, metadataJson: beforeAgent.metadata_json }; - const changedKeys = Object.keys(afterConfig).filter(k => JSON.stringify((afterConfig as any)[k]) !== JSON.stringify((beforeConfig as any)[k])); - if (changedKeys.length > 0) { - try { - this.recordConfigRevision({ companyId: input.companyId, agentId: id, changedKeys, configBefore: beforeConfig, configAfter: afterConfig }); - } catch { /* Non-fatal: revision recording should never block agent save */ } - } - } - } else { - this.db - .prepare( - "insert into agents (id, company_id, name, role, title, department, status, reports_to, connector_id, workspace_id, model, capabilities, budget_monthly_usd, spent_monthly_usd, metadata_json, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)", - ) - .run( - id, - input.companyId, - input.name, - input.role, - input.title, - department, - input.status, - reportsTo, - input.connectorId, - normalizeOptionalId(input.workspaceId), - normalizeOptionalId(input.model), - input.capabilities, - input.budgetMonthlyUsd, - metadataJson, - ts, - ts, - ); - } - return id; - } - - createHireRequest(input: { - companyId: string; - requestedByAgentId?: string | null; - name: string; - role: string; - title: string; - department?: string | null; - reportsTo?: string | null; - connectorId: ConnectorId; - workspaceId?: string | null; - model?: string | null; - metadataJson?: string; - capabilities: string; - budgetMonthlyUsd: number; - payloadSummary: string; - impactSummary: string; - payloadJson?: string | null; - }) { - return this.transaction(() => { - const agentId = this.saveAgent({ - companyId: input.companyId, - name: input.name, - role: input.role, - title: input.title, - department: input.department ?? null, - status: "pending_approval", - reportsTo: input.reportsTo ?? null, - connectorId: input.connectorId, - workspaceId: input.workspaceId ?? null, - model: input.model ?? null, - metadataJson: input.metadataJson ?? "{}", - capabilities: input.capabilities, - budgetMonthlyUsd: input.budgetMonthlyUsd, - }); - const approvalId = this.requestApproval({ - companyId: input.companyId, - requestedByAgentId: input.requestedByAgentId ?? null, - relatedAgentId: agentId, - type: "hire_agent", - payloadSummary: input.payloadSummary, - impactSummary: input.impactSummary, - payloadJson: input.payloadJson ?? null, - }); - return { agentId, approvalId }; - }); - } - - saveGoal(input: { id?: string | null; companyId: string; title: string; description: string; parentId?: string | null; ownerAgentId?: string | null; status: string }) { - const id = ensureId(input.id ?? null); - const ts = nowIso(); - const existing = this.db.prepare("select id from goals where id = ? and company_id = ?").get(id, input.companyId) as { id: string } | undefined; - if (existing) { - this.db - .prepare( - "update goals set title = ?, description = ?, parent_id = ?, owner_agent_id = ?, status = ?, updated_at = ? where id = ? and company_id = ?", - ) - .run(input.title, input.description, normalizeOptionalId(input.parentId), normalizeOptionalId(input.ownerAgentId), input.status, ts, id, input.companyId); - } else { - this.db - .prepare( - "insert into goals (id, company_id, title, description, parent_id, owner_agent_id, status, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?)", - ) - .run(id, input.companyId, input.title, input.description, normalizeOptionalId(input.parentId), normalizeOptionalId(input.ownerAgentId), input.status, ts, ts); - } - return id; - } - - saveProject(input: { id?: string | null; companyId: string; goalId?: string | null; name: string; description: string; leadAgentId?: string | null; targetDate?: string | null; status: string }) { - const id = ensureId(input.id ?? null); - const ts = nowIso(); - const existing = this.db.prepare("select id from projects where id = ? and company_id = ?").get(id, input.companyId) as { id: string } | undefined; - if (existing) { - this.db - .prepare( - "update projects set goal_id = ?, name = ?, description = ?, lead_agent_id = ?, status = ?, target_date = ?, updated_at = ? where id = ? and company_id = ?", - ) - .run(normalizeOptionalId(input.goalId), input.name, input.description, normalizeOptionalId(input.leadAgentId), input.status, normalizeOptionalId(input.targetDate), ts, id, input.companyId); - } else { - this.db - .prepare( - "insert into projects (id, company_id, goal_id, name, description, lead_agent_id, status, target_date, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ) - .run(id, input.companyId, normalizeOptionalId(input.goalId), input.name, input.description, normalizeOptionalId(input.leadAgentId), input.status, normalizeOptionalId(input.targetDate), ts, ts); - } - return id; - } - - saveTask(input: { - id?: string | null; - companyId: string; - projectId?: string | null; - goalId?: string | null; - parentId?: string | null; - title: string; - description: string; - assigneeAgentId?: string | null; - workspaceId?: string | null; - priority: string; - status: string; - }) { - const id = ensureId(input.id ?? null); - const ts = nowIso(); - const assigneeAgentId = normalizeOptionalId(input.assigneeAgentId); - if (input.status === "in_progress" && !assigneeAgentId) { - throw new Error("Tasks in progress require an assignee."); - } - const existing = this.db.prepare("select id from tasks where id = ? and company_id = ?").get(id, input.companyId) as { id: string } | undefined; - if (existing) { - this.db - .prepare( - "update tasks set project_id = ?, goal_id = ?, parent_id = ?, title = ?, description = ?, assignee_agent_id = ?, workspace_id = ?, priority = ?, status = ?, updated_at = ? where id = ? and company_id = ?", - ) - .run(normalizeOptionalId(input.projectId), normalizeOptionalId(input.goalId), normalizeOptionalId(input.parentId), input.title, input.description, assigneeAgentId, normalizeOptionalId(input.workspaceId), input.priority, input.status, ts, id, input.companyId); - } else { - this.db - .prepare( - "insert into tasks (id, company_id, project_id, goal_id, parent_id, title, description, assignee_agent_id, workspace_id, status, priority, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ) - .run(id, input.companyId, normalizeOptionalId(input.projectId), normalizeOptionalId(input.goalId), normalizeOptionalId(input.parentId), input.title, input.description, assigneeAgentId, normalizeOptionalId(input.workspaceId), input.status, input.priority, ts, ts); - } - return id; - } - - requestApproval(input: { - companyId: string; - relatedTaskId?: string | null; - requestedByAgentId?: string | null; - relatedAgentId?: string | null; - type: string; - payloadSummary: string; - impactSummary: string; - payloadJson?: string | null; - }) { - const id = randomUUID(); - const ts = nowIso(); - let relatedAgentId = normalizeOptionalId(input.relatedAgentId); - if (!relatedAgentId && input.type === "hire_agent" && input.payloadJson) { - try { - const payload = JSON.parse(input.payloadJson) as { agentId?: string }; - relatedAgentId = payload.agentId ?? null; - } catch { /* ignore malformed payload */ } - } - if (input.type === "hire_agent") { - if (!relatedAgentId) { - throw new Error("hire_agent approvals require a linked pending agent."); - } - const agent = this.getAgent(relatedAgentId); - if (agent.companyId !== input.companyId) { - throw new Error(`Agent ${relatedAgentId} does not belong to company ${input.companyId}.`); - } - if (agent.status !== "pending_approval") { - throw new Error(`hire_agent approvals can only target agents pending approval. Current status: ${agent.status}.`); - } - } - this.db - .prepare( - "insert into approvals (id, company_id, related_task_id, requested_by_agent_id, related_agent_id, type, payload_summary, impact_summary, payload_json, decision_note, state, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, '', 'pending', ?, ?)", - ) - .run( - id, - input.companyId, - normalizeOptionalId(input.relatedTaskId), - normalizeOptionalId(input.requestedByAgentId), - relatedAgentId, - input.type, - input.payloadSummary, - input.impactSummary, - input.payloadJson ?? null, - ts, - ts, - ); - return id; - } - - decideApproval(input: { companyId?: string | null; approvalId: string; state: string; decisionNote: string }) { - const approval = this.getApprovalRecord(input.approvalId); - if (input.companyId && approval.companyId !== input.companyId) { - throw new Error(`Approval ${input.approvalId} does not belong to company ${input.companyId}.`); - } - if (!["pending", "revision_requested"].includes(approval.state)) { - throw new Error(`Approval ${input.approvalId} is already ${approval.state} and cannot be re-decided.`); - } - const ts = nowIso(); - this.transaction(() => { - this.db - .prepare("update approvals set state = ?, decision_note = ?, updated_at = ? where id = ?") - .run(input.state, input.decisionNote, ts, input.approvalId); - if (approval.type === "hire_agent") { - const agentId = approval.relatedAgentId - ?? parseJson<{ agentId?: string } | null>(approval.payloadJson, null)?.agentId - ?? null; - if (!agentId) { - throw new Error(`hire_agent approval ${input.approvalId} has no linked agent — cannot resolve hire target. Ensure relatedAgentId or payloadJson.agentId is set.`); - } - if (input.state === "approved") { - const result = this.db - .prepare("update agents set status = 'idle', updated_at = ? where id = ? and company_id = ? and status = 'pending_approval'") - .run(ts, agentId, approval.companyId); - if (result.changes === 0) { - throw new Error(`hire_agent approval ${input.approvalId} could not activate agent ${agentId}; the agent is missing or no longer pending approval.`); - } - } - if (input.state === "rejected") { - const result = this.db - .prepare("update agents set status = 'terminated', updated_at = ? where id = ? and company_id = ? and status = 'pending_approval'") - .run(ts, agentId, approval.companyId); - if (result.changes === 0) { - throw new Error(`hire_agent approval ${input.approvalId} could not reject agent ${agentId}; the agent is missing or no longer pending approval.`); - } - } - } - }); - } - - saveSecret(input: { id?: string | null; companyId: string; name: string; description: string; value: string }) { - const id = ensureId(input.id ?? null); - const encrypted = this.vault.encrypt(input.value); - const ts = nowIso(); - const existing = this.db.prepare("select id, company_id as companyId from secrets where id = ?").get(id) as - | { id: string; companyId: string } - | undefined; - if (existing) { - if (existing.companyId !== input.companyId) { - throw new Error("Secrets cannot be moved across companies."); - } - this.db - .prepare("update secrets set name = ?, description = ?, backend = ?, ciphertext = ?, updated_at = ? where id = ? and company_id = ?") - .run(input.name, input.description, encrypted.backend, encrypted.payload, ts, id, input.companyId); - } else { - this.db - .prepare("insert into secrets (id, company_id, name, description, backend, ciphertext, updated_at, created_at) values (?, ?, ?, ?, ?, ?, ?, ?)") - .run(id, input.companyId, input.name, input.description, encrypted.backend, encrypted.payload, ts, ts); - } - return id; - } - - resolveSecretEnv(bindingText: string, companyId: string): Record { - const invalidKeys = new Set(listInvalidEnvBindingKeys(bindingText)); - const bindings = parseEnvBindingText(bindingText); - const result: Record = {}; - for (const [envKey, rawValue] of Object.entries(bindings)) { - if (invalidKeys.has(envKey)) { - continue; - } - // First try to resolve as a secret name from the vault. - const row = this.db.prepare("select backend, ciphertext from secrets where company_id = ? and name = ?").get(companyId, rawValue) as - | { backend: "safe_storage" | "vault"; ciphertext: Uint8Array } - | undefined; - if (row) { - result[envKey] = this.vault.decrypt(row.backend, Buffer.from(row.ciphertext)); - } else { - // No matching secret — use the value literally. - // This allows direct env bindings like ANTHROPIC_API_KEY=sk-ant-xxx - // without requiring a stored secret entry. - result[envKey] = rawValue; - } - } - return result; - } - - getConnector(id: ConnectorId): ConnectorRecord { - const record = this.readConnectors().find((entry) => entry.id === id); - if (!record) throw new Error(`Connector not found: ${id}`); - return record; - } - - getTask(id: string): TaskRecord { - const row = this.db - .prepare( - "select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, requires_user_review as requiresUserReview, started_at as startedAt, completed_at as completedAt, cancelled_at as cancelledAt, created_at as createdAt, updated_at as updatedAt from tasks where id = ?", - ) - .get(id) as TaskRecord | undefined; - if (!row) throw new Error(`Task not found: ${id}`); - row.requiresUserReview = Boolean(row.requiresUserReview); - row.startedAt = row.startedAt ?? null; - row.completedAt = row.completedAt ?? null; - row.cancelledAt = row.cancelledAt ?? null; - return row; - } - - getApprovalRecord(id: string) { - const row = this.db - .prepare( - "select id, company_id as companyId, related_task_id as relatedTaskId, requested_by_agent_id as requestedByAgentId, related_agent_id as relatedAgentId, type, payload_summary as payloadSummary, impact_summary as impactSummary, payload_json as payloadJson, decision_note as decisionNote, state, created_at as createdAt, updated_at as updatedAt from approvals where id = ?", - ) - .get(id) as (ApprovalRecord & { payloadJson: string | null; relatedAgentId: string | null }) | undefined; - if (!row) throw new Error(`Approval not found: ${id}`); - return row; - } - - getAgent(id: string): AgentRecord { - const row = this.db - .prepare( - "select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, created_at as createdAt, updated_at as updatedAt from agents where id = ?", - ) - .get(id) as AgentRecord | undefined; - if (!row) throw new Error(`Agent not found: ${id}`); - return row; - } - - getWorkspace(id: string | null | undefined): WorkspaceRecord | null { - if (!id) return null; - const row = this.db - .prepare( - "select id, company_id as companyId, name, local_path as localPath, repo_url as repoUrl, repo_ref as repoRef, project_id as projectId, state, is_primary as isPrimary, created_at as createdAt, updated_at as updatedAt from workspaces where id = ?", - ) - .get(id) as WorkspaceRecord | undefined; - return row ? { ...row, isPrimary: toBoolean(row.isPrimary) } : null; - } - - getProjectPrimaryWorkspace(projectId: string | null | undefined, companyId: string): WorkspaceRecord | null { - if (!projectId) { - return null; - } - - const row = this.db - .prepare( - "select id, company_id as companyId, name, local_path as localPath, repo_url as repoUrl, repo_ref as repoRef, project_id as projectId, state, is_primary as isPrimary, created_at as createdAt, updated_at as updatedAt from workspaces where company_id = ? and project_id = ? order by is_primary desc, created_at asc limit 1", - ) - .get(companyId, projectId) as WorkspaceRecord | undefined; - return row ? { ...row, isPrimary: toBoolean(row.isPrimary) } : null; - } - - listWorkspaceLocalPaths(companyId: string): string[] { - const rows = this.db - .prepare("select local_path as localPath from workspaces where company_id = ? and local_path is not null") - .all(companyId) as Array<{ localPath: string }>; - return rows.map((r) => r.localPath).filter(Boolean); - } - - getAnyCompanyWorkspace(companyId: string): WorkspaceRecord | null { - const row = this.db - .prepare( - "select id, company_id as companyId, name, local_path as localPath, repo_url as repoUrl, repo_ref as repoRef, project_id as projectId, state, is_primary as isPrimary, created_at as createdAt, updated_at as updatedAt from workspaces where company_id = ? order by is_primary desc, created_at asc limit 1", - ) - .get(companyId) as WorkspaceRecord | undefined; - return row ? { ...row, isPrimary: toBoolean(row.isPrimary) } : null; - } - - getRun(id: string): RunRecord { - const row = this.db - .prepare( - "select id, company_id as companyId, task_id as taskId, agent_id as agentId, workspace_id as workspaceId, connector_id as connectorId, status, summary, error_message as errorMessage, exit_code as exitCode, signal, model, session_display_id as sessionDisplayId, cost_usd as costUsd, unattributed_cost as unattributedCost, started_at as startedAt, finished_at as finishedAt, log_path as logPath, created_at as createdAt, updated_at as updatedAt from runs where id = ?", - ) - .get(id) as RunRecord | undefined; - if (!row) throw new Error(`Run not found: ${id}`); - row.unattributedCost = Boolean(row.unattributedCost); - return row; - } - - getRunAuthState(runId: string): { id: string; companyId: string; agentId: string; status: RunRecord["status"] } | null { - const row = this.db - .prepare("select id, company_id as companyId, agent_id as agentId, status from runs where id = ?") - .get(runId) as { id: string; companyId: string; agentId: string; status: RunRecord["status"] } | undefined; - return row ?? null; - } - - createRun(input: { - id?: string; - companyId: string; - taskId: string; - agentId: string; - workspaceId: string | null; - connectorId: ConnectorId; - }) { - const id = input.id ?? randomUUID(); - const ts = nowIso(); - const logPath = join(this.logsDir, `${id}.log`); - this.transaction(() => { - this.db - .prepare( - "insert into runs (id, company_id, task_id, agent_id, workspace_id, connector_id, status, summary, unattributed_cost, log_path, created_at, updated_at) values (?, ?, ?, ?, ?, ?, 'queued', '', 0, ?, ?, ?)", - ) - .run(id, input.companyId, input.taskId, input.agentId, input.workspaceId, input.connectorId, logPath, ts, ts); - this.db.prepare("update tasks set active_run_id = ?, updated_at = ? where id = ?").run(id, ts, input.taskId); - }); - writeFileSync(logPath, "", "utf8"); - return { id, logPath }; - } - - appendRunLog(runId: string, stream: "stdout" | "stderr", chunk: string) { - const run = this.db.prepare("select log_path as logPath from runs where id = ?").get(runId) as { logPath: string | null } | undefined; - if (!run?.logPath) return; - return appendFile(run.logPath, `[${new Date().toISOString()}][${stream}] ${chunk}`, "utf8"); - } - - readRunLog(runId: string) { - const run = this.db.prepare("select log_path as logPath from runs where id = ?").get(runId) as { logPath: string | null } | undefined; - if (!run?.logPath) return ""; - try { - return readFileSync(run.logPath, "utf8"); - } catch { - return ""; - } - } - - readRunLogChunk(runId: string, offset = 0, limit = 65_536): RunLogChunk { - const run = this.db.prepare("select log_path as logPath from runs where id = ?").get(runId) as { logPath: string | null } | undefined; - if (!run?.logPath || !existsSync(run.logPath)) { - return { - content: "", - offset: 0, - nextOffset: 0, - totalBytes: 0, - eof: true, - }; - } - - const totalBytes = statSync(run.logPath).size; - const safeOffset = Math.max(0, Math.min(offset, totalBytes)); - const safeLimit = Math.max(1_024, Math.min(limit, 262_144)); - const bytesToRead = Math.max(0, Math.min(safeLimit, totalBytes - safeOffset)); - - if (bytesToRead === 0) { - return { - content: "", - offset: safeOffset, - nextOffset: safeOffset, - totalBytes, - eof: true, - }; - } - - const fileDescriptor = openSync(run.logPath, "r"); - try { - const buffer = Buffer.allocUnsafe(bytesToRead); - const bytesRead = readSync(fileDescriptor, buffer, 0, bytesToRead, safeOffset); - // Find the last complete UTF-8 character boundary to avoid corrupting - // multi-byte characters (e.g. Chinese/CJK 3-byte sequences) at the edge. - const safeBytesRead = findUtf8Boundary(buffer, bytesRead); - const nextOffset = safeOffset + safeBytesRead; - return { - content: buffer.subarray(0, safeBytesRead).toString("utf8"), - offset: safeOffset, - nextOffset, - totalBytes, - eof: nextOffset >= totalBytes, - }; - } finally { - closeSync(fileDescriptor); - } - } - - markRunStatus(runId: string, status: RunRecord["status"], message: string) { - const ts = nowIso(); - const startedAt = status === "running" ? ts : null; - this.transaction(() => { - this.db - .prepare( - "update runs set status = ?, summary = case when ? != '' then ? else summary end, started_at = coalesce(started_at, ?), updated_at = ? where id = ?", - ) - .run(status, message, message, startedAt, ts, runId); - - if (status === "running") { - const run = this.db.prepare("select agent_id from runs where id = ?").get(runId) as { agent_id: string } | undefined; - if (run) { - this.db - .prepare("update agents set status = 'running', updated_at = ? where id = ? and status in ('idle', 'active')") - .run(ts, run.agent_id); - } - } - }); - } - - finishRun(input: { - runId: string; - status: RunRecord["status"]; - summary: string | null; - errorMessage: string | null; - exitCode: number | null; - signal: string | null; - model: string | null; - sessionDisplayId: string | null; - costUsd: number | null; - unattributedCost: boolean; - }) { - const run = this.db.prepare("select task_id as taskId, agent_id as agentId, company_id as companyId, connector_id as connectorId from runs where id = ?").get(input.runId) as - | { taskId: string; agentId: string; companyId: string; connectorId: ConnectorId } - | undefined; - if (!run) return { budgetHardStopped: false }; - const ts = nowIso(); - const agent = this.db.prepare( - "select budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, status from agents where id = ?", - ).get(run.agentId) as { budgetMonthlyUsd: number; spentMonthlyUsd: number; status: AgentRecord["status"] } | undefined; - const nextSpend = - agent && typeof input.costUsd === "number" && Number.isFinite(input.costUsd) - ? agent.spentMonthlyUsd + input.costUsd - : agent?.spentMonthlyUsd ?? 0; - const budgetHardStopped = Boolean( - agent && - agent.budgetMonthlyUsd > 0 && - nextSpend >= agent.budgetMonthlyUsd && - agent.status !== "paused" && - agent.status !== "terminated", - ); - this.transaction(() => { - this.db - .prepare( - "update runs set status = ?, summary = ?, error_message = ?, exit_code = ?, signal = ?, model = ?, session_display_id = ?, cost_usd = ?, unattributed_cost = ?, finished_at = ?, updated_at = ? where id = ?", - ) - .run( - input.status, - input.summary ?? "", - input.errorMessage, - input.exitCode, - input.signal, - input.model, - input.sessionDisplayId, - input.costUsd, - input.unattributedCost ? 1 : 0, - ts, - ts, - input.runId, - ); - this.db.prepare("update tasks set active_run_id = null, updated_at = ? where id = ?").run(ts, run.taskId); - this.db - .prepare("UPDATE tasks SET checkout_run_id = NULL, execution_locked_at = NULL, updated_at = ? WHERE id = ? AND checkout_run_id = ?") - .run(ts, run.taskId, input.runId); - if (typeof input.costUsd === "number" && Number.isFinite(input.costUsd)) { - this.db - .prepare("update tasks set cost_usd = cost_usd + ?, updated_at = ? where id = ?") - .run(input.costUsd, ts, run.taskId); - this.db - .prepare("update agents set spent_monthly_usd = spent_monthly_usd + ?, updated_at = ? where id = ?") - .run(input.costUsd, ts, run.agentId); - this.db - .prepare( - "update agents set status = case when budget_monthly_usd > 0 and (spent_monthly_usd + ?) >= budget_monthly_usd then 'paused' else status end, updated_at = ? where id = ?", - ) - .run(input.costUsd, ts, run.agentId); - } - // Look up task's project_id for cost attribution - const taskProjectRow = this.db.prepare("SELECT project_id FROM tasks WHERE id = ?").get(run.taskId) as { project_id: string | null } | undefined; - const projectId = taskProjectRow?.project_id ?? null; - this.db - .prepare("insert into cost_entries (id, company_id, run_id, task_id, agent_id, connector_id, amount_usd, unattributed, project_id, created_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") - .run(randomUUID(), run.companyId, input.runId, run.taskId, run.agentId, run.connectorId, input.costUsd, input.unattributedCost ? 1 : 0, projectId, ts); - if (budgetHardStopped) { - this.db - .prepare("update agents set status = 'paused', updated_at = ? where id = ?") - .run(ts, run.agentId); - } else { - // Reset agent status from "running" back to "idle" unless paused/terminated/pending_approval - this.db - .prepare("update agents set status = 'idle', updated_at = ? where id = ? and status = 'running'") - .run(ts, run.agentId); - } - }); - return { budgetHardStopped }; - } - - private cancelRunForDeletion(runId: string, taskId: string, workspaceId: string | null, summary: string) { - const ts = nowIso(); - this.db - .prepare( - "update runs set status = 'cancelled', summary = ?, error_message = null, exit_code = null, signal = null, model = null, session_display_id = null, cost_usd = null, unattributed_cost = 1, finished_at = ?, updated_at = ? where id = ?", - ) - .run(summary, ts, ts, runId); - this.db.prepare("update tasks set active_run_id = null, updated_at = ? where id = ?").run(ts, taskId); - this.db - .prepare("UPDATE tasks SET checkout_run_id = NULL, execution_locked_at = NULL, updated_at = ? WHERE id = ? AND checkout_run_id = ?") - .run(ts, taskId, runId); - if (workspaceId) { - this.db - .prepare( - `update workspaces - set lock_holder_run_id = null, lock_acquired_at = null, state = 'ready', updated_at = ? - where id = ? and lock_holder_run_id = ?`, - ) - .run(ts, workspaceId, runId); - } - } - - updateTaskSession(taskId: string, sessionJson: Record | null, sessionDisplayId: string | null) { - this.db - .prepare("update tasks set session_json = ?, session_display_id = ?, updated_at = ? where id = ?") - .run(sessionJson ? JSON.stringify(sessionJson) : null, sessionDisplayId, nowIso(), taskId); - } - - getTaskSession(taskId: string) { - const row = this.db.prepare("select session_json as sessionJson, session_display_id as sessionDisplayId from tasks where id = ?").get(taskId) as - | { sessionJson: string | null; sessionDisplayId: string | null } - | undefined; - return { - sessionParams: parseJson | null>(row?.sessionJson, null), - sessionDisplayId: row?.sessionDisplayId ?? null, - }; - } - - // --------------------------------------------------------------------------- - // task_sessions CRUD — session management (Plan 06-01) - // --------------------------------------------------------------------------- - - getActiveTaskSession(taskId: string): TaskSessionRecord | null { - const task = this.db.prepare( - "SELECT active_session_id FROM tasks WHERE id = ?" - ).get(taskId) as { active_session_id: string | null } | undefined; - if (!task?.active_session_id) return null; - return this.db.prepare( - `SELECT id, company_id as companyId, task_id as taskId, agent_id as agentId, - adapter_type as adapterType, session_params_json as sessionParamsJson, - session_display_id as sessionDisplayId, last_run_id as lastRunId, - last_error as lastError, run_count as runCount, - total_input_tokens as totalInputTokens, total_output_tokens as totalOutputTokens, - rotated_at as rotatedAt, rotation_reason as rotationReason, - handoff_markdown as handoffMarkdown, created_at as createdAt, updated_at as updatedAt - FROM task_sessions WHERE id = ?` - ).get(task.active_session_id) as TaskSessionRecord | undefined ?? null; - } - - incrementSessionUsage(sessionId: string, inputTokens: number, outputTokens: number, runId: string): void { - const ts = nowIso(); - this.db.prepare( - `UPDATE task_sessions - SET run_count = run_count + 1, - total_input_tokens = total_input_tokens + ?, - total_output_tokens = total_output_tokens + ?, - last_run_id = ?, - updated_at = ? - WHERE id = ?` - ).run(inputTokens, outputTokens, runId, ts, sessionId); - } - - archiveSession(sessionId: string, reason: string, handoffMarkdown: string): void { - const ts = nowIso(); - this.db.prepare( - `UPDATE task_sessions SET rotated_at = ?, rotation_reason = ?, handoff_markdown = ?, updated_at = ? WHERE id = ?` - ).run(ts, reason, handoffMarkdown, ts, sessionId); - } - - createNewSession(input: { - taskId: string; - companyId: string; - agentId: string; - adapterType: string; - sessionParamsJson: string | null; - sessionDisplayId: string | null; - }): string { - const sessionId = randomUUID(); - const ts = nowIso(); - this.db.prepare( - `INSERT INTO task_sessions (id, company_id, task_id, agent_id, adapter_type, - session_params_json, session_display_id, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` - ).run( - sessionId, input.companyId, input.taskId, input.agentId, input.adapterType, - input.sessionParamsJson, input.sessionDisplayId, ts, ts - ); - this.db.prepare("UPDATE tasks SET active_session_id = ?, updated_at = ? WHERE id = ?") - .run(sessionId, ts, input.taskId); - return sessionId; - } - - getRecentRunsForTask(taskId: string, limit: number): Array<{ - id: string; - status: string; - summary: string | null; - errorMessage: string | null; - finishedAt: string | null; - connectorId: string; - }> { - return this.readAll( - `SELECT id, status, summary, error_message as errorMessage, - finished_at as finishedAt, connector_id as connectorId - FROM runs WHERE task_id = ? ORDER BY finished_at DESC LIMIT ?`, - taskId, limit - ); - } - - getRecentActivityForTask(taskId: string, limit: number): Array<{ - action: string; - detail: string; - createdAt: string; - }> { - return this.readAll( - `SELECT action, detail, created_at as createdAt - FROM activity WHERE entity_type = 'task' AND entity_id = ? - ORDER BY created_at DESC LIMIT ?`, - taskId, limit - ); - } - - addActivity(input: { companyId: string; actor: string; action: string; entityType: string; entityId: string; detail: string }) { - this.db - .prepare("insert into activity (id, company_id, actor, action, entity_type, entity_id, detail, created_at) values (?, ?, ?, ?, ?, ?, ?, ?)") - .run(randomUUID(), input.companyId, input.actor, input.action, input.entityType, input.entityId, input.detail, nowIso()); - } - - deleteCompany(companyId: string) { - this.transaction(() => { - try { - this.db.prepare( - "delete from agent_message_reads where message_id in (select id from agent_messages where company_id = ?)", - ).run(companyId); - } catch (err) { - if (!(err instanceof Error) || !err.message.includes("no such table")) throw err; - } - // New table cleanup (budget_incidents before budget_policies due to FK) - this.db.prepare("delete from budget_incidents where company_id = ?").run(companyId); - this.db.prepare("delete from budget_policies where company_id = ?").run(companyId); - // agent_skill_assignments references company_skills -- delete before skills - try { - this.db.prepare( - "delete from agent_skill_assignments where skill_id in (select id from company_skills where company_id = ?)", - ).run(companyId); - } catch (err) { - if (!(err instanceof Error) || !err.message.includes("no such table")) throw err; - } - this.db.prepare("delete from company_skills where company_id = ?").run(companyId); - this.db.prepare("delete from task_sessions where company_id = ?").run(companyId); - this.db.prepare("delete from wakeup_requests where company_id = ?").run(companyId); - this.db.prepare("delete from cost_entries where company_id = ?").run(companyId); - this.db.prepare("delete from activity where company_id = ?").run(companyId); - this.db.prepare("delete from task_comments where company_id = ?").run(companyId); - this.db.prepare("delete from runs where company_id = ?").run(companyId); - this.db.prepare("delete from approvals where company_id = ?").run(companyId); - this.db.prepare("delete from tasks where company_id = ?").run(companyId); - this.db.prepare("delete from projects where company_id = ?").run(companyId); - this.db.prepare("delete from goals where company_id = ?").run(companyId); - this.db.prepare("delete from agents where company_id = ?").run(companyId); - this.db.prepare("delete from secrets where company_id = ?").run(companyId); - this.db.prepare("delete from workspaces where company_id = ?").run(companyId); - for (const table of ["automation_rules", "automation_log", "agent_messages", "workflow_pipelines", "meetings", "documents", "knowledge_base", "sprints", "browser_actions", "social_accounts"]) { - try { - this.db.prepare(`delete from ${table} where company_id = ?`).run(companyId); - } catch (err) { - // Only tolerate "no such table" — re-throw anything else (disk error, etc.) - if (!(err instanceof Error) || !err.message.includes("no such table")) throw err; - } - } - this.db.prepare("delete from companies where id = ?").run(companyId); - if (this.getCurrentCompanyId() === companyId) { - const next = this.db.prepare("select id from companies order by created_at asc limit 1").get() as { id: string } | undefined; - if (next) { - this.setCurrentCompany(next.id); - } else { - this.setSetting("current_company_id", ""); - } - } - }); - } - - deleteAgent(id: string, companyId: string) { - const activeAgentRuns = this.readAll<{ id: string; taskId: string; workspaceId: string | null }>( - "select id, task_id as taskId, workspace_id as workspaceId from runs where agent_id = ? and company_id = ? and status in ('queued', 'running')", - id, companyId, - ); - this.transaction(() => { - for (const run of activeAgentRuns) { - this.cancelRunForDeletion(run.id, run.taskId, run.workspaceId, "Agent deleted."); - } - this.db.prepare("update tasks set assignee_agent_id = null where assignee_agent_id = ? and company_id = ?").run(id, companyId); - this.db.prepare("update goals set owner_agent_id = null where owner_agent_id = ? and company_id = ?").run(id, companyId); - this.db.prepare("update projects set lead_agent_id = null where lead_agent_id = ? and company_id = ?").run(id, companyId); - this.db.prepare("update agents set reports_to = null where reports_to = ? and company_id = ?").run(id, companyId); - // Clean up orphaned approvals referencing this agent - this.db.prepare("update approvals set requested_by_agent_id = null where requested_by_agent_id = ? and company_id = ?").run(id, companyId); - this.db.prepare("update approvals set related_agent_id = null where related_agent_id = ? and company_id = ?").run(id, companyId); - // Clean up meetings organized by this agent - this.db.prepare("update meetings set organizer_agent_id = null where organizer_agent_id = ? and company_id = ?").run(id, companyId); - // Clean up documents authored/reviewed by this agent - this.db.prepare("update documents set author_agent_id = null where author_agent_id = ? and company_id = ?").run(id, companyId); - this.db.prepare("update documents set reviewer_agent_id = null where reviewer_agent_id = ? and company_id = ?").run(id, companyId); - // Clean up knowledge entries authored by this agent - this.db.prepare("update knowledge_base set author_agent_id = null where author_agent_id = ? and company_id = ?").run(id, companyId); - // Remove wakeup requests for this agent - this.db.prepare("DELETE FROM wakeup_requests WHERE agent_id = ? AND company_id = ?").run(id, companyId); - this.db.prepare("delete from agents where id = ? and company_id = ?").run(id, companyId); - }); - } - - deleteTask(id: string, companyId: string) { - const activeTaskRuns = this.readAll<{ id: string; taskId: string; workspaceId: string | null }>( - "select id, task_id as taskId, workspace_id as workspaceId from runs where task_id = ? and company_id = ? and status in ('queued', 'running')", - id, companyId, - ); - this.transaction(() => { - for (const run of activeTaskRuns) { - this.cancelRunForDeletion(run.id, run.taskId, run.workspaceId, "Task deleted."); - } - this.db.prepare("update tasks set parent_id = null where parent_id = ? and company_id = ?").run(id, companyId); - this.db.prepare("delete from approvals where related_task_id = ? and company_id = ?").run(id, companyId); - this.db.prepare("delete from task_comments where task_id = ? and company_id = ?").run(id, companyId); - this.db.prepare("delete from tasks where id = ? and company_id = ?").run(id, companyId); - }); - } - - deleteGoal(id: string, companyId: string) { - this.transaction(() => { - this.db.prepare("update goals set parent_id = null where parent_id = ? and company_id = ?").run(id, companyId); - this.db.prepare("update projects set goal_id = null where goal_id = ? and company_id = ?").run(id, companyId); - this.db.prepare("update tasks set goal_id = null where goal_id = ? and company_id = ?").run(id, companyId); - this.db.prepare("delete from goals where id = ? and company_id = ?").run(id, companyId); - }); - } - - deleteProject(id: string, companyId: string) { - this.transaction(() => { - this.db.prepare("update tasks set project_id = null where project_id = ? and company_id = ?").run(id, companyId); - this.db.prepare("update workspaces set project_id = null where project_id = ? and company_id = ?").run(id, companyId); - this.db.prepare("delete from projects where id = ? and company_id = ?").run(id, companyId); - }); - } - - deleteWorkspace(id: string, companyId: string) { - this.transaction(() => { - this.db.prepare("update agents set workspace_id = null where workspace_id = ? and company_id = ?").run(id, companyId); - this.db.prepare("update tasks set workspace_id = null where workspace_id = ? and company_id = ?").run(id, companyId); - this.db.prepare("delete from workspaces where id = ? and company_id = ?").run(id, companyId); - }); - } - - deleteApproval(id: string, companyId: string) { - this.db.prepare("delete from approvals where id = ? and company_id = ?").run(id, companyId); - } - - deleteSecret(id: string, companyId: string) { - this.db.prepare("delete from secrets where id = ? and company_id = ?").run(id, companyId); - } - - saveSocialAccount(input: { - id?: string | null; - companyId: string; - platform: string; - accountName: string; - displayName?: string; - profileUrl?: string; - credentialSecretId?: string | null; - status?: string; - requireApproval?: boolean; - metadataJson?: string; - }): string { - const id = ensureId(input.id); - const now = nowIso(); - this.db.prepare(`INSERT INTO social_accounts (id, company_id, platform, account_name, display_name, profile_url, credential_secret_id, status, require_approval, metadata_json, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET - platform = excluded.platform, - account_name = excluded.account_name, - display_name = excluded.display_name, - profile_url = excluded.profile_url, - credential_secret_id = excluded.credential_secret_id, - status = excluded.status, - require_approval = excluded.require_approval, - metadata_json = excluded.metadata_json, - updated_at = excluded.updated_at`).run( - id, input.companyId, input.platform, input.accountName, - input.displayName ?? "", input.profileUrl ?? "", - input.credentialSecretId ?? null, input.status ?? "login_required", - input.requireApproval !== false ? 1 : 0, - input.metadataJson ?? "{}", now, now, - ); - return id; - } - - deleteSocialAccount(id: string, companyId: string) { - this.transaction(() => { - this.db.prepare("DELETE FROM social_accounts WHERE id = ? AND company_id = ?").run(id, companyId); - this.db.prepare("DELETE FROM browser_actions WHERE social_account_id = ? AND company_id = ?").run(id, companyId); - }); - } - - listSocialAccounts(companyId: string): SocialAccountRecord[] { - const rows = this.db.prepare("SELECT * FROM social_accounts WHERE company_id = ? ORDER BY created_at DESC").all(companyId) as SqliteRow[]; - return rows.map((r) => ({ - id: r.id, - companyId: r.company_id, - platform: r.platform, - accountName: r.account_name, - displayName: r.display_name, - profileUrl: r.profile_url, - credentialSecretId: r.credential_secret_id, - status: r.status, - requireApproval: toBoolean(r.require_approval), - metadataJson: r.metadata_json, - lastUsedAt: r.last_used_at, - createdAt: r.created_at, - updatedAt: r.updated_at, - })); - } - - saveBrowserAction(input: { - companyId: string; - socialAccountId: string; - agentId: string; - taskId?: string | null; - actionType: string; - payloadJson: string; - status?: string; - approvalId?: string | null; - }): string { - const id = randomUUID(); - const now = nowIso(); - this.db.prepare(`INSERT INTO browser_actions (id, company_id, social_account_id, agent_id, task_id, action_type, payload_json, status, approval_id, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run( - id, input.companyId, input.socialAccountId, input.agentId, - input.taskId ?? null, input.actionType, input.payloadJson, - input.status ?? "queued", input.approvalId ?? null, now, now, - ); - return id; - } - - updateBrowserAction(id: string, updates: { status?: string; resultSummary?: string; screenshotPath?: string | null; errorMessage?: string | null; startedAt?: string; finishedAt?: string }) { - const now = nowIso(); - const sets: string[] = ["updated_at = ?"]; - const values: SQLInputValue[] = [now]; - if (updates.status) { sets.push("status = ?"); values.push(updates.status); } - if (updates.resultSummary !== undefined) { sets.push("result_summary = ?"); values.push(updates.resultSummary); } - if (updates.screenshotPath !== undefined) { sets.push("screenshot_path = ?"); values.push(updates.screenshotPath); } - if (updates.errorMessage !== undefined) { sets.push("error_message = ?"); values.push(updates.errorMessage); } - if (updates.startedAt) { sets.push("started_at = ?"); values.push(updates.startedAt); } - if (updates.finishedAt) { sets.push("finished_at = ?"); values.push(updates.finishedAt); } - values.push(id); - this.db.prepare(`UPDATE browser_actions SET ${sets.join(", ")} WHERE id = ?`).run(...values); - } - - listBrowserActions(companyId: string, filters?: { socialAccountId?: string; status?: string; limit?: number }): BrowserActionRecord[] { - let sql = "SELECT * FROM browser_actions WHERE company_id = ?"; - const params: SQLInputValue[] = [companyId]; - if (filters?.socialAccountId) { sql += " AND social_account_id = ?"; params.push(filters.socialAccountId); } - if (filters?.status) { - const statuses = filters.status.split(",").filter(Boolean); - if (statuses.length > 0) { - sql += ` AND status IN (${statuses.map(() => "?").join(",")})`; - params.push(...statuses); - } - } - sql += " ORDER BY created_at DESC"; - if (filters?.limit) { sql += " LIMIT ?"; params.push(filters.limit); } - - const rows = this.db.prepare(sql).all(...params) as SqliteRow[]; - return rows.map((r) => ({ - id: r.id, - companyId: r.company_id, - socialAccountId: r.social_account_id, - agentId: r.agent_id, - taskId: r.task_id, - status: r.status, - actionType: r.action_type, - payloadJson: r.payload_json, - resultSummary: r.result_summary, - screenshotPath: r.screenshot_path, - approvalId: r.approval_id, - errorMessage: r.error_message, - startedAt: r.started_at, - finishedAt: r.finished_at, - createdAt: r.created_at, - updatedAt: r.updated_at, - })); - } - - getBrowserAction(id: string, companyId: string): BrowserActionRecord | null { - const row = this.db - .prepare("SELECT * FROM browser_actions WHERE id = ? AND company_id = ?") - .get(id, companyId) as SqliteRow | undefined; - if (!row) { - return null; - } - return { - id: row.id, - companyId: row.company_id, - socialAccountId: row.social_account_id, - agentId: row.agent_id, - taskId: row.task_id, - status: row.status, - actionType: row.action_type, - payloadJson: row.payload_json, - resultSummary: row.result_summary, - screenshotPath: row.screenshot_path, - approvalId: row.approval_id, - errorMessage: row.error_message, - startedAt: row.started_at, - finishedAt: row.finished_at, - createdAt: row.created_at, - updatedAt: row.updated_at, - }; - } - - getBrowserActionByApprovalId(approvalId: string, companyId: string): BrowserActionRecord | null { - const row = this.db - .prepare("SELECT * FROM browser_actions WHERE approval_id = ? AND company_id = ? ORDER BY created_at DESC LIMIT 1") - .get(approvalId, companyId) as SqliteRow | undefined; - if (!row) { - return null; - } - return { - id: row.id, - companyId: row.company_id, - socialAccountId: row.social_account_id, - agentId: row.agent_id, - taskId: row.task_id, - status: row.status, - actionType: row.action_type, - payloadJson: row.payload_json, - resultSummary: row.result_summary, - screenshotPath: row.screenshot_path, - approvalId: row.approval_id, - errorMessage: row.error_message, - startedAt: row.started_at, - finishedAt: row.finished_at, - createdAt: row.created_at, - updatedAt: row.updated_at, - }; - } - - getSocialAccount(id: string, companyId: string): SocialAccountRecord | null { - const row = this.db - .prepare("SELECT * FROM social_accounts WHERE id = ? AND company_id = ?") - .get(id, companyId) as SqliteRow | undefined; - if (!row) { - return null; - } - return { - id: row.id, - companyId: row.company_id, - platform: row.platform, - accountName: row.account_name, - displayName: row.display_name, - profileUrl: row.profile_url, - credentialSecretId: row.credential_secret_id, - status: row.status, - requireApproval: toBoolean(row.require_approval), - metadataJson: row.metadata_json, - lastUsedAt: row.last_used_at, - createdAt: row.created_at, - updatedAt: row.updated_at, - }; - } - - getSidebarBadges(companyId: string): { pendingApprovals: number; activeRuns: number; failedRuns: number; todoTasks: number; budgetWarnings: number; unreadMessages: number } { - const pendingApprovals = (this.db.prepare("select count(*) as c from approvals where company_id = ? and state = 'pending'").get(companyId) as { c: number })?.c ?? 0; - const activeRuns = (this.db.prepare("select count(*) as c from runs where company_id = ? and status in ('queued', 'running')").get(companyId) as { c: number })?.c ?? 0; - const failedRuns = (this.db.prepare("select count(*) as c from runs where company_id = ? and status in ('failed', 'timed_out', 'interrupted')").get(companyId) as { c: number })?.c ?? 0; - const todoTasks = (this.db.prepare("select count(*) as c from tasks where company_id = ? and status in ('todo', 'in_progress', 'in_review')").get(companyId) as { c: number })?.c ?? 0; - const budgetWarnings = (this.db.prepare("select count(*) as c from agents where company_id = ? and budget_monthly_usd > 0 and (spent_monthly_usd * 1.0 / budget_monthly_usd) >= 0.8").get(companyId) as { c: number })?.c ?? 0; - const unreadMessages = ( - this.db.prepare( - `select count(*) as c - from agent_messages messages - left join agent_message_reads reads - on reads.message_id = messages.id - and reads.agent_id = ? - where messages.company_id = ? - and reads.read_at is null - and (messages.channel = 'company' or messages.channel = 'incident' or messages.priority = 'urgent')`, - ).get(BOARD_READER_ID, companyId) as { c: number } - )?.c ?? 0; - return { pendingApprovals, activeRuns, failedRuns, todoTasks, budgetWarnings, unreadMessages }; - } - - addComment(input: { companyId: string; taskId: string; authorAgentId: string | null; authorName: string; body: string }): CommentRecord { - const taskRow = this.db.prepare("select company_id from tasks where id = ?").get(input.taskId) as { company_id: string } | undefined; - if (!taskRow) throw new Error("Task not found."); - if (taskRow.company_id !== input.companyId) throw new Error("Task does not belong to this company."); - const id = randomUUID(); - const ts = nowIso(); - this.db.prepare( - "insert into task_comments (id, company_id, task_id, author_agent_id, author_name, body, created_at) values (?, ?, ?, ?, ?, ?, ?)", - ).run(id, input.companyId, input.taskId, input.authorAgentId, input.authorName, input.body, ts); - return { id, companyId: input.companyId, taskId: input.taskId, authorAgentId: input.authorAgentId, authorName: input.authorName, body: input.body, createdAt: ts }; - } - - listComments(taskId: string): CommentRecord[] { - return this.readAll( - "select id, company_id as companyId, task_id as taskId, author_agent_id as authorAgentId, author_name as authorName, body, created_at as createdAt from task_comments where task_id = ? order by created_at asc", - taskId, - ); - } - - setHeartbeat(agentId: string, enabled: boolean, intervalSec: number) { - this.db.prepare( - "update agents set heartbeat_enabled = ?, heartbeat_interval_sec = ?, updated_at = ? where id = ?", - ).run(enabled ? 1 : 0, intervalSec, nowIso(), agentId); - } - - markHeartbeatAt(agentId: string) { - this.db.prepare("update agents set last_heartbeat_at = ? where id = ?").run(nowIso(), agentId); - } - - resetMonthlyBudgets(companyId: string) { - this.db.prepare( - "update agents set spent_monthly_usd = 0, updated_at = ? where company_id = ? and spent_monthly_usd > 0", - ).run(nowIso(), companyId); - } - - getLastBudgetResetMonth(companyId: string): string | null { - return this.getSetting(`budget_reset_month_${companyId}`); - } - - setLastBudgetResetMonth(companyId: string, month: string) { - this.setSetting(`budget_reset_month_${companyId}`, month); - } - - getDisabledModels(connectorId: string): Array<{ id: string; label: string }> { - const raw = this.getSetting(`disabled_models:${connectorId}`); - if (!raw) return []; - try { - return JSON.parse(raw); - } catch { - return raw.split(",").filter(Boolean).map((id) => ({ id, label: id })); - } - } - - setDisabledModels(connectorId: string, models: Array<{ id: string; label: string }>) { - if (models.length === 0) { - this.db.prepare("delete from settings where key = ?").run(`disabled_models:${connectorId}`); - } else { - this.setSetting(`disabled_models:${connectorId}`, JSON.stringify(models)); - } - } - - listHeartbeatEligibleAgents(companyId: string): AgentRecord[] { - return this.readAll( - "select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, created_at as createdAt, updated_at as updatedAt from agents where company_id = ? and heartbeat_enabled = 1 and status not in ('terminated', 'paused', 'error', 'pending_approval')", - companyId, - ).map((record) => ({ ...record, heartbeatEnabled: toBoolean(record.heartbeatEnabled) })) as AgentRecord[]; - } - - checkoutTask(taskId: string, runId: string): boolean { - const ts = nowIso(); - const result = this.db - .prepare( - `UPDATE tasks - SET checkout_run_id = ?, execution_locked_at = ?, status = 'in_progress', updated_at = ? - WHERE id = ? - AND checkout_run_id IS NULL - AND status IN ('todo', 'backlog')`, - ) - .run(runId, ts, ts, taskId); - return result.changes > 0; - } - - releaseTaskLock(taskId: string, runId: string): void { - this.db - .prepare( - "UPDATE tasks SET checkout_run_id = NULL, execution_locked_at = NULL, updated_at = ? WHERE id = ? AND checkout_run_id = ?", - ) - .run(nowIso(), taskId, runId); - } - - recoverStaleLocks(): Array<{ taskId: string; companyId: string; title: string }> { - const cutoff = new Date(Date.now() - STALE_LOCK_TIMEOUT_MS).toISOString(); - const stale = this.readAll<{ id: string; companyId: string; title: string }>( - `SELECT id, company_id AS companyId, title - FROM tasks - WHERE execution_locked_at IS NOT NULL - AND execution_locked_at < ? - AND status = 'in_progress'`, - cutoff, - ); - const ts = nowIso(); - for (const task of stale) { - this.db - .prepare( - `UPDATE tasks - SET checkout_run_id = NULL, - execution_locked_at = NULL, - status = 'todo', - updated_at = ? - WHERE id = ?`, - ) - .run(ts, task.id); - } - return stale.map((t) => ({ taskId: t.id, companyId: t.companyId, title: t.title })); - } - - listAgentTasks(agentId: string, companyId: string, statuses: string[]): TaskRecord[] { - const placeholders = statuses.map(() => "?").join(","); - return this.readAll( - `select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, created_at as createdAt, updated_at as updatedAt - from tasks - where company_id = ? and assignee_agent_id = ? and status in (${placeholders}) - order by - case status - when 'in_progress' then 0 - when 'todo' then 1 - when 'blocked' then 2 - when 'backlog' then 3 - else 4 - end asc, - ${taskPriorityOrderSql("priority")} desc, - created_at asc`, - companyId, agentId, ...statuses, - ); - } - - /** - * Find the most recent completed heartbeat task for this agent that can be reused. - * Returns a task with status "done" or "in_review", ordered by most recently updated. - * Returns null if no reusable heartbeat task exists. - */ - findReusableHeartbeatTask(agentId: string, companyId: string): TaskRecord | null { - const row = this.db - .prepare( - `SELECT id, company_id as companyId, project_id as projectId, goal_id as goalId, - parent_id as parentId, title, description, - assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, - status, priority, active_run_id as activeRunId, - session_json as sessionJson, session_display_id as sessionDisplayId, - cost_usd as costUsd, requires_user_review as requiresUserReview, - created_at as createdAt, updated_at as updatedAt - FROM tasks - WHERE company_id = ? AND assignee_agent_id = ? AND title LIKE '[Heartbeat]%' - AND status IN ('done', 'in_review') - ORDER BY updated_at DESC - LIMIT 1`, - ) - .get(companyId, agentId) as TaskRecord | undefined; - if (row) { - row.requiresUserReview = Boolean(row.requiresUserReview); - } - return row ?? null; - } - - /** - * Cancel all duplicate heartbeat tasks per agent within a company, keeping only the most - * recently updated one per agent. Does NOT touch in_progress tasks (they may have active runs). - * Returns the number of tasks cancelled. - */ - cleanupDuplicateHeartbeatTasks(companyId: string): number { - const ts = nowIso(); - const result = this.db - .prepare( - `UPDATE tasks SET status = 'cancelled', updated_at = ? - WHERE company_id = ? AND title LIKE '[Heartbeat]%' - AND status IN ('done', 'in_review', 'todo', 'blocked', 'backlog') - AND id NOT IN ( - SELECT id FROM ( - SELECT id, ROW_NUMBER() OVER (PARTITION BY assignee_agent_id ORDER BY updated_at DESC) as rn - FROM tasks - WHERE company_id = ? AND title LIKE '[Heartbeat]%' AND status != 'cancelled' - ) WHERE rn = 1 - )`, - ) - .run(ts, companyId, companyId); - return Number(result.changes); - } - - /** - * List tasks from an agent's direct reports that are awaiting review (status = in_review). - * Used to populate the "Tasks Awaiting Your Review" section in the agent prompt. - */ - listReviewTasksForManager(managerId: string, companyId: string): TaskRecord[] { - return this.readAll( - `select t.id, t.company_id as companyId, t.project_id as projectId, t.goal_id as goalId, - t.parent_id as parentId, t.title, t.description, - t.assignee_agent_id as assigneeAgentId, t.workspace_id as workspaceId, - t.status, t.priority, t.active_run_id as activeRunId, - t.session_json as sessionJson, t.session_display_id as sessionDisplayId, - t.cost_usd as costUsd, t.created_at as createdAt, t.updated_at as updatedAt - from tasks t - inner join agents a on t.assignee_agent_id = a.id - where t.company_id = ? and t.status = 'in_review' and a.reports_to = ? - order by ${taskPriorityOrderSql("t.priority")} desc, t.created_at asc`, - companyId, managerId, - ); - } - - getChainOfCommand(agentId: string): AgentRecord[] { - const chain: AgentRecord[] = []; - const visited = new Set(); - let current: AgentRecord | undefined; - try { current = this.getAgent(agentId); } catch { return chain; } - while (current?.reportsTo) { - if (visited.has(current.reportsTo)) { - break; - } - visited.add(current.reportsTo); - try { - current = this.getAgent(current.reportsTo); - chain.push(current); - } catch { break; } - } - return chain; - } - - getDirectReports(agentId: string, companyId: string): AgentRecord[] { - return this.readAll( - "select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, created_at as createdAt, updated_at as updatedAt from agents where company_id = ? and reports_to = ?", - companyId, agentId, - ).map((record) => ({ ...record, heartbeatEnabled: toBoolean(record.heartbeatEnabled) })) as AgentRecord[]; - } - - enqueueWakeupRequest(companyId: string, agentId: string, trigger: string, detail: string, context?: Record): void { - const ts = nowIso(); - this.transaction(() => { - const existing = this.db - .prepare( - `SELECT id, trigger_detail, coalesced_count, context_snapshot - FROM wakeup_requests - WHERE agent_id = ? AND company_id = ? AND status IN ('queued', 'deferred') - LIMIT 1`, - ) - .get(agentId, companyId) as - | { id: string; trigger_detail: string | null; coalesced_count: number; context_snapshot: string | null } - | undefined; - - if (existing) { - const triggers: Array<{ trigger: string; at: string; detail: string }> = existing.trigger_detail - ? JSON.parse(existing.trigger_detail) - : []; - triggers.push({ trigger, at: ts, detail }); - // Merge context snapshots (Paperclip pattern: incoming overwrites existing, preserve commentId) - let mergedContext: string | null = existing.context_snapshot; - if (context) { - const prev: Record = existing.context_snapshot ? JSON.parse(existing.context_snapshot) : {}; - const merged = { ...prev, ...context }; - mergedContext = JSON.stringify(merged); - } - this.db - .prepare( - `UPDATE wakeup_requests - SET trigger_detail = ?, - coalesced_count = coalesced_count + 1, - context_snapshot = ?, - updated_at = ? - WHERE id = ?`, - ) - .run(JSON.stringify(triggers), mergedContext, ts, existing.id); - } else { - const triggerDetail = JSON.stringify([{ trigger, at: ts, detail }]); - const contextJson = context ? JSON.stringify(context) : null; - this.db - .prepare( - `INSERT INTO wakeup_requests - (id, company_id, agent_id, source, trigger_detail, status, coalesced_count, context_snapshot, requested_at, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, 'queued', 0, ?, ?, ?, ?)`, - ) - .run(randomUUID(), companyId, agentId, trigger, triggerDetail, contextJson, ts, ts, ts); - } - }); - } - - private mergeContextSnapshots(existingJson: string | null, incomingJson: string | null): string | null { - if (!incomingJson) return existingJson; - if (!existingJson) return incomingJson; - const existing: Record = JSON.parse(existingJson); - const incoming: Record = JSON.parse(incomingJson); - return JSON.stringify({ ...existing, ...incoming }); - } - - promoteWakeupRequests(agentId: string, companyId: string): number { - let promoted = 0; - this.transaction(() => { - const queued = this.db - .prepare( - `SELECT id, trigger_detail, context_snapshot FROM wakeup_requests - WHERE agent_id = ? AND company_id = ? AND status = 'queued' LIMIT 1`, - ) - .get(agentId, companyId) as { id: string; trigger_detail: string | null; context_snapshot: string | null } | undefined; - const deferred = this.readAll<{ id: string; trigger_detail: string | null; coalesced_count: number; context_snapshot: string | null }>( - `SELECT id, trigger_detail, coalesced_count, context_snapshot FROM wakeup_requests - WHERE agent_id = ? AND company_id = ? AND status = 'deferred'`, - agentId, companyId, - ); - if (deferred.length === 0) return; - const ts = nowIso(); - if (queued) { - const queuedTriggers: Array<{ trigger: string; at: string; detail: string }> = queued.trigger_detail ? JSON.parse(queued.trigger_detail) : []; - let addedCount = 0; - let mergedCtx = queued.context_snapshot; - for (const d of deferred) { - const dTriggers: Array<{ trigger: string; at: string; detail: string }> = d.trigger_detail ? JSON.parse(d.trigger_detail) : []; - queuedTriggers.push(...dTriggers); - addedCount += d.coalesced_count + 1; - mergedCtx = this.mergeContextSnapshots(mergedCtx, d.context_snapshot); - } - this.db.prepare( - `UPDATE wakeup_requests SET trigger_detail = ?, coalesced_count = coalesced_count + ?, context_snapshot = ?, updated_at = ? WHERE id = ?`, - ).run(JSON.stringify(queuedTriggers), addedCount, mergedCtx, ts, queued.id); - for (const d of deferred) { - this.db.prepare("DELETE FROM wakeup_requests WHERE id = ?").run(d.id); - } - promoted = deferred.length; - } else { - const [first, ...rest] = deferred; - this.db.prepare( - `UPDATE wakeup_requests SET status = 'queued', updated_at = ? WHERE id = ?`, - ).run(ts, first!.id); - if (rest.length > 0) { - const firstTriggers: Array<{ trigger: string; at: string; detail: string }> = first!.trigger_detail ? JSON.parse(first!.trigger_detail) : []; - let addedCount = 0; - let mergedCtx = first!.context_snapshot; - for (const d of rest) { - const dTriggers: Array<{ trigger: string; at: string; detail: string }> = d.trigger_detail ? JSON.parse(d.trigger_detail) : []; - firstTriggers.push(...dTriggers); - addedCount += d.coalesced_count + 1; - mergedCtx = this.mergeContextSnapshots(mergedCtx, d.context_snapshot); - } - this.db.prepare( - `UPDATE wakeup_requests SET trigger_detail = ?, coalesced_count = coalesced_count + ?, context_snapshot = ?, updated_at = ? WHERE id = ?`, - ).run(JSON.stringify(firstTriggers), addedCount, mergedCtx, ts, first!.id); - for (const d of rest) { - this.db.prepare("DELETE FROM wakeup_requests WHERE id = ?").run(d.id); - } - } - promoted = deferred.length; - } - }); - return promoted; - } - - listWakeupRequests(companyId: string, filters?: { status?: string; agentId?: string; limit?: number }): Array<{ - id: string; companyId: string; agentId: string; source: string; triggerDetail: string | null; - status: string; coalescedCount: number; runId: string | null; requestedAt: string; - claimedAt: string | null; finishedAt: string | null; createdAt: string; updatedAt: string; - }> { - const conditions = ["company_id = ?"]; - const params: SQLInputValue[] = [companyId]; - if (filters?.status) { conditions.push("status = ?"); params.push(filters.status); } - if (filters?.agentId) { conditions.push("agent_id = ?"); params.push(filters.agentId); } - const limit = filters?.limit ?? 50; - return this.readAll( - `SELECT id, company_id AS companyId, agent_id AS agentId, source, trigger_detail AS triggerDetail, - status, coalesced_count AS coalescedCount, run_id AS runId, requested_at AS requestedAt, - claimed_at AS claimedAt, finished_at AS finishedAt, created_at AS createdAt, updated_at AS updatedAt - FROM wakeup_requests - WHERE ${conditions.join(" AND ")} - ORDER BY requested_at DESC - LIMIT ?`, - ...params, limit, - ); - } - - exportBackup(targetDir: string) { - mkdirSync(targetDir, { recursive: true }); - this.db.exec("PRAGMA wal_checkpoint(TRUNCATE);"); - const copyJobs = [ - backupDatabase(this.db, join(targetDir, "profile.sqlite")), - cp(this.logsDir, join(targetDir, "logs"), { recursive: true, force: true }), - cp(this.artifactsDir, join(targetDir, "artifacts"), { recursive: true, force: true }), - copyIfExists(join(this.profileDir, "vault.key"), join(targetDir, "vault.key")), - copyIfExists(join(this.profileDir, "agent-api-signing.key"), join(targetDir, "agent-api-signing.key")), - copyIfExists(`${this.dbPath}-wal`, join(targetDir, "profile.sqlite-wal")), - copyIfExists(`${this.dbPath}-shm`, join(targetDir, "profile.sqlite-shm")), - ]; - return Promise.all(copyJobs); - } - - async restoreFromBackup(sourceDir: string) { - const sourceDbPath = join(sourceDir, "profile.sqlite"); - if (!existsSync(sourceDbPath)) { - throw new Error("Backup directory is missing profile.sqlite."); - } - - this.validateBackupDirectory(sourceDir); - const recoveryDir = join(this.backupsDir, `pre-restore-${Date.now()}`); - await this.exportBackup(recoveryDir); - - this.close(); - try { - await this.applyBackup(sourceDir); - } catch (error) { - await this.applyBackup(recoveryDir); - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Restore failed. Previous profile was recovered from ${recoveryDir}. ${message}`); - } - } - - private readConnectors(): ConnectorRecord[] { - const rows = this.readAll<{ - id: string; - label: string; - description: string; - status: ConnectorRecord["status"]; - command: string; - version: string | null; - auth_state: ConnectorRecord["authState"]; - last_checked_at: string | null; - last_error: string | null; - model: string | null; - env_binding_text: string; - notes: string; - }>( - "select id, label, description, status, command, version, auth_state, last_checked_at, last_error, model, env_binding_text, notes from connectors order by label asc", - ); - const result: ConnectorRecord[] = []; - for (const row of rows) { - let definition; - try { - definition = getConnectorDefinition(row.id as ConnectorId); - } catch { - // Connector was removed (e.g. kimi_local, opencode_local) — skip it. - continue; - } - result.push({ - id: row.id as ConnectorId, - label: row.label, - description: row.description, - status: row.status, - command: row.command, - version: row.version, - authState: row.auth_state, - lastCheckedAt: row.last_checked_at, - lastError: row.last_error, - model: row.model, - envBindingText: row.env_binding_text, - notes: row.notes, - configurationDoc: definition.configurationDoc, - capabilityMatrix: definition.capabilityMatrix, - }); - } - return result; - } - - private seedConnectors() { - // Remove connector rows for adapters that have been removed from the codebase. - // Preserve native connectors and all claude_compat slots (user-configured API providers). - this.db.prepare("delete from connectors where id not in ('codex_local', 'claude_local', 'gemini_local') and id not like 'claude_compat%'").run(); - const currentRows = this.readAll>( - "select id, label, description, status, command, version, auth_state as authState, last_checked_at as lastCheckedAt, last_error as lastError, model, env_binding_text as envBindingText, notes from connectors", - ); - const merged = buildDefaultConnectorRows(currentRows); - const statement = this.db.prepare( - "insert or replace into connectors (id, label, description, status, command, version, auth_state, last_checked_at, last_error, model, env_binding_text, notes, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ); - const updatedAt = nowIso(); - for (const row of merged) { - statement.run( - row.id, - row.label, - row.description, - row.status, - row.command, - row.version, - row.authState, - row.lastCheckedAt, - row.lastError, - row.model, - row.envBindingText, - row.notes, - updatedAt, - ); - } - } - - private seedSettings() { - if (!this.getSetting("theme")) { - this.setSetting("theme", "system"); - } - if (!this.getSetting("updates_enabled")) { - this.setSetting("updates_enabled", "false"); - } - if (!this.getSetting("log_retention_days")) { - this.setSetting("log_retention_days", "90"); - } - } - - purgeExpiredWakeupRequests() { - // Remove completed wakeup requests older than 2 hours -- only clean completed, not active ones - const cutoff = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); - const result = this.db.prepare("DELETE FROM wakeup_requests WHERE created_at < ? AND status IN ('completed')").run(cutoff); - return Number(result.changes); - } - - private _lastInterruptedCount = 0; - - get lastInterruptedCount(): number { - return this._lastInterruptedCount; - } - - interruptStaleRuns(): number { - const ts = nowIso(); - let interruptedCount = 0; - this.transaction(() => { - const result = this.db - .prepare( - "update runs set status = 'interrupted', error_message = 'AgentCompany restarted before the run could report completion.', finished_at = ?, updated_at = ? where status = 'running'", - ) - .run(ts, ts); - interruptedCount = Number(result.changes); - - if (interruptedCount > 0) { - // Mark associated tasks as blocked to prevent heartbeat re-queuing - this.db.prepare( - `update tasks set status = 'blocked', updated_at = ? where id in ( - select task_id from runs where status = 'interrupted' and finished_at = ? - ) and status in ('in_progress', 'todo')` - ).run(ts, ts); - } - - this.db.prepare("update tasks set active_run_id = null where active_run_id in (select id from runs where status = 'interrupted')").run(); - // Reset any agents stuck in "running" status back to "idle" - this.db - .prepare("update agents set status = 'idle', updated_at = ? where status = 'running'") - .run(ts); - this.releaseAllWorkspaceLocks(); - }); - return interruptedCount; - } - - getCompanyStatus(companyId: string): string | null { - const row = this.db.prepare("select status from companies where id = ?").get(companyId) as { status: string } | undefined; - return row?.status ?? null; - } - - getCompanyBasic(companyId: string): { name: string; description: string } | null { - const row = this.db.prepare("select name, description from companies where id = ?").get(companyId) as { name: string; description: string } | undefined; - return row ?? null; - } - - listActiveCompanyIds(): Array<{ id: string; name: string }> { - return this.readAll<{ id: string; name: string }>( - "select id, name from companies where status = 'active'", - ); - } - - countActiveTasksForCompany(companyId: string): number { - const row = this.db.prepare( - "select count(*) as cnt from tasks where company_id = ? and status not in ('done', 'cancelled') and title not like '[Heartbeat]%'", - ).get(companyId) as { cnt: number }; - return row.cnt; - } - - countActiveGoalsForCompany(companyId: string): number { - const row = this.db.prepare( - "select count(*) as cnt from goals where company_id = ? and status in ('active', 'planned')", - ).get(companyId) as { cnt: number }; - return row.cnt; - } - - getCeoForCompany(companyId: string): { id: string; name: string; lastHeartbeatAt: string | null } | null { - const row = this.db.prepare( - "select id, name, last_heartbeat_at as lastHeartbeatAt from agents where company_id = ? and reports_to is null and status != 'terminated' limit 1", - ).get(companyId) as { id: string; name: string; lastHeartbeatAt: string | null } | undefined; - return row ?? null; - } - - listActiveAgentsForCompany(companyId: string): Array<{ id: string; name: string; department: string | null; lastHeartbeatAt: string | null }> { - return this.readAll<{ id: string; name: string; department: string | null; lastHeartbeatAt: string | null }>( - "select id, name, department, last_heartbeat_at as lastHeartbeatAt from agents where company_id = ? and status not in ('terminated', 'paused', 'pending_approval')", - companyId, - ); - } - - hasAgentAssignedTasks(agentId: string, companyId: string): boolean { - const row = this.db.prepare( - "select 1 from tasks where assignee_agent_id = ? and company_id = ? and status in ('todo', 'in_progress', 'blocked') limit 1", - ).get(agentId, companyId); - return !!row; - } - - listChildTasks(parentId: string, companyId: string): Array<{ id: string; status: string; assigneeAgentId: string | null }> { - return this.readAll<{ id: string; status: string; assigneeAgentId: string | null }>( - `select id, status, assignee_agent_id as assigneeAgentId from tasks where parent_id = ? and company_id = ?`, - parentId, companyId, - ); - } - - updateAgentStatus(agentId: string, status: string) { - const ts = new Date().toISOString(); - this.db.prepare("update agents set status = ?, updated_at = ? where id = ?").run(status, ts, agentId); - } - - // --------------------------------------------------------------------------- - // Skill CRUD methods - // --------------------------------------------------------------------------- - - /** - * Upsert a company skill -- INSERT with ON CONFLICT UPDATE. - * Returns isUpdate=true if the record already existed and was updated. - */ - upsertCompanySkill(skill: { - id: string; - companyId: string; - key: string; - slug: string; - name: string; - description: string | null; - markdown: string; - sourceType: string; - sourceLocator: string | null; - sourceRef: string | null; - trustLevel: string; - compatibility: string; - fileInventory: string; - metadata: string | null; - createdAt: string; - updatedAt: string; - }): { isUpdate: boolean } { - // Check if record exists before upsert to determine isUpdate - const existing = this.db - .prepare("SELECT 1 FROM company_skills WHERE company_id = ? AND key = ?") - .get(skill.companyId, skill.key); - const isUpdate = existing != null; - - this.db.prepare(` - INSERT INTO company_skills ( - id, company_id, key, slug, name, description, markdown, - source_type, source_locator, source_ref, trust_level, - compatibility, file_inventory, metadata, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(company_id, key) DO UPDATE SET - slug = excluded.slug, - name = excluded.name, - description = excluded.description, - markdown = excluded.markdown, - source_ref = excluded.source_ref, - trust_level = excluded.trust_level, - file_inventory = excluded.file_inventory, - metadata = excluded.metadata, - updated_at = excluded.updated_at - `).run( - skill.id, skill.companyId, skill.key, skill.slug, - skill.name, skill.description, skill.markdown, - skill.sourceType, skill.sourceLocator, skill.sourceRef, - skill.trustLevel, skill.compatibility, skill.fileInventory, - skill.metadata, skill.createdAt, skill.updatedAt, - ); - - return { isUpdate }; - } - - /** List all skills for a company. */ - listCompanySkills(companyId: string): SkillRecord[] { - const rows = this.readAll( - "SELECT * FROM company_skills WHERE company_id = ? ORDER BY created_at ASC", - companyId, - ); - return rows.map((row) => this.mapSkillRow(row)); - } - - /** Get a single skill by ID within a company. */ - getCompanySkill(companyId: string, skillId: string): SkillRecord | null { - const row = this.db - .prepare("SELECT * FROM company_skills WHERE id = ? AND company_id = ?") - .get(skillId, companyId) as SqliteRow | undefined; - return row ? this.mapSkillRow(row) : null; - } - - /** Delete a skill record. Cascade deletes agent_skill_assignments via FK. */ - deleteCompanySkill(companyId: string, skillId: string): boolean { - const result = this.db - .prepare("DELETE FROM company_skills WHERE id = ? AND company_id = ?") - .run(skillId, companyId); - return result.changes > 0; - } - - /** Assign a skill to an agent (INSERT OR IGNORE for idempotency). */ - assignSkillToAgent(agentId: string, skillId: string): void { - this.db - .prepare("INSERT OR IGNORE INTO agent_skill_assignments (agent_id, skill_id, created_at) VALUES (?, ?, ?)") - .run(agentId, skillId, nowIso()); - } - - /** Remove a skill assignment from an agent. */ - removeSkillFromAgent(agentId: string, skillId: string): void { - this.db - .prepare("DELETE FROM agent_skill_assignments WHERE agent_id = ? AND skill_id = ?") - .run(agentId, skillId); - } - - /** List all skill IDs assigned to a specific agent. */ - listAgentSkillAssignments(agentId: string): string[] { - return this.readAll<{ skill_id: string }>( - "SELECT skill_id FROM agent_skill_assignments WHERE agent_id = ?", - agentId, - ).map((row) => row.skill_id); - } - - /** List all agent IDs assigned to a specific skill. */ - listSkillAgentAssignments(skillId: string): string[] { - return this.readAll<{ agent_id: string }>( - "SELECT agent_id FROM agent_skill_assignments WHERE skill_id = ?", - skillId, - ).map((row) => row.agent_id); - } - - /** Map a raw SQLite row to a typed SkillRecord. */ - private mapSkillRow(row: SqliteRow): SkillRecord { - return { - id: row.id, - companyId: row.company_id, - key: row.key, - slug: row.slug, - name: row.name, - description: row.description ?? null, - markdown: row.markdown ?? "", - sourceType: (row.source_type ?? "local_path") as SkillSourceType, - sourceLocator: row.source_locator ?? null, - sourceRef: row.source_ref ?? null, - trustLevel: (row.trust_level ?? "markdown_only") as TrustLevel, - compatibility: row.compatibility ?? "compatible", - fileInventory: parseJson(row.file_inventory, []), - metadata: parseJson | null>(row.metadata, null), - createdAt: row.created_at, - updatedAt: row.updated_at, - }; - } - - private getSetting(key: string): string | null { - const row = this.db.prepare("select value from settings where key = ?").get(key) as { value: string } | undefined; - return row?.value ?? null; - } - - private setSetting(key: string, value: string) { - this.db - .prepare("insert into settings (key, value) values (?, ?) on conflict(key) do update set value = excluded.value") - .run(key, value); - } - - private readAll(sql: string, ...params: SQLInputValue[]): T[] { - return this.db.prepare(sql).all(...params) as T[]; - } - - /** Public read-only query method for cross-service deliverable checks. */ - queryAll(sql: string, ...params: SQLInputValue[]): T[] { - return this.readAll(sql, ...params); - } - - private wouldCreateAgentCycle(agentId: string, reportsTo: string | null) { - if (!reportsTo) return false; - if (reportsTo === agentId) return true; - - const visited = new Set([agentId]); - let currentId: string | null = reportsTo; - while (currentId) { - if (visited.has(currentId)) { - return true; - } - visited.add(currentId); - const current = this.db - .prepare("select reports_to as reportsTo from agents where id = ?") - .get(currentId) as { reportsTo: string | null } | undefined; - currentId = current?.reportsTo ?? null; - } - return false; - } - - private ensureOptionalColumns() { - const addColumnIfMissing = (sql: string) => { - try { this.db.prepare(sql).run(); } catch { /* column already exists */ } - }; - - addColumnIfMissing("alter table connectors add column updated_at text"); - addColumnIfMissing("alter table approvals add column payload_json text"); - addColumnIfMissing("alter table agents add column heartbeat_enabled integer not null default 0"); - addColumnIfMissing("alter table agents add column heartbeat_interval_sec integer not null default 120"); - addColumnIfMissing("alter table agents add column last_heartbeat_at text"); - addColumnIfMissing("alter table companies add column auto_approve_hires integer not null default 0"); - addColumnIfMissing("alter table companies add column review_deliverables integer not null default 0"); - addColumnIfMissing("alter table tasks add column task_type text not null default 'general'"); - addColumnIfMissing("alter table tasks add column requires_user_review integer not null default 0"); - addColumnIfMissing("alter table tasks add column retry_count integer not null default 0"); - addColumnIfMissing("alter table tasks add column last_failure_at text"); - addColumnIfMissing("alter table tasks add column checkout_run_id text"); - addColumnIfMissing("alter table tasks add column execution_locked_at text"); - addColumnIfMissing("alter table tasks add column active_session_id text"); - addColumnIfMissing("alter table cost_entries add column project_id text"); - this.db.exec(` - create table if not exists task_comments ( - id text primary key, - company_id text not null, - task_id text not null, - author_agent_id text, - author_name text not null, - body text not null, - created_at text not null - ); - create index if not exists idx_comments_task on task_comments(task_id); - create index if not exists idx_comments_company on task_comments(company_id); - `); - - this.db.exec(` - create table if not exists social_accounts ( - id text primary key, - company_id text not null references companies(id) on delete cascade, - platform text not null default 'other', - account_name text not null, - display_name text not null default '', - profile_url text not null default '', - credential_secret_id text, - status text not null default 'login_required', - require_approval integer not null default 1, - metadata_json text not null default '{}', - last_used_at text, - created_at text not null default (datetime('now')), - updated_at text not null default (datetime('now')) - ); - create index if not exists idx_social_accounts_company on social_accounts(company_id); - `); - - this.db.exec(` - create table if not exists browser_actions ( - id text primary key, - company_id text not null references companies(id) on delete cascade, - social_account_id text not null references social_accounts(id) on delete cascade, - agent_id text not null references agents(id) on delete cascade, - task_id text, - action_type text not null default 'custom_script', - payload_json text not null default '{}', - status text not null default 'queued', - result_summary text not null default '', - screenshot_path text, - approval_id text, - error_message text, - started_at text, - finished_at text, - created_at text not null default (datetime('now')), - updated_at text not null default (datetime('now')) - ); - create index if not exists idx_browser_actions_company on browser_actions(company_id); - create index if not exists idx_browser_actions_account on browser_actions(social_account_id); - `); - - // Add composite indexes for tables created in migrations (safe: uses IF NOT EXISTS) - try { - this.db.exec(` - create index if not exists idx_messages_company on agent_messages(company_id); - create index if not exists idx_messages_channel on agent_messages(channel, channel_target_id); - create index if not exists idx_automation_rules_company_status on automation_rules(company_id, status); - create index if not exists idx_automation_log_company on automation_log(company_id); - `); - } catch { /* tables may not exist yet - indexes will be created by migrations */ } - } - - /** - * Check whether a run can start given pending approvals. - * Returns null if no blocking approval exists, or the blocking approval record. - * - * Uses the structured `related_agent_id` column for hire_agent lookups - * instead of fragile payload_json LIKE matching. - */ - getBlockingApproval(companyId: string, agentId: string, taskId: string | null): ApprovalRecord | null { - const taskCondition = taskId != null - ? "(related_task_id = ? or requested_by_agent_id = ?)" - : "requested_by_agent_id = ?"; - const taskParams: SQLInputValue[] = taskId != null ? [taskId, agentId] : [agentId]; - const rows = this.readAll( - `select id, company_id as companyId, related_task_id as relatedTaskId, - requested_by_agent_id as requestedByAgentId, related_agent_id as relatedAgentId, - type, payload_summary as payloadSummary, impact_summary as impactSummary, - payload_json as payloadJson, decision_note as decisionNote, state, - created_at as createdAt, updated_at as updatedAt - from approvals - where company_id = ? and state = 'pending' - and ( - (type = 'hire_agent' and related_agent_id = ?) - or (type = 'dangerous_command' and ${taskCondition}) - or (type = 'secret_access' and ${taskCondition}) - or (type = 'approve_ceo_strategy' and ${taskCondition}) - or (type = 'deliverable_review' and (requested_by_agent_id = ? or (related_task_id is not null and ${taskCondition}))) - ) - order by created_at asc limit 1`, - companyId, - agentId, - ...taskParams, - ...taskParams, - ...taskParams, - agentId, - ...taskParams, - ); - return rows.length > 0 ? rows[0] : null; - } - - isAgentPendingApproval(agentId: string): boolean { - const row = this.db - .prepare("select status from agents where id = ?") - .get(agentId) as { status: string } | undefined; - return row?.status === "pending_approval"; - } - - /** - * Acquire a workspace write lock for a run. Returns true if acquired. - * If another run already holds the lock, returns false. - */ - acquireWorkspaceLock(workspaceId: string, runId: string): boolean { - const ts = nowIso(); - const result = this.db - .prepare( - `update workspaces - set lock_holder_run_id = ?, lock_acquired_at = ?, state = 'busy', updated_at = ? - where id = ? - and (lock_holder_run_id is null or lock_holder_run_id = ?)`, - ) - .run(runId, ts, ts, workspaceId, runId); - return result.changes > 0; - } - - claimWorkspaceLock(workspaceId: string, runId: string): boolean { - const holder = this.db - .prepare("select lock_holder_run_id as lockHolderRunId from workspaces where id = ?") - .get(workspaceId) as { lockHolderRunId: string | null } | undefined; - if (!holder) return false; - if (!holder.lockHolderRunId || holder.lockHolderRunId === runId) { - return this.acquireWorkspaceLock(workspaceId, runId); - } - const blockingRun = this.db - .prepare("select status from runs where id = ?") - .get(holder.lockHolderRunId) as { status: RunStatus } | undefined; - if (blockingRun && (blockingRun.status === "queued" || blockingRun.status === "running")) { - return false; - } - const ts = nowIso(); - const result = this.db - .prepare( - `update workspaces - set lock_holder_run_id = ?, lock_acquired_at = ?, state = 'busy', updated_at = ? - where id = ? and lock_holder_run_id = ?`, - ) - .run(runId, ts, ts, workspaceId, holder.lockHolderRunId); - return result.changes > 0; - } - - releaseWorkspaceLock(workspaceId: string, runId: string) { - this.db - .prepare( - `update workspaces - set lock_holder_run_id = null, lock_acquired_at = null, state = 'ready', updated_at = ? - where id = ? and lock_holder_run_id = ?`, - ) - .run(nowIso(), workspaceId, runId); - } - - /** - * Force-release all workspace locks (used during crash recovery on startup). - */ - releaseAllWorkspaceLocks() { - this.db - .prepare( - "update workspaces set lock_holder_run_id = null, lock_acquired_at = null, state = 'ready', updated_at = ? where lock_holder_run_id is not null", - ) - .run(nowIso()); - } - - releaseStaleWorkspaceLocks(): number { - const result = this.db - .prepare( - `update workspaces - set lock_holder_run_id = null, lock_acquired_at = null, state = 'ready', updated_at = ? - where lock_holder_run_id is not null - and not exists ( - select 1 - from runs - where runs.id = workspaces.lock_holder_run_id - and runs.status in ('queued', 'running') - )`, - ) - .run(nowIso()); - return Number(result.changes); - } - - listQueuedRuns() { - return this.readAll( - `select id, company_id as companyId, task_id as taskId, agent_id as agentId, workspace_id as workspaceId, - connector_id as connectorId, status, summary, error_message as errorMessage, exit_code as exitCode, - signal, model, session_display_id as sessionDisplayId, cost_usd as costUsd, - unattributed_cost as unattributedCost, started_at as startedAt, finished_at as finishedAt, - log_path as logPath, created_at as createdAt, updated_at as updatedAt - from runs - where status = 'queued' - order by created_at asc`, - ).map((record) => ({ ...record, unattributedCost: toBoolean(record.unattributedCost) })) as RunRecord[]; - } - - isWorkspaceLocked(workspaceId: string): boolean { - const row = this.db - .prepare("select lock_holder_run_id from workspaces where id = ?") - .get(workspaceId) as { lock_holder_run_id: string | null } | undefined; - return Boolean(row?.lock_holder_run_id); - } - - saveMeeting(input: { - id?: string | null; companyId: string; type: string; title: string; organizerAgentId?: string | null; - participantAgentIds?: string; scheduledAt: string; durationMinutes?: number; - agendaJson?: string; notesJson?: string; decisionsJson?: string; actionItemsJson?: string; - status?: string; - }) { - const id = ensureId(input.id ?? null); - const ts = nowIso(); - const existing = this.db.prepare("select id from meetings where id = ?").get(id) as { id: string } | undefined; - if (existing) { - this.db.prepare( - "update meetings set type = ?, title = ?, organizer_agent_id = ?, participant_agent_ids = ?, scheduled_at = ?, duration_minutes = ?, agenda_json = ?, notes_json = ?, decisions_json = ?, action_items_json = ?, status = ?, updated_at = ? where id = ?", - ).run(input.type, input.title, input.organizerAgentId ?? null, input.participantAgentIds ?? "[]", input.scheduledAt, input.durationMinutes ?? 30, input.agendaJson ?? "[]", input.notesJson ?? "[]", input.decisionsJson ?? "[]", input.actionItemsJson ?? "[]", input.status ?? "scheduled", ts, id); - } else { - this.db.prepare( - "insert into meetings (id, company_id, type, title, organizer_agent_id, participant_agent_ids, scheduled_at, duration_minutes, agenda_json, notes_json, decisions_json, action_items_json, status, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ).run(id, input.companyId, input.type, input.title, input.organizerAgentId ?? null, input.participantAgentIds ?? "[]", input.scheduledAt, input.durationMinutes ?? 30, input.agendaJson ?? "[]", input.notesJson ?? "[]", input.decisionsJson ?? "[]", input.actionItemsJson ?? "[]", input.status ?? "scheduled", ts, ts); - } - return id; - } - - deleteMeeting(id: string, companyId: string) { - this.db.prepare("delete from meetings where id = ? and company_id = ?").run(id, companyId); - } - - saveDocument(input: { - id?: string | null; companyId: string; type: string; title: string; content?: string; - authorAgentId?: string | null; reviewerAgentId?: string | null; projectId?: string | null; - goalId?: string | null; parentDocId?: string | null; version?: number; status?: string; tagsJson?: string; - }) { - const id = ensureId(input.id ?? null); - const ts = nowIso(); - const existing = this.db.prepare("select id from documents where id = ?").get(id) as { id: string } | undefined; - if (existing) { - this.db.prepare( - "update documents set type = ?, title = ?, content = ?, author_agent_id = ?, reviewer_agent_id = ?, project_id = ?, goal_id = ?, parent_doc_id = ?, version = ?, status = ?, tags_json = ?, updated_at = ? where id = ?", - ).run(input.type, input.title, input.content ?? "", input.authorAgentId ?? null, input.reviewerAgentId ?? null, input.projectId ?? null, input.goalId ?? null, input.parentDocId ?? null, input.version ?? 1, input.status ?? "draft", input.tagsJson ?? "[]", ts, id); - } else { - this.db.prepare( - "insert into documents (id, company_id, type, title, content, author_agent_id, reviewer_agent_id, project_id, goal_id, parent_doc_id, version, status, tags_json, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ).run(id, input.companyId, input.type, input.title, input.content ?? "", input.authorAgentId ?? null, input.reviewerAgentId ?? null, input.projectId ?? null, input.goalId ?? null, input.parentDocId ?? null, input.version ?? 1, input.status ?? "draft", input.tagsJson ?? "[]", ts, ts); - } - return id; - } - - updateDocumentStatus(id: string, companyId: string, status: string): void { - this.db.prepare( - "UPDATE documents SET status = ?, updated_at = ? WHERE id = ? AND company_id = ?", - ).run(status, nowIso(), id, companyId); - } - - deleteDocument(id: string, companyId: string) { - this.db.prepare("delete from documents where id = ? and company_id = ?").run(id, companyId); - } - - createDocumentRevision(input: { - documentId: string; companyId: string; body: string; - changeSummary?: string | null; createdByAgentId?: string | null; createdByUserId?: string | null; - }): { id: string; revisionNumber: number } { - const id = randomUUID(); - const ts = nowIso(); - const current = this.db.prepare( - "SELECT version FROM documents WHERE id = ? AND company_id = ?", - ).get(input.documentId, input.companyId) as { version: number } | undefined; - if (!current) throw new Error(`Document ${input.documentId} not found`); - const revisionNumber = current.version + 1; - this.db.prepare( - "INSERT INTO document_revisions (id, company_id, document_id, revision_number, body, change_summary, created_by_agent_id, created_by_user_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", - ).run(id, input.companyId, input.documentId, revisionNumber, input.body, input.changeSummary ?? null, input.createdByAgentId ?? null, input.createdByUserId ?? null, ts); - this.db.prepare( - "UPDATE documents SET version = ?, latest_revision_id = ?, updated_at = ? WHERE id = ?", - ).run(revisionNumber, id, ts, input.documentId); - return { id, revisionNumber }; - } - - getDocumentLatestRevisionId(documentId: string): string | null { - const row = this.db.prepare( - "SELECT latest_revision_id FROM documents WHERE id = ?", - ).get(documentId) as { latest_revision_id: string | null } | undefined; - return row?.latest_revision_id ?? null; - } - - listDocumentRevisions(documentId: string, companyId: string) { - return this.readAll<{ - id: string; company_id: string; document_id: string; revision_number: number; - body: string; change_summary: string | null; created_by_agent_id: string | null; - created_by_user_id: string | null; created_at: string; - }>( - "SELECT id, company_id, document_id, revision_number, body, change_summary, created_by_agent_id, created_by_user_id, created_at FROM document_revisions WHERE document_id = ? AND company_id = ? ORDER BY revision_number ASC", - documentId, companyId, - ).map((r) => ({ - id: r.id, companyId: r.company_id, documentId: r.document_id, - revisionNumber: r.revision_number, body: r.body, - changeSummary: r.change_summary, createdByAgentId: r.created_by_agent_id, - createdByUserId: r.created_by_user_id, createdAt: r.created_at, - })); - } - - addApprovalComment(input: { - companyId: string; approvalId: string; authorAgentId?: string | null; - authorUserId?: string | null; body: string; - }): string { - const id = randomUUID(); - const ts = nowIso(); - this.db.prepare( - "INSERT INTO approval_comments (id, company_id, approval_id, author_agent_id, author_user_id, body, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", - ).run(id, input.companyId, input.approvalId, input.authorAgentId ?? null, input.authorUserId ?? null, input.body, ts, ts); - return id; - } - - listApprovalComments(approvalId: string, companyId: string) { - return this.readAll<{ - id: string; company_id: string; approval_id: string; author_agent_id: string | null; - author_user_id: string | null; body: string; created_at: string; updated_at: string; - }>( - "SELECT id, company_id, approval_id, author_agent_id, author_user_id, body, created_at, updated_at FROM approval_comments WHERE approval_id = ? AND company_id = ? ORDER BY created_at ASC", - approvalId, companyId, - ).map((r) => ({ - id: r.id, companyId: r.company_id, approvalId: r.approval_id, - authorAgentId: r.author_agent_id, authorUserId: r.author_user_id, - body: r.body, createdAt: r.created_at, updatedAt: r.updated_at, - })); - } - - findMentionedAgentIds(companyId: string, text: string): string[] { - const mentionRe = /\B@([^\s@,!?.]+)/g; - const tokens = new Set(); - let m: RegExpExecArray | null; - while ((m = mentionRe.exec(text)) !== null) { - tokens.add(m[1]!.toLowerCase()); - } - if (tokens.size === 0) return []; - const agents = this.readAll<{ id: string; name: string }>( - "SELECT id, name FROM agents WHERE company_id = ?", companyId, - ); - const matched: string[] = []; - for (const agent of agents) { - if (tokens.has(agent.name.toLowerCase())) { - matched.push(agent.id); - } - } - return matched; - } - - resubmitApproval(approvalId: string, companyId: string): void { - const ts = nowIso(); - this.db.prepare( - "UPDATE approvals SET state = 'pending', decision_note = '', updated_at = ? WHERE id = ? AND company_id = ? AND state = 'revision_requested'", - ).run(ts, approvalId, companyId); - } - - linkTaskToApproval(taskId: string, approvalId: string, companyId: string, linkedByAgentId?: string | null): void { - this.db.prepare( - "INSERT OR IGNORE INTO task_approval_links (task_id, approval_id, company_id, linked_by_agent_id, created_at) VALUES (?, ?, ?, ?, ?)", - ).run(taskId, approvalId, companyId, linkedByAgentId ?? null, nowIso()); - } - - unlinkTaskFromApproval(taskId: string, approvalId: string): void { - this.db.prepare( - "DELETE FROM task_approval_links WHERE task_id = ? AND approval_id = ?", - ).run(taskId, approvalId); - } - - listApprovalsForTask(taskId: string): string[] { - return this.readAll<{ approval_id: string }>( - "SELECT approval_id FROM task_approval_links WHERE task_id = ?", taskId, - ).map((r) => r.approval_id); - } - - listTasksForApproval(approvalId: string): string[] { - return this.readAll<{ task_id: string }>( - "SELECT task_id FROM task_approval_links WHERE approval_id = ?", approvalId, - ).map((r) => r.task_id); - } - - linkManyTasksToApproval(approvalId: string, taskIds: string[], companyId: string, linkedByAgentId?: string | null): void { - const ts = nowIso(); - for (const taskId of taskIds) { - this.db.prepare( - "INSERT OR IGNORE INTO task_approval_links (task_id, approval_id, company_id, linked_by_agent_id, created_at) VALUES (?, ?, ?, ?, ?)", - ).run(taskId, approvalId, companyId, linkedByAgentId ?? null, ts); - } - } - - // ── Work Products ── - - createWorkProduct(input: { - companyId: string; taskId: string; projectId?: string | null; - type: string; provider: string; externalId?: string | null; - title: string; url?: string | null; status?: string; reviewState?: string; - isPrimary?: boolean; summary?: string | null; metadataJson?: string | null; - createdByRunId?: string | null; - }): string { - const id = randomUUID(); - const ts = nowIso(); - if (input.isPrimary) { - this.db.prepare( - "UPDATE work_products SET is_primary = 0, updated_at = ? WHERE company_id = ? AND task_id = ? AND type = ?", - ).run(ts, input.companyId, input.taskId, input.type); - } - this.db.prepare( - `INSERT INTO work_products (id, company_id, project_id, task_id, type, provider, external_id, title, url, status, review_state, is_primary, summary, metadata_json, created_by_run_id, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - id, input.companyId, input.projectId ?? null, input.taskId, - input.type, input.provider, input.externalId ?? null, - input.title, input.url ?? null, input.status ?? "active", - input.reviewState ?? "none", input.isPrimary ? 1 : 0, - input.summary ?? null, input.metadataJson ?? null, - input.createdByRunId ?? null, ts, ts, - ); - return id; - } - - updateWorkProduct(id: string, companyId: string, patch: Record): void { - const ts = nowIso(); - const sets: string[] = ["updated_at = ?"]; - const values: unknown[] = [ts]; - const ALLOWED = new Set(["type", "provider", "external_id", "title", "url", "status", "review_state", "is_primary", "summary", "metadata_json"]); - for (const [key, value] of Object.entries(patch)) { - const col = key.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`); - if (ALLOWED.has(col)) { - sets.push(`${col} = ?`); - values.push(col === "is_primary" ? (value ? 1 : 0) : value ?? null); - } - } - values.push(id, companyId); - this.db.prepare(`UPDATE work_products SET ${sets.join(", ")} WHERE id = ? AND company_id = ?`).run(...values); - } - - listWorkProductsForTask(taskId: string, companyId: string) { - return this.readAll<{ - id: string; company_id: string; project_id: string | null; task_id: string; - type: string; provider: string; external_id: string | null; title: string; - url: string | null; status: string; review_state: string; is_primary: number; - summary: string | null; metadata_json: string | null; created_by_run_id: string | null; - created_at: string; updated_at: string; - }>( - "SELECT * FROM work_products WHERE task_id = ? AND company_id = ? ORDER BY is_primary DESC, updated_at DESC", - taskId, companyId, - ).map((r) => ({ - id: r.id, companyId: r.company_id, projectId: r.project_id, taskId: r.task_id, - type: r.type, provider: r.provider, externalId: r.external_id, title: r.title, - url: r.url, status: r.status, reviewState: r.review_state, - isPrimary: Boolean(r.is_primary), summary: r.summary, - metadataJson: r.metadata_json, createdByRunId: r.created_by_run_id, - createdAt: r.created_at, updatedAt: r.updated_at, - })); - } - - deleteWorkProduct(id: string, companyId: string): void { - this.db.prepare("DELETE FROM work_products WHERE id = ? AND company_id = ?").run(id, companyId); - } - - // ── Task-Document Links ── - - linkDocumentToTask(taskId: string, documentId: string, companyId: string, key: string): string { - const id = randomUUID(); - const ts = nowIso(); - this.db.prepare( - "INSERT OR REPLACE INTO task_documents (id, company_id, task_id, document_id, key, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", - ).run(id, companyId, taskId, documentId, key.toLowerCase(), ts, ts); - return id; - } - - unlinkDocumentFromTask(taskId: string, key: string, companyId: string): void { - this.db.prepare( - "DELETE FROM task_documents WHERE task_id = ? AND key = ? AND company_id = ?", - ).run(taskId, key.toLowerCase(), companyId); - } - - listDocumentsForTask(taskId: string, companyId: string) { - return this.readAll<{ - id: string; company_id: string; task_id: string; document_id: string; - key: string; created_at: string; updated_at: string; - }>( - "SELECT * FROM task_documents WHERE task_id = ? AND company_id = ? ORDER BY key ASC", - taskId, companyId, - ).map((r) => ({ - id: r.id, companyId: r.company_id, taskId: r.task_id, - documentId: r.document_id, key: r.key, - createdAt: r.created_at, updatedAt: r.updated_at, - })); - } - - saveKnowledgeEntry(input: { - id?: string | null; companyId: string; category: string; topic: string; content: string; - authorAgentId?: string | null; importance?: string; tagsJson?: string; - referencedEntityType?: string | null; referencedEntityId?: string | null; - }) { - const id = ensureId(input.id ?? null); - const ts = nowIso(); - const existing = this.db.prepare("select id from knowledge_base where id = ?").get(id) as { id: string } | undefined; - if (existing) { - this.db.prepare( - "update knowledge_base set category = ?, topic = ?, content = ?, author_agent_id = ?, importance = ?, tags_json = ?, referenced_entity_type = ?, referenced_entity_id = ?, updated_at = ? where id = ?", - ).run(input.category, input.topic, input.content, input.authorAgentId ?? null, input.importance ?? "medium", input.tagsJson ?? "[]", input.referencedEntityType ?? null, input.referencedEntityId ?? null, ts, id); - } else { - this.db.prepare( - "insert into knowledge_base (id, company_id, category, topic, content, author_agent_id, importance, tags_json, referenced_entity_type, referenced_entity_id, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ).run(id, input.companyId, input.category, input.topic, input.content, input.authorAgentId ?? null, input.importance ?? "medium", input.tagsJson ?? "[]", input.referencedEntityType ?? null, input.referencedEntityId ?? null, ts, ts); - } - return id; - } - - deleteKnowledgeEntry(id: string, companyId: string) { - this.db.prepare("delete from knowledge_base where id = ? and company_id = ?").run(id, companyId); - } - - saveSprint(input: { - id?: string | null; companyId: string; name: string; goal?: string; - startDate: string; endDate: string; status?: string; retrospectiveNotes?: string; - velocityPoints?: number; completedPoints?: number; - }) { - const id = ensureId(input.id ?? null); - const ts = nowIso(); - const existing = this.db.prepare("select id from sprints where id = ?").get(id) as { id: string } | undefined; - if (existing) { - this.db.prepare( - "update sprints set name = ?, goal = ?, start_date = ?, end_date = ?, status = ?, retrospective_notes = ?, velocity_points = ?, completed_points = ?, updated_at = ? where id = ?", - ).run(input.name, input.goal ?? "", input.startDate, input.endDate, input.status ?? "planning", input.retrospectiveNotes ?? "", input.velocityPoints ?? 0, input.completedPoints ?? 0, ts, id); - } else { - this.db.prepare( - "insert into sprints (id, company_id, name, goal, start_date, end_date, status, retrospective_notes, velocity_points, completed_points, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ).run(id, input.companyId, input.name, input.goal ?? "", input.startDate, input.endDate, input.status ?? "planning", input.retrospectiveNotes ?? "", input.velocityPoints ?? 0, input.completedPoints ?? 0, ts, ts); - } - return id; - } - - deleteSprint(id: string, companyId: string) { - this.db.prepare("delete from sprints where id = ? and company_id = ?").run(id, companyId); - } - - saveAutomationRule(input: { - id?: string | null; companyId: string; name: string; description?: string; - trigger: string; conditionsJson?: string; action: string; actionConfigJson?: string; - sourceDepartment?: string | null; targetDepartment?: string | null; - priority?: number; status?: string; - }) { - const id = ensureId(input.id ?? null); - const ts = nowIso(); - const existing = this.db.prepare("select id from automation_rules where id = ?").get(id) as { id: string } | undefined; - if (existing) { - this.db.prepare( - "update automation_rules set name = ?, description = ?, trigger = ?, conditions_json = ?, action = ?, action_config_json = ?, source_department = ?, target_department = ?, priority = ?, status = ?, updated_at = ? where id = ?", - ).run(input.name, input.description ?? "", input.trigger, input.conditionsJson ?? "{}", input.action, input.actionConfigJson ?? "{}", input.sourceDepartment ?? null, input.targetDepartment ?? null, input.priority ?? 50, input.status ?? "active", ts, id); - } else { - this.db.prepare( - "insert into automation_rules (id, company_id, name, description, trigger, conditions_json, action, action_config_json, source_department, target_department, priority, status, execution_count, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)", - ).run(id, input.companyId, input.name, input.description ?? "", input.trigger, input.conditionsJson ?? "{}", input.action, input.actionConfigJson ?? "{}", input.sourceDepartment ?? null, input.targetDepartment ?? null, input.priority ?? 50, input.status ?? "active", ts, ts); - } - this.addActivity({ companyId: input.companyId, actor: "system", action: existing ? "updated" : "created", entityType: "automation_rule", entityId: id, detail: `${existing ? "Updated" : "Created"} automation rule: ${input.name}` }); - return id; - } - - deleteAutomationRule(id: string, companyId: string) { - this.db.prepare("delete from automation_rules where id = ? and company_id = ?").run(id, companyId); - } - - listAutomationRuleNames(companyId: string): Set { - return new Set( - this.readAll<{ name: string }>( - "select name from automation_rules where company_id = ?", - companyId, - ).map((row) => row.name), - ); - } - - toggleAutomationRule(id: string, companyId: string, status: "active" | "paused") { - this.db.prepare("update automation_rules set status = ?, updated_at = ? where id = ? and company_id = ?").run(status, nowIso(), id, companyId); - } - - recordAutomationExecution(ruleId: string, companyId: string, ruleName: string, trigger: string, action: string, detail: string) { - const id = randomUUID(); - this.db.prepare("insert into automation_log (id, company_id, rule_id, rule_name, trigger, action, detail, executed_at) values (?, ?, ?, ?, ?, ?, ?, ?)").run(id, companyId, ruleId, ruleName, trigger, action, detail, nowIso()); - this.db.prepare("update automation_rules set execution_count = execution_count + 1, last_executed_at = ? where id = ?").run(nowIso(), ruleId); - } - - getAutomationLog(companyId: string) { - return this.readAll<{ ruleId: string; ruleName: string; trigger: string; action: string; executedAt: string }>( - "select rule_id as ruleId, rule_name as ruleName, trigger, action, executed_at as executedAt from automation_log where company_id = ? order by executed_at desc limit 100", - companyId, - ); - } - - sendAgentMessage(input: { - companyId: string; fromAgentId: string; toAgentId?: string | null; - channel?: string; channelTargetId?: string | null; subject: string; body: string; - priority?: string; parentMessageId?: string | null; attachmentsJson?: string; - }) { - const channel = input.channel ?? "direct"; - if (channel === "direct" && !input.toAgentId) { - throw new Error("Direct messages require a recipient."); - } - const id = randomUUID(); - this.db.prepare( - "insert into agent_messages (id, company_id, from_agent_id, to_agent_id, channel, channel_target_id, subject, body, priority, parent_message_id, attachments_json, created_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ).run(id, input.companyId, input.fromAgentId, input.toAgentId ?? null, channel, input.channelTargetId ?? null, input.subject, input.body, input.priority ?? "normal", input.parentMessageId ?? null, input.attachmentsJson ?? "[]", nowIso()); - this.addActivity({ companyId: input.companyId, actor: input.fromAgentId, action: "sent_message", entityType: "agent_message", entityId: id, detail: `Message: ${input.subject}` }); - return id; - } - - getAgentMessage(messageId: string, companyId: string): AgentMessageRecord | null { - const row = this.db.prepare( - "select id, company_id as companyId, from_agent_id as fromAgentId, to_agent_id as toAgentId, channel, channel_target_id as channelTargetId, subject, body, priority, read_at as readAt, parent_message_id as parentMessageId, attachments_json as attachmentsJson, created_at as createdAt from agent_messages where id = ? and company_id = ?", - ).get(messageId, companyId) as AgentMessageRecord | undefined; - return row ?? null; - } - - listAgentMessages(companyId: string, opts?: { - agentId?: string; - messageId?: string; - channel?: string; - channelTargetId?: string | null; - unreadOnly?: boolean; - limit?: number; - }) { - let sql = "select id, company_id as companyId, from_agent_id as fromAgentId, to_agent_id as toAgentId, channel, channel_target_id as channelTargetId, subject, body, priority, read_at as readAt, parent_message_id as parentMessageId, attachments_json as attachmentsJson, created_at as createdAt from agent_messages where company_id = ?"; - const params: SQLInputValue[] = [companyId]; - if (opts?.messageId) { - sql += " and id = ?"; - params.push(opts.messageId); - } - if (opts?.channel) { sql += " and channel = ?"; params.push(opts.channel); } - if (opts?.channelTargetId !== undefined && opts?.channelTargetId !== null) { - sql += " and channel_target_id = ?"; params.push(opts.channelTargetId); - } - sql += " order by created_at asc"; - - let messages = this.readAll(sql, ...params); - if (!opts?.agentId) { - const boardReadReceiptMap = new Map( - this.readAll<{ messageId: string; readAt: string }>( - `select reads.message_id as messageId, reads.read_at as readAt - from agent_message_reads reads - join agent_messages messages on messages.id = reads.message_id - where reads.agent_id = ? and messages.company_id = ?`, - BOARD_READER_ID, - companyId, - ).map((row) => [row.messageId, row.readAt]), - ); - messages = messages.map((message) => ({ - ...message, - readAt: boardReadReceiptMap.get(message.id) ?? null, - })); - } - if (opts?.agentId) { - let department: string | null = null; - try { - const agent = this.getAgent(opts.agentId); - if (agent.companyId === companyId) { - department = agent.department; - } - } catch { - department = null; - } - - const relevantProjectIds = new Set(); - for (const row of this.readAll<{ projectId: string | null }>( - "select distinct project_id as projectId from tasks where company_id = ? and assignee_agent_id = ? and project_id is not null", - companyId, - opts.agentId, - )) { - if (row.projectId) relevantProjectIds.add(row.projectId); - } - for (const row of this.readAll<{ id: string }>( - "select id from projects where company_id = ? and lead_agent_id = ?", - companyId, - opts.agentId, - )) { - relevantProjectIds.add(row.id); - } - - const readReceiptMap = new Map( - this.readAll<{ messageId: string; readAt: string }>( - `select reads.message_id as messageId, reads.read_at as readAt - from agent_message_reads reads - join agent_messages messages on messages.id = reads.message_id - where reads.agent_id = ? and messages.company_id = ?`, - opts.agentId, - companyId, - ).map((row) => [row.messageId, row.readAt]), - ); - - messages = messages.filter((message) => { - if (message.fromAgentId === opts.agentId) { - return true; - } - if (message.channel === "direct") { - return message.toAgentId === opts.agentId; - } - if (message.channel === "company" || message.channel === "incident") { - return true; - } - if (message.channel === "department") { - return Boolean(department) && message.channelTargetId === department; - } - if (message.channel === "project") { - return Boolean(message.channelTargetId && relevantProjectIds.has(message.channelTargetId)); - } - return false; - }).map((message) => { - if (message.fromAgentId === opts.agentId) { - return { ...message, readAt: message.createdAt }; - } - return { - ...message, - readAt: readReceiptMap.get(message.id) ?? null, - }; - }); - } - if (opts?.unreadOnly) { - messages = messages.filter((message) => !message.readAt); - } - const limit = opts?.limit ?? 200; - return limit > 0 ? messages.slice(-limit) : messages; - } - - searchMessages(companyId: string, query: string, opts?: { - channel?: string; - channelTargetId?: string | null; - limit?: number; - }): AgentMessageRecord[] { - const escaped = query.replace(/[%_]/g, "\\$&"); - const pattern = `%${escaped}%`; - let sql = `SELECT id, company_id AS companyId, from_agent_id AS fromAgentId, - to_agent_id AS toAgentId, channel, channel_target_id AS channelTargetId, - subject, body, priority, read_at AS readAt, parent_message_id AS parentMessageId, - attachments_json AS attachmentsJson, created_at AS createdAt - FROM agent_messages WHERE company_id = ? AND (subject LIKE ? ESCAPE '\\' OR body LIKE ? ESCAPE '\\')`; - const params: SQLInputValue[] = [companyId, pattern, pattern]; - if (opts?.channel) { sql += " AND channel = ?"; params.push(opts.channel); } - if (opts?.channelTargetId !== undefined && opts?.channelTargetId !== null) { sql += " AND channel_target_id = ?"; params.push(opts.channelTargetId); } - sql += " ORDER BY created_at DESC LIMIT ?"; - params.push(opts?.limit ?? 50); - return this.readAll(sql, ...params); - } - - markMessageRead(messageId: string, companyId: string) { - this.markAgentMessageRead(messageId, companyId, BOARD_READER_ID); - } - - markMessageReadForReader(messageId: string, companyId: string, readerAgentId?: string | null) { - this.markAgentMessageRead(messageId, companyId, readerAgentId ?? BOARD_READER_ID); - } - - markAgentMessageRead(messageId: string, companyId: string, agentId: string) { - const ts = nowIso(); - this.db.prepare( - `insert into agent_message_reads (message_id, agent_id, read_at) - select id, ?, ? - from agent_messages - where id = ? and company_id = ? - on conflict(message_id, agent_id) do update set read_at = excluded.read_at`, - ).run(agentId, ts, messageId, companyId); - } - - saveWorkflow(input: { - id?: string | null; companyId: string; name: string; description?: string; - stepsJson?: string; triggerType: string; triggerConfigJson?: string; status?: string; - }) { - const id = ensureId(input.id ?? null); - const ts = nowIso(); - const existing = this.db.prepare("select id from workflow_pipelines where id = ?").get(id) as { id: string } | undefined; - if (existing) { - this.db.prepare( - "update workflow_pipelines set name = ?, description = ?, steps_json = ?, trigger_type = ?, trigger_config_json = ?, status = ?, updated_at = ? where id = ?", - ).run(input.name, input.description ?? "", input.stepsJson ?? "[]", input.triggerType, input.triggerConfigJson ?? "{}", input.status ?? "draft", ts, id); - } else { - this.db.prepare( - "insert into workflow_pipelines (id, company_id, name, description, steps_json, trigger_type, trigger_config_json, status, current_step_index, run_count, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?)", - ).run(id, input.companyId, input.name, input.description ?? "", input.stepsJson ?? "[]", input.triggerType, input.triggerConfigJson ?? "{}", input.status ?? "draft", ts, ts); - } - this.addActivity({ companyId: input.companyId, actor: "system", action: existing ? "updated" : "created", entityType: "workflow", entityId: id, detail: `${existing ? "Updated" : "Created"} workflow: ${input.name}` }); - return id; - } - - getWorkflowRecord(id: string, companyId: string): WorkflowPipelineRecord | null { - const row = this.db.prepare( - "select id, company_id as companyId, name, description, steps_json as stepsJson, trigger_type as triggerType, trigger_config_json as triggerConfigJson, status, current_step_index as currentStepIndex, run_count as runCount, last_run_at as lastRunAt, created_at as createdAt, updated_at as updatedAt from workflow_pipelines where id = ? and company_id = ?", - ).get(id, companyId) as WorkflowPipelineRecord | undefined; - return row ?? null; - } - - updateWorkflowExecution(input: { - id: string; - companyId: string; - status: string; - currentStepIndex: number; - stepsJson: string; - incrementRunCount?: boolean; - lastRunAt?: string | null; - }) { - const ts = nowIso(); - const runCountClause = input.incrementRunCount ? ", run_count = run_count + 1" : ""; - const lastRunAt = input.lastRunAt ?? null; - this.db.prepare( - `update workflow_pipelines - set status = ?, current_step_index = ?, steps_json = ?, last_run_at = ?, updated_at = ?${runCountClause} - where id = ? and company_id = ?`, - ).run(input.status, input.currentStepIndex, input.stepsJson, lastRunAt, ts, input.id, input.companyId); - } - - deleteWorkflow(id: string, companyId: string) { - this.db.prepare("delete from workflow_pipelines where id = ? and company_id = ?").run(id, companyId); - } - - listActiveAutomationRules(companyId: string, trigger: string): Array<{ - id: string; name: string; trigger: string; conditionsJson: string; - action: string; actionConfigJson: string; sourceDepartment: string | null; - targetDepartment: string | null; priority: number; - }> { - return this.readAll( - "select id, name, trigger, conditions_json as conditionsJson, action, action_config_json as actionConfigJson, source_department as sourceDepartment, target_department as targetDepartment, priority from automation_rules where company_id = ? and trigger = ? and status = 'active' order by priority desc", - companyId, trigger, - ); - } - - private static readonly VALID_TASK_STATUSES = new Set(["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"]); - - advanceTaskStatus(taskId: string, newStatus: string, comment?: string): { previousStatus: string } { - if (!AppDatabase.VALID_TASK_STATUSES.has(newStatus)) { - throw new Error(`Invalid task status: ${newStatus}`); - } - const task = this.db.prepare("select status, company_id from tasks where id = ?").get(taskId) as { status: string; company_id: string } | undefined; - if (!task) throw new Error(`Task not found: ${taskId}`); - const previousStatus = task.status; - if (previousStatus === newStatus) return { previousStatus }; - const ts = nowIso(); - - // Status side-effect timestamps (Paperclip pattern) - const timestampSets: string[] = ["status = ?", "updated_at = ?"]; - const timestampValues: (string | null)[] = [newStatus, ts]; - - if (newStatus === "in_progress") { - // Set startedAt only once (first time entering in_progress) - timestampSets.push("started_at = COALESCE(started_at, ?)"); - timestampValues.push(ts); - } - if (newStatus === "done") { - timestampSets.push("completed_at = ?"); - timestampValues.push(ts); - } else { - timestampSets.push("completed_at = NULL"); - } - if (newStatus === "cancelled") { - timestampSets.push("cancelled_at = ?"); - timestampValues.push(ts); - } else { - timestampSets.push("cancelled_at = NULL"); - } - if (newStatus !== "in_progress") { - timestampSets.push("checkout_run_id = NULL"); - } - - this.db.prepare(`UPDATE tasks SET ${timestampSets.join(", ")} WHERE id = ?`).run(...timestampValues, taskId); - - if (comment) { - this.addComment({ - companyId: task.company_id, - taskId, - authorAgentId: null, - authorName: "System", - body: comment, - }); - } - if (newStatus === "done" && previousStatus !== "done") { - this.incrementActiveSprintVelocity(task.company_id, 1); - } - if (previousStatus === "done" && newStatus !== "done") { - this.incrementActiveSprintVelocity(task.company_id, -1); - } - return { previousStatus }; - } - - setTaskRequiresUserReview(taskId: string, value: boolean) { - const ts = nowIso(); - this.db.prepare("update tasks set requires_user_review = ?, updated_at = ? where id = ?").run(value ? 1 : 0, ts, taskId); - } - - /** Returns true if the company has any pending deliverable_review approvals or tasks awaiting review. */ - hasPendingDeliverableReviews(companyId: string): boolean { - const approval = this.db.prepare( - "select 1 from approvals where company_id = ? and type = 'deliverable_review' and state = 'pending' limit 1", - ).get(companyId); - if (approval) return true; - const flaggedTask = this.db.prepare( - "select 1 from tasks where company_id = ? and requires_user_review = 1 limit 1", - ).get(companyId); - return !!flaggedTask; - } - - /** Returns true if this specific agent has tasks with pending deliverable_review approvals. */ - hasAgentPendingDeliverableReview(agentId: string, companyId: string): boolean { - const row = this.db.prepare( - `select 1 from approvals a - join tasks t on a.related_task_id = t.id - where a.company_id = ? and a.type = 'deliverable_review' and a.state = 'pending' - and t.assignee_agent_id = ? - limit 1`, - ).get(companyId, agentId); - return !!row; - } - - /** Returns true if agent has any tasks flagged requires_user_review (before approval is created). */ - hasAgentTaskAwaitingReview(agentId: string, companyId: string): boolean { - const row = this.db.prepare( - "select 1 from tasks where assignee_agent_id = ? and company_id = ? and requires_user_review = 1 limit 1", - ).get(agentId, companyId); - return !!row; - } - - private incrementActiveSprintVelocity(companyId: string, delta: number) { - const ts = nowIso(); - const today = ts.slice(0, 10); - this.db.prepare( - "update sprints set completed_points = max(0, completed_points + ?), updated_at = ? where company_id = ? and status = 'active' and start_date <= ? and end_date >= ?", - ).run(delta, ts, companyId, today, today); - } - - getIdleAgentsForDepartment(companyId: string, department: string): Array<{ id: string; name: string }> { - return this.readAll( - "select id, name from agents where company_id = ? and department = ? and status in ('idle', 'active') order by spent_monthly_usd asc", - companyId, department, - ); - } - - getManagerForAgent(agentId: string): { id: string; name: string; companyId: string } | null { - const agent = this.db.prepare("select reports_to, company_id from agents where id = ?").get(agentId) as { reports_to: string | null; company_id: string } | undefined; - if (!agent || !agent.reports_to) return null; - const manager = this.db.prepare("select id, name from agents where id = ?").get(agent.reports_to) as { id: string; name: string } | undefined; - return manager ? { ...manager, companyId: agent.company_id } : null; - } - - listTriggerableWorkflows(companyId: string, triggerType: string): WorkflowPipelineRecord[] { - return this.readAll( - "select id, company_id as companyId, name, description, steps_json as stepsJson, trigger_type as triggerType, trigger_config_json as triggerConfigJson, status, current_step_index as currentStepIndex, run_count as runCount, last_run_at as lastRunAt, created_at as createdAt, updated_at as updatedAt from workflow_pipelines where company_id = ? and trigger_type = ? and status = 'active' order by created_at asc", - companyId, - triggerType, - ) as WorkflowPipelineRecord[]; - } - - autoAssignTask(taskId: string, companyId: string): string | null { - const task = this.db.prepare( - "select id, title, description, project_id as projectId, assignee_agent_id as assigneeAgentId from tasks where id = ? and company_id = ?", - ).get(taskId, companyId) as { id: string; title: string; description: string; projectId: string | null; assigneeAgentId: string | null } | undefined; - if (!task) return null; - - // If task already has an assignee, return them (don't reassign) - if (task.assigneeAgentId) return task.assigneeAgentId; - - // Get eligible agents: active/idle with remaining budget - const candidates = this.readAll<{ id: string; name: string; department: string | null; capabilities: string; taskCount: number; budgetMonthlyUsd: number; spentMonthlyUsd: number }>( - `select a.id, a.name, a.department, a.capabilities, - a.budget_monthly_usd as budgetMonthlyUsd, a.spent_monthly_usd as spentMonthlyUsd, - (select count(*) from tasks t where t.assignee_agent_id = a.id and t.status in ('todo', 'in_progress')) as taskCount - from agents a - where a.company_id = ? and a.status in ('idle', 'active') - order by taskCount asc`, - companyId, - ); - - // Filter out agents who have exhausted their budget (budget=0 means unlimited) - const eligible = candidates.filter((a) => { - if (a.budgetMonthlyUsd > 0 && a.spentMonthlyUsd >= a.budgetMonthlyUsd) return false; - return true; - }); - - if (eligible.length === 0) return null; - - // Score candidates by capability match, department relevance, project context, and workload - const words = `${task.title} ${task.description}`.toLowerCase().split(/\W+/).filter(w => w.length > 3); - - // If task belongs to a project, prefer agents who lead that project or its department - let projectDept: string | null = null; - let projectLeadId: string | null = null; - if (task.projectId) { - const project = this.db.prepare("select lead_agent_id, goal_id from projects where id = ? and company_id = ?").get(task.projectId, companyId) as { lead_agent_id: string | null; goal_id: string | null } | undefined; - projectLeadId = project?.lead_agent_id ?? null; - if (projectLeadId) { - const lead = this.db.prepare("select department from agents where id = ? and company_id = ?").get(projectLeadId, companyId) as { department: string | null } | undefined; - projectDept = lead?.department ?? null; - } - } - - let bestAgent: string | null = null; - let bestScore = -Infinity; - - for (const agent of eligible) { - const capWords = agent.capabilities.toLowerCase().split(/\W+/).filter(w => w.length > 3); - const overlap = words.filter(w => capWords.includes(w)).length; - let score = (overlap * 10) - (agent.taskCount * 5); - // Bonus for same department as project lead - if (projectDept && agent.department === projectDept) score += 15; - // Bonus for agents who are not managers (ICs should do the work) - const hasReports = this.readAll<{ id: string }>("select id from agents where reports_to = ? limit 1", agent.id); - if (hasReports.length === 0) score += 5; - if (score > bestScore || (score === bestScore && agent.taskCount < (eligible.find(c => c.id === bestAgent)?.taskCount ?? 999))) { - bestScore = score; - bestAgent = agent.id; - } - } - - // Fallback: pick least loaded agent - if (!bestAgent && eligible.length > 0) { - bestAgent = eligible[0].id; // Already sorted by taskCount asc - } - - this.db.prepare("update tasks set assignee_agent_id = ?, updated_at = ? where id = ? and company_id = ?").run(bestAgent, nowIso(), taskId, companyId); - this.addActivity({ - companyId, - actor: "system", - action: "task.auto_assigned", - entityType: "task", - entityId: taskId, - detail: `Auto-assigned to ${eligible.find(c => c.id === bestAgent)?.name ?? bestAgent}`, - }); - return bestAgent; - } - - private runMigrations() { - this.db.exec(` - create table if not exists schema_migrations ( - version integer primary key, - name text not null, - applied_at text not null - ); - `); - - const applied = new Set( - this.readAll<{ version: number }>("select version from schema_migrations") - .map((row) => row.version), - ); - - const migrations: Array<{ version: number; name: string; sql?: string; fn?: () => void }> = [ - { - version: 1, - name: "add_workspace_lock_lease", - sql: ` - alter table workspaces add column lock_holder_run_id text; - alter table workspaces add column lock_acquired_at text; - `, - }, - { - version: 2, - name: "add_approval_related_agent_id", - sql: ` - alter table approvals add column related_agent_id text; - `, - }, - { - version: 3, - name: "add_profile_metadata", - sql: ` - insert or ignore into settings (key, value) values ('profile_version', '1'); - insert or ignore into settings (key, value) values ('profile_created_at', '${nowIso()}'); - `, - }, - { - version: 4, - name: "add_connector_capability_snapshot", - sql: ` - alter table connectors add column capability_json text; - `, - }, - { - version: 6, - name: "add_agent_department", - sql: `alter table agents add column department text`, - }, - { - version: 7, - name: "add_meetings_table", - sql: ` - create table if not exists meetings ( - id text primary key, - company_id text not null, - type text not null, - title text not null, - organizer_agent_id text, - participant_agent_ids text not null default '[]', - scheduled_at text not null, - duration_minutes integer not null default 30, - agenda_json text not null default '[]', - notes_json text not null default '[]', - decisions_json text not null default '[]', - action_items_json text not null default '[]', - status text not null default 'scheduled', - created_at text not null, - updated_at text not null - ); - create index if not exists idx_meetings_company on meetings(company_id); - create index if not exists idx_meetings_organizer on meetings(organizer_agent_id) - `, - }, - { - version: 8, - name: "add_documents_table", - sql: ` - create table if not exists documents ( - id text primary key, - company_id text not null, - type text not null, - title text not null, - content text not null default '', - author_agent_id text, - reviewer_agent_id text, - project_id text, - goal_id text, - parent_doc_id text, - version integer not null default 1, - status text not null default 'draft', - tags_json text not null default '[]', - created_at text not null, - updated_at text not null - ); - create index if not exists idx_documents_company on documents(company_id); - create index if not exists idx_documents_author on documents(author_agent_id); - create index if not exists idx_documents_project on documents(project_id) - `, - }, - { - version: 9, - name: "add_knowledge_base_table", - sql: ` - create table if not exists knowledge_base ( - id text primary key, - company_id text not null, - category text not null, - topic text not null, - content text not null, - author_agent_id text, - importance text not null default 'medium', - tags_json text not null default '[]', - referenced_entity_type text, - referenced_entity_id text, - created_at text not null, - updated_at text not null - ); - create index if not exists idx_knowledge_company on knowledge_base(company_id); - create index if not exists idx_knowledge_category on knowledge_base(category) - `, - }, - { - version: 10, - name: "add_sprints_table", - sql: ` - create table if not exists sprints ( - id text primary key, - company_id text not null, - name text not null, - goal text not null default '', - start_date text not null, - end_date text not null, - status text not null default 'planning', - retrospective_notes text not null default '', - velocity_points integer not null default 0, - completed_points integer not null default 0, - created_at text not null, - updated_at text not null - ); - create index if not exists idx_sprints_company on sprints(company_id) - `, - }, - { - version: 11, - name: "add_automation_messages_workflows", - sql: ` - create table if not exists automation_rules ( - id text primary key, - company_id text not null, - name text not null, - description text not null default '', - trigger text not null, - conditions_json text not null default '{}', - action text not null, - action_config_json text not null default '{}', - source_department text, - target_department text, - priority integer not null default 50, - status text not null default 'active', - execution_count integer not null default 0, - last_executed_at text, - created_at text not null, - updated_at text not null - ); - create index if not exists idx_automation_rules_company on automation_rules(company_id); - create index if not exists idx_automation_rules_trigger on automation_rules(trigger); - - create table if not exists agent_messages ( - id text primary key, - company_id text not null, - from_agent_id text not null, - to_agent_id text, - channel text not null default 'direct', - channel_target_id text, - subject text not null, - body text not null, - priority text not null default 'normal', - read_at text, - parent_message_id text, - attachments_json text not null default '[]', - created_at text not null - ); - create index if not exists idx_agent_messages_company on agent_messages(company_id); - create index if not exists idx_agent_messages_to on agent_messages(to_agent_id); - create index if not exists idx_agent_messages_from on agent_messages(from_agent_id); - create index if not exists idx_agent_messages_channel on agent_messages(channel); - - create table if not exists workflow_pipelines ( - id text primary key, - company_id text not null, - name text not null, - description text not null default '', - steps_json text not null default '[]', - trigger_type text not null, - trigger_config_json text not null default '{}', - status text not null default 'draft', - current_step_index integer not null default 0, - run_count integer not null default 0, - last_run_at text, - created_at text not null, - updated_at text not null - ); - create index if not exists idx_workflows_company on workflow_pipelines(company_id); - - create table if not exists automation_log ( - id text primary key, - company_id text not null, - rule_id text not null, - rule_name text not null, - trigger text not null, - action text not null, - detail text not null default '', - executed_at text not null - ); - create index if not exists idx_automation_log_company on automation_log(company_id) - `, - }, - { - version: 12, - name: "add_agent_message_reads", - sql: ` - create table if not exists agent_message_reads ( - message_id text not null, - agent_id text not null, - read_at text not null, - primary key (message_id, agent_id) - ); - create index if not exists idx_agent_message_reads_agent on agent_message_reads(agent_id); - `, - }, - { - version: 13, - name: "add_checkout_session_budget_columns", - sql: ` - ALTER TABLE tasks ADD COLUMN checkout_run_id TEXT; - ALTER TABLE tasks ADD COLUMN execution_locked_at TEXT; - ALTER TABLE tasks ADD COLUMN active_session_id TEXT; - ALTER TABLE cost_entries ADD COLUMN project_id TEXT; - CREATE INDEX IF NOT EXISTS idx_cost_entries_project ON cost_entries(project_id) - `, - }, - { - version: 14, - name: "create_skills_budget_wakeup_session_tables", - sql: ` - CREATE TABLE IF NOT EXISTS company_skills ( - id TEXT PRIMARY KEY, - company_id TEXT NOT NULL, - key TEXT NOT NULL, - slug TEXT NOT NULL, - name TEXT NOT NULL, - description TEXT, - markdown TEXT NOT NULL DEFAULT '', - source_type TEXT NOT NULL DEFAULT 'local_path', - source_locator TEXT, - source_ref TEXT, - trust_level TEXT NOT NULL DEFAULT 'markdown_only', - compatibility TEXT NOT NULL DEFAULT 'compatible', - file_inventory TEXT NOT NULL DEFAULT '[]', - metadata TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - ); - CREATE UNIQUE INDEX IF NOT EXISTS idx_company_skills_company_key ON company_skills(company_id, key); - CREATE INDEX IF NOT EXISTS idx_company_skills_company_name ON company_skills(company_id, name); - - CREATE TABLE IF NOT EXISTS budget_policies ( - id TEXT PRIMARY KEY, - company_id TEXT NOT NULL, - scope_type TEXT NOT NULL, - scope_id TEXT NOT NULL, - metric TEXT NOT NULL DEFAULT 'billed_cents', - window_kind TEXT NOT NULL DEFAULT 'calendar_month_utc', - amount INTEGER NOT NULL DEFAULT 0, - warn_percent INTEGER NOT NULL DEFAULT 80, - hard_stop_enabled INTEGER NOT NULL DEFAULT 1, - notify_enabled INTEGER NOT NULL DEFAULT 1, - is_active INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_budget_policies_company_scope ON budget_policies(company_id, scope_type, scope_id, is_active); - CREATE UNIQUE INDEX IF NOT EXISTS idx_budget_policies_unique ON budget_policies(company_id, scope_type, scope_id, metric, window_kind); - - CREATE TABLE IF NOT EXISTS budget_incidents ( - id TEXT PRIMARY KEY, - company_id TEXT NOT NULL, - policy_id TEXT NOT NULL REFERENCES budget_policies(id), - scope_type TEXT NOT NULL, - scope_id TEXT NOT NULL, - metric TEXT NOT NULL, - window_kind TEXT NOT NULL, - window_start TEXT NOT NULL, - window_end TEXT NOT NULL, - threshold_type TEXT NOT NULL, - amount_limit INTEGER NOT NULL, - amount_observed INTEGER NOT NULL, - status TEXT NOT NULL DEFAULT 'open', - resolved_at TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_budget_incidents_company_status ON budget_incidents(company_id, status); - CREATE INDEX IF NOT EXISTS idx_budget_incidents_company_scope ON budget_incidents(company_id, scope_type, scope_id, status); - CREATE INDEX IF NOT EXISTS idx_budget_incidents_policy ON budget_incidents(policy_id, window_start, threshold_type); - - CREATE TABLE IF NOT EXISTS task_sessions ( - id TEXT PRIMARY KEY, - company_id TEXT NOT NULL, - task_id TEXT NOT NULL, - agent_id TEXT NOT NULL, - adapter_type TEXT NOT NULL, - session_params_json TEXT, - session_display_id TEXT, - last_run_id TEXT, - last_error TEXT, - run_count INTEGER NOT NULL DEFAULT 0, - total_input_tokens INTEGER NOT NULL DEFAULT 0, - total_output_tokens INTEGER NOT NULL DEFAULT 0, - rotated_at TEXT, - rotation_reason TEXT, - handoff_markdown TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_task_sessions_lookup ON task_sessions(company_id, agent_id, adapter_type, task_id); - CREATE INDEX IF NOT EXISTS idx_task_sessions_company_agent ON task_sessions(company_id, agent_id, updated_at); - CREATE INDEX IF NOT EXISTS idx_task_sessions_company_task ON task_sessions(company_id, task_id, updated_at); - - CREATE TABLE IF NOT EXISTS wakeup_requests ( - id TEXT PRIMARY KEY, - company_id TEXT NOT NULL, - agent_id TEXT NOT NULL, - source TEXT NOT NULL, - trigger_detail TEXT, - reason TEXT, - payload TEXT, - status TEXT NOT NULL DEFAULT 'queued', - coalesced_count INTEGER NOT NULL DEFAULT 0, - requested_by_actor_type TEXT, - requested_by_actor_id TEXT, - idempotency_key TEXT, - run_id TEXT, - requested_at TEXT NOT NULL, - claimed_at TEXT, - finished_at TEXT, - error TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_wakeup_requests_company_agent_status ON wakeup_requests(company_id, agent_id, status); - CREATE INDEX IF NOT EXISTS idx_wakeup_requests_company_requested ON wakeup_requests(company_id, requested_at); - CREATE INDEX IF NOT EXISTS idx_wakeup_requests_agent_requested ON wakeup_requests(agent_id, requested_at) - `, - }, - { - version: 15, - name: "migrate_session_data_to_task_sessions", - fn: () => { - // D-01: Eager copy of session_json to task_sessions table - // Only process tasks that have session data but no active_session_id yet (idempotent) - const tasksWithSessions = this.readAll<{ - id: string; - companyId: string; - assigneeAgentId: string | null; - sessionJson: string | null; - sessionDisplayId: string | null; - }>( - `SELECT id, company_id as companyId, assignee_agent_id as assigneeAgentId, - session_json as sessionJson, session_display_id as sessionDisplayId - FROM tasks WHERE session_json IS NOT NULL AND active_session_id IS NULL` - ); - - let migrated = 0; - let skipped = 0; - - for (const task of tasksWithSessions) { - try { - // Validate JSON is parseable (D-02: skip corrupt rows) - JSON.parse(task.sessionJson!); - - // Look up agent's connector_id for adapter_type - const agent = task.assigneeAgentId - ? this.db.prepare("SELECT connector_id FROM agents WHERE id = ?").get(task.assigneeAgentId) as { connector_id: string } | undefined - : undefined; - const adapterType = agent?.connector_id ?? "unknown"; - const sessionId = randomUUID(); - const ts = nowIso(); - - this.db.prepare( - `INSERT INTO task_sessions (id, company_id, task_id, agent_id, adapter_type, - session_params_json, session_display_id, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` - ).run( - sessionId, task.companyId, task.id, - task.assigneeAgentId ?? "unassigned", adapterType, - task.sessionJson, task.sessionDisplayId, ts, ts - ); - - this.db.prepare("UPDATE tasks SET active_session_id = ? WHERE id = ?") - .run(sessionId, task.id); - - migrated++; - } catch (err) { - // D-02: Skip bad rows, log warning, don't block app startup - log.warn(`Session migration skipped task ${task.id}: ${err instanceof Error ? err.message : String(err)}`); - skipped++; - } - } - - log.info(`Session migration complete: ${migrated} migrated, ${skipped} skipped`); - }, - }, - { - version: 16, - name: "create_agent_skill_assignments", - sql: ` - CREATE TABLE IF NOT EXISTS agent_skill_assignments ( - agent_id TEXT NOT NULL, - skill_id TEXT NOT NULL, - created_at TEXT NOT NULL, - PRIMARY KEY (agent_id, skill_id), - FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE, - FOREIGN KEY (skill_id) REFERENCES company_skills(id) ON DELETE CASCADE - ); - CREATE INDEX IF NOT EXISTS idx_agent_skill_assignments_skill ON agent_skill_assignments(skill_id) - `, - }, - { - version: 17, - name: "migrate_pending_wakes_to_wakeup_requests", - fn: () => { - // D-08: Copy existing pending_wakes rows into wakeup_requests with 'queued' status - const existingWakes = this.readAll<{ - id: string; - companyId: string; - agentId: string; - triggerReason: string; - createdAt: string; - }>( - `SELECT id, company_id AS companyId, agent_id AS agentId, trigger_reason AS triggerReason, created_at AS createdAt - FROM pending_wakes`, - ); - const ts = nowIso(); - for (const wake of existingWakes) { - const triggerDetail = JSON.stringify([{ trigger: wake.triggerReason, at: wake.createdAt, detail: wake.triggerReason }]); - this.db - .prepare( - `INSERT OR IGNORE INTO wakeup_requests - (id, company_id, agent_id, source, trigger_detail, status, coalesced_count, requested_at, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, 'queued', 0, ?, ?, ?)`, - ) - .run(wake.id, wake.companyId, wake.agentId, wake.triggerReason, triggerDetail, wake.createdAt, ts, ts); - } - // Drop the old table - this.db.exec("DROP TABLE IF EXISTS pending_wakes"); - }, - }, - { - version: 18, - name: "migrate_agent_budget_to_policies", - fn: () => { - const agents = this.readAll<{ id: string; companyId: string; budgetMonthlyUsd: number }>( - "SELECT id, company_id as companyId, budget_monthly_usd as budgetMonthlyUsd FROM agents WHERE budget_monthly_usd > 0", - ); - const ts = nowIso(); - for (const agent of agents) { - const existing = this.db - .prepare( - "SELECT id FROM budget_policies WHERE company_id = ? AND scope_type = 'agent' AND scope_id = ? AND metric = 'billed_cents' AND window_kind = 'calendar_month_utc'", - ) - .get(agent.companyId, agent.id); - if (!existing) { - this.db - .prepare( - "INSERT INTO budget_policies (id, company_id, scope_type, scope_id, metric, window_kind, amount, warn_percent, hard_stop_enabled, notify_enabled, is_active, created_at, updated_at) VALUES (?, ?, 'agent', ?, 'billed_cents', 'calendar_month_utc', ?, 80, 1, 1, 1, ?, ?)", - ) - .run(randomUUID(), agent.companyId, agent.id, Math.round(agent.budgetMonthlyUsd * 100), ts, ts); - } - } - log.info(`Budget migration: converted ${agents.length} agent budget(s) to policy rows`); - }, - }, - { - version: 19, - name: "add_document_revisions_and_approval_comments", - fn: () => { - this.db.exec(` - CREATE TABLE IF NOT EXISTS document_revisions ( - id TEXT PRIMARY KEY, - company_id TEXT NOT NULL, - document_id TEXT NOT NULL, - revision_number INTEGER NOT NULL, - body TEXT NOT NULL, - change_summary TEXT, - created_by_agent_id TEXT, - created_by_user_id TEXT, - created_at TEXT NOT NULL, - FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE - ); - CREATE UNIQUE INDEX IF NOT EXISTS idx_doc_rev_doc_num ON document_revisions(document_id, revision_number); - CREATE INDEX IF NOT EXISTS idx_doc_rev_company ON document_revisions(company_id, document_id, created_at); - - CREATE TABLE IF NOT EXISTS approval_comments ( - id TEXT PRIMARY KEY, - company_id TEXT NOT NULL, - approval_id TEXT NOT NULL, - author_agent_id TEXT, - author_user_id TEXT, - body TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - FOREIGN KEY (approval_id) REFERENCES approvals(id) ON DELETE CASCADE - ); - CREATE INDEX IF NOT EXISTS idx_approval_comments_approval ON approval_comments(approval_id, created_at); - `); - // Add latest_revision_id to documents (may already exist via ensureOptionalColumns) - try { this.db.prepare("ALTER TABLE documents ADD COLUMN latest_revision_id TEXT").run(); } catch { /* column already exists */ } - }, - }, - { - version: 20, - name: "add_task_timestamps_and_task_approval_links", - fn: () => { - const addCol = (sql: string) => { try { this.db.prepare(sql).run(); } catch { /* already exists */ } }; - addCol("ALTER TABLE tasks ADD COLUMN started_at TEXT"); - addCol("ALTER TABLE tasks ADD COLUMN completed_at TEXT"); - addCol("ALTER TABLE tasks ADD COLUMN cancelled_at TEXT"); - this.db.exec(` - CREATE TABLE IF NOT EXISTS task_approval_links ( - task_id TEXT NOT NULL, - approval_id TEXT NOT NULL, - company_id TEXT NOT NULL, - linked_by_agent_id TEXT, - created_at TEXT NOT NULL, - PRIMARY KEY (task_id, approval_id), - FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, - FOREIGN KEY (approval_id) REFERENCES approvals(id) ON DELETE CASCADE - ); - CREATE INDEX IF NOT EXISTS idx_task_approval_links_task ON task_approval_links(task_id); - CREATE INDEX IF NOT EXISTS idx_task_approval_links_approval ON task_approval_links(approval_id); - `); - // Backfill: set started_at for tasks already in_progress/in_review/done - this.db.prepare( - "UPDATE tasks SET started_at = updated_at WHERE started_at IS NULL AND status IN ('in_progress', 'in_review', 'done')", - ).run(); - this.db.prepare( - "UPDATE tasks SET completed_at = updated_at WHERE completed_at IS NULL AND status = 'done'", - ).run(); - this.db.prepare( - "UPDATE tasks SET cancelled_at = updated_at WHERE cancelled_at IS NULL AND status = 'cancelled'", - ).run(); - }, - }, - { - version: 21, - name: "add_work_products_task_documents_wakeup_context", - fn: () => { - this.db.exec(` - CREATE TABLE IF NOT EXISTS work_products ( - id TEXT PRIMARY KEY, - company_id TEXT NOT NULL, - project_id TEXT, - task_id TEXT NOT NULL, - type TEXT NOT NULL, - provider TEXT NOT NULL, - external_id TEXT, - title TEXT NOT NULL, - url TEXT, - status TEXT NOT NULL DEFAULT 'active', - review_state TEXT NOT NULL DEFAULT 'none', - is_primary INTEGER NOT NULL DEFAULT 0, - summary TEXT, - metadata_json TEXT, - created_by_run_id TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE - ); - CREATE INDEX IF NOT EXISTS idx_work_products_task ON work_products(company_id, task_id, type); - CREATE INDEX IF NOT EXISTS idx_work_products_updated ON work_products(company_id, updated_at); - - CREATE TABLE IF NOT EXISTS task_documents ( - id TEXT PRIMARY KEY, - company_id TEXT NOT NULL, - task_id TEXT NOT NULL, - document_id TEXT NOT NULL, - key TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, - FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE - ); - CREATE UNIQUE INDEX IF NOT EXISTS idx_task_documents_task_key ON task_documents(company_id, task_id, key); - CREATE UNIQUE INDEX IF NOT EXISTS idx_task_documents_doc ON task_documents(document_id); - `); - // Add context_snapshot column to wakeup_requests for rich context merging - try { this.db.prepare("ALTER TABLE wakeup_requests ADD COLUMN context_snapshot TEXT").run(); } catch { /* already exists */ } - }, - }, - { - version: 22, - name: "add_config_revisions_run_events_agent_permissions", - fn: () => { - this.db.exec(` - CREATE TABLE IF NOT EXISTS agent_config_revisions ( - id TEXT PRIMARY KEY, - company_id TEXT NOT NULL, - agent_id TEXT NOT NULL, - revision_number INTEGER NOT NULL, - changed_keys TEXT NOT NULL DEFAULT '[]', - config_before TEXT NOT NULL, - config_after TEXT NOT NULL, - source TEXT NOT NULL DEFAULT 'patch', - rolled_back_from_revision_id TEXT, - created_by_agent_id TEXT, - created_by_user_id TEXT, - created_at TEXT NOT NULL, - FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE - ); - CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_config_rev_num ON agent_config_revisions(agent_id, revision_number); - CREATE INDEX IF NOT EXISTS idx_agent_config_rev_agent ON agent_config_revisions(company_id, agent_id, created_at); - - CREATE TABLE IF NOT EXISTS run_events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - company_id TEXT NOT NULL, - run_id TEXT NOT NULL, - agent_id TEXT NOT NULL, - seq INTEGER NOT NULL, - event_type TEXT NOT NULL, - stream TEXT, - level TEXT, - message TEXT, - payload TEXT, - created_at TEXT NOT NULL, - FOREIGN KEY (run_id) REFERENCES runs(id) ON DELETE CASCADE - ); - CREATE INDEX IF NOT EXISTS idx_run_events_run ON run_events(run_id, seq); - CREATE INDEX IF NOT EXISTS idx_run_events_company ON run_events(company_id, run_id); - `); - const addCol = (sql: string) => { try { this.db.prepare(sql).run(); } catch { /* already exists */ } }; - addCol("ALTER TABLE agents ADD COLUMN permissions_json TEXT NOT NULL DEFAULT '{}'"); - addCol("ALTER TABLE agents ADD COLUMN heartbeat_policy_json TEXT"); - addCol("ALTER TABLE agents ADD COLUMN runtime_state_json TEXT"); - addCol("ALTER TABLE runs ADD COLUMN context_snapshot_json TEXT"); - addCol("ALTER TABLE runs ADD COLUMN actor_type TEXT"); - addCol("ALTER TABLE runs ADD COLUMN actor_id TEXT"); - }, - }, - ]; - - for (const migration of migrations) { - if (applied.has(migration.version)) continue; - try { - this.transaction(() => { - if (migration.fn) { - migration.fn(); - } else if (migration.sql) { - for (const stmt of migration.sql.split(";").map((s) => s.trim()).filter(Boolean)) { - this.db.prepare(stmt).run(); - } - } - this.db - .prepare("insert into schema_migrations (version, name, applied_at) values (?, ?, ?)") - .run(migration.version, migration.name, nowIso()); - }); - } catch (error) { - // Only swallow "duplicate column" errors — these happen when - // ensureOptionalColumns already added the column before the migration - // system existed. All other errors must propagate. - const message = error instanceof Error ? error.message : String(error); - const isDuplicateColumn = /duplicate column|already exists/i.test(message); - if (isDuplicateColumn) { - // Idempotent case: column existed, mark migration as applied - this.db - .prepare("insert or ignore into schema_migrations (version, name, applied_at) values (?, ?, ?)") - .run(migration.version, migration.name, nowIso()); - } else { - // Real failure: disk error, SQL syntax, table corruption, etc. - // Do NOT mark as applied — the migration must be retried on next startup. - throw new Error(`Migration ${migration.version} (${migration.name}) failed: ${message}`); - } - } - } - } - - private migrateSecretsTable() { - const columns = this.readAll<{ name: string }>("pragma table_info(secrets)"); - if (columns.some((column) => column.name === "company_id")) { - this.db.prepare("create unique index if not exists secrets_company_name_idx on secrets(company_id, name)").run(); - return; - } - - const fallbackCompanyId = - ((this.db.prepare("select value from settings where key = 'current_company_id'").get() as { value: string } | undefined)?.value) ?? - ((this.db.prepare("select id from companies order by created_at asc limit 1").get() as { id: string } | undefined)?.id) ?? - "00000000-0000-0000-0000-000000000000"; - - this.db.exec(` - alter table secrets rename to secrets_legacy; - create table secrets ( - id text primary key, - company_id text not null, - name text not null, - description text not null, - backend text not null, - ciphertext blob not null, - updated_at text not null, - created_at text not null, - unique(company_id, name) - ); - `); - this.db - .prepare( - "insert into secrets (id, company_id, name, description, backend, ciphertext, updated_at, created_at) select id, ?, name, description, backend, ciphertext, updated_at, created_at from secrets_legacy", - ) - .run(fallbackCompanyId); - this.db.exec("drop table secrets_legacy;"); - } - - private transaction(operation: () => T): T { - this.db.exec("BEGIN IMMEDIATE"); - try { - const result = operation(); - this.db.exec("COMMIT"); - return result; - } catch (error) { - this.db.exec("ROLLBACK"); - throw error; - } - } - - private validateBackupDirectory(sourceDir: string) { - const validationDb = new DatabaseSync(join(sourceDir, "profile.sqlite")); - try { - const requiredTables = ["settings", "companies", "connectors"] as const; - for (const table of requiredTables) { - const row = validationDb - .prepare("select name from sqlite_master where type = 'table' and name = ?") - .get(table) as { name: string } | undefined; - if (!row) { - throw new Error(`Backup is missing the required table "${table}".`); - } - } - } finally { - validationDb.close(); - } - } - - private async applyBackup(sourceDir: string) { - await rm(this.dbPath, { force: true }); - await rm(`${this.dbPath}-wal`, { force: true }); - await rm(`${this.dbPath}-shm`, { force: true }); - - await copyFile(join(sourceDir, "profile.sqlite"), this.dbPath); - await copyIfExists(join(sourceDir, "profile.sqlite-wal"), `${this.dbPath}-wal`); - await copyIfExists(join(sourceDir, "profile.sqlite-shm"), `${this.dbPath}-shm`); - - await rm(this.logsDir, { recursive: true, force: true }); - await rm(this.artifactsDir, { recursive: true, force: true }); - mkdirSync(this.logsDir, { recursive: true }); - mkdirSync(this.artifactsDir, { recursive: true }); - - if (existsSync(join(sourceDir, "logs"))) { - await cp(join(sourceDir, "logs"), this.logsDir, { recursive: true, force: true }); - } - if (existsSync(join(sourceDir, "artifacts"))) { - await cp(join(sourceDir, "artifacts"), this.artifactsDir, { recursive: true, force: true }); - } - await copyIfExists(join(sourceDir, "vault.key"), join(this.profileDir, "vault.key")); - await copyIfExists(join(sourceDir, "agent-api-signing.key"), join(this.profileDir, "agent-api-signing.key")); - if (existsSync(join(this.profileDir, "vault.key"))) { - chmodSync(join(this.profileDir, "vault.key"), 0o600); - } - if (existsSync(join(this.profileDir, "agent-api-signing.key"))) { - chmodSync(join(this.profileDir, "agent-api-signing.key"), 0o600); - } - - this.db = this.openConnection(); - this.init(); - } - - getInbox(companyId: string): InboxItem[] { - const items: InboxItem[] = []; - const snapshot = this.listSnapshot({ companyId }); - const agents = snapshot.agents; - const agentMap = new Map(agents.map(a => [a.id, a])); - - // Pending approvals - for (const approval of snapshot.approvals.filter(a => a.companyId === companyId && a.state === "pending")) { - const requester = agentMap.get(approval.requestedByAgentId ?? ""); - items.push({ - id: `approval-${approval.id}`, - type: "pending_approval", - title: approval.payloadSummary, - subtitle: `${approval.type.replaceAll("_", " ")} — requested by ${requester?.name ?? "Unknown"}`, - severity: approval.type === "dangerous_command" ? "critical" : "high", - entityType: "approval", - entityId: approval.id, - agentId: approval.requestedByAgentId, - agentName: requester?.name ?? null, - createdAt: approval.createdAt, - }); - } - - // Failed runs (last 24h) - const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); - for (const run of snapshot.runs.filter(r => r.companyId === companyId && (r.status === "failed" || r.status === "timed_out") && r.createdAt > oneDayAgo)) { - const agent = agentMap.get(run.agentId); - items.push({ - id: `run-${run.id}`, - type: "failed_run", - title: run.summary || "Run failed", - subtitle: `${agent?.name ?? "Unknown agent"} — ${run.errorMessage?.slice(0, 100) ?? "Unknown error"}`, - severity: "high", - entityType: "run", - entityId: run.id, - agentId: run.agentId, - agentName: agent?.name ?? null, - createdAt: run.createdAt, - }); - } - - // Budget warnings (agents at >80% budget) - for (const agent of agents.filter(a => a.budgetMonthlyUsd > 0 && a.spentMonthlyUsd / a.budgetMonthlyUsd > 0.8)) { - const pct = Math.round((agent.spentMonthlyUsd / agent.budgetMonthlyUsd) * 100); - items.push({ - id: `budget-${agent.id}`, - type: "budget_warning", - title: `${agent.name} at ${pct}% budget`, - subtitle: `$${agent.spentMonthlyUsd.toFixed(2)} of $${agent.budgetMonthlyUsd.toFixed(2)} monthly limit`, - severity: pct >= 100 ? "critical" : "high", - entityType: "agent", - entityId: agent.id, - agentId: agent.id, - agentName: agent.name, - createdAt: agent.updatedAt, - }); - } - - // Blocked tasks - for (const task of snapshot.tasks.filter(t => t.companyId === companyId && t.status === "blocked")) { - const assignee = agentMap.get(task.assigneeAgentId ?? ""); - items.push({ - id: `blocked-${task.id}`, - type: "blocked_task", - title: task.title, - subtitle: `Assigned to ${assignee?.name ?? "Unassigned"} — blocked`, - severity: task.priority === "critical" ? "critical" : "medium", - entityType: "task", - entityId: task.id, - agentId: task.assigneeAgentId, - agentName: assignee?.name ?? null, - createdAt: task.updatedAt, - }); - } - - // Tasks needing review - for (const task of snapshot.tasks.filter(t => t.companyId === companyId && t.status === "in_review")) { - const assignee = agentMap.get(task.assigneeAgentId ?? ""); - items.push({ - id: `review-${task.id}`, - type: "review_needed", - title: task.title, - subtitle: `Submitted by ${assignee?.name ?? "Unknown"} — awaiting review`, - severity: task.priority === "critical" ? "high" : "medium", - entityType: "task", - entityId: task.id, - agentId: task.assigneeAgentId, - agentName: assignee?.name ?? null, - createdAt: task.updatedAt, - }); - } - - // Unread urgent agent messages (company-wide or incident channels that the Board should see) - for (const msg of snapshot.agentMessages.filter(m => - m.companyId === companyId && - !m.readAt && - (m.channel === "company" || m.channel === "incident" || m.priority === "urgent"), - )) { - const sender = agentMap.get(msg.fromAgentId); - items.push({ - id: `message-${msg.id}`, - type: "agent_message", - title: msg.subject, - subtitle: `${sender?.name ?? "Unknown"} — ${msg.channel === "incident" ? "INCIDENT" : msg.channel} ${msg.priority === "urgent" ? "(urgent)" : ""}`, - severity: msg.priority === "urgent" || msg.channel === "incident" ? "critical" : "medium", - entityType: "message", - entityId: msg.id, - agentId: msg.fromAgentId, - agentName: sender?.name ?? null, - createdAt: msg.createdAt, - }); - } - - const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 }; - items.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity] || new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); - return items; - } - - getCompanyMetrics(companyId: string, days: number = 30): CompanyMetrics { - const snapshot = this.listSnapshot({ companyId }); - const agents = snapshot.agents; - const tasks = snapshot.tasks; - const runs = snapshot.runs; - const costs = snapshot.costs; - - const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString(); - const recentRuns = runs.filter(r => r.createdAt > cutoff); - const recentTasks = tasks.filter(t => t.status === "done" && t.updatedAt > cutoff); - - const successfulRuns = recentRuns.filter(r => r.status === "succeeded").length; - const failedRuns = recentRuns.filter(r => r.status === "failed" || r.status === "timed_out").length; - const totalCost = costs.reduce((sum, c) => sum + (c.amountUsd ?? 0), 0); - const completedTasks = tasks.filter(t => t.status === "done").length; - - const agentMetrics: AgentMetrics[] = agents - .filter(a => a.status !== "pending_approval" && a.status !== "terminated") - .map(agent => { - const agentTasks = tasks.filter(t => t.assigneeAgentId === agent.id); - const agentRuns = runs.filter(r => r.agentId === agent.id); - const agentRecentRuns = agentRuns.filter(r => r.createdAt > cutoff); - const agentCosts = costs.filter(c => c.agentId === agent.id); - - const agentSucceeded = agentRecentRuns.filter(r => r.status === "succeeded").length; - const agentFailed = agentRecentRuns.filter(r => r.status === "failed" || r.status === "timed_out").length; - - // Avg run duration - const finishedRuns = agentRuns.filter(r => r.startedAt && r.finishedAt); - const avgDuration = finishedRuns.length > 0 - ? finishedRuns.reduce((sum, r) => sum + (new Date(r.finishedAt!).getTime() - new Date(r.startedAt!).getTime()) / 1000, 0) / finishedRuns.length - : 0; - - // Review cycles: count task status transitions by this agent - // Actual action strings: "task.status_changed" (api-server), "task.updated" (index.ts), "task.auto_assigned" (database) - const reviewCycles = snapshot.activity.filter( - a => a.companyId === companyId && a.actor === agent.id - && (a.action === "task.status_changed" || a.action === "task.updated" || a.action === "task.auto_assigned") - ).length; - - const lastRun = agentRuns.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0]; - - return { - agentId: agent.id, - agentName: agent.name, - tasksCompleted: agentTasks.filter(t => t.status === "done").length, - tasksAssigned: agentTasks.filter(t => t.status !== "done" && t.status !== "cancelled").length, - tasksFailed: agentTasks.filter(t => t.status === "cancelled").length, - successRate: agentRecentRuns.length > 0 ? Math.round((agentSucceeded / agentRecentRuns.length) * 100) : 0, - avgRunDurationSec: Math.round(avgDuration), - totalCostUsd: agentCosts.reduce((sum, c) => sum + (c.amountUsd ?? 0), 0), - runsTotal: agentRecentRuns.length, - runsSucceeded: agentSucceeded, - runsFailed: agentFailed, - reviewCycles, - lastActiveAt: lastRun?.createdAt ?? null, - }; - }); - - return { - totalAgents: agents.filter(a => a.status !== "pending_approval" && a.status !== "terminated").length, - activeAgents: agents.filter(a => a.status === "active" || a.status === "running").length, - totalTasks: tasks.length, - completedTasks, - blockedTasks: tasks.filter(t => t.status === "blocked").length, - totalRuns: recentRuns.length, - successfulRuns, - failedRuns, - overallSuccessRate: recentRuns.length > 0 ? Math.round((successfulRuns / recentRuns.length) * 100) : 0, - totalCostUsd: totalCost, - avgCostPerTask: completedTasks > 0 ? totalCost / completedTasks : 0, - throughputTasksPerDay: days > 0 ? recentTasks.length / days : 0, - agentMetrics, - }; - } - - getStandupReport(companyId: string): StandupReport { - const snapshot = this.listSnapshot({ companyId }); - const agents = snapshot.agents.filter(a => a.status !== "terminated" && a.status !== "pending_approval"); - const tasks = snapshot.tasks; - const runs = snapshot.runs; - const costs = snapshot.costs; - - const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); - const recentActivity = snapshot.activity.filter(a => a.companyId === companyId && a.createdAt > oneDayAgo); - - const companyHighlights: string[] = []; - const blockers: string[] = []; - - const recentCompletedTasks = tasks.filter(t => t.status === "done" && t.updatedAt > oneDayAgo); - if (recentCompletedTasks.length > 0) { - companyHighlights.push(`${recentCompletedTasks.length} task(s) completed in last 24h`); - } - - const recentRuns = runs.filter(r => r.createdAt > oneDayAgo); - const successfulRuns = recentRuns.filter(r => r.status === "succeeded").length; - const failedRuns = recentRuns.filter(r => r.status === "failed" || r.status === "timed_out").length; - if (recentRuns.length > 0) { - companyHighlights.push(`${recentRuns.length} runs (${successfulRuns} succeeded, ${failedRuns} failed)`); - } - - const blockedTasks = tasks.filter(t => t.status === "blocked"); - for (const task of blockedTasks) { - const assignee = agents.find(a => a.id === task.assigneeAgentId); - blockers.push(`${task.title} — blocked (${assignee?.name ?? "unassigned"})`); - } - - const pendingApprovals = snapshot.approvals.filter(a => a.companyId === companyId && a.state === "pending").length; - const totalBudget = agents.reduce((sum, a) => sum + a.budgetMonthlyUsd, 0); - const totalSpent = agents.reduce((sum, a) => sum + a.spentMonthlyUsd, 0); - const budgetUtilization = totalBudget > 0 ? Math.round((totalSpent / totalBudget) * 100) : 0; - - // Active sprint progress - const activeSprint = snapshot.sprints.find(s => s.companyId === companyId && s.status === "active"); - if (activeSprint) { - const pct = activeSprint.velocityPoints > 0 ? Math.round((activeSprint.completedPoints / activeSprint.velocityPoints) * 100) : 0; - companyHighlights.push(`Sprint "${activeSprint.name}": ${activeSprint.completedPoints}/${activeSprint.velocityPoints} pts (${pct}%)`); - } - - // Unread messages requiring attention - const urgentUnread = snapshot.agentMessages.filter(m => m.companyId === companyId && !m.readAt && (m.priority === "urgent" || m.channel === "incident")).length; - if (urgentUnread > 0) { - blockers.push(`${urgentUnread} urgent unread message(s) need attention`); - } - - const agentReports = agents.map(agent => { - const agentTasks = tasks.filter(t => t.assigneeAgentId === agent.id); - const agentRuns = runs.filter(r => r.agentId === agent.id); - const agentRecentRuns = agentRuns.filter(r => r.createdAt > oneDayAgo); - const agentCosts = costs.filter(c => c.agentId === agent.id && c.createdAt > oneDayAgo); - const lastRun = agentRecentRuns.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0]; - - const highlights: string[] = []; - const completedRecently = agentTasks.filter(t => t.status === "done" && t.updatedAt > oneDayAgo); - if (completedRecently.length > 0) highlights.push(`Completed ${completedRecently.length} task(s)`); - if (agentRecentRuns.length > 0) highlights.push(`${agentRecentRuns.length} run(s) in last 24h`); - const reviewedTasks = recentActivity.filter(a => a.actor === agent.id && (a.action === "task.status_changed" || a.action === "task.updated" || a.action === "task.auto_assigned")).length; - if (reviewedTasks > 0) highlights.push(`${reviewedTasks} status update(s)`); - - return { - agentId: agent.id, - agentName: agent.name, - role: agent.role, - status: agent.status, - tasksInProgress: agentTasks.filter(t => t.status === "in_progress").length, - tasksCompleted24h: completedRecently.length, - tasksBlocked: agentTasks.filter(t => t.status === "blocked").length, - lastRunStatus: (lastRun?.status ?? null) as RunStatus | null, - lastRunAt: lastRun?.createdAt ?? null, - costLast24h: agentCosts.reduce((sum, c) => sum + (c.amountUsd ?? 0), 0), - highlights, - }; - }); - - const summaryParts: string[] = []; - if (agents.length > 0) summaryParts.push(`${agents.length} active agents`); - summaryParts.push(`${recentCompletedTasks.length} tasks done`); - if (blockedTasks.length > 0) summaryParts.push(`${blockedTasks.length} blocked`); - if (pendingApprovals > 0) summaryParts.push(`${pendingApprovals} pending approvals`); - - return { - companyId, - generatedAt: new Date().toISOString(), - summary: summaryParts.join(" · "), - agentReports, - companyHighlights, - blockers, - pendingApprovals, - budgetUtilization, - }; - } - - // --------------------------------------------------------------------------- - // Task retry and type helpers (used by connector-matching and crash-recovery) - // --------------------------------------------------------------------------- - - getTaskRetryCount(taskId: string): number { - const row = this.db - .prepare("select retry_count from tasks where id = ?") - .get(taskId) as { retry_count: number } | undefined; - return row?.retry_count ?? 0; - } - - incrementTaskRetryCount(taskId: string): void { - const ts = nowIso(); - this.db - .prepare( - "update tasks set retry_count = retry_count + 1, last_failure_at = ?, updated_at = ? where id = ?", - ) - .run(ts, ts, taskId); - } - - resetTaskRetryCount(taskId: string): void { - const ts = nowIso(); - this.db - .prepare( - "update tasks set retry_count = 0, last_failure_at = null, updated_at = ? where id = ?", - ) - .run(ts, taskId); - } - - // --------------------------------------------------------------------------- - // Crash recovery helpers - // --------------------------------------------------------------------------- - - getInterruptedRunsForRecovery(): Array<{ - runId: string; - taskId: string; - taskTitle: string; - agentId: string; - agentName: string; - connectorId: string; - interruptedAt: string; - }> { - return this.readAll<{ - runId: string; - taskId: string; - taskTitle: string; - agentId: string; - agentName: string; - connectorId: string; - interruptedAt: string; - }>( - `select r.id as runId, r.task_id as taskId, t.title as taskTitle, - r.agent_id as agentId, a.name as agentName, - r.connector_id as connectorId, r.finished_at as interruptedAt - from runs r - join tasks t on t.id = r.task_id - join agents a on a.id = r.agent_id - where r.status = 'interrupted' - and t.status = 'blocked' - order by r.finished_at desc` - ); - } - - retryInterruptedTask(taskId: string, _agentId: string, _companyId: string): void { - const ts = nowIso(); - this.db.prepare("update tasks set status = 'todo', updated_at = ? where id = ?").run(ts, taskId); - this.db.prepare("update tasks set retry_count = 0, last_failure_at = null, updated_at = ? where id = ?").run(ts, taskId); - } - - dismissInterruptedTask(taskId: string): void { - const taskRow = this.db.prepare("select company_id from tasks where id = ?").get(taskId) as { company_id: string } | undefined; - this.addComment({ - companyId: taskRow?.company_id ?? '', - taskId, - authorAgentId: null, - authorName: "System", - body: "Interrupted run dismissed by user. Task remains blocked for manual handling.", - }); - } - - markInterruptedTaskFailed(taskId: string): void { - const ts = nowIso(); - this.db.prepare("update tasks set status = 'cancelled', updated_at = ? where id = ?").run(ts, taskId); - } - - setTaskType(taskId: string, taskType: string): void { - const ts = nowIso(); - this.db - .prepare("update tasks set task_type = ?, updated_at = ? where id = ?") - .run(taskType, ts, taskId); - } - - // --------------------------------------------------------------------------- - // Budget Policy CRUD - // --------------------------------------------------------------------------- - - saveBudgetPolicy(input: { - id?: string | null; - companyId: string; - scopeType: string; - scopeId: string; - metric?: string; - windowKind?: string; - amount: number; - warnPercent?: number; - hardStopEnabled?: boolean; - notifyEnabled?: boolean; - isActive?: boolean; - }): string { - const id = input.id || randomUUID(); - const ts = nowIso(); - this.db - .prepare( - `INSERT OR REPLACE INTO budget_policies - (id, company_id, scope_type, scope_id, metric, window_kind, amount, warn_percent, - hard_stop_enabled, notify_enabled, is_active, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .run( - id, - input.companyId, - input.scopeType, - input.scopeId, - input.metric ?? "billed_cents", - input.windowKind ?? "calendar_month_utc", - input.amount, - input.warnPercent ?? 80, - (input.hardStopEnabled ?? true) ? 1 : 0, - (input.notifyEnabled ?? true) ? 1 : 0, - (input.isActive ?? true) ? 1 : 0, - ts, - ts, - ); - return id; - } - - deleteBudgetPolicy(companyId: string, policyId: string): void { - this.transaction(() => { - this.db.prepare("DELETE FROM budget_incidents WHERE policy_id = ?").run(policyId); - this.db.prepare("DELETE FROM budget_policies WHERE id = ? AND company_id = ?").run(policyId, companyId); - }); - } - - listBudgetPolicies(companyId: string): BudgetPolicyRecord[] { - return this.readAll( - "SELECT * FROM budget_policies WHERE company_id = ? ORDER BY scope_type, created_at", - companyId, - ).map((r) => ({ - id: r.id, - companyId: r.company_id, - scopeType: r.scope_type, - scopeId: r.scope_id, - metric: r.metric, - windowKind: r.window_kind, - amount: r.amount, - warnPercent: r.warn_percent, - hardStopEnabled: !!r.hard_stop_enabled, - notifyEnabled: !!r.notify_enabled, - isActive: !!r.is_active, - createdAt: r.created_at, - updatedAt: r.updated_at, - })); - } - - getBudgetPolicy(policyId: string): BudgetPolicyRecord | null { - const r = this.db.prepare("SELECT * FROM budget_policies WHERE id = ?").get(policyId) as SqliteRow | undefined; - if (!r) return null; - return { - id: r.id, - companyId: r.company_id, - scopeType: r.scope_type, - scopeId: r.scope_id, - metric: r.metric, - windowKind: r.window_kind, - amount: r.amount, - warnPercent: r.warn_percent, - hardStopEnabled: !!r.hard_stop_enabled, - notifyEnabled: !!r.notify_enabled, - isActive: !!r.is_active, - createdAt: r.created_at, - updatedAt: r.updated_at, - }; - } - - getActivePoliciesForRun(companyId: string, agentId: string, projectId: string | null): BudgetPolicyRecord[] { - const sql = projectId - ? `SELECT * FROM budget_policies - WHERE company_id = ? AND is_active = 1 - AND ( - (scope_type = 'company' AND scope_id = ?) - OR (scope_type = 'agent' AND scope_id = ?) - OR (scope_type = 'project' AND scope_id = ?) - )` - : `SELECT * FROM budget_policies - WHERE company_id = ? AND is_active = 1 - AND ( - (scope_type = 'company' AND scope_id = ?) - OR (scope_type = 'agent' AND scope_id = ?) - )`; - const params: SQLInputValue[] = projectId - ? [companyId, companyId, agentId, projectId] - : [companyId, companyId, agentId]; - return this.readAll(sql, ...params).map((r) => ({ - id: r.id, - companyId: r.company_id, - scopeType: r.scope_type, - scopeId: r.scope_id, - metric: r.metric, - windowKind: r.window_kind, - amount: r.amount, - warnPercent: r.warn_percent, - hardStopEnabled: !!r.hard_stop_enabled, - notifyEnabled: !!r.notify_enabled, - isActive: !!r.is_active, - createdAt: r.created_at, - updatedAt: r.updated_at, - })); - } - - getCalendarMonthWindow(): { start: string; end: string } { - const now = new Date(); - const year = now.getUTCFullYear(); - const month = now.getUTCMonth(); - const start = new Date(Date.UTC(year, month, 1)).toISOString(); - const end = new Date(Date.UTC(year, month + 1, 1)).toISOString(); - return { start, end }; - } - - getSpentInWindow(policy: BudgetPolicyRecord): number { - const { start, end } = this.getCalendarMonthWindow(); - let sql: string; - let params: SQLInputValue[]; - if (policy.scopeType === "agent") { - sql = "SELECT COALESCE(SUM(amount_usd), 0) * 100 as total_cents FROM cost_entries WHERE company_id = ? AND created_at >= ? AND created_at < ? AND agent_id = ?"; - params = [policy.companyId, start, end, policy.scopeId]; - } else if (policy.scopeType === "project") { - sql = "SELECT COALESCE(SUM(amount_usd), 0) * 100 as total_cents FROM cost_entries WHERE company_id = ? AND created_at >= ? AND created_at < ? AND project_id = ?"; - params = [policy.companyId, start, end, policy.scopeId]; - } else { - sql = "SELECT COALESCE(SUM(amount_usd), 0) * 100 as total_cents FROM cost_entries WHERE company_id = ? AND created_at >= ? AND created_at < ?"; - params = [policy.companyId, start, end]; - } - const row = this.db.prepare(sql).get(...params) as { total_cents: number } | undefined; - return Math.round(row?.total_cents ?? 0); - } - - // --------------------------------------------------------------------------- - // Budget Incident CRUD - // --------------------------------------------------------------------------- - - upsertBudgetIncident( - policy: BudgetPolicyRecord, - thresholdType: "warn_threshold" | "hard_stop", - amountObserved: number, - ): string { - const { start, end } = this.getCalendarMonthWindow(); - const existing = this.db - .prepare( - "SELECT id FROM budget_incidents WHERE policy_id = ? AND window_start = ? AND threshold_type = ? AND status = 'open'", - ) - .get(policy.id, start, thresholdType) as { id: string } | undefined; - - const ts = nowIso(); - if (existing) { - this.db - .prepare("UPDATE budget_incidents SET amount_observed = ?, updated_at = ? WHERE id = ?") - .run(amountObserved, ts, existing.id); - return existing.id; - } - - const id = randomUUID(); - this.db - .prepare( - `INSERT INTO budget_incidents - (id, company_id, policy_id, scope_type, scope_id, metric, window_kind, - window_start, window_end, threshold_type, amount_limit, amount_observed, - status, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)`, - ) - .run( - id, - policy.companyId, - policy.id, - policy.scopeType, - policy.scopeId, - policy.metric, - policy.windowKind, - start, - end, - thresholdType, - policy.amount, - amountObserved, - ts, - ts, - ); - return id; - } - - listBudgetIncidents(companyId: string, statusFilter: string = "all"): BudgetIncidentRecord[] { - const sql = statusFilter === "all" - ? "SELECT * FROM budget_incidents WHERE company_id = ? ORDER BY created_at DESC" - : "SELECT * FROM budget_incidents WHERE company_id = ? AND status = ? ORDER BY created_at DESC"; - const rows = statusFilter === "all" - ? this.readAll(sql, companyId) - : this.readAll(sql, companyId, statusFilter); - return rows.map((r) => ({ - id: r.id, - companyId: r.company_id, - policyId: r.policy_id, - scopeType: r.scope_type, - scopeId: r.scope_id, - metric: r.metric, - windowKind: r.window_kind, - windowStart: r.window_start, - windowEnd: r.window_end, - thresholdType: r.threshold_type, - amountLimit: r.amount_limit, - amountObserved: r.amount_observed, - status: r.status, - resolvedAt: r.resolved_at, - createdAt: r.created_at, - updatedAt: r.updated_at, - })); - } - - resolveBudgetIncident(incidentId: string, companyId: string): void { - const ts = nowIso(); - this.db - .prepare( - "UPDATE budget_incidents SET status = 'resolved', resolved_at = ?, updated_at = ? WHERE id = ? AND company_id = ? AND status = 'open'", - ) - .run(ts, ts, incidentId, companyId); - } - - resolveIncidentsForPolicy(policyId: string, resolutionStatus: string): number { - const ts = nowIso(); - const result = this.db - .prepare( - "UPDATE budget_incidents SET status = ?, resolved_at = ?, updated_at = ? WHERE policy_id = ? AND status = 'open'", - ) - .run(resolutionStatus, ts, ts, policyId); - return Number(result.changes); - } - - resolveWarnIncidentsForPolicy(policyId: string, resolutionStatus: string): number { - const ts = nowIso(); - const result = this.db - .prepare( - "UPDATE budget_incidents SET status = ?, resolved_at = ?, updated_at = ? WHERE policy_id = ? AND status = 'open' AND threshold_type = 'warn_threshold'", - ) - .run(resolutionStatus, ts, ts, policyId); - return Number(result.changes); - } - - autoCloseExpiredIncidents(companyId: string): number { - const ts = nowIso(); - const result = this.db - .prepare( - "UPDATE budget_incidents SET status = 'auto_closed', resolved_at = ?, updated_at = ? WHERE company_id = ? AND status = 'open' AND window_end <= ?", - ) - .run(ts, ts, companyId, ts); - return Number(result.changes); - } - - recordConfigRevision(input: { - companyId: string; - agentId: string; - changedKeys: string[]; - configBefore: Record; - configAfter: Record; - source?: string; - rolledBackFromRevisionId?: string | null; - createdByAgentId?: string | null; - createdByUserId?: string | null; - }): string { - const id = randomUUID(); - const ts = nowIso(); - const lastRev = this.db.prepare( - "SELECT MAX(revision_number) as maxRev FROM agent_config_revisions WHERE agent_id = ?" - ).get(input.agentId) as { maxRev: number | null } | undefined; - const revisionNumber = (lastRev?.maxRev ?? 0) + 1; - this.db.prepare( - "INSERT INTO agent_config_revisions (id, company_id, agent_id, revision_number, changed_keys, config_before, config_after, source, rolled_back_from_revision_id, created_by_agent_id, created_by_user_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" - ).run( - id, input.companyId, input.agentId, revisionNumber, - JSON.stringify(input.changedKeys), - JSON.stringify(input.configBefore), - JSON.stringify(input.configAfter), - input.source ?? "patch", - input.rolledBackFromRevisionId ?? null, - input.createdByAgentId ?? null, - input.createdByUserId ?? null, - ts - ); - return id; - } - - listConfigRevisions(agentId: string, companyId: string): Array<{ - id: string; agentId: string; revisionNumber: number; changedKeys: string[]; - source: string; rolledBackFromRevisionId: string | null; - createdByAgentId: string | null; createdByUserId: string | null; createdAt: string; - }> { - return this.readAll( - "SELECT id, agent_id, revision_number, changed_keys, source, rolled_back_from_revision_id, created_by_agent_id, created_by_user_id, created_at FROM agent_config_revisions WHERE agent_id = ? AND company_id = ? ORDER BY revision_number DESC", - agentId, companyId - ).map(row => ({ - id: row.id, - agentId: row.agent_id, - revisionNumber: row.revision_number, - changedKeys: parseJson(row.changed_keys, []), - source: row.source, - rolledBackFromRevisionId: row.rolled_back_from_revision_id ?? null, - createdByAgentId: row.created_by_agent_id ?? null, - createdByUserId: row.created_by_user_id ?? null, - createdAt: row.created_at, - })); - } - - getConfigRevision(revisionId: string, companyId: string): { - id: string; agentId: string; revisionNumber: number; changedKeys: string[]; - configBefore: Record; configAfter: Record; - source: string; rolledBackFromRevisionId: string | null; - createdByAgentId: string | null; createdByUserId: string | null; createdAt: string; - } { - const row = this.db.prepare( - "SELECT * FROM agent_config_revisions WHERE id = ? AND company_id = ?" - ).get(revisionId, companyId) as any; - if (!row) throw new Error(`Config revision ${revisionId} not found.`); - return { - id: row.id, - agentId: row.agent_id, - revisionNumber: row.revision_number, - changedKeys: parseJson(row.changed_keys, []), - configBefore: parseJson>(row.config_before, {}), - configAfter: parseJson>(row.config_after, {}), - source: row.source, - rolledBackFromRevisionId: row.rolled_back_from_revision_id ?? null, - createdByAgentId: row.created_by_agent_id ?? null, - createdByUserId: row.created_by_user_id ?? null, - createdAt: row.created_at, - }; - } - - rollbackConfigRevision(agentId: string, revisionId: string, companyId: string): string { - return this.transaction(() => { - const revision = this.getConfigRevision(revisionId, companyId); - if (revision.agentId !== agentId) throw new Error("Revision does not belong to this agent."); - const before = revision.configBefore as Record; - const ts = nowIso(); - this.db.prepare( - "UPDATE agents SET name = ?, role = ?, title = ?, department = ?, capabilities = ?, connector_id = ?, model = ?, budget_monthly_usd = ?, metadata_json = ?, updated_at = ? WHERE id = ? AND company_id = ?" - ).run( - before.name ?? "", before.role ?? "", before.title ?? "", before.department ?? null, - before.capabilities ?? "", before.connectorId ?? before.connector_id ?? "", - before.model ?? null, before.budgetMonthlyUsd ?? before.budget_monthly_usd ?? 0, - before.metadataJson ?? before.metadata_json ?? "{}", ts, agentId, companyId - ); - const agent = this.getAgent(agentId); - const currentConfig = { name: agent.name, role: agent.role, title: agent.title, department: (agent as any).department, capabilities: agent.capabilities, connectorId: agent.connectorId, model: agent.model, budgetMonthlyUsd: agent.budgetMonthlyUsd, metadataJson: agent.metadataJson }; - return this.recordConfigRevision({ - companyId, agentId, - changedKeys: revision.changedKeys, - configBefore: currentConfig, - configAfter: before, - source: "rollback", - rolledBackFromRevisionId: revisionId, - }); - }); - } - - appendRunEvent(input: { - companyId: string; runId: string; agentId: string; - eventType: string; stream?: string | null; level?: string | null; - message?: string | null; payload?: Record | null; - }): void { - const ts = nowIso(); - const lastSeq = this.db.prepare( - "SELECT MAX(seq) as maxSeq FROM run_events WHERE run_id = ?" - ).get(input.runId) as { maxSeq: number | null } | undefined; - const seq = (lastSeq?.maxSeq ?? 0) + 1; - this.db.prepare( - "INSERT INTO run_events (company_id, run_id, agent_id, seq, event_type, stream, level, message, payload, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" - ).run( - input.companyId, input.runId, input.agentId, seq, - input.eventType, input.stream ?? null, input.level ?? null, - input.message ?? null, input.payload ? JSON.stringify(input.payload) : null, ts - ); - } - - listRunEvents(runId: string, companyId: string, opts?: { offset?: number; limit?: number }): Array<{ - id: number; runId: string; agentId: string; seq: number; eventType: string; - stream: string | null; level: string | null; message: string | null; - payload: Record | null; createdAt: string; - }> { - const offset = opts?.offset ?? 0; - const limit = opts?.limit ?? 500; - return this.readAll( - "SELECT id, run_id, agent_id, seq, event_type, stream, level, message, payload, created_at FROM run_events WHERE run_id = ? AND company_id = ? ORDER BY seq ASC LIMIT ? OFFSET ?", - runId, companyId, limit, offset - ).map(row => ({ - id: row.id, - runId: row.run_id, - agentId: row.agent_id, - seq: row.seq, - eventType: row.event_type, - stream: row.stream, - level: row.level, - message: row.message, - payload: parseJson | null>(row.payload, null), - createdAt: row.created_at, - })); - } - - getAgentPermissions(agentId: string): Record { - const row = this.db.prepare( - "SELECT permissions_json FROM agents WHERE id = ?" - ).get(agentId) as { permissions_json: string } | undefined; - return parseJson>(row?.permissions_json, {}); - } - - setAgentPermission(agentId: string, companyId: string, permissionKey: string, enabled: boolean): void { - const current = this.getAgentPermissions(agentId); - if (enabled) { - current[permissionKey] = true; - } else { - delete current[permissionKey]; - } - const ts = nowIso(); - this.db.prepare( - "UPDATE agents SET permissions_json = ?, updated_at = ? WHERE id = ? AND company_id = ?" - ).run(JSON.stringify(current), ts, agentId, companyId); - } - - hasAgentPermission(agentId: string, permissionKey: string): boolean { - const perms = this.getAgentPermissions(agentId); - return perms[permissionKey] === true; - } - - getRuntimeState(agentId: string): Record { - const row = this.db.prepare( - "SELECT runtime_state_json FROM agents WHERE id = ?" - ).get(agentId) as { runtime_state_json: string | null } | undefined; - return parseJson>(row?.runtime_state_json, {}); - } - - setRuntimeState(agentId: string, companyId: string, state: Record): void { - const ts = nowIso(); - this.db.prepare( - "UPDATE agents SET runtime_state_json = ?, updated_at = ? WHERE id = ? AND company_id = ?" - ).run(JSON.stringify(state), ts, agentId, companyId); - } - - getHeartbeatPolicy(agentId: string): { - enabled: boolean; intervalSec: number; cooldownSec: number; - maxConcurrentRuns: number; wakeOnDemand: boolean; - } { - const row = this.db.prepare( - "SELECT heartbeat_enabled, heartbeat_interval_sec, heartbeat_policy_json FROM agents WHERE id = ?" - ).get(agentId) as { heartbeat_enabled: number; heartbeat_interval_sec: number; heartbeat_policy_json: string | null } | undefined; - if (!row) throw new Error(`Agent ${agentId} not found.`); - const policy = parseJson>(row.heartbeat_policy_json, {}); - return { - enabled: toBoolean(row.heartbeat_enabled), - intervalSec: row.heartbeat_interval_sec ?? 120, - cooldownSec: typeof policy.cooldownSec === "number" ? policy.cooldownSec : 10, - maxConcurrentRuns: typeof policy.maxConcurrentRuns === "number" ? policy.maxConcurrentRuns : 1, - wakeOnDemand: policy.wakeOnDemand !== false, - }; - } - - setHeartbeatPolicy(agentId: string, companyId: string, policy: { - enabled?: boolean; intervalSec?: number; cooldownSec?: number; - maxConcurrentRuns?: number; wakeOnDemand?: boolean; - }): void { - const ts = nowIso(); - const current = this.getHeartbeatPolicy(agentId); - const merged = { ...current, ...policy }; - const policyJson = JSON.stringify({ - cooldownSec: merged.cooldownSec, - maxConcurrentRuns: merged.maxConcurrentRuns, - wakeOnDemand: merged.wakeOnDemand, - }); - this.db.prepare( - "UPDATE agents SET heartbeat_enabled = ?, heartbeat_interval_sec = ?, heartbeat_policy_json = ?, updated_at = ? WHERE id = ? AND company_id = ?" - ).run(merged.enabled ? 1 : 0, merged.intervalSec, policyJson, ts, agentId, companyId); - } - - countActiveRunsForAgent(agentId: string, companyId: string): number { - const row = this.db.prepare( - "SELECT COUNT(*) as cnt FROM runs WHERE agent_id = ? AND company_id = ? AND status IN ('queued', 'running')" - ).get(agentId, companyId) as { cnt: number } | undefined; - return row?.cnt ?? 0; - } - - updateRunActor(runId: string, actorType: string, actorId: string | null): void { - this.db.prepare( - "UPDATE runs SET actor_type = ?, actor_id = ?, updated_at = ? WHERE id = ?" - ).run(actorType, actorId, nowIso(), runId); - } - - updateCompanySkillMetadata(companyId: string, skillId: string, metadataJson: string): void { - const ts = nowIso(); - this.db.prepare( - "UPDATE company_skills SET metadata = ?, updated_at = ? WHERE id = ? AND company_id = ?" - ).run(metadataJson, ts, skillId, companyId); - } - - private openConnection(): SqliteDatabase { - return new DatabaseSync(this.dbPath, { timeout: 5000 }); - } -} - -async function copyIfExists(source: string, destination: string) { - if (!existsSync(source)) { - return; - } - await copyFile(source, destination); +export class AppDatabase extends AppDatabaseBase { + + // ─── Activity & Comments ─────────────────────────────────────────────────── + + listComments(taskId: string) { return this.activityMod.listComments(taskId); } + + // ─── Messages ────────────────────────────────────────────────────────────── + + sendAgentMessage(input: { companyId: string; fromAgentId: string; toAgentId?: string | null; channel?: string; channelTargetId?: string | null; subject: string; body: string; priority?: string; parentMessageId?: string | null; attachmentsJson?: string; senderKind?: "agent" | "human" | "system" }) { return this.messages.sendAgentMessage(input); } + getAgentMessage(messageId: string, companyId: string) { return this.messages.getAgentMessage(messageId, companyId); } + listAgentMessages(companyId: string, opts?: { agentId?: string; messageId?: string; channel?: string; channelTargetId?: string | null; unreadOnly?: boolean; limit?: number }) { return this.messages.listAgentMessages(companyId, opts); } + searchMessages(companyId: string, query: string, opts?: { channel?: string; channelTargetId?: string | null; limit?: number }) { return this.messages.searchMessages(companyId, query, opts); } + markMessageRead(messageId: string, companyId: string) { return this.messages.markMessageRead(messageId, companyId); } + markMessageReadForReader(messageId: string, companyId: string, readerAgentId?: string | null) { return this.messages.markMessageReadForReader(messageId, companyId, readerAgentId); } + markAgentMessageRead(messageId: string, companyId: string, agentId: string) { return this.messages.markAgentMessageRead(messageId, companyId, agentId); } + + // ─── Social & Browser Actions ────────────────────────────────────────────── + + saveSocialAccount(input: { id?: string | null; companyId: string; platform: string; accountName: string; displayName?: string; profileUrl?: string; credentialSecretId?: string | null; status?: string; requireApproval?: boolean; metadataJson?: string }) { return this.social.saveSocialAccount(input); } + deleteSocialAccount(id: string, companyId: string) { this.social.deleteSocialAccount(id, companyId); } + listSocialAccounts(companyId: string) { return this.social.listSocialAccounts(companyId); } + getSocialAccount(id: string, companyId: string) { return this.social.getSocialAccount(id, companyId); } + saveBrowserAction(input: { companyId: string; socialAccountId: string; agentId: string; taskId?: string | null; actionType: string; payloadJson: string; status?: string; approvalId?: string | null }) { return this.social.saveBrowserAction(input); } + updateBrowserAction(id: string, updates: { status?: string; resultSummary?: string; screenshotPath?: string | null; errorMessage?: string | null; startedAt?: string; finishedAt?: string }) { this.social.updateBrowserAction(id, updates); } + listBrowserActions(companyId: string, filters?: { socialAccountId?: string; status?: string; limit?: number }) { return this.social.listBrowserActions(companyId, filters); } + getBrowserAction(id: string, companyId: string) { return this.social.getBrowserAction(id, companyId); } + getBrowserActionByApprovalId(approvalId: string, companyId: string) { return this.social.getBrowserActionByApprovalId(approvalId, companyId); } + + // ─── Skills ──────────────────────────────────────────────────────────────── + + upsertCompanySkill(skill: { id: string; companyId: string; key: string; slug: string; name: string; description: string | null; markdown: string; sourceType: string; sourceLocator: string | null; sourceRef: string | null; trustLevel: string; compatibility: string; fileInventory: string; metadata: string | null; createdAt: string; updatedAt: string }) { return this.skills.upsertCompanySkill(skill); } + listCompanySkills(companyId: string) { return this.skills.listCompanySkills(companyId); } + getCompanySkill(companyId: string, skillId: string) { return this.skills.getCompanySkill(companyId, skillId); } + deleteCompanySkill(companyId: string, skillId: string) { return this.skills.deleteCompanySkill(companyId, skillId); } + assignSkillToAgent(agentId: string, skillId: string) { this.skills.assignSkillToAgent(agentId, skillId); } + removeSkillFromAgent(agentId: string, skillId: string) { this.skills.removeSkillFromAgent(agentId, skillId); } + listAgentSkillAssignments(agentId: string) { return this.skills.listAgentSkillAssignments(agentId); } + listSkillAgentAssignments(skillId: string) { return this.skills.listSkillAgentAssignments(skillId); } + updateCompanySkillMetadata(companyId: string, skillId: string, metadataJson: string) { this.skills.updateCompanySkillMetadata(companyId, skillId, metadataJson); } + + // ─── Budget ──────────────────────────────────────────────────────────────── + + saveBudgetPolicy(input: { id?: string | null; companyId: string; scopeType: string; scopeId: string; metric?: string; windowKind?: string; amount: number; warnPercent?: number; hardStopEnabled?: boolean; notifyEnabled?: boolean; isActive?: boolean }) { return this.budget.saveBudgetPolicy(input); } + deleteBudgetPolicy(companyId: string, policyId: string) { this.budget.deleteBudgetPolicy(companyId, policyId); } + listBudgetPolicies(companyId: string) { return this.budget.listBudgetPolicies(companyId); } + getBudgetPolicy(policyId: string) { return this.budget.getBudgetPolicy(policyId); } + getActivePoliciesForRun(companyId: string, agentId: string, projectId: string | null) { return this.budget.getActivePoliciesForRun(companyId, agentId, projectId); } + getCalendarMonthWindow() { return this.budget.getCalendarMonthWindow(); } + getSpentInWindow(policy: Parameters[0]) { return this.budget.getSpentInWindow(policy); } + upsertBudgetIncident(policy: Parameters[0], thresholdType: "warn_threshold" | "hard_stop", amountObserved: number) { return this.budget.upsertBudgetIncident(policy, thresholdType, amountObserved); } + listBudgetIncidents(companyId: string, statusFilter: string = "all") { return this.budget.listBudgetIncidents(companyId, statusFilter); } + resolveBudgetIncident(incidentId: string, companyId: string) { this.budget.resolveBudgetIncident(incidentId, companyId); } + resolveIncidentsForPolicy(policyId: string, resolutionStatus: string) { return this.budget.resolveIncidentsForPolicy(policyId, resolutionStatus); } + resolveWarnIncidentsForPolicy(policyId: string, resolutionStatus: string) { return this.budget.resolveWarnIncidentsForPolicy(policyId, resolutionStatus); } + autoCloseExpiredIncidents(companyId: string) { return this.budget.autoCloseExpiredIncidents(companyId); } + + // ─── Entity CRUD ─────────────────────────────────────────────────────────── + + saveGoal(input: { id?: string | null; companyId: string; title: string; description: string; parentId?: string | null; ownerAgentId?: string | null; status: string }) { return this.entityCrud.saveGoal(input); } + saveProject(input: { id?: string | null; companyId: string; goalId?: string | null; name: string; description: string; leadAgentId?: string | null; targetDate?: string | null; status: string }) { return this.entityCrud.saveProject(input); } + saveSecret(input: { id?: string | null; companyId: string; name: string; description: string; value: string }) { return this.entityCrud.saveSecret(input, this.vault); } + resolveSecretEnv(bindingText: string, companyId: string) { return this.entityCrud.resolveSecretEnv(bindingText, companyId, this.vault, listInvalidEnvBindingKeys, parseEnvBindingText); } + deleteGoal(id: string, companyId: string) { this.entityCrud.deleteGoal(id, companyId); } + deleteProject(id: string, companyId: string) { this.entityCrud.deleteProject(id, companyId); } + deleteSecret(id: string, companyId: string) { this.entityCrud.deleteSecret(id, companyId); } + countActiveGoalsForCompany(companyId: string) { return this.entityCrud.countActiveGoalsForCompany(companyId); } + createHireRequest(input: { companyId: string; requestedByAgentId?: string | null; name: string; role: string; title: string; department?: string | null; reportsTo?: string | null; connectorId: ConnectorId; workspaceId?: string | null; model?: string | null; metadataJson?: string; capabilities: string; budgetMonthlyUsd: number; payloadSummary: string; impactSummary: string; payloadJson?: string | null }) { + return this.entityCrud.createHireRequest(input, { + saveAgent: (agentInput) => this.saveAgent(agentInput), + requestApproval: (approvalInput) => this.requestApproval(approvalInput), + }); + } + + // ─── Documents & Work Products ───────────────────────────────────────────── + + saveDocument(input: { id?: string | null; companyId: string; type: string; title: string; content?: string; authorAgentId?: string | null; reviewerAgentId?: string | null; projectId?: string | null; goalId?: string | null; parentDocId?: string | null; version?: number; status?: string; tagsJson?: string; sourceRunId?: string | null }) { return this.documentsMod.saveDocument(input); } + updateDocumentStatus(id: string, companyId: string, status: string) { this.documentsMod.updateDocumentStatus(id, companyId, status); } + deleteDocument(id: string, companyId: string) { this.documentsMod.deleteDocument(id, companyId); } + createDocumentRevision(input: { documentId: string; companyId: string; body: string; changeSummary?: string | null; createdByAgentId?: string | null; createdByUserId?: string | null }) { return this.documentsMod.createDocumentRevision(input); } + getDocumentLatestRevisionId(documentId: string) { return this.documentsMod.getDocumentLatestRevisionId(documentId); } + listDocumentRevisions(documentId: string, companyId: string) { return this.documentsMod.listDocumentRevisions(documentId, companyId); } + createWorkProduct(input: { companyId: string; taskId: string; projectId?: string | null; type: string; provider: string; externalId?: string | null; title: string; url?: string | null; status?: string; reviewState?: string; isPrimary?: boolean; summary?: string | null; metadataJson?: string | null; createdByRunId?: string | null }) { return this.documentsMod.createWorkProduct(input); } + updateWorkProduct(id: string, companyId: string, patch: Record) { this.documentsMod.updateWorkProduct(id, companyId, patch); } + listWorkProductsForTask(taskId: string, companyId: string) { return this.documentsMod.listWorkProductsForTask(taskId, companyId); } + deleteWorkProduct(id: string, companyId: string) { this.documentsMod.deleteWorkProduct(id, companyId); } + linkDocumentToTask(taskId: string, documentId: string, companyId: string, key: string) { return this.documentsMod.linkDocumentToTask(taskId, documentId, companyId, key); } + unlinkDocumentFromTask(taskId: string, key: string, companyId: string) { this.documentsMod.unlinkDocumentFromTask(taskId, key, companyId); } + listDocumentsForTask(taskId: string, companyId: string) { return this.documentsMod.listDocumentsForTask(taskId, companyId); } + listConfigRevisions(agentId: string, companyId: string) { return this.documentsMod.listConfigRevisions(agentId, companyId); } + getConfigRevision(revisionId: string, companyId: string) { return this.documentsMod.getConfigRevision(revisionId, companyId); } + rollbackConfigRevision(agentId: string, revisionId: string, companyId: string) { + return this.documentsMod.rollbackConfigRevision(agentId, revisionId, companyId, { + getConfigRevision: (rid, cid) => this.getConfigRevision(rid, cid), + getAgent: (id) => this.getAgent(id), + recordConfigRevision: (input) => this.recordConfigRevision(input), + }); + } + + // ─── Knowledge ───────────────────────────────────────────────────────────── + + saveMeeting(input: { id?: string | null; companyId: string; type: string; title: string; organizerAgentId?: string | null; participantAgentIds?: string; scheduledAt: string; durationMinutes?: number; agendaJson?: string; notesJson?: string; decisionsJson?: string; actionItemsJson?: string; status?: string }) { return this.knowledgeMod.saveMeeting(input); } + deleteMeeting(id: string, companyId: string) { this.knowledgeMod.deleteMeeting(id, companyId); } + saveKnowledgeEntry(input: { id?: string | null; companyId: string; category: string; topic: string; content: string; authorAgentId?: string | null; importance?: string; tagsJson?: string; referencedEntityType?: string | null; referencedEntityId?: string | null; sourceRunId?: string | null }) { return this.knowledgeMod.saveKnowledgeEntry(input); } + deleteKnowledgeEntry(id: string, companyId: string) { this.knowledgeMod.deleteKnowledgeEntry(id, companyId); } + saveSprint(input: { id?: string | null; companyId: string; name: string; goal?: string; startDate: string; endDate: string; status?: string; retrospectiveNotes?: string; velocityPoints?: number; completedPoints?: number }) { return this.knowledgeMod.saveSprint(input); } + deleteSprint(id: string, companyId: string) { this.knowledgeMod.deleteSprint(id, companyId); } + + // ─── Automation ──────────────────────────────────────────────────────────── + + saveAutomationRule(input: { id?: string | null; companyId: string; name: string; description?: string; trigger: string; conditionsJson?: string; action: string; actionConfigJson?: string; sourceDepartment?: string | null; targetDepartment?: string | null; priority?: number; status?: string }) { return this.automationMod.saveAutomationRule(input); } + deleteAutomationRule(id: string, companyId: string) { this.automationMod.deleteAutomationRule(id, companyId); } + listAutomationRuleNames(companyId: string) { return this.automationMod.listAutomationRuleNames(companyId); } + toggleAutomationRule(id: string, companyId: string, status: "active" | "paused") { this.automationMod.toggleAutomationRule(id, companyId, status); } + recordAutomationExecution(ruleId: string, companyId: string, ruleName: string, trigger: string, action: string, detail: string) { this.automationMod.recordAutomationExecution(ruleId, companyId, ruleName, trigger, action, detail); } + getAutomationLog(companyId: string) { return this.automationMod.getAutomationLog(companyId); } + listActiveAutomationRules(companyId: string, trigger: string) { return this.automationMod.listActiveAutomationRules(companyId, trigger); } + saveWorkflow(input: { id?: string | null; companyId: string; name: string; description?: string; stepsJson?: string; triggerType: string; triggerConfigJson?: string; status?: string }) { return this.automationMod.saveWorkflow(input); } + getWorkflowRecord(id: string, companyId: string) { return this.automationMod.getWorkflowRecord(id, companyId); } + updateWorkflowExecution(input: { id: string; companyId: string; status: string; currentStepIndex: number; stepsJson: string; incrementRunCount?: boolean; lastRunAt?: string | null }) { this.automationMod.updateWorkflowExecution(input); } + deleteWorkflow(id: string, companyId: string) { this.automationMod.deleteWorkflow(id, companyId); } + listTriggerableWorkflows(companyId: string, triggerType: string): WorkflowPipelineRecord[] { return this.automationMod.listTriggerableWorkflows(companyId, triggerType); } + + // ─── Wakeup ──────────────────────────────────────────────────────────────── + + enqueueWakeupRequest(companyId: string, agentId: string, trigger: string, detail: string, context?: Record) { this.wakeup.enqueueWakeupRequest(companyId, agentId, trigger, detail, context); } + promoteWakeupRequests(agentId: string, companyId: string) { return this.wakeup.promoteWakeupRequests(agentId, companyId); } + listWakeupRequests(companyId: string, filters?: { status?: string; agentId?: string; limit?: number }) { return this.wakeup.listWakeupRequests(companyId, filters); } + getPreferredWakeTrigger(agentId: string, companyId: string) { return this.wakeup.getPreferredWakeTrigger(agentId, companyId); } + completePendingWakeupRequests(agentId: string, companyId: string, runId: string) { return this.wakeup.completePendingWakeupRequests(agentId, companyId, runId); } + purgeExpiredWakeupRequests() { return this.wakeup.purgeExpiredWakeupRequests(); } + + // ─── Backup ──────────────────────────────────────────────────────────────── + + exportBackup(targetDir: string) { return this.backupMod.exportBackup(targetDir); } + async restoreFromBackup(sourceDir: string) { return this.backupMod.restoreFromBackup(sourceDir); } + + // ─── Thread Runtime ──────────────────────────────────────────────────────── + + createThread(input: Parameters["createThread"]>[0]) { return this.threads.createThread(input); } + listThreads(options: Parameters["listThreads"]>[0]) { return this.threads.listThreads(options); } + getThread(threadId: string, companyId: string) { return this.threads.getThread(threadId, companyId); } + addThreadEvent(input: Parameters["addThreadEvent"]>[0]) { return this.threads.addThreadEvent(input); } + listThreadEvents(threadId: string, companyId: string, options?: Parameters["listThreadEvents"]>[2]) { return this.threads.listThreadEvents(threadId, companyId, options); } + setThreadWaitState(input: Parameters["setThreadWaitState"]>[0]) { return this.threads.setThreadWaitState(input); } + resolveThreadWaitState(waitStateId: string, companyId: string) { return this.threads.resolveThreadWaitState(waitStateId, companyId); } + listThreadWaitStates(companyId: string, options?: Parameters["listThreadWaitStates"]>[1]) { return this.threads.listThreadWaitStates(companyId, options); } + addParticipant(input: Parameters["addParticipant"]>[0]) { return this.threads.addParticipant(input); } + listParticipants(threadId: string) { return this.threads.listParticipants(threadId); } + removeParticipant(threadId: string, participantId: string) { return this.threads.removeParticipant(threadId, participantId); } + subscribe(input: Parameters["subscribe"]>[0]) { return this.threads.subscribe(input); } + unsubscribe(threadId: string, subscriberId: string) { return this.threads.unsubscribe(threadId, subscriberId); } + listSubscriptions(threadId: string) { return this.threads.listSubscriptions(threadId); } + listSubscribedThreads(subscriberId: string, companyId: string) { return this.threads.listSubscribedThreads(subscriberId, companyId); } + + // ─── Public query ────────────────────────────────────────────────────────── + + queryAll(sql: string, ...params: SQLInputValue[]): T[] { return this.readAll(sql, ...params); } } diff --git a/src/main/database/activity.ts b/src/main/database/activity.ts new file mode 100644 index 00000000..705fce97 --- /dev/null +++ b/src/main/database/activity.ts @@ -0,0 +1,37 @@ +import { randomUUID } from "node:crypto"; +import type { DatabaseSync } from "node:sqlite"; +import type { CommentRecord } from "@shared/types"; +import type { ReadAll } from "./helpers"; +import { nowIso } from "./helpers"; + +export function buildActivityMethods( + getDb: () => DatabaseSync, + readAll: ReadAll, +) { + return { + addActivity(input: { companyId: string; actor: string; action: string; entityType: string; entityId: string; detail: string }) { + getDb() + .prepare("insert into activity (id, company_id, actor, action, entity_type, entity_id, detail, created_at) values (?, ?, ?, ?, ?, ?, ?, ?)") + .run(randomUUID(), input.companyId, input.actor, input.action, input.entityType, input.entityId, input.detail, nowIso()); + }, + + addComment(input: { companyId: string; taskId: string; authorAgentId: string | null; authorName: string; body: string }): CommentRecord { + const taskRow = getDb().prepare("select company_id from tasks where id = ?").get(input.taskId) as { company_id: string } | undefined; + if (!taskRow) throw new Error("Task not found."); + if (taskRow.company_id !== input.companyId) throw new Error("Task does not belong to this company."); + const id = randomUUID(); + const ts = nowIso(); + getDb().prepare( + "insert into task_comments (id, company_id, task_id, author_agent_id, author_name, body, created_at) values (?, ?, ?, ?, ?, ?, ?)", + ).run(id, input.companyId, input.taskId, input.authorAgentId, input.authorName, input.body, ts); + return { id, companyId: input.companyId, taskId: input.taskId, authorAgentId: input.authorAgentId, authorName: input.authorName, body: input.body, createdAt: ts }; + }, + + listComments(taskId: string): CommentRecord[] { + return readAll( + "select id, company_id as companyId, task_id as taskId, author_agent_id as authorAgentId, author_name as authorName, body, created_at as createdAt from task_comments where task_id = ? order by created_at asc", + taskId, + ); + }, + }; +} diff --git a/src/main/database/agents-crud.ts b/src/main/database/agents-crud.ts new file mode 100644 index 00000000..e5e39186 --- /dev/null +++ b/src/main/database/agents-crud.ts @@ -0,0 +1,301 @@ +import type { DatabaseSync } from "node:sqlite"; +import type { AgentRecord, ConnectorId } from "@shared/types"; +import { + type ReadAll, + type Transaction, + ensureId, + normalizeOptionalId, + nowIso, +} from "./helpers"; + +export function buildAgentCrudMethods( + getDb: () => DatabaseSync, + readAll: ReadAll, + transaction: Transaction, + deps: { + getAgent: (id: string) => AgentRecord; + wouldCreateAgentCycle: (agentId: string, reportsTo: string | null) => boolean; + recordConfigRevision: (input: { + companyId: string; + agentId: string; + changedKeys: string[]; + configBefore: Record; + configAfter: Record; + source?: string; + rolledBackFromRevisionId?: string | null; + createdByAgentId?: string | null; + createdByUserId?: string | null; + }) => string; + cancelRunForDeletion: (runId: string, taskId: string, workspaceId: string | null, summary: string) => void; + addActivity: (input: { companyId: string; actor: string; action: string; entityType: string; entityId: string; detail: string }) => void; + }, +) { + return { + saveAgent(input: { + id?: string | null; + companyId: string; + name: string; + role: string; + title: string; + department?: string | null; + status: AgentRecord["status"]; + reportsTo?: string | null; + connectorId: ConnectorId; + workspaceId?: string | null; + model?: string | null; + metadataJson?: string; + capabilities: string; + budgetMonthlyUsd: number; + }): string { + const id = ensureId(input.id ?? null); + const ts = nowIso(); + const metadataJson = input.metadataJson ?? "{}"; + const department = input.department ?? null; + const reportsTo = normalizeOptionalId(input.reportsTo); + if (deps.wouldCreateAgentCycle(id, reportsTo)) { + throw new Error("Agent reporting relationship would create a cycle."); + } + const existing = getDb() + .prepare("select id from agents where id = ? and company_id = ?") + .get(id, input.companyId) as { id: string } | undefined; + if (existing) { + // Snapshot before-config for revision tracking + const beforeAgent = getDb() + .prepare( + "SELECT name, role, title, department, capabilities, connector_id, model, budget_monthly_usd, metadata_json FROM agents WHERE id = ? AND company_id = ?", + ) + .get(id, input.companyId) as Record | undefined; + getDb() + .prepare( + "update agents set name = ?, role = ?, title = ?, department = ?, status = ?, reports_to = ?, connector_id = ?, workspace_id = ?, model = ?, metadata_json = ?, capabilities = ?, budget_monthly_usd = ?, updated_at = ? where id = ? and company_id = ?", + ) + .run( + input.name, + input.role, + input.title, + department, + input.status, + reportsTo, + input.connectorId, + normalizeOptionalId(input.workspaceId), + normalizeOptionalId(input.model), + metadataJson, + input.capabilities, + input.budgetMonthlyUsd, + ts, + id, + input.companyId, + ); + // Record config revision if fields changed + if (beforeAgent) { + const afterConfig = { + name: input.name, + role: input.role, + title: input.title, + department, + capabilities: input.capabilities, + connectorId: input.connectorId, + model: normalizeOptionalId(input.model), + budgetMonthlyUsd: input.budgetMonthlyUsd, + metadataJson, + }; + const beforeConfig = { + name: beforeAgent.name, + role: beforeAgent.role, + title: beforeAgent.title, + department: beforeAgent.department, + capabilities: beforeAgent.capabilities, + connectorId: beforeAgent.connector_id, + model: beforeAgent.model, + budgetMonthlyUsd: beforeAgent.budget_monthly_usd, + metadataJson: beforeAgent.metadata_json, + }; + const changedKeys = Object.keys(afterConfig).filter( + (k) => + JSON.stringify((afterConfig as any)[k]) !== + JSON.stringify((beforeConfig as any)[k]), + ); + if (changedKeys.length > 0) { + try { + deps.recordConfigRevision({ + companyId: input.companyId, + agentId: id, + changedKeys, + configBefore: beforeConfig, + configAfter: afterConfig, + }); + } catch { + /* Non-fatal: revision recording should never block agent save */ + } + } + } + } else { + getDb() + .prepare( + "insert into agents (id, company_id, name, role, title, department, status, reports_to, connector_id, workspace_id, model, capabilities, budget_monthly_usd, spent_monthly_usd, metadata_json, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)", + ) + .run( + id, + input.companyId, + input.name, + input.role, + input.title, + department, + input.status, + reportsTo, + input.connectorId, + normalizeOptionalId(input.workspaceId), + normalizeOptionalId(input.model), + input.capabilities, + input.budgetMonthlyUsd, + metadataJson, + ts, + ts, + ); + } + return id; + }, + + deleteAgent(id: string, companyId: string): void { + const activeAgentRuns = readAll<{ id: string; taskId: string; workspaceId: string | null }>( + "select id, task_id as taskId, workspace_id as workspaceId from runs where agent_id = ? and company_id = ? and status in ('queued', 'running')", + id, + companyId, + ); + transaction(() => { + for (const run of activeAgentRuns) { + deps.cancelRunForDeletion(run.id, run.taskId, run.workspaceId, "Agent deleted."); + } + getDb() + .prepare( + "update tasks set assignee_agent_id = null where assignee_agent_id = ? and company_id = ?", + ) + .run(id, companyId); + getDb() + .prepare( + "update goals set owner_agent_id = null where owner_agent_id = ? and company_id = ?", + ) + .run(id, companyId); + getDb() + .prepare( + "update projects set lead_agent_id = null where lead_agent_id = ? and company_id = ?", + ) + .run(id, companyId); + getDb() + .prepare("update agents set reports_to = null where reports_to = ? and company_id = ?") + .run(id, companyId); + // Clean up orphaned approvals referencing this agent + getDb() + .prepare( + "update approvals set requested_by_agent_id = null where requested_by_agent_id = ? and company_id = ?", + ) + .run(id, companyId); + getDb() + .prepare( + "update approvals set related_agent_id = null where related_agent_id = ? and company_id = ?", + ) + .run(id, companyId); + // Clean up meetings organized by this agent + getDb() + .prepare( + "update meetings set organizer_agent_id = null where organizer_agent_id = ? and company_id = ?", + ) + .run(id, companyId); + // Clean up documents authored/reviewed by this agent + getDb() + .prepare( + "update documents set author_agent_id = null where author_agent_id = ? and company_id = ?", + ) + .run(id, companyId); + getDb() + .prepare( + "update documents set reviewer_agent_id = null where reviewer_agent_id = ? and company_id = ?", + ) + .run(id, companyId); + // Clean up knowledge entries authored by this agent + getDb() + .prepare( + "update knowledge_base set author_agent_id = null where author_agent_id = ? and company_id = ?", + ) + .run(id, companyId); + // Remove wakeup requests for this agent + getDb() + .prepare("DELETE FROM wakeup_requests WHERE agent_id = ? AND company_id = ?") + .run(id, companyId); + getDb().prepare("delete from agents where id = ? and company_id = ?").run(id, companyId); + }); + }, + + updateAgentStatus(agentId: string, status: string): void { + const ts = new Date().toISOString(); + getDb() + .prepare("update agents set status = ?, updated_at = ? where id = ?") + .run(status, ts, agentId); + }, + + hasAgentAssignedTasks(agentId: string, companyId: string): boolean { + const row = getDb() + .prepare( + "select 1 from tasks where assignee_agent_id = ? and company_id = ? and status in ('todo', 'in_progress', 'blocked') limit 1", + ) + .get(agentId, companyId); + return !!row; + }, + + isAgentPendingApproval(agentId: string): boolean { + const row = getDb() + .prepare("select status from agents where id = ?") + .get(agentId) as { status: string } | undefined; + return row?.status === "pending_approval"; + }, + + findMentionedAgentIds(companyId: string, text: string): string[] { + if (!text.includes("@")) return []; + const agents = readAll<{ id: string; name: string; role: string; title: string }>( + "SELECT id, name, role, title FROM agents WHERE company_id = ?", + companyId, + ); + const lowerText = text.toLowerCase(); + const aliasMap = new Map(); + + const registerAlias = (alias: string, agentId: string) => { + const normalized = alias.trim().toLowerCase(); + if (!normalized) return; + const existing = aliasMap.get(normalized) ?? []; + existing.push(agentId); + aliasMap.set(normalized, existing); + }; + + for (const agent of agents) { + registerAlias(agent.name, agent.id); + registerAlias(agent.role, agent.id); + registerAlias(agent.title, agent.id); + } + + const matched = new Set(); + const uniqueAliases = [...aliasMap.entries()] + .filter(([, ids]) => ids.length === 1) + .sort((a, b) => b[0].length - a[0].length); + + for (const [alias, ids] of uniqueAliases) { + const pattern = `@${alias}`; + const idx = lowerText.indexOf(pattern); + if (idx === -1) continue; + const afterIdx = idx + pattern.length; + if (afterIdx >= lowerText.length || /[\s,!?.;:\])}>]/.test(lowerText[afterIdx]!)) { + matched.add(ids[0]!); + } + } + return [...matched]; + }, + + countActiveRunsForAgent(agentId: string, companyId: string): number { + const row = getDb() + .prepare( + "SELECT COUNT(*) as cnt FROM runs WHERE agent_id = ? AND company_id = ? AND status IN ('queued', 'running')", + ) + .get(agentId, companyId) as { cnt: number } | undefined; + return row?.cnt ?? 0; + }, + }; +} diff --git a/src/main/database/agents-hierarchy.ts b/src/main/database/agents-hierarchy.ts new file mode 100644 index 00000000..9760a99e --- /dev/null +++ b/src/main/database/agents-hierarchy.ts @@ -0,0 +1,279 @@ +import type { DatabaseSync } from "node:sqlite"; +import type { AgentRecord } from "@shared/types"; +import { + type ReadAll, + type Transaction, + nowIso, + parseJson, + toBoolean, +} from "./helpers"; + +export function buildAgentHierarchyMethods( + getDb: () => DatabaseSync, + readAll: ReadAll, + transaction: Transaction, + deps: { + getAgent: (id: string) => AgentRecord; + }, +) { + return { + getChainOfCommand(agentId: string): AgentRecord[] { + const chain: AgentRecord[] = []; + const visited = new Set(); + let current: AgentRecord | undefined; + try { + current = deps.getAgent(agentId); + } catch { + return chain; + } + while (current?.reportsTo) { + if (visited.has(current.reportsTo)) { + break; + } + visited.add(current.reportsTo); + try { + current = deps.getAgent(current.reportsTo); + chain.push(current); + } catch { + break; + } + } + return chain; + }, + + getDirectReports(agentId: string, companyId: string): AgentRecord[] { + return readAll( + "select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, created_at as createdAt, updated_at as updatedAt from agents where company_id = ? and reports_to = ?", + companyId, + agentId, + ).map((record) => ({ + ...record, + heartbeatEnabled: toBoolean(record.heartbeatEnabled), + })) as AgentRecord[]; + }, + + setHeartbeat(agentId: string, enabled: boolean, intervalSec: number): void { + getDb() + .prepare( + "update agents set heartbeat_enabled = ?, heartbeat_interval_sec = ?, updated_at = ? where id = ?", + ) + .run(enabled ? 1 : 0, intervalSec, nowIso(), agentId); + }, + + markHeartbeatAt(agentId: string): void { + getDb() + .prepare("update agents set last_heartbeat_at = ? where id = ?") + .run(nowIso(), agentId); + }, + + listHeartbeatEligibleAgents(companyId: string): AgentRecord[] { + return readAll( + "select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, created_at as createdAt, updated_at as updatedAt from agents where company_id = ? and heartbeat_enabled = 1 and status not in ('terminated', 'paused', 'error', 'pending_approval')", + companyId, + ).map((record) => ({ + ...record, + heartbeatEnabled: toBoolean(record.heartbeatEnabled), + })) as AgentRecord[]; + }, + + getCeoForCompany( + companyId: string, + ): { id: string; name: string; lastHeartbeatAt: string | null } | null { + const row = getDb() + .prepare( + "select id, name, last_heartbeat_at as lastHeartbeatAt from agents where company_id = ? and reports_to is null and status != 'terminated' limit 1", + ) + .get(companyId) as + | { id: string; name: string; lastHeartbeatAt: string | null } + | undefined; + return row ?? null; + }, + + listActiveAgentsForCompany( + companyId: string, + ): Array<{ + id: string; + name: string; + department: string | null; + lastHeartbeatAt: string | null; + }> { + return readAll<{ + id: string; + name: string; + department: string | null; + lastHeartbeatAt: string | null; + }>( + "select id, name, department, last_heartbeat_at as lastHeartbeatAt from agents where company_id = ? and status not in ('terminated', 'paused', 'pending_approval')", + companyId, + ); + }, + + getIdleAgentsForDepartment( + companyId: string, + department: string, + ): Array<{ id: string; name: string }> { + return readAll( + "select id, name from agents where company_id = ? and department = ? and status in ('idle', 'active') order by spent_monthly_usd asc", + companyId, + department, + ); + }, + + getManagerForAgent( + agentId: string, + ): { id: string; name: string; companyId: string } | null { + const agent = getDb() + .prepare("select reports_to, company_id from agents where id = ?") + .get(agentId) as { reports_to: string | null; company_id: string } | undefined; + if (!agent || !agent.reports_to) return null; + const manager = getDb() + .prepare("select id, name from agents where id = ?") + .get(agent.reports_to) as { id: string; name: string } | undefined; + return manager ? { ...manager, companyId: agent.company_id } : null; + }, + + resetMonthlyBudgets(companyId: string): void { + getDb() + .prepare( + "update agents set spent_monthly_usd = 0, updated_at = ? where company_id = ? and spent_monthly_usd > 0", + ) + .run(nowIso(), companyId); + }, + + getAgentPermissions(agentId: string): Record { + const row = getDb() + .prepare("SELECT permissions_json FROM agents WHERE id = ?") + .get(agentId) as { permissions_json: string } | undefined; + return parseJson>(row?.permissions_json, {}); + }, + + setAgentPermission( + agentId: string, + companyId: string, + permissionKey: string, + enabled: boolean, + ): void { + const row = getDb() + .prepare("SELECT permissions_json FROM agents WHERE id = ?") + .get(agentId) as { permissions_json: string } | undefined; + const current = parseJson>(row?.permissions_json, {}); + if (enabled) { + current[permissionKey] = true; + } else { + delete current[permissionKey]; + } + const ts = nowIso(); + getDb() + .prepare( + "UPDATE agents SET permissions_json = ?, updated_at = ? WHERE id = ? AND company_id = ?", + ) + .run(JSON.stringify(current), ts, agentId, companyId); + }, + + hasAgentPermission(agentId: string, permissionKey: string): boolean { + const row = getDb() + .prepare("SELECT permissions_json FROM agents WHERE id = ?") + .get(agentId) as { permissions_json: string } | undefined; + const perms = parseJson>(row?.permissions_json, {}); + return perms[permissionKey] === true; + }, + + getRuntimeState(agentId: string): Record { + const row = getDb() + .prepare("SELECT runtime_state_json FROM agents WHERE id = ?") + .get(agentId) as { runtime_state_json: string | null } | undefined; + return parseJson>(row?.runtime_state_json, {}); + }, + + setRuntimeState( + agentId: string, + companyId: string, + state: Record, + ): void { + const ts = nowIso(); + getDb() + .prepare( + "UPDATE agents SET runtime_state_json = ?, updated_at = ? WHERE id = ? AND company_id = ?", + ) + .run(JSON.stringify(state), ts, agentId, companyId); + }, + + getHeartbeatPolicy(agentId: string): { + enabled: boolean; + intervalSec: number; + cooldownSec: number; + maxConcurrentRuns: number; + wakeOnDemand: boolean; + } { + const row = getDb() + .prepare( + "SELECT heartbeat_enabled, heartbeat_interval_sec, heartbeat_policy_json FROM agents WHERE id = ?", + ) + .get(agentId) as + | { + heartbeat_enabled: number; + heartbeat_interval_sec: number; + heartbeat_policy_json: string | null; + } + | undefined; + if (!row) throw new Error(`Agent ${agentId} not found.`); + const policy = parseJson>(row.heartbeat_policy_json, {}); + return { + enabled: toBoolean(row.heartbeat_enabled), + intervalSec: row.heartbeat_interval_sec ?? 120, + cooldownSec: typeof policy.cooldownSec === "number" ? policy.cooldownSec : 10, + maxConcurrentRuns: + typeof policy.maxConcurrentRuns === "number" ? policy.maxConcurrentRuns : 1, + wakeOnDemand: policy.wakeOnDemand !== false, + }; + }, + + setHeartbeatPolicy( + agentId: string, + companyId: string, + policy: { + enabled?: boolean; + intervalSec?: number; + cooldownSec?: number; + maxConcurrentRuns?: number; + wakeOnDemand?: boolean; + }, + ): void { + const row = getDb() + .prepare( + "SELECT heartbeat_enabled, heartbeat_interval_sec, heartbeat_policy_json FROM agents WHERE id = ?", + ) + .get(agentId) as + | { + heartbeat_enabled: number; + heartbeat_interval_sec: number; + heartbeat_policy_json: string | null; + } + | undefined; + if (!row) throw new Error(`Agent ${agentId} not found.`); + const current = parseJson>(row.heartbeat_policy_json, {}); + const merged = { + enabled: policy.enabled ?? toBoolean(row.heartbeat_enabled), + intervalSec: policy.intervalSec ?? row.heartbeat_interval_sec ?? 120, + cooldownSec: + policy.cooldownSec ?? + (typeof current.cooldownSec === "number" ? current.cooldownSec : 10), + maxConcurrentRuns: + policy.maxConcurrentRuns ?? + (typeof current.maxConcurrentRuns === "number" ? current.maxConcurrentRuns : 1), + wakeOnDemand: policy.wakeOnDemand ?? (current.wakeOnDemand !== false), + }; + const policyJson = JSON.stringify({ + cooldownSec: merged.cooldownSec, + maxConcurrentRuns: merged.maxConcurrentRuns, + wakeOnDemand: merged.wakeOnDemand, + }); + const ts = nowIso(); + getDb() + .prepare( + "UPDATE agents SET heartbeat_enabled = ?, heartbeat_interval_sec = ?, heartbeat_policy_json = ?, updated_at = ? WHERE id = ? AND company_id = ?", + ) + .run(merged.enabled ? 1 : 0, merged.intervalSec, policyJson, ts, agentId, companyId); + }, + }; +} diff --git a/src/main/database/agents.ts b/src/main/database/agents.ts new file mode 100644 index 00000000..dd924219 --- /dev/null +++ b/src/main/database/agents.ts @@ -0,0 +1,33 @@ +import type { DatabaseSync } from "node:sqlite"; +import type { AgentRecord } from "@shared/types"; +import type { ReadAll, Transaction } from "./helpers"; +import { buildAgentCrudMethods } from "./agents-crud"; +import { buildAgentHierarchyMethods } from "./agents-hierarchy"; + +export function buildAgentMethods( + getDb: () => DatabaseSync, + readAll: ReadAll, + transaction: Transaction, + deps: { + getAgent: (id: string) => AgentRecord; + wouldCreateAgentCycle: (agentId: string, reportsTo: string | null) => boolean; + recordConfigRevision: (input: { + companyId: string; + agentId: string; + changedKeys: string[]; + configBefore: Record; + configAfter: Record; + source?: string; + rolledBackFromRevisionId?: string | null; + createdByAgentId?: string | null; + createdByUserId?: string | null; + }) => string; + cancelRunForDeletion: (runId: string, taskId: string, workspaceId: string | null, summary: string) => void; + addActivity: (input: { companyId: string; actor: string; action: string; entityType: string; entityId: string; detail: string }) => void; + }, +) { + return { + ...buildAgentCrudMethods(getDb, readAll, transaction, deps), + ...buildAgentHierarchyMethods(getDb, readAll, transaction, { getAgent: deps.getAgent }), + }; +} diff --git a/src/main/database/approvals.ts b/src/main/database/approvals.ts new file mode 100644 index 00000000..0fb6d480 --- /dev/null +++ b/src/main/database/approvals.ts @@ -0,0 +1,213 @@ +import { randomUUID } from "node:crypto"; +import type { DatabaseSync, SQLInputValue } from "node:sqlite"; +import type { ApprovalRecord } from "@shared/types"; +import type { ReadAll, Transaction } from "./helpers"; +import { normalizeOptionalId, nowIso, parseJson } from "./helpers"; + +export function buildApprovalMethods( + getDb: () => DatabaseSync, + readAll: ReadAll, + transaction: Transaction, + deps: { + getAgent: (id: string) => { companyId: string; status: string }; + }, +) { + return { + getApprovalRecord(id: string) { + const row = getDb() + .prepare( + "select id, company_id as companyId, related_task_id as relatedTaskId, requested_by_agent_id as requestedByAgentId, related_agent_id as relatedAgentId, type, payload_summary as payloadSummary, impact_summary as impactSummary, payload_json as payloadJson, decision_note as decisionNote, state, created_at as createdAt, updated_at as updatedAt from approvals where id = ?", + ) + .get(id) as (ApprovalRecord & { payloadJson: string | null; relatedAgentId: string | null }) | undefined; + if (!row) throw new Error(`Approval not found: ${id}`); + return row; + }, + + requestApproval(input: { + companyId: string; + relatedTaskId?: string | null; + requestedByAgentId?: string | null; + relatedAgentId?: string | null; + type: string; + payloadSummary: string; + impactSummary: string; + payloadJson?: string | null; + }) { + const id = randomUUID(); + const ts = nowIso(); + let relatedAgentId = normalizeOptionalId(input.relatedAgentId); + if (!relatedAgentId && input.type === "hire_agent" && input.payloadJson) { + try { + const payload = JSON.parse(input.payloadJson) as { agentId?: string }; + relatedAgentId = payload.agentId ?? null; + } catch { /* ignore malformed payload */ } + } + if (input.type === "hire_agent") { + if (!relatedAgentId) { + throw new Error("hire_agent approvals require a linked pending agent."); + } + const agent = deps.getAgent(relatedAgentId); + if (agent.companyId !== input.companyId) { + throw new Error(`Agent ${relatedAgentId} does not belong to company ${input.companyId}.`); + } + if (agent.status !== "pending_approval") { + throw new Error(`hire_agent approvals can only target agents pending approval. Current status: ${agent.status}.`); + } + } + getDb() + .prepare( + "insert into approvals (id, company_id, related_task_id, requested_by_agent_id, related_agent_id, type, payload_summary, impact_summary, payload_json, decision_note, state, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, '', 'pending', ?, ?)", + ) + .run( + id, input.companyId, normalizeOptionalId(input.relatedTaskId), + normalizeOptionalId(input.requestedByAgentId), relatedAgentId, + input.type, input.payloadSummary, input.impactSummary, + input.payloadJson ?? null, ts, ts, + ); + return id; + }, + + decideApproval(input: { companyId?: string | null; approvalId: string; state: string; decisionNote: string }) { + const approval = getDb() + .prepare( + "select id, company_id as companyId, related_task_id as relatedTaskId, requested_by_agent_id as requestedByAgentId, related_agent_id as relatedAgentId, type, payload_summary as payloadSummary, impact_summary as impactSummary, payload_json as payloadJson, decision_note as decisionNote, state, created_at as createdAt, updated_at as updatedAt from approvals where id = ?", + ) + .get(input.approvalId) as (ApprovalRecord & { payloadJson: string | null; relatedAgentId: string | null }) | undefined; + if (!approval) throw new Error(`Approval not found: ${input.approvalId}`); + if (input.companyId && approval.companyId !== input.companyId) { + throw new Error(`Approval ${input.approvalId} does not belong to company ${input.companyId}.`); + } + if (!["pending", "revision_requested"].includes(approval.state)) { + throw new Error(`Approval ${input.approvalId} is already ${approval.state} and cannot be re-decided.`); + } + const ts = nowIso(); + transaction(() => { + getDb() + .prepare("update approvals set state = ?, decision_note = ?, updated_at = ? where id = ?") + .run(input.state, input.decisionNote, ts, input.approvalId); + if (approval.type === "hire_agent") { + const agentId = approval.relatedAgentId + ?? parseJson<{ agentId?: string } | null>(approval.payloadJson, null)?.agentId + ?? null; + if (!agentId) { + throw new Error(`hire_agent approval ${input.approvalId} has no linked agent — cannot resolve hire target. Ensure relatedAgentId or payloadJson.agentId is set.`); + } + if (input.state === "approved") { + const result = getDb() + .prepare("update agents set status = 'idle', updated_at = ? where id = ? and company_id = ? and status = 'pending_approval'") + .run(ts, agentId, approval.companyId); + if (result.changes === 0) { + throw new Error(`hire_agent approval ${input.approvalId} could not activate agent ${agentId}; the agent is missing or no longer pending approval.`); + } + } + if (input.state === "rejected") { + const result = getDb() + .prepare("update agents set status = 'terminated', updated_at = ? where id = ? and company_id = ? and status = 'pending_approval'") + .run(ts, agentId, approval.companyId); + if (result.changes === 0) { + throw new Error(`hire_agent approval ${input.approvalId} could not reject agent ${agentId}; the agent is missing or no longer pending approval.`); + } + } + } + }); + }, + + deleteApproval(id: string, companyId: string) { + getDb().prepare("delete from approvals where id = ? and company_id = ?").run(id, companyId); + }, + + getBlockingApproval(companyId: string, agentId: string, taskId: string | null): ApprovalRecord | null { + const taskCondition = taskId != null + ? "(related_task_id = ? or requested_by_agent_id = ?)" + : "requested_by_agent_id = ?"; + const taskParams: SQLInputValue[] = taskId != null ? [taskId, agentId] : [agentId]; + const rows = readAll( + `select id, company_id as companyId, related_task_id as relatedTaskId, + requested_by_agent_id as requestedByAgentId, related_agent_id as relatedAgentId, + type, payload_summary as payloadSummary, impact_summary as impactSummary, + payload_json as payloadJson, decision_note as decisionNote, state, + created_at as createdAt, updated_at as updatedAt + from approvals + where company_id = ? and state = 'pending' + and ( + (type = 'hire_agent' and related_agent_id = ?) + or (type = 'dangerous_command' and ${taskCondition}) + or (type = 'secret_access' and ${taskCondition}) + or (type = 'approve_ceo_strategy' and ${taskCondition}) + or (type = 'deliverable_review' and (requested_by_agent_id = ? or (related_task_id is not null and ${taskCondition}))) + ) + order by created_at asc limit 1`, + companyId, agentId, + ...taskParams, ...taskParams, ...taskParams, + agentId, ...taskParams, + ); + return rows.length > 0 ? rows[0] : null; + }, + + resubmitApproval(approvalId: string, companyId: string): void { + const ts = nowIso(); + getDb().prepare( + "UPDATE approvals SET state = 'pending', decision_note = '', updated_at = ? WHERE id = ? AND company_id = ? AND state = 'revision_requested'", + ).run(ts, approvalId, companyId); + }, + + addApprovalComment(input: { + companyId: string; approvalId: string; authorAgentId?: string | null; + authorUserId?: string | null; body: string; + }): string { + const id = randomUUID(); + const ts = nowIso(); + getDb().prepare( + "INSERT INTO approval_comments (id, company_id, approval_id, author_agent_id, author_user_id, body, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ).run(id, input.companyId, input.approvalId, input.authorAgentId ?? null, input.authorUserId ?? null, input.body, ts, ts); + return id; + }, + + listApprovalComments(approvalId: string, companyId: string) { + return readAll<{ + id: string; company_id: string; approval_id: string; author_agent_id: string | null; + author_user_id: string | null; body: string; created_at: string; updated_at: string; + }>( + "SELECT id, company_id, approval_id, author_agent_id, author_user_id, body, created_at, updated_at FROM approval_comments WHERE approval_id = ? AND company_id = ? ORDER BY created_at ASC", + approvalId, companyId, + ).map((r) => ({ + id: r.id, companyId: r.company_id, approvalId: r.approval_id, + authorAgentId: r.author_agent_id, authorUserId: r.author_user_id, + body: r.body, createdAt: r.created_at, updatedAt: r.updated_at, + })); + }, + + linkTaskToApproval(taskId: string, approvalId: string, companyId: string, linkedByAgentId?: string | null): void { + getDb().prepare( + "INSERT OR IGNORE INTO task_approval_links (task_id, approval_id, company_id, linked_by_agent_id, created_at) VALUES (?, ?, ?, ?, ?)", + ).run(taskId, approvalId, companyId, linkedByAgentId ?? null, nowIso()); + }, + + unlinkTaskFromApproval(taskId: string, approvalId: string): void { + getDb().prepare( + "DELETE FROM task_approval_links WHERE task_id = ? AND approval_id = ?", + ).run(taskId, approvalId); + }, + + listApprovalsForTask(taskId: string): string[] { + return readAll<{ approval_id: string }>( + "SELECT approval_id FROM task_approval_links WHERE task_id = ?", taskId, + ).map((r) => r.approval_id); + }, + + listTasksForApproval(approvalId: string): string[] { + return readAll<{ task_id: string }>( + "SELECT task_id FROM task_approval_links WHERE approval_id = ?", approvalId, + ).map((r) => r.task_id); + }, + + linkManyTasksToApproval(approvalId: string, taskIds: string[], companyId: string, linkedByAgentId?: string | null): void { + const ts = nowIso(); + for (const taskId of taskIds) { + getDb().prepare( + "INSERT OR IGNORE INTO task_approval_links (task_id, approval_id, company_id, linked_by_agent_id, created_at) VALUES (?, ?, ?, ?, ?)", + ).run(taskId, approvalId, companyId, linkedByAgentId ?? null, ts); + } + }, + }; +} diff --git a/src/main/database/automation.ts b/src/main/database/automation.ts new file mode 100644 index 00000000..5384dcc2 --- /dev/null +++ b/src/main/database/automation.ts @@ -0,0 +1,133 @@ +import { randomUUID } from "node:crypto"; +import type { DatabaseSync } from "node:sqlite"; +import type { WorkflowPipelineRecord } from "@shared/types"; +import type { ReadAll } from "./helpers"; +import { ensureId, nowIso } from "./helpers"; + +type SqliteRow = Record; + +export function buildAutomationMethods( + getDb: () => DatabaseSync, + readAll: ReadAll, + deps: { + addActivity: (input: { companyId: string; actor: string; action: string; entityType: string; entityId: string; detail: string }) => void; + }, +) { + return { + saveAutomationRule(input: { + id?: string | null; companyId: string; name: string; description?: string; + trigger: string; conditionsJson?: string; action: string; actionConfigJson?: string; + sourceDepartment?: string | null; targetDepartment?: string | null; + priority?: number; status?: string; + }) { + const id = ensureId(input.id ?? null); + const ts = nowIso(); + const existing = getDb().prepare("select id from automation_rules where id = ?").get(id) as { id: string } | undefined; + if (existing) { + getDb().prepare( + "update automation_rules set name = ?, description = ?, trigger = ?, conditions_json = ?, action = ?, action_config_json = ?, source_department = ?, target_department = ?, priority = ?, status = ?, updated_at = ? where id = ?", + ).run(input.name, input.description ?? "", input.trigger, input.conditionsJson ?? "{}", input.action, input.actionConfigJson ?? "{}", input.sourceDepartment ?? null, input.targetDepartment ?? null, input.priority ?? 50, input.status ?? "active", ts, id); + } else { + getDb().prepare( + "insert into automation_rules (id, company_id, name, description, trigger, conditions_json, action, action_config_json, source_department, target_department, priority, status, execution_count, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)", + ).run(id, input.companyId, input.name, input.description ?? "", input.trigger, input.conditionsJson ?? "{}", input.action, input.actionConfigJson ?? "{}", input.sourceDepartment ?? null, input.targetDepartment ?? null, input.priority ?? 50, input.status ?? "active", ts, ts); + } + deps.addActivity({ companyId: input.companyId, actor: "system", action: existing ? "updated" : "created", entityType: "automation_rule", entityId: id, detail: `${existing ? "Updated" : "Created"} automation rule: ${input.name}` }); + return id; + }, + + deleteAutomationRule(id: string, companyId: string) { + getDb().prepare("delete from automation_rules where id = ? and company_id = ?").run(id, companyId); + }, + + listAutomationRuleNames(companyId: string): Set { + return new Set( + readAll<{ name: string }>( + "select name from automation_rules where company_id = ?", + companyId, + ).map((row) => row.name), + ); + }, + + toggleAutomationRule(id: string, companyId: string, status: "active" | "paused") { + getDb().prepare("update automation_rules set status = ?, updated_at = ? where id = ? and company_id = ?").run(status, nowIso(), id, companyId); + }, + + recordAutomationExecution(ruleId: string, companyId: string, ruleName: string, trigger: string, action: string, detail: string) { + const id = randomUUID(); + getDb().prepare("insert into automation_log (id, company_id, rule_id, rule_name, trigger, action, detail, executed_at) values (?, ?, ?, ?, ?, ?, ?, ?)").run(id, companyId, ruleId, ruleName, trigger, action, detail, nowIso()); + getDb().prepare("update automation_rules set execution_count = execution_count + 1, last_executed_at = ? where id = ?").run(nowIso(), ruleId); + }, + + getAutomationLog(companyId: string) { + return readAll<{ ruleId: string; ruleName: string; trigger: string; action: string; executedAt: string }>( + "select rule_id as ruleId, rule_name as ruleName, trigger, action, executed_at as executedAt from automation_log where company_id = ? order by executed_at desc limit 100", + companyId, + ); + }, + + listActiveAutomationRules(companyId: string, trigger: string): Array<{ + id: string; name: string; trigger: string; conditionsJson: string; + action: string; actionConfigJson: string; sourceDepartment: string | null; + targetDepartment: string | null; priority: number; + }> { + return readAll( + "select id, name, trigger, conditions_json as conditionsJson, action, action_config_json as actionConfigJson, source_department as sourceDepartment, target_department as targetDepartment, priority from automation_rules where company_id = ? and trigger = ? and status = 'active' order by priority desc", + companyId, trigger, + ); + }, + + saveWorkflow(input: { + id?: string | null; companyId: string; name: string; description?: string; + stepsJson?: string; triggerType: string; triggerConfigJson?: string; status?: string; + }) { + const id = ensureId(input.id ?? null); + const ts = nowIso(); + const existing = getDb().prepare("select id from workflow_pipelines where id = ?").get(id) as { id: string } | undefined; + if (existing) { + getDb().prepare( + "update workflow_pipelines set name = ?, description = ?, steps_json = ?, trigger_type = ?, trigger_config_json = ?, status = ?, updated_at = ? where id = ?", + ).run(input.name, input.description ?? "", input.stepsJson ?? "[]", input.triggerType, input.triggerConfigJson ?? "{}", input.status ?? "draft", ts, id); + } else { + getDb().prepare( + "insert into workflow_pipelines (id, company_id, name, description, steps_json, trigger_type, trigger_config_json, status, current_step_index, run_count, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?)", + ).run(id, input.companyId, input.name, input.description ?? "", input.stepsJson ?? "[]", input.triggerType, input.triggerConfigJson ?? "{}", input.status ?? "draft", ts, ts); + } + deps.addActivity({ companyId: input.companyId, actor: "system", action: existing ? "updated" : "created", entityType: "workflow", entityId: id, detail: `${existing ? "Updated" : "Created"} workflow: ${input.name}` }); + return id; + }, + + getWorkflowRecord(id: string, companyId: string): WorkflowPipelineRecord | null { + const row = getDb().prepare( + "select id, company_id as companyId, name, description, steps_json as stepsJson, trigger_type as triggerType, trigger_config_json as triggerConfigJson, status, current_step_index as currentStepIndex, run_count as runCount, last_run_at as lastRunAt, created_at as createdAt, updated_at as updatedAt from workflow_pipelines where id = ? and company_id = ?", + ).get(id, companyId) as WorkflowPipelineRecord | undefined; + return row ?? null; + }, + + updateWorkflowExecution(input: { + id: string; companyId: string; status: string; + currentStepIndex: number; stepsJson: string; + incrementRunCount?: boolean; lastRunAt?: string | null; + }) { + const ts = nowIso(); + const runCountClause = input.incrementRunCount ? ", run_count = run_count + 1" : ""; + const lastRunAt = input.lastRunAt ?? null; + getDb().prepare( + `update workflow_pipelines + set status = ?, current_step_index = ?, steps_json = ?, last_run_at = ?, updated_at = ?${runCountClause} + where id = ? and company_id = ?`, + ).run(input.status, input.currentStepIndex, input.stepsJson, lastRunAt, ts, input.id, input.companyId); + }, + + deleteWorkflow(id: string, companyId: string) { + getDb().prepare("delete from workflow_pipelines where id = ? and company_id = ?").run(id, companyId); + }, + + listTriggerableWorkflows(companyId: string, triggerType: string): WorkflowPipelineRecord[] { + return readAll( + "select id, company_id as companyId, name, description, steps_json as stepsJson, trigger_type as triggerType, trigger_config_json as triggerConfigJson, status, current_step_index as currentStepIndex, run_count as runCount, last_run_at as lastRunAt, created_at as createdAt, updated_at as updatedAt from workflow_pipelines where company_id = ? and trigger_type = ? and status = 'active' order by created_at asc", + companyId, triggerType, + ) as WorkflowPipelineRecord[]; + }, + }; +} diff --git a/src/main/database/backup.ts b/src/main/database/backup.ts new file mode 100644 index 00000000..26c88caa --- /dev/null +++ b/src/main/database/backup.ts @@ -0,0 +1,121 @@ +import { chmodSync, existsSync, mkdirSync } from "node:fs"; +import { copyFile, cp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { DatabaseSync, backup as backupDatabase } from "node:sqlite"; + +async function copyIfExists(source: string, destination: string) { + if (!existsSync(source)) { + return; + } + await copyFile(source, destination); +} + +export function buildBackupMethods( + deps: { + getDb: () => DatabaseSync; + setDb: (db: DatabaseSync) => void; + openConnection: () => DatabaseSync; + init: () => void; + close: () => void; + profileDir: string; + dbPath: string; + logsDir: string; + artifactsDir: string; + backupsDir: string; + }, +) { + return { + exportBackup(targetDir: string) { + mkdirSync(targetDir, { recursive: true }); + deps.getDb().exec("PRAGMA wal_checkpoint(TRUNCATE);"); + const copyJobs = [ + backupDatabase(deps.getDb(), join(targetDir, "profile.sqlite")), + cp(deps.logsDir, join(targetDir, "logs"), { recursive: true, force: true }), + cp(deps.artifactsDir, join(targetDir, "artifacts"), { recursive: true, force: true }), + copyIfExists(join(deps.profileDir, "vault.key"), join(targetDir, "vault.key")), + copyIfExists(join(deps.profileDir, "agent-api-signing.key"), join(targetDir, "agent-api-signing.key")), + copyIfExists(`${deps.dbPath}-wal`, join(targetDir, "profile.sqlite-wal")), + copyIfExists(`${deps.dbPath}-shm`, join(targetDir, "profile.sqlite-shm")), + ]; + return Promise.all(copyJobs); + }, + + async restoreFromBackup(sourceDir: string) { + const sourceDbPath = join(sourceDir, "profile.sqlite"); + if (!existsSync(sourceDbPath)) { + throw new Error("Backup directory is missing profile.sqlite."); + } + + // Validate + const validationDb = new DatabaseSync(sourceDbPath); + try { + const requiredTables = ["settings", "companies", "connectors"] as const; + for (const table of requiredTables) { + const row = validationDb + .prepare("select name from sqlite_master where type = 'table' and name = ?") + .get(table) as { name: string } | undefined; + if (!row) { + throw new Error(`Backup is missing the required table "${table}".`); + } + } + } finally { + validationDb.close(); + } + + const recoveryDir = join(deps.backupsDir, `pre-restore-${Date.now()}`); + mkdirSync(recoveryDir, { recursive: true }); + deps.getDb().exec("PRAGMA wal_checkpoint(TRUNCATE);"); + await Promise.all([ + backupDatabase(deps.getDb(), join(recoveryDir, "profile.sqlite")), + cp(deps.logsDir, join(recoveryDir, "logs"), { recursive: true, force: true }), + cp(deps.artifactsDir, join(recoveryDir, "artifacts"), { recursive: true, force: true }), + copyIfExists(join(deps.profileDir, "vault.key"), join(recoveryDir, "vault.key")), + copyIfExists(join(deps.profileDir, "agent-api-signing.key"), join(recoveryDir, "agent-api-signing.key")), + copyIfExists(`${deps.dbPath}-wal`, join(recoveryDir, "profile.sqlite-wal")), + copyIfExists(`${deps.dbPath}-shm`, join(recoveryDir, "profile.sqlite-shm")), + ]); + + deps.close(); + try { + await applyBackup(sourceDir); + } catch (error) { + await applyBackup(recoveryDir); + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Restore failed. Previous profile was recovered from ${recoveryDir}. ${message}`); + } + }, + }; + + async function applyBackup(sourceDir: string) { + await rm(deps.dbPath, { force: true }); + await rm(`${deps.dbPath}-wal`, { force: true }); + await rm(`${deps.dbPath}-shm`, { force: true }); + + await copyFile(join(sourceDir, "profile.sqlite"), deps.dbPath); + await copyIfExists(join(sourceDir, "profile.sqlite-wal"), `${deps.dbPath}-wal`); + await copyIfExists(join(sourceDir, "profile.sqlite-shm"), `${deps.dbPath}-shm`); + + await rm(deps.logsDir, { recursive: true, force: true }); + await rm(deps.artifactsDir, { recursive: true, force: true }); + mkdirSync(deps.logsDir, { recursive: true }); + mkdirSync(deps.artifactsDir, { recursive: true }); + + if (existsSync(join(sourceDir, "logs"))) { + await cp(join(sourceDir, "logs"), deps.logsDir, { recursive: true, force: true }); + } + if (existsSync(join(sourceDir, "artifacts"))) { + await cp(join(sourceDir, "artifacts"), deps.artifactsDir, { recursive: true, force: true }); + } + await copyIfExists(join(sourceDir, "vault.key"), join(deps.profileDir, "vault.key")); + await copyIfExists(join(sourceDir, "agent-api-signing.key"), join(deps.profileDir, "agent-api-signing.key")); + if (existsSync(join(deps.profileDir, "vault.key"))) { + chmodSync(join(deps.profileDir, "vault.key"), 0o600); + } + if (existsSync(join(deps.profileDir, "agent-api-signing.key"))) { + chmodSync(join(deps.profileDir, "agent-api-signing.key"), 0o600); + } + + deps.setDb(deps.openConnection()); + deps.init(); + } +} diff --git a/src/main/database/budget.ts b/src/main/database/budget.ts new file mode 100644 index 00000000..bb47b430 --- /dev/null +++ b/src/main/database/budget.ts @@ -0,0 +1,229 @@ +import { randomUUID } from "node:crypto"; +import type { DatabaseSync, SQLInputValue } from "node:sqlite"; +import type { BudgetIncidentRecord, BudgetPolicyRecord } from "@shared/types"; +import type { ReadAll, Transaction } from "./helpers"; +import { nowIso } from "./helpers"; + +type SqliteRow = Record; + +function mapPolicyRow(r: SqliteRow): BudgetPolicyRecord { + return { + id: r.id, + companyId: r.company_id, + scopeType: r.scope_type, + scopeId: r.scope_id, + metric: r.metric, + windowKind: r.window_kind, + amount: r.amount, + warnPercent: r.warn_percent, + hardStopEnabled: !!r.hard_stop_enabled, + notifyEnabled: !!r.notify_enabled, + isActive: !!r.is_active, + createdAt: r.created_at, + updatedAt: r.updated_at, + }; +} + +export function buildBudgetMethods( + getDb: () => DatabaseSync, + readAll: ReadAll, + transaction: Transaction, +) { + function getCalendarMonthWindow(): { start: string; end: string } { + const now = new Date(); + const year = now.getUTCFullYear(); + const month = now.getUTCMonth(); + const start = new Date(Date.UTC(year, month, 1)).toISOString(); + const end = new Date(Date.UTC(year, month + 1, 1)).toISOString(); + return { start, end }; + } + + return { + saveBudgetPolicy(input: { + id?: string | null; + companyId: string; + scopeType: string; + scopeId: string; + metric?: string; + windowKind?: string; + amount: number; + warnPercent?: number; + hardStopEnabled?: boolean; + notifyEnabled?: boolean; + isActive?: boolean; + }): string { + const id = input.id || randomUUID(); + const ts = nowIso(); + getDb() + .prepare( + `INSERT OR REPLACE INTO budget_policies + (id, company_id, scope_type, scope_id, metric, window_kind, amount, warn_percent, + hard_stop_enabled, notify_enabled, is_active, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + id, input.companyId, input.scopeType, input.scopeId, + input.metric ?? "billed_cents", input.windowKind ?? "calendar_month_utc", + input.amount, input.warnPercent ?? 80, + (input.hardStopEnabled ?? true) ? 1 : 0, + (input.notifyEnabled ?? true) ? 1 : 0, + (input.isActive ?? true) ? 1 : 0, ts, ts, + ); + return id; + }, + + deleteBudgetPolicy(companyId: string, policyId: string): void { + transaction(() => { + getDb().prepare("DELETE FROM budget_incidents WHERE policy_id = ?").run(policyId); + getDb().prepare("DELETE FROM budget_policies WHERE id = ? AND company_id = ?").run(policyId, companyId); + }); + }, + + listBudgetPolicies(companyId: string): BudgetPolicyRecord[] { + return readAll( + "SELECT * FROM budget_policies WHERE company_id = ? ORDER BY scope_type, created_at", + companyId, + ).map(mapPolicyRow); + }, + + getBudgetPolicy(policyId: string): BudgetPolicyRecord | null { + const r = getDb().prepare("SELECT * FROM budget_policies WHERE id = ?").get(policyId) as SqliteRow | undefined; + if (!r) return null; + return mapPolicyRow(r); + }, + + getActivePoliciesForRun(companyId: string, agentId: string, projectId: string | null): BudgetPolicyRecord[] { + const sql = projectId + ? `SELECT * FROM budget_policies + WHERE company_id = ? AND is_active = 1 + AND ( + (scope_type = 'company' AND scope_id = ?) + OR (scope_type = 'agent' AND scope_id = ?) + OR (scope_type = 'project' AND scope_id = ?) + )` + : `SELECT * FROM budget_policies + WHERE company_id = ? AND is_active = 1 + AND ( + (scope_type = 'company' AND scope_id = ?) + OR (scope_type = 'agent' AND scope_id = ?) + )`; + const params: SQLInputValue[] = projectId + ? [companyId, companyId, agentId, projectId] + : [companyId, companyId, agentId]; + return readAll(sql, ...params).map(mapPolicyRow); + }, + + getCalendarMonthWindow, + + getSpentInWindow(policy: BudgetPolicyRecord): number { + const { start, end } = getCalendarMonthWindow(); + let sql: string; + let params: SQLInputValue[]; + if (policy.scopeType === "agent") { + sql = "SELECT COALESCE(SUM(amount_usd), 0) * 100 as total_cents FROM cost_entries WHERE company_id = ? AND created_at >= ? AND created_at < ? AND agent_id = ?"; + params = [policy.companyId, start, end, policy.scopeId]; + } else if (policy.scopeType === "project") { + sql = "SELECT COALESCE(SUM(amount_usd), 0) * 100 as total_cents FROM cost_entries WHERE company_id = ? AND created_at >= ? AND created_at < ? AND project_id = ?"; + params = [policy.companyId, start, end, policy.scopeId]; + } else { + sql = "SELECT COALESCE(SUM(amount_usd), 0) * 100 as total_cents FROM cost_entries WHERE company_id = ? AND created_at >= ? AND created_at < ?"; + params = [policy.companyId, start, end]; + } + const row = getDb().prepare(sql).get(...params) as { total_cents: number } | undefined; + return Math.round(row?.total_cents ?? 0); + }, + + upsertBudgetIncident( + policy: BudgetPolicyRecord, + thresholdType: "warn_threshold" | "hard_stop", + amountObserved: number, + ): string { + const { start, end } = getCalendarMonthWindow(); + const existing = getDb() + .prepare( + "SELECT id FROM budget_incidents WHERE policy_id = ? AND window_start = ? AND threshold_type = ? AND status = 'open'", + ) + .get(policy.id, start, thresholdType) as { id: string } | undefined; + + const ts = nowIso(); + if (existing) { + getDb() + .prepare("UPDATE budget_incidents SET amount_observed = ?, updated_at = ? WHERE id = ?") + .run(amountObserved, ts, existing.id); + return existing.id; + } + + const id = randomUUID(); + getDb() + .prepare( + `INSERT INTO budget_incidents + (id, company_id, policy_id, scope_type, scope_id, metric, window_kind, + window_start, window_end, threshold_type, amount_limit, amount_observed, + status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)`, + ) + .run( + id, policy.companyId, policy.id, policy.scopeType, policy.scopeId, + policy.metric, policy.windowKind, start, end, thresholdType, + policy.amount, amountObserved, ts, ts, + ); + return id; + }, + + listBudgetIncidents(companyId: string, statusFilter: string = "all"): BudgetIncidentRecord[] { + const sql = statusFilter === "all" + ? "SELECT * FROM budget_incidents WHERE company_id = ? ORDER BY created_at DESC" + : "SELECT * FROM budget_incidents WHERE company_id = ? AND status = ? ORDER BY created_at DESC"; + const rows = statusFilter === "all" + ? readAll(sql, companyId) + : readAll(sql, companyId, statusFilter); + return rows.map((r) => ({ + id: r.id, companyId: r.company_id, policyId: r.policy_id, + scopeType: r.scope_type, scopeId: r.scope_id, metric: r.metric, + windowKind: r.window_kind, windowStart: r.window_start, windowEnd: r.window_end, + thresholdType: r.threshold_type, amountLimit: r.amount_limit, + amountObserved: r.amount_observed, status: r.status, + resolvedAt: r.resolved_at, createdAt: r.created_at, updatedAt: r.updated_at, + })); + }, + + resolveBudgetIncident(incidentId: string, companyId: string): void { + const ts = nowIso(); + getDb() + .prepare( + "UPDATE budget_incidents SET status = 'resolved', resolved_at = ?, updated_at = ? WHERE id = ? AND company_id = ? AND status = 'open'", + ) + .run(ts, ts, incidentId, companyId); + }, + + resolveIncidentsForPolicy(policyId: string, resolutionStatus: string): number { + const ts = nowIso(); + const result = getDb() + .prepare( + "UPDATE budget_incidents SET status = ?, resolved_at = ?, updated_at = ? WHERE policy_id = ? AND status = 'open'", + ) + .run(resolutionStatus, ts, ts, policyId); + return Number(result.changes); + }, + + resolveWarnIncidentsForPolicy(policyId: string, resolutionStatus: string): number { + const ts = nowIso(); + const result = getDb() + .prepare( + "UPDATE budget_incidents SET status = ?, resolved_at = ?, updated_at = ? WHERE policy_id = ? AND status = 'open' AND threshold_type = 'warn_threshold'", + ) + .run(resolutionStatus, ts, ts, policyId); + return Number(result.changes); + }, + + autoCloseExpiredIncidents(companyId: string): number { + const ts = nowIso(); + const result = getDb() + .prepare( + "UPDATE budget_incidents SET status = 'auto_closed', resolved_at = ?, updated_at = ? WHERE company_id = ? AND status = 'open' AND window_end <= ?", + ) + .run(ts, ts, companyId, ts); + return Number(result.changes); + }, + }; +} diff --git a/src/main/database/company-crud.ts b/src/main/database/company-crud.ts new file mode 100644 index 00000000..5047fe2c --- /dev/null +++ b/src/main/database/company-crud.ts @@ -0,0 +1,309 @@ +import { randomUUID } from "node:crypto"; +import type { DatabaseSync } from "node:sqlite"; +import type { + InboxItem, + ProfileSnapshot, +} from "@shared/types"; +import { + type ReadAll, + type Transaction, + nowIso, +} from "./helpers"; + +export function buildCompanyCrudMethods( + getDb: () => DatabaseSync, + readAll: ReadAll, + transaction: Transaction, + deps: { + getSetting: (key: string) => string | null; + setSetting: (key: string, value: string) => void; + listSnapshot: (options: { companyId?: string | null }) => ProfileSnapshot; + addComment: (input: { companyId: string; taskId: string; authorAgentId: string | null; authorName: string; body: string }) => void; + }, +) { + return { + saveCompany(input: { + id?: string | null; + name: string; + description: string; + status: string; + autoApproveHires?: boolean; + reviewDeliverables?: boolean; + executionMode?: string; + }): string { + const id = input.id && input.id.length > 0 ? input.id : randomUUID(); + const ts = nowIso(); + const autoApprove = input.autoApproveHires ? 1 : 0; + const reviewDeliverables = input.reviewDeliverables ? 1 : 0; + const executionMode = input.executionMode ?? "ai_ceo"; + const existing = getDb() + .prepare("select id from companies where id = ?") + .get(id) as { id: string } | undefined; + if (existing) { + getDb() + .prepare( + "update companies set name = ?, description = ?, status = ?, auto_approve_hires = ?, review_deliverables = ?, execution_mode = ?, updated_at = ? where id = ?", + ) + .run(input.name, input.description, input.status, autoApprove, reviewDeliverables, executionMode, ts, id); + } else { + getDb() + .prepare( + "insert into companies (id, name, description, status, auto_approve_hires, review_deliverables, execution_mode, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .run(id, input.name, input.description, input.status, autoApprove, reviewDeliverables, executionMode, ts, ts); + if (!deps.getSetting("current_company_id")) { + deps.setSetting("current_company_id", id); + } + } + return id; + }, + + deleteCompany(companyId: string): void { + transaction(() => { + try { + getDb() + .prepare( + "delete from agent_message_reads where message_id in (select id from agent_messages where company_id = ?)", + ) + .run(companyId); + } catch (err) { + if (!(err instanceof Error) || !err.message.includes("no such table")) throw err; + } + // New table cleanup (budget_incidents before budget_policies due to FK) + getDb().prepare("delete from budget_incidents where company_id = ?").run(companyId); + getDb().prepare("delete from budget_policies where company_id = ?").run(companyId); + // agent_skill_assignments references company_skills -- delete before skills + try { + getDb() + .prepare( + "delete from agent_skill_assignments where skill_id in (select id from company_skills where company_id = ?)", + ) + .run(companyId); + } catch (err) { + if (!(err instanceof Error) || !err.message.includes("no such table")) throw err; + } + getDb().prepare("delete from company_skills where company_id = ?").run(companyId); + getDb().prepare("delete from task_sessions where company_id = ?").run(companyId); + getDb().prepare("delete from wakeup_requests where company_id = ?").run(companyId); + // Thread runtime tables (events + participants cascade from threads via FK) + getDb().prepare("delete from thread_wait_states where company_id = ?").run(companyId); + getDb().prepare("delete from conversation_threads where company_id = ?").run(companyId); + getDb().prepare("delete from cost_entries where company_id = ?").run(companyId); + getDb().prepare("delete from activity where company_id = ?").run(companyId); + getDb().prepare("delete from task_comments where company_id = ?").run(companyId); + getDb().prepare("delete from runs where company_id = ?").run(companyId); + getDb().prepare("delete from approvals where company_id = ?").run(companyId); + getDb().prepare("delete from tasks where company_id = ?").run(companyId); + getDb().prepare("delete from projects where company_id = ?").run(companyId); + getDb().prepare("delete from goals where company_id = ?").run(companyId); + getDb().prepare("delete from agents where company_id = ?").run(companyId); + getDb().prepare("delete from secrets where company_id = ?").run(companyId); + getDb().prepare("delete from workspaces where company_id = ?").run(companyId); + for (const table of [ + "automation_rules", + "automation_log", + "agent_messages", + "workflow_pipelines", + "meetings", + "documents", + "knowledge_base", + "sprints", + "browser_actions", + "social_accounts", + ]) { + try { + getDb().prepare(`delete from ${table} where company_id = ?`).run(companyId); + } catch (err) { + // Only tolerate "no such table" — re-throw anything else (disk error, etc.) + if (!(err instanceof Error) || !err.message.includes("no such table")) throw err; + } + } + getDb().prepare("delete from companies where id = ?").run(companyId); + if (deps.getSetting("current_company_id") === companyId) { + const next = getDb() + .prepare("select id from companies order by created_at asc limit 1") + .get() as { id: string } | undefined; + if (next) { + deps.setSetting("current_company_id", next.id); + } else { + deps.setSetting("current_company_id", ""); + } + } + }); + }, + + getCompanyStatus(companyId: string): string | null { + const row = getDb() + .prepare("select status from companies where id = ?") + .get(companyId) as { status: string } | undefined; + return row?.status ?? null; + }, + + getCompanyBasic(companyId: string): { name: string; description: string } | null { + const row = getDb() + .prepare("select name, description from companies where id = ?") + .get(companyId) as { name: string; description: string } | undefined; + return row ?? null; + }, + + listActiveCompanyIds(): Array<{ id: string; name: string }> { + return readAll<{ id: string; name: string }>( + "select id, name from companies where status = 'active'", + ); + }, + + getCompanyExecutionMode(companyId: string): string { + const row = getDb() + .prepare("SELECT execution_mode FROM companies WHERE id = ? LIMIT 1") + .get(companyId) as { execution_mode: string } | undefined; + return row?.execution_mode ?? "ai_ceo"; + }, + + getCompanyApprovalSettings(companyId: string): { autoApproveHires: boolean; reviewDeliverables: boolean } | null { + const row = getDb() + .prepare("SELECT auto_approve_hires, review_deliverables FROM companies WHERE id = ? LIMIT 1") + .get(companyId) as { auto_approve_hires: number; review_deliverables: number } | undefined; + if (!row) return null; + return { + autoApproveHires: row.auto_approve_hires === 1, + reviewDeliverables: row.review_deliverables === 1, + }; + }, + + getInbox(companyId: string): InboxItem[] { + const items: InboxItem[] = []; + const snapshot = deps.listSnapshot({ companyId }); + const agents = snapshot.agents; + const agentMap = new Map(agents.map((a) => [a.id, a])); + + // Pending approvals + for (const approval of snapshot.approvals.filter( + (a) => a.companyId === companyId && a.state === "pending", + )) { + const requester = agentMap.get(approval.requestedByAgentId ?? ""); + items.push({ + id: `approval-${approval.id}`, + type: "pending_approval", + title: approval.payloadSummary, + subtitle: `${approval.type.replaceAll("_", " ")} — requested by ${requester?.name ?? "Unknown"}`, + severity: approval.type === "dangerous_command" ? "critical" : "high", + entityType: "approval", + entityId: approval.id, + agentId: approval.requestedByAgentId, + agentName: requester?.name ?? null, + createdAt: approval.createdAt, + }); + } + + // Failed runs (last 24h) + const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + for (const run of snapshot.runs.filter( + (r) => + r.companyId === companyId && + (r.status === "failed" || r.status === "timed_out") && + r.createdAt > oneDayAgo, + )) { + const agent = agentMap.get(run.agentId); + items.push({ + id: `run-${run.id}`, + type: "failed_run", + title: run.summary || "Run failed", + subtitle: `${agent?.name ?? "Unknown agent"} — ${run.errorMessage?.slice(0, 100) ?? "Unknown error"}`, + severity: "high", + entityType: "run", + entityId: run.id, + agentId: run.agentId, + agentName: agent?.name ?? null, + createdAt: run.createdAt, + }); + } + + // Budget warnings (agents at >80% budget) + for (const agent of agents.filter( + (a) => a.budgetMonthlyUsd > 0 && a.spentMonthlyUsd / a.budgetMonthlyUsd > 0.8, + )) { + const pct = Math.round((agent.spentMonthlyUsd / agent.budgetMonthlyUsd) * 100); + items.push({ + id: `budget-${agent.id}`, + type: "budget_warning", + title: `${agent.name} at ${pct}% budget`, + subtitle: `$${agent.spentMonthlyUsd.toFixed(2)} of $${agent.budgetMonthlyUsd.toFixed(2)} monthly limit`, + severity: pct >= 100 ? "critical" : "high", + entityType: "agent", + entityId: agent.id, + agentId: agent.id, + agentName: agent.name, + createdAt: agent.updatedAt, + }); + } + + // Blocked tasks + for (const task of snapshot.tasks.filter( + (t) => t.companyId === companyId && t.status === "blocked", + )) { + const assignee = agentMap.get(task.assigneeAgentId ?? ""); + items.push({ + id: `blocked-${task.id}`, + type: "blocked_task", + title: task.title, + subtitle: `Assigned to ${assignee?.name ?? "Unassigned"} — blocked`, + severity: task.priority === "critical" ? "critical" : "medium", + entityType: "task", + entityId: task.id, + agentId: task.assigneeAgentId, + agentName: assignee?.name ?? null, + createdAt: task.updatedAt, + }); + } + + // Tasks needing review + for (const task of snapshot.tasks.filter( + (t) => t.companyId === companyId && t.status === "in_review", + )) { + const assignee = agentMap.get(task.assigneeAgentId ?? ""); + items.push({ + id: `review-${task.id}`, + type: "review_needed", + title: task.title, + subtitle: `Submitted by ${assignee?.name ?? "Unknown"} — awaiting review`, + severity: task.priority === "critical" ? "high" : "medium", + entityType: "task", + entityId: task.id, + agentId: task.assigneeAgentId, + agentName: assignee?.name ?? null, + createdAt: task.updatedAt, + }); + } + + // Unread urgent agent messages (company-wide or incident channels that the Board should see) + for (const msg of snapshot.agentMessages.filter( + (m) => + m.companyId === companyId && + !m.readAt && + (m.channel === "company" || m.channel === "incident" || m.priority === "urgent"), + )) { + const sender = agentMap.get(msg.fromAgentId); + items.push({ + id: `message-${msg.id}`, + type: "agent_message", + title: msg.subject, + subtitle: `${sender?.name ?? "Unknown"} — ${msg.channel === "incident" ? "INCIDENT" : msg.channel} ${msg.priority === "urgent" ? "(urgent)" : ""}`, + severity: msg.priority === "urgent" || msg.channel === "incident" ? "critical" : "medium", + entityType: "message", + entityId: msg.id, + agentId: msg.fromAgentId, + agentName: sender?.name ?? null, + createdAt: msg.createdAt, + }); + } + + const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 }; + items.sort( + (a, b) => + severityOrder[a.severity] - severityOrder[b.severity] || + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + return items; + }, + }; +} diff --git a/src/main/database/company-settings.ts b/src/main/database/company-settings.ts new file mode 100644 index 00000000..955eb81d --- /dev/null +++ b/src/main/database/company-settings.ts @@ -0,0 +1,327 @@ +import type { DatabaseSync } from "node:sqlite"; +import type { + CompanyMetrics, + AgentMetrics, + ProfileSnapshot, + RunStatus, + StandupReport, +} from "@shared/types"; +import { + type ReadAll, + type Transaction, +} from "./helpers"; + +export function buildCompanySettingsMethods( + getDb: () => DatabaseSync, + readAll: ReadAll, + transaction: Transaction, + deps: { + listSnapshot: (options: { companyId?: string | null }) => ProfileSnapshot; + }, +) { + return { + getCompanyMetrics(companyId: string, days: number = 30): CompanyMetrics { + const snapshot = deps.listSnapshot({ companyId }); + const agents = snapshot.agents; + const tasks = snapshot.tasks; + const runs = snapshot.runs; + const costs = snapshot.costs; + + const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString(); + const recentRuns = runs.filter((r) => r.createdAt > cutoff); + const recentTasks = tasks.filter((t) => t.status === "done" && t.updatedAt > cutoff); + + const successfulRuns = recentRuns.filter((r) => r.status === "succeeded").length; + const failedRuns = recentRuns.filter( + (r) => r.status === "failed" || r.status === "timed_out", + ).length; + const totalCost = costs.reduce((sum, c) => sum + (c.amountUsd ?? 0), 0); + const completedTasks = tasks.filter((t) => t.status === "done").length; + + const agentMetrics: AgentMetrics[] = agents + .filter((a) => a.status !== "pending_approval" && a.status !== "terminated") + .map((agent) => { + const agentTasks = tasks.filter((t) => t.assigneeAgentId === agent.id); + const agentRuns = runs.filter((r) => r.agentId === agent.id); + const agentRecentRuns = agentRuns.filter((r) => r.createdAt > cutoff); + const agentCosts = costs.filter((c) => c.agentId === agent.id); + + const agentSucceeded = agentRecentRuns.filter((r) => r.status === "succeeded").length; + const agentFailed = agentRecentRuns.filter( + (r) => r.status === "failed" || r.status === "timed_out", + ).length; + + // Avg run duration + const finishedRuns = agentRuns.filter((r) => r.startedAt && r.finishedAt); + const avgDuration = + finishedRuns.length > 0 + ? finishedRuns.reduce( + (sum, r) => + sum + + (new Date(r.finishedAt!).getTime() - new Date(r.startedAt!).getTime()) / 1000, + 0, + ) / finishedRuns.length + : 0; + + // Review cycles: count task status transitions by this agent + // Actual action strings: "task.status_changed" (api-server), "task.updated" (index.ts), "task.auto_assigned" (database) + const reviewCycles = snapshot.activity.filter( + (a) => + a.companyId === companyId && + a.actor === agent.id && + (a.action === "task.status_changed" || + a.action === "task.updated" || + a.action === "task.auto_assigned"), + ).length; + + const lastRun = agentRuns.sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + )[0]; + + return { + agentId: agent.id, + agentName: agent.name, + tasksCompleted: agentTasks.filter((t) => t.status === "done").length, + tasksAssigned: agentTasks.filter( + (t) => t.status !== "done" && t.status !== "cancelled", + ).length, + tasksFailed: agentTasks.filter((t) => t.status === "cancelled").length, + successRate: + agentRecentRuns.length > 0 + ? Math.round((agentSucceeded / agentRecentRuns.length) * 100) + : 0, + avgRunDurationSec: Math.round(avgDuration), + totalCostUsd: agentCosts.reduce((sum, c) => sum + (c.amountUsd ?? 0), 0), + runsTotal: agentRecentRuns.length, + runsSucceeded: agentSucceeded, + runsFailed: agentFailed, + reviewCycles, + lastActiveAt: lastRun?.createdAt ?? null, + }; + }); + + return { + totalAgents: agents.filter( + (a) => a.status !== "pending_approval" && a.status !== "terminated", + ).length, + activeAgents: agents.filter((a) => a.status === "active" || a.status === "running").length, + totalTasks: tasks.length, + completedTasks, + blockedTasks: tasks.filter((t) => t.status === "blocked").length, + totalRuns: recentRuns.length, + successfulRuns, + failedRuns, + overallSuccessRate: + recentRuns.length > 0 ? Math.round((successfulRuns / recentRuns.length) * 100) : 0, + totalCostUsd: totalCost, + avgCostPerTask: completedTasks > 0 ? totalCost / completedTasks : 0, + throughputTasksPerDay: days > 0 ? recentTasks.length / days : 0, + agentMetrics, + }; + }, + + getStandupReport(companyId: string): StandupReport { + const snapshot = deps.listSnapshot({ companyId }); + const agents = snapshot.agents.filter( + (a) => a.status !== "terminated" && a.status !== "pending_approval", + ); + const tasks = snapshot.tasks; + const runs = snapshot.runs; + const costs = snapshot.costs; + + const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + const recentActivity = snapshot.activity.filter( + (a) => a.companyId === companyId && a.createdAt > oneDayAgo, + ); + + const companyHighlights: string[] = []; + const blockers: string[] = []; + + const recentCompletedTasks = tasks.filter( + (t) => t.status === "done" && t.updatedAt > oneDayAgo, + ); + if (recentCompletedTasks.length > 0) { + companyHighlights.push(`${recentCompletedTasks.length} task(s) completed in last 24h`); + } + + const recentRuns = runs.filter((r) => r.createdAt > oneDayAgo); + const successfulRuns = recentRuns.filter((r) => r.status === "succeeded").length; + const failedRuns = recentRuns.filter( + (r) => r.status === "failed" || r.status === "timed_out", + ).length; + if (recentRuns.length > 0) { + companyHighlights.push( + `${recentRuns.length} runs (${successfulRuns} succeeded, ${failedRuns} failed)`, + ); + } + + const blockedTasks = tasks.filter((t) => t.status === "blocked"); + for (const task of blockedTasks) { + const assignee = agents.find((a) => a.id === task.assigneeAgentId); + blockers.push(`${task.title} — blocked (${assignee?.name ?? "unassigned"})`); + } + + const pendingApprovals = snapshot.approvals.filter( + (a) => a.companyId === companyId && a.state === "pending", + ).length; + const totalBudget = agents.reduce((sum, a) => sum + a.budgetMonthlyUsd, 0); + const totalSpent = agents.reduce((sum, a) => sum + a.spentMonthlyUsd, 0); + const budgetUtilization = + totalBudget > 0 ? Math.round((totalSpent / totalBudget) * 100) : 0; + + // Active sprint progress + const activeSprint = snapshot.sprints.find( + (s) => s.companyId === companyId && s.status === "active", + ); + if (activeSprint) { + const pct = + activeSprint.velocityPoints > 0 + ? Math.round( + (activeSprint.completedPoints / activeSprint.velocityPoints) * 100, + ) + : 0; + companyHighlights.push( + `Sprint "${activeSprint.name}": ${activeSprint.completedPoints}/${activeSprint.velocityPoints} pts (${pct}%)`, + ); + } + + // Unread messages requiring attention + const urgentUnread = snapshot.agentMessages.filter( + (m) => + m.companyId === companyId && + !m.readAt && + (m.priority === "urgent" || m.channel === "incident"), + ).length; + if (urgentUnread > 0) { + blockers.push(`${urgentUnread} urgent unread message(s) need attention`); + } + + const agentReports = agents.map((agent) => { + const agentTasks = tasks.filter((t) => t.assigneeAgentId === agent.id); + const agentRuns = runs.filter((r) => r.agentId === agent.id); + const agentRecentRuns = agentRuns.filter((r) => r.createdAt > oneDayAgo); + const agentCosts = costs.filter( + (c) => c.agentId === agent.id && c.createdAt > oneDayAgo, + ); + const lastRun = agentRecentRuns.sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + )[0]; + + const highlights: string[] = []; + const completedRecently = agentTasks.filter( + (t) => t.status === "done" && t.updatedAt > oneDayAgo, + ); + if (completedRecently.length > 0) + highlights.push(`Completed ${completedRecently.length} task(s)`); + if (agentRecentRuns.length > 0) + highlights.push(`${agentRecentRuns.length} run(s) in last 24h`); + const reviewedTasks = recentActivity.filter( + (a) => + a.actor === agent.id && + (a.action === "task.status_changed" || + a.action === "task.updated" || + a.action === "task.auto_assigned"), + ).length; + if (reviewedTasks > 0) highlights.push(`${reviewedTasks} status update(s)`); + + return { + agentId: agent.id, + agentName: agent.name, + role: agent.role, + status: agent.status, + tasksInProgress: agentTasks.filter((t) => t.status === "in_progress").length, + tasksCompleted24h: completedRecently.length, + tasksBlocked: agentTasks.filter((t) => t.status === "blocked").length, + lastRunStatus: (lastRun?.status ?? null) as RunStatus | null, + lastRunAt: lastRun?.createdAt ?? null, + costLast24h: agentCosts.reduce((sum, c) => sum + (c.amountUsd ?? 0), 0), + highlights, + }; + }); + + const summaryParts: string[] = []; + if (agents.length > 0) summaryParts.push(`${agents.length} active agents`); + summaryParts.push(`${recentCompletedTasks.length} tasks done`); + if (blockedTasks.length > 0) summaryParts.push(`${blockedTasks.length} blocked`); + if (pendingApprovals > 0) summaryParts.push(`${pendingApprovals} pending approvals`); + + return { + companyId, + generatedAt: new Date().toISOString(), + summary: summaryParts.join(" · "), + agentReports, + companyHighlights, + blockers, + pendingApprovals, + budgetUtilization, + }; + }, + + getSidebarBadges(companyId: string): { + pendingApprovals: number; + activeRuns: number; + failedRuns: number; + todoTasks: number; + budgetWarnings: number; + unreadMessages: number; + } { + const BOARD_READER_ID = "__board__"; + const pendingApprovals = + ( + getDb() + .prepare( + "select count(*) as c from approvals where company_id = ? and state = 'pending'", + ) + .get(companyId) as { c: number } + )?.c ?? 0; + const activeRuns = + ( + getDb() + .prepare( + "select count(*) as c from runs where company_id = ? and status in ('queued', 'running')", + ) + .get(companyId) as { c: number } + )?.c ?? 0; + const failedRuns = + ( + getDb() + .prepare( + "select count(*) as c from runs where company_id = ? and status in ('failed', 'timed_out', 'interrupted')", + ) + .get(companyId) as { c: number } + )?.c ?? 0; + const todoTasks = + ( + getDb() + .prepare( + "select count(*) as c from tasks where company_id = ? and status in ('todo', 'in_progress', 'in_review')", + ) + .get(companyId) as { c: number } + )?.c ?? 0; + const budgetWarnings = + ( + getDb() + .prepare( + "select count(*) as c from agents where company_id = ? and budget_monthly_usd > 0 and (spent_monthly_usd * 1.0 / budget_monthly_usd) >= 0.8", + ) + .get(companyId) as { c: number } + )?.c ?? 0; + const unreadMessages = + ( + getDb() + .prepare( + `select count(*) as c + from agent_messages messages + left join agent_message_reads reads + on reads.message_id = messages.id + and reads.agent_id = ? + where messages.company_id = ? + and reads.read_at is null + and (messages.channel = 'company' or messages.channel = 'incident' or messages.priority = 'urgent')`, + ) + .get(BOARD_READER_ID, companyId) as { c: number } + )?.c ?? 0; + return { pendingApprovals, activeRuns, failedRuns, todoTasks, budgetWarnings, unreadMessages }; + }, + }; +} diff --git a/src/main/database/company.ts b/src/main/database/company.ts new file mode 100644 index 00000000..5e2a6b39 --- /dev/null +++ b/src/main/database/company.ts @@ -0,0 +1,22 @@ +import type { DatabaseSync } from "node:sqlite"; +import type { ProfileSnapshot } from "@shared/types"; +import type { ReadAll, Transaction } from "./helpers"; +import { buildCompanyCrudMethods } from "./company-crud"; +import { buildCompanySettingsMethods } from "./company-settings"; + +export function buildCompanyMethods( + getDb: () => DatabaseSync, + readAll: ReadAll, + transaction: Transaction, + deps: { + getSetting: (key: string) => string | null; + setSetting: (key: string, value: string) => void; + listSnapshot: (options: { companyId?: string | null }) => ProfileSnapshot; + addComment: (input: { companyId: string; taskId: string; authorAgentId: string | null; authorName: string; body: string }) => void; + }, +) { + return { + ...buildCompanyCrudMethods(getDb, readAll, transaction, deps), + ...buildCompanySettingsMethods(getDb, readAll, transaction, { listSnapshot: deps.listSnapshot }), + }; +} diff --git a/src/main/database/connectors-crud.ts b/src/main/database/connectors-crud.ts new file mode 100644 index 00000000..f27a4ad6 --- /dev/null +++ b/src/main/database/connectors-crud.ts @@ -0,0 +1,107 @@ +import type { DatabaseSync } from "node:sqlite"; +import { normalizeCommandValue } from "@agentcompany/adapter-utils/server-utils"; +import type { ConnectorId, ConnectorRecord } from "@shared/types"; +import type { ReadAll } from "./helpers"; +import { nowIso } from "./helpers"; +import { getConnectorDefinition, buildDefaultConnectorRows } from "../connectors"; + + +export function buildConnectorMethods( + getDb: () => DatabaseSync, + readAll: ReadAll, +) { + function readConnectors(): ConnectorRecord[] { + const rows = readAll<{ + id: string; label: string; description: string; status: ConnectorRecord["status"]; + command: string; version: string | null; auth_state: ConnectorRecord["authState"]; + last_checked_at: string | null; last_error: string | null; model: string | null; + env_binding_text: string; notes: string; + }>( + "select id, label, description, status, command, version, auth_state, last_checked_at, last_error, model, env_binding_text, notes from connectors order by label asc", + ); + const result: ConnectorRecord[] = []; + for (const row of rows) { + let definition; + try { + definition = getConnectorDefinition(row.id as ConnectorId); + } catch { + continue; + } + result.push({ + id: row.id as ConnectorId, + label: row.label, + description: row.description, + status: row.status, + command: normalizeCommandValue(row.command), + version: row.version, + authState: row.auth_state, + lastCheckedAt: row.last_checked_at, + lastError: row.last_error, + model: row.model, + envBindingText: row.env_binding_text, + notes: row.notes, + configurationDoc: definition.configurationDoc, + capabilityMatrix: definition.capabilityMatrix, + }); + } + return result; + } + + return { + readConnectors, + + saveConnector(input: { + id: ConnectorId; + command: string; + model: string | null; + envBindingText: string; + notes: string; + }) { + const normalizedCommand = normalizeCommandValue(input.command); + getDb() + .prepare( + "update connectors set command = ?, model = ?, env_binding_text = ?, notes = ?, updated_at = ? where id = ?", + ) + .run(normalizedCommand, input.model, input.envBindingText, input.notes, nowIso(), input.id); + }, + + updateConnectorHealth(input: { + id: ConnectorId; + status: string; + authState: string; + version: string | null; + lastError: string | null; + }) { + getDb() + .prepare( + "update connectors set status = ?, auth_state = ?, version = ?, last_checked_at = ?, last_error = ?, updated_at = ? where id = ?", + ) + .run(input.status, input.authState, input.version, nowIso(), input.lastError, nowIso(), input.id); + }, + + getConnector(id: ConnectorId): ConnectorRecord { + const record = readConnectors().find((entry) => entry.id === id); + if (!record) throw new Error(`Connector not found: ${id}`); + return record; + }, + + seedConnectors() { + getDb().prepare("delete from connectors where id not in ('codex_local', 'claude_local', 'gemini_local') and id not like 'claude_compat%'").run(); + const currentRows = readAll>( + "select id, label, description, status, command, version, auth_state as authState, last_checked_at as lastCheckedAt, last_error as lastError, model, env_binding_text as envBindingText, notes from connectors", + ); + const merged = buildDefaultConnectorRows(currentRows); + const statement = getDb().prepare( + "insert or replace into connectors (id, label, description, status, command, version, auth_state, last_checked_at, last_error, model, env_binding_text, notes, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ); + const updatedAt = nowIso(); + for (const row of merged) { + statement.run( + row.id, row.label, row.description, row.status, + row.command, row.version, row.authState, row.lastCheckedAt, + row.lastError, row.model, row.envBindingText, row.notes, updatedAt, + ); + } + }, + }; +} diff --git a/src/main/database/documents.ts b/src/main/database/documents.ts new file mode 100644 index 00000000..bed2d88b --- /dev/null +++ b/src/main/database/documents.ts @@ -0,0 +1,285 @@ +import { randomUUID } from "node:crypto"; +import type { DatabaseSync, SQLInputValue } from "node:sqlite"; +import type { AgentRecord } from "@shared/types"; +import type { ReadAll, Transaction } from "./helpers"; +import { ensureId, nowIso, parseJson } from "./helpers"; + +type SqliteRow = Record; + +export function buildDocumentMethods( + getDb: () => DatabaseSync, + readAll: ReadAll, + transaction: Transaction, +) { + return { + saveDocument(input: { + id?: string | null; companyId: string; type: string; title: string; content?: string; + authorAgentId?: string | null; reviewerAgentId?: string | null; projectId?: string | null; + goalId?: string | null; parentDocId?: string | null; version?: number; status?: string; tagsJson?: string; + sourceRunId?: string | null; + }) { + const id = ensureId(input.id ?? null); + const ts = nowIso(); + const existing = getDb().prepare("select id from documents where id = ?").get(id) as { id: string } | undefined; + if (existing) { + getDb().prepare( + "update documents set type = ?, title = ?, content = ?, author_agent_id = ?, reviewer_agent_id = ?, project_id = ?, goal_id = ?, parent_doc_id = ?, version = ?, status = ?, tags_json = ?, source_run_id = coalesce(?, source_run_id), updated_at = ? where id = ?", + ).run(input.type, input.title, input.content ?? "", input.authorAgentId ?? null, input.reviewerAgentId ?? null, input.projectId ?? null, input.goalId ?? null, input.parentDocId ?? null, input.version ?? 1, input.status ?? "draft", input.tagsJson ?? "[]", input.sourceRunId ?? null, ts, id); + } else { + getDb().prepare( + "insert into documents (id, company_id, type, title, content, author_agent_id, reviewer_agent_id, project_id, goal_id, parent_doc_id, version, status, tags_json, source_run_id, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ).run(id, input.companyId, input.type, input.title, input.content ?? "", input.authorAgentId ?? null, input.reviewerAgentId ?? null, input.projectId ?? null, input.goalId ?? null, input.parentDocId ?? null, input.version ?? 1, input.status ?? "draft", input.tagsJson ?? "[]", input.sourceRunId ?? null, ts, ts); + } + return id; + }, + + updateDocumentStatus(id: string, companyId: string, status: string): void { + getDb().prepare( + "UPDATE documents SET status = ?, updated_at = ? WHERE id = ? AND company_id = ?", + ).run(status, nowIso(), id, companyId); + }, + + deleteDocument(id: string, companyId: string) { + getDb().prepare("delete from documents where id = ? and company_id = ?").run(id, companyId); + }, + + createDocumentRevision(input: { + documentId: string; companyId: string; body: string; + changeSummary?: string | null; createdByAgentId?: string | null; createdByUserId?: string | null; + }): { id: string; revisionNumber: number } { + const id = randomUUID(); + const ts = nowIso(); + const current = getDb().prepare( + "SELECT version FROM documents WHERE id = ? AND company_id = ?", + ).get(input.documentId, input.companyId) as { version: number } | undefined; + if (!current) throw new Error(`Document ${input.documentId} not found`); + const revisionNumber = current.version + 1; + getDb().prepare( + "INSERT INTO document_revisions (id, company_id, document_id, revision_number, body, change_summary, created_by_agent_id, created_by_user_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ).run(id, input.companyId, input.documentId, revisionNumber, input.body, input.changeSummary ?? null, input.createdByAgentId ?? null, input.createdByUserId ?? null, ts); + getDb().prepare( + "UPDATE documents SET version = ?, latest_revision_id = ?, updated_at = ? WHERE id = ?", + ).run(revisionNumber, id, ts, input.documentId); + return { id, revisionNumber }; + }, + + getDocumentLatestRevisionId(documentId: string): string | null { + const row = getDb().prepare( + "SELECT latest_revision_id FROM documents WHERE id = ?", + ).get(documentId) as { latest_revision_id: string | null } | undefined; + return row?.latest_revision_id ?? null; + }, + + listDocumentRevisions(documentId: string, companyId: string) { + return readAll<{ + id: string; company_id: string; document_id: string; revision_number: number; + body: string; change_summary: string | null; created_by_agent_id: string | null; + created_by_user_id: string | null; created_at: string; + }>( + "SELECT id, company_id, document_id, revision_number, body, change_summary, created_by_agent_id, created_by_user_id, created_at FROM document_revisions WHERE document_id = ? AND company_id = ? ORDER BY revision_number ASC", + documentId, companyId, + ).map((r) => ({ + id: r.id, companyId: r.company_id, documentId: r.document_id, + revisionNumber: r.revision_number, body: r.body, + changeSummary: r.change_summary, createdByAgentId: r.created_by_agent_id, + createdByUserId: r.created_by_user_id, createdAt: r.created_at, + })); + }, + + // ── Work Products ── + + createWorkProduct(input: { + companyId: string; taskId: string; projectId?: string | null; + type: string; provider: string; externalId?: string | null; + title: string; url?: string | null; status?: string; reviewState?: string; + isPrimary?: boolean; summary?: string | null; metadataJson?: string | null; + createdByRunId?: string | null; + }): string { + const id = randomUUID(); + const ts = nowIso(); + if (input.isPrimary) { + getDb().prepare( + "UPDATE work_products SET is_primary = 0, updated_at = ? WHERE company_id = ? AND task_id = ? AND type = ?", + ).run(ts, input.companyId, input.taskId, input.type); + } + getDb().prepare( + `INSERT INTO work_products (id, company_id, project_id, task_id, type, provider, external_id, title, url, status, review_state, is_primary, summary, metadata_json, created_by_run_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + id, input.companyId, input.projectId ?? null, input.taskId, + input.type, input.provider, input.externalId ?? null, + input.title, input.url ?? null, input.status ?? "active", + input.reviewState ?? "none", input.isPrimary ? 1 : 0, + input.summary ?? null, input.metadataJson ?? null, + input.createdByRunId ?? null, ts, ts, + ); + return id; + }, + + updateWorkProduct(id: string, companyId: string, patch: Record): void { + const ts = nowIso(); + const sets: string[] = ["updated_at = ?"]; + const values: unknown[] = [ts]; + const ALLOWED = new Set(["type", "provider", "external_id", "title", "url", "status", "review_state", "is_primary", "summary", "metadata_json"]); + for (const [key, value] of Object.entries(patch)) { + const col = key.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`); + if (ALLOWED.has(col)) { + sets.push(`${col} = ?`); + values.push(col === "is_primary" ? (value ? 1 : 0) : value ?? null); + } + } + values.push(id, companyId); + getDb().prepare(`UPDATE work_products SET ${sets.join(", ")} WHERE id = ? AND company_id = ?`).run(...(values as SQLInputValue[])); + }, + + listWorkProductsForTask(taskId: string, companyId: string) { + return readAll<{ + id: string; company_id: string; project_id: string | null; task_id: string; + type: string; provider: string; external_id: string | null; title: string; + url: string | null; status: string; review_state: string; is_primary: number; + summary: string | null; metadata_json: string | null; created_by_run_id: string | null; + created_at: string; updated_at: string; + }>( + "SELECT * FROM work_products WHERE task_id = ? AND company_id = ? ORDER BY is_primary DESC, updated_at DESC", + taskId, companyId, + ).map((r) => ({ + id: r.id, companyId: r.company_id, projectId: r.project_id, taskId: r.task_id, + type: r.type, provider: r.provider, externalId: r.external_id, title: r.title, + url: r.url, status: r.status, reviewState: r.review_state, + isPrimary: Boolean(r.is_primary), summary: r.summary, + metadataJson: r.metadata_json, createdByRunId: r.created_by_run_id, + createdAt: r.created_at, updatedAt: r.updated_at, + })); + }, + + deleteWorkProduct(id: string, companyId: string): void { + getDb().prepare("DELETE FROM work_products WHERE id = ? AND company_id = ?").run(id, companyId); + }, + + // ── Task-Document Links ── + + linkDocumentToTask(taskId: string, documentId: string, companyId: string, key: string): string { + const id = randomUUID(); + const ts = nowIso(); + getDb().prepare( + "INSERT OR REPLACE INTO task_documents (id, company_id, task_id, document_id, key, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + ).run(id, companyId, taskId, documentId, key.toLowerCase(), ts, ts); + return id; + }, + + unlinkDocumentFromTask(taskId: string, key: string, companyId: string): void { + getDb().prepare( + "DELETE FROM task_documents WHERE task_id = ? AND key = ? AND company_id = ?", + ).run(taskId, key.toLowerCase(), companyId); + }, + + listDocumentsForTask(taskId: string, companyId: string) { + return readAll<{ + id: string; company_id: string; task_id: string; document_id: string; + key: string; created_at: string; updated_at: string; + }>( + "SELECT * FROM task_documents WHERE task_id = ? AND company_id = ? ORDER BY key ASC", + taskId, companyId, + ).map((r) => ({ + id: r.id, companyId: r.company_id, taskId: r.task_id, + documentId: r.document_id, key: r.key, + createdAt: r.created_at, updatedAt: r.updated_at, + })); + }, + + // ── Config Revisions ── + + recordConfigRevision(input: { + companyId: string; agentId: string; changedKeys: string[]; + configBefore: Record; configAfter: Record; + source?: string; rolledBackFromRevisionId?: string | null; + createdByAgentId?: string | null; createdByUserId?: string | null; + }): string { + const id = randomUUID(); + const ts = nowIso(); + const lastRev = getDb().prepare( + "SELECT MAX(revision_number) as maxRev FROM agent_config_revisions WHERE agent_id = ?" + ).get(input.agentId) as { maxRev: number | null } | undefined; + const revisionNumber = (lastRev?.maxRev ?? 0) + 1; + getDb().prepare( + "INSERT INTO agent_config_revisions (id, company_id, agent_id, revision_number, changed_keys, config_before, config_after, source, rolled_back_from_revision_id, created_by_agent_id, created_by_user_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + ).run( + id, input.companyId, input.agentId, revisionNumber, + JSON.stringify(input.changedKeys), JSON.stringify(input.configBefore), + JSON.stringify(input.configAfter), input.source ?? "patch", + input.rolledBackFromRevisionId ?? null, input.createdByAgentId ?? null, + input.createdByUserId ?? null, ts + ); + return id; + }, + + listConfigRevisions(agentId: string, companyId: string): Array<{ + id: string; agentId: string; revisionNumber: number; changedKeys: string[]; + source: string; rolledBackFromRevisionId: string | null; + createdByAgentId: string | null; createdByUserId: string | null; createdAt: string; + }> { + return readAll( + "SELECT id, agent_id, revision_number, changed_keys, source, rolled_back_from_revision_id, created_by_agent_id, created_by_user_id, created_at FROM agent_config_revisions WHERE agent_id = ? AND company_id = ? ORDER BY revision_number DESC", + agentId, companyId + ).map((row: SqliteRow) => ({ + id: row.id, agentId: row.agent_id, revisionNumber: row.revision_number, + changedKeys: parseJson(row.changed_keys, []), + source: row.source, rolledBackFromRevisionId: row.rolled_back_from_revision_id ?? null, + createdByAgentId: row.created_by_agent_id ?? null, + createdByUserId: row.created_by_user_id ?? null, createdAt: row.created_at, + })); + }, + + getConfigRevision(revisionId: string, companyId: string): { + id: string; agentId: string; revisionNumber: number; changedKeys: string[]; + configBefore: Record; configAfter: Record; + source: string; rolledBackFromRevisionId: string | null; + createdByAgentId: string | null; createdByUserId: string | null; createdAt: string; + } { + const row = getDb().prepare( + "SELECT * FROM agent_config_revisions WHERE id = ? AND company_id = ?" + ).get(revisionId, companyId) as any; + if (!row) throw new Error(`Config revision ${revisionId} not found.`); + return { + id: row.id, agentId: row.agent_id, revisionNumber: row.revision_number, + changedKeys: parseJson(row.changed_keys, []), + configBefore: parseJson>(row.config_before, {}), + configAfter: parseJson>(row.config_after, {}), + source: row.source, rolledBackFromRevisionId: row.rolled_back_from_revision_id ?? null, + createdByAgentId: row.created_by_agent_id ?? null, + createdByUserId: row.created_by_user_id ?? null, createdAt: row.created_at, + }; + }, + + rollbackConfigRevision(agentId: string, revisionId: string, companyId: string, selfDeps: { + getConfigRevision: (revisionId: string, companyId: string) => any; + getAgent: (id: string) => AgentRecord; + recordConfigRevision: (input: any) => string; + }): string { + return transaction(() => { + const revision = selfDeps.getConfigRevision(revisionId, companyId); + if (revision.agentId !== agentId) throw new Error("Revision does not belong to this agent."); + const before = revision.configBefore as Record; + const ts = nowIso(); + getDb().prepare( + "UPDATE agents SET name = ?, role = ?, title = ?, department = ?, capabilities = ?, connector_id = ?, model = ?, budget_monthly_usd = ?, metadata_json = ?, updated_at = ? WHERE id = ? AND company_id = ?" + ).run( + before.name ?? "", before.role ?? "", before.title ?? "", before.department ?? null, + before.capabilities ?? "", before.connectorId ?? before.connector_id ?? "", + before.model ?? null, before.budgetMonthlyUsd ?? before.budget_monthly_usd ?? 0, + before.metadataJson ?? before.metadata_json ?? "{}", ts, agentId, companyId + ); + const agent = selfDeps.getAgent(agentId); + const currentConfig = { name: agent.name, role: agent.role, title: agent.title, department: (agent as any).department, capabilities: agent.capabilities, connectorId: agent.connectorId, model: agent.model, budgetMonthlyUsd: agent.budgetMonthlyUsd, metadataJson: agent.metadataJson }; + return selfDeps.recordConfigRevision({ + companyId, agentId, + changedKeys: revision.changedKeys, + configBefore: currentConfig, + configAfter: before, + source: "rollback", + rolledBackFromRevisionId: revisionId, + }); + }); + }, + }; +} diff --git a/src/main/database/entity-crud.ts b/src/main/database/entity-crud.ts new file mode 100644 index 00000000..212b4c2d --- /dev/null +++ b/src/main/database/entity-crud.ts @@ -0,0 +1,188 @@ +import type { DatabaseSync } from "node:sqlite"; +import type { AgentRecord, ConnectorId, TaskRecord } from "@shared/types"; +import type { ReadAll, Transaction } from "./helpers"; +import { ensureId, normalizeOptionalId, nowIso } from "./helpers"; + +export function buildEntityCrudMethods( + getDb: () => DatabaseSync, + readAll: ReadAll, + transaction: Transaction, + _deps: { + addActivity: (input: { companyId: string; actor: string; action: string; entityType: string; entityId: string; detail: string }) => void; + }, +) { + return { + getTask(id: string): TaskRecord { + const row = getDb() + .prepare( + "select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, requires_user_review as requiresUserReview, started_at as startedAt, completed_at as completedAt, cancelled_at as cancelledAt, created_at as createdAt, updated_at as updatedAt from tasks where id = ?", + ) + .get(id) as TaskRecord | undefined; + if (!row) throw new Error(`Task not found: ${id}`); + row.requiresUserReview = Boolean(row.requiresUserReview); + row.startedAt = row.startedAt ?? null; + row.completedAt = row.completedAt ?? null; + row.cancelledAt = row.cancelledAt ?? null; + return row; + }, + + getAgent(id: string): AgentRecord { + const row = getDb() + .prepare( + "select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, created_at as createdAt, updated_at as updatedAt from agents where id = ?", + ) + .get(id) as AgentRecord | undefined; + if (!row) throw new Error(`Agent not found: ${id}`); + return row; + }, + + saveGoal(input: { id?: string | null; companyId: string; title: string; description: string; parentId?: string | null; ownerAgentId?: string | null; status: string }) { + const id = ensureId(input.id ?? null); + const ts = nowIso(); + const existing = getDb().prepare("select id from goals where id = ? and company_id = ?").get(id, input.companyId) as { id: string } | undefined; + if (existing) { + getDb() + .prepare( + "update goals set title = ?, description = ?, parent_id = ?, owner_agent_id = ?, status = ?, updated_at = ? where id = ? and company_id = ?", + ) + .run(input.title, input.description, normalizeOptionalId(input.parentId), normalizeOptionalId(input.ownerAgentId), input.status, ts, id, input.companyId); + } else { + getDb() + .prepare( + "insert into goals (id, company_id, title, description, parent_id, owner_agent_id, status, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .run(id, input.companyId, input.title, input.description, normalizeOptionalId(input.parentId), normalizeOptionalId(input.ownerAgentId), input.status, ts, ts); + } + return id; + }, + + saveProject(input: { id?: string | null; companyId: string; goalId?: string | null; name: string; description: string; leadAgentId?: string | null; targetDate?: string | null; status: string }) { + const id = ensureId(input.id ?? null); + const ts = nowIso(); + const existing = getDb().prepare("select id from projects where id = ? and company_id = ?").get(id, input.companyId) as { id: string } | undefined; + if (existing) { + getDb() + .prepare( + "update projects set goal_id = ?, name = ?, description = ?, lead_agent_id = ?, status = ?, target_date = ?, updated_at = ? where id = ? and company_id = ?", + ) + .run(normalizeOptionalId(input.goalId), input.name, input.description, normalizeOptionalId(input.leadAgentId), input.status, normalizeOptionalId(input.targetDate), ts, id, input.companyId); + } else { + getDb() + .prepare( + "insert into projects (id, company_id, goal_id, name, description, lead_agent_id, status, target_date, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .run(id, input.companyId, normalizeOptionalId(input.goalId), input.name, input.description, normalizeOptionalId(input.leadAgentId), input.status, normalizeOptionalId(input.targetDate), ts, ts); + } + return id; + }, + + saveSecret(input: { id?: string | null; companyId: string; name: string; description: string; value: string }, vault: { encrypt: (v: string) => { backend: string; payload: Uint8Array } }) { + const id = ensureId(input.id ?? null); + const encrypted = vault.encrypt(input.value); + const ts = nowIso(); + const existing = getDb().prepare("select id, company_id as companyId from secrets where id = ?").get(id) as + | { id: string; companyId: string } + | undefined; + if (existing) { + if (existing.companyId !== input.companyId) { + throw new Error("Secrets cannot be moved across companies."); + } + getDb() + .prepare("update secrets set name = ?, description = ?, backend = ?, ciphertext = ?, updated_at = ? where id = ? and company_id = ?") + .run(input.name, input.description, encrypted.backend, encrypted.payload, ts, id, input.companyId); + } else { + getDb() + .prepare("insert into secrets (id, company_id, name, description, backend, ciphertext, updated_at, created_at) values (?, ?, ?, ?, ?, ?, ?, ?)") + .run(id, input.companyId, input.name, input.description, encrypted.backend, encrypted.payload, ts, ts); + } + return id; + }, + + resolveSecretEnv(bindingText: string, companyId: string, vault: { decrypt: (backend: "safe_storage" | "vault", ciphertext: Buffer) => string }, listInvalidEnvBindingKeys: (t: string) => string[], parseEnvBindingText: (t: string) => Record): Record { + const invalidKeys = new Set(listInvalidEnvBindingKeys(bindingText)); + const bindings = parseEnvBindingText(bindingText); + const result: Record = {}; + for (const [envKey, rawValue] of Object.entries(bindings)) { + if (invalidKeys.has(envKey)) continue; + const row = getDb().prepare("select backend, ciphertext from secrets where company_id = ? and name = ?").get(companyId, rawValue) as + | { backend: "safe_storage" | "vault"; ciphertext: Uint8Array } + | undefined; + if (row) { + result[envKey] = vault.decrypt(row.backend, Buffer.from(row.ciphertext)); + } else { + result[envKey] = rawValue; + } + } + return result; + }, + + deleteGoal(id: string, companyId: string) { + transaction(() => { + getDb().prepare("update goals set parent_id = null where parent_id = ? and company_id = ?").run(id, companyId); + getDb().prepare("update projects set goal_id = null where goal_id = ? and company_id = ?").run(id, companyId); + getDb().prepare("update tasks set goal_id = null where goal_id = ? and company_id = ?").run(id, companyId); + getDb().prepare("delete from goals where id = ? and company_id = ?").run(id, companyId); + }); + }, + + deleteProject(id: string, companyId: string) { + transaction(() => { + getDb().prepare("update tasks set project_id = null where project_id = ? and company_id = ?").run(id, companyId); + getDb().prepare("update workspaces set project_id = null where project_id = ? and company_id = ?").run(id, companyId); + getDb().prepare("delete from projects where id = ? and company_id = ?").run(id, companyId); + }); + }, + + deleteSecret(id: string, companyId: string) { + getDb().prepare("delete from secrets where id = ? and company_id = ?").run(id, companyId); + }, + + countActiveGoalsForCompany(companyId: string): number { + const row = getDb().prepare( + "select count(*) as cnt from goals where company_id = ? and status in ('active', 'planned')", + ).get(companyId) as { cnt: number }; + return row.cnt; + }, + + createHireRequest(input: { + companyId: string; requestedByAgentId?: string | null; + name: string; role: string; title: string; department?: string | null; + reportsTo?: string | null; connectorId: ConnectorId; workspaceId?: string | null; + model?: string | null; metadataJson?: string; capabilities: string; + budgetMonthlyUsd: number; payloadSummary: string; impactSummary: string; + payloadJson?: string | null; + }, selfDeps: { + saveAgent: (input: any) => string; + requestApproval: (input: any) => string; + }) { + return transaction(() => { + const agentId = selfDeps.saveAgent({ + companyId: input.companyId, name: input.name, role: input.role, + title: input.title, department: input.department ?? null, + status: "pending_approval", reportsTo: input.reportsTo ?? null, + connectorId: input.connectorId, workspaceId: input.workspaceId ?? null, + model: input.model ?? null, metadataJson: input.metadataJson ?? "{}", + capabilities: input.capabilities, budgetMonthlyUsd: input.budgetMonthlyUsd, + }); + const approvalId = selfDeps.requestApproval({ + companyId: input.companyId, + requestedByAgentId: input.requestedByAgentId ?? null, + relatedAgentId: agentId, + type: "hire_agent", + payloadSummary: input.payloadSummary, + impactSummary: input.impactSummary, + payloadJson: input.payloadJson ?? null, + }); + return { agentId, approvalId }; + }); + }, + + incrementActiveSprintVelocity(companyId: string, delta: number) { + const ts = nowIso(); + const today = ts.slice(0, 10); + getDb().prepare( + "update sprints set completed_points = max(0, completed_points + ?), updated_at = ? where company_id = ? and status = 'active' and start_date <= ? and end_date >= ?", + ).run(delta, ts, companyId, today, today); + }, + }; +} diff --git a/src/main/database/helpers.ts b/src/main/database/helpers.ts new file mode 100644 index 00000000..3f764411 --- /dev/null +++ b/src/main/database/helpers.ts @@ -0,0 +1,41 @@ +import { randomUUID } from "node:crypto"; +import type { SQLInputValue } from "node:sqlite"; + +export type SqliteRow = Record; +export type ReadAll = (sql: string, ...params: SQLInputValue[]) => T[]; +export type Transaction = (operation: () => T) => T; + +export function nowIso(): string { + return new Date().toISOString(); +} + +export function parseJson(value: string | null | undefined, fallback: T): T { + if (!value) return fallback; + try { + return JSON.parse(value) as T; + } catch { + return fallback; + } +} + +export function ensureId(id: string | null | undefined): string { + return id && id.length > 0 ? id : randomUUID(); +} + +export function normalizeOptionalId(id: string | null | undefined): string | null { + return id && id.length > 0 ? id : null; +} + +export function toBoolean(value: unknown): boolean { + return value === true || value === 1 || value === "1"; +} + +export function taskPriorityOrderSql(columnName: string): string { + return `case ${columnName} + when 'critical' then 4 + when 'high' then 3 + when 'medium' then 2 + when 'low' then 1 + else 0 + end`; +} diff --git a/src/main/database/index.ts b/src/main/database/index.ts new file mode 100644 index 00000000..f3d04130 --- /dev/null +++ b/src/main/database/index.ts @@ -0,0 +1 @@ +export { AppDatabase, STALE_LOCK_TIMEOUT_MS } from "../database"; diff --git a/src/main/database/knowledge.ts b/src/main/database/knowledge.ts new file mode 100644 index 00000000..ce1d0417 --- /dev/null +++ b/src/main/database/knowledge.ts @@ -0,0 +1,84 @@ +import type { DatabaseSync } from "node:sqlite"; +import type { ReadAll } from "./helpers"; +import { ensureId, nowIso } from "./helpers"; + +export function buildKnowledgeMethods( + getDb: () => DatabaseSync, + _readAll: ReadAll, +) { + return { + saveMeeting(input: { + id?: string | null; companyId: string; type: string; title: string; organizerAgentId?: string | null; + participantAgentIds?: string; scheduledAt: string; durationMinutes?: number; + agendaJson?: string; notesJson?: string; decisionsJson?: string; actionItemsJson?: string; + status?: string; + }) { + const id = ensureId(input.id ?? null); + const ts = nowIso(); + const existing = getDb().prepare("select id from meetings where id = ?").get(id) as { id: string } | undefined; + if (existing) { + getDb().prepare( + "update meetings set type = ?, title = ?, organizer_agent_id = ?, participant_agent_ids = ?, scheduled_at = ?, duration_minutes = ?, agenda_json = ?, notes_json = ?, decisions_json = ?, action_items_json = ?, status = ?, updated_at = ? where id = ?", + ).run(input.type, input.title, input.organizerAgentId ?? null, input.participantAgentIds ?? "[]", input.scheduledAt, input.durationMinutes ?? 30, input.agendaJson ?? "[]", input.notesJson ?? "[]", input.decisionsJson ?? "[]", input.actionItemsJson ?? "[]", input.status ?? "scheduled", ts, id); + } else { + getDb().prepare( + "insert into meetings (id, company_id, type, title, organizer_agent_id, participant_agent_ids, scheduled_at, duration_minutes, agenda_json, notes_json, decisions_json, action_items_json, status, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ).run(id, input.companyId, input.type, input.title, input.organizerAgentId ?? null, input.participantAgentIds ?? "[]", input.scheduledAt, input.durationMinutes ?? 30, input.agendaJson ?? "[]", input.notesJson ?? "[]", input.decisionsJson ?? "[]", input.actionItemsJson ?? "[]", input.status ?? "scheduled", ts, ts); + } + return id; + }, + + deleteMeeting(id: string, companyId: string) { + getDb().prepare("delete from meetings where id = ? and company_id = ?").run(id, companyId); + }, + + saveKnowledgeEntry(input: { + id?: string | null; companyId: string; category: string; topic: string; content: string; + authorAgentId?: string | null; importance?: string; tagsJson?: string; + referencedEntityType?: string | null; referencedEntityId?: string | null; + sourceRunId?: string | null; + }) { + const id = ensureId(input.id ?? null); + const ts = nowIso(); + const existing = getDb().prepare("select id from knowledge_base where id = ?").get(id) as { id: string } | undefined; + if (existing) { + getDb().prepare( + "update knowledge_base set category = ?, topic = ?, content = ?, author_agent_id = ?, importance = ?, tags_json = ?, referenced_entity_type = ?, referenced_entity_id = ?, source_run_id = coalesce(?, source_run_id), updated_at = ? where id = ?", + ).run(input.category, input.topic, input.content, input.authorAgentId ?? null, input.importance ?? "medium", input.tagsJson ?? "[]", input.referencedEntityType ?? null, input.referencedEntityId ?? null, input.sourceRunId ?? null, ts, id); + } else { + getDb().prepare( + "insert into knowledge_base (id, company_id, category, topic, content, author_agent_id, importance, tags_json, referenced_entity_type, referenced_entity_id, source_run_id, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ).run(id, input.companyId, input.category, input.topic, input.content, input.authorAgentId ?? null, input.importance ?? "medium", input.tagsJson ?? "[]", input.referencedEntityType ?? null, input.referencedEntityId ?? null, input.sourceRunId ?? null, ts, ts); + } + return id; + }, + + deleteKnowledgeEntry(id: string, companyId: string) { + getDb().prepare("delete from knowledge_base where id = ? and company_id = ?").run(id, companyId); + }, + + saveSprint(input: { + id?: string | null; companyId: string; name: string; goal?: string; + startDate: string; endDate: string; status?: string; retrospectiveNotes?: string; + velocityPoints?: number; completedPoints?: number; + }) { + const id = ensureId(input.id ?? null); + const ts = nowIso(); + const existing = getDb().prepare("select id from sprints where id = ?").get(id) as { id: string } | undefined; + if (existing) { + getDb().prepare( + "update sprints set name = ?, goal = ?, start_date = ?, end_date = ?, status = ?, retrospective_notes = ?, velocity_points = ?, completed_points = ?, updated_at = ? where id = ?", + ).run(input.name, input.goal ?? "", input.startDate, input.endDate, input.status ?? "planning", input.retrospectiveNotes ?? "", input.velocityPoints ?? 0, input.completedPoints ?? 0, ts, id); + } else { + getDb().prepare( + "insert into sprints (id, company_id, name, goal, start_date, end_date, status, retrospective_notes, velocity_points, completed_points, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ).run(id, input.companyId, input.name, input.goal ?? "", input.startDate, input.endDate, input.status ?? "planning", input.retrospectiveNotes ?? "", input.velocityPoints ?? 0, input.completedPoints ?? 0, ts, ts); + } + return id; + }, + + deleteSprint(id: string, companyId: string) { + getDb().prepare("delete from sprints where id = ? and company_id = ?").run(id, companyId); + }, + }; +} diff --git a/src/main/database/messages.ts b/src/main/database/messages.ts new file mode 100644 index 00000000..192b61fd --- /dev/null +++ b/src/main/database/messages.ts @@ -0,0 +1,277 @@ +import { randomUUID } from "node:crypto"; +import type { DatabaseSync, SQLInputValue } from "node:sqlite"; +import type { AgentMessageRecord, AgentRecord } from "@shared/types"; +import { + type ReadAll, + nowIso, +} from "./helpers"; + +const BOARD_READER_ID = "__board__"; + +export function buildMessageMethods( + getDb: () => DatabaseSync, + readAll: ReadAll, + deps: { + getAgent: (id: string) => AgentRecord; + addActivity: (input: { companyId: string; actor: string; action: string; entityType: string; entityId: string; detail: string }) => void; + }, +) { + return { + sendAgentMessage(input: { + companyId: string; + fromAgentId: string; + toAgentId?: string | null; + channel?: string; + channelTargetId?: string | null; + subject: string; + body: string; + priority?: string; + parentMessageId?: string | null; + attachmentsJson?: string; + senderKind?: "agent" | "human" | "system"; + }): string { + const channel = input.channel ?? "direct"; + if (channel === "direct" && !input.toAgentId) { + throw new Error("Direct messages require a recipient."); + } + const senderKind = input.senderKind + ?? (input.fromAgentId === BOARD_READER_ID ? "human" : "agent"); + const id = randomUUID(); + getDb() + .prepare( + "insert into agent_messages (id, company_id, from_agent_id, to_agent_id, sender_kind, channel, channel_target_id, subject, body, priority, parent_message_id, attachments_json, created_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .run( + id, + input.companyId, + input.fromAgentId, + input.toAgentId ?? null, + senderKind, + channel, + input.channelTargetId ?? null, + input.subject, + input.body, + input.priority ?? "normal", + input.parentMessageId ?? null, + input.attachmentsJson ?? "[]", + nowIso(), + ); + deps.addActivity({ + companyId: input.companyId, + actor: input.fromAgentId, + action: "sent_message", + entityType: "agent_message", + entityId: id, + detail: `Message: ${input.subject}`, + }); + return id; + }, + + getAgentMessage(messageId: string, companyId: string): AgentMessageRecord | null { + const row = getDb() + .prepare( + "select id, company_id as companyId, from_agent_id as fromAgentId, to_agent_id as toAgentId, sender_kind as senderKind, channel, channel_target_id as channelTargetId, subject, body, priority, read_at as readAt, parent_message_id as parentMessageId, attachments_json as attachmentsJson, created_at as createdAt from agent_messages where id = ? and company_id = ?", + ) + .get(messageId, companyId) as AgentMessageRecord | undefined; + return row ?? null; + }, + + listAgentMessages( + companyId: string, + opts?: { + agentId?: string; + messageId?: string; + channel?: string; + channelTargetId?: string | null; + unreadOnly?: boolean; + limit?: number; + }, + ): AgentMessageRecord[] { + let sql = + "select id, company_id as companyId, from_agent_id as fromAgentId, to_agent_id as toAgentId, sender_kind as senderKind, channel, channel_target_id as channelTargetId, subject, body, priority, read_at as readAt, parent_message_id as parentMessageId, attachments_json as attachmentsJson, created_at as createdAt from agent_messages where company_id = ?"; + const params: SQLInputValue[] = [companyId]; + if (opts?.messageId) { + sql += " and id = ?"; + params.push(opts.messageId); + } + if (opts?.channel) { + sql += " and channel = ?"; + params.push(opts.channel); + } + if (opts?.channelTargetId !== undefined && opts?.channelTargetId !== null) { + sql += " and channel_target_id = ?"; + params.push(opts.channelTargetId); + } + sql += " order by created_at asc"; + + let messages = readAll(sql, ...params); + if (!opts?.agentId) { + const boardReadReceiptMap = new Map( + readAll<{ messageId: string; readAt: string }>( + `select reads.message_id as messageId, reads.read_at as readAt + from agent_message_reads reads + join agent_messages messages on messages.id = reads.message_id + where reads.agent_id = ? and messages.company_id = ?`, + BOARD_READER_ID, + companyId, + ).map((row) => [row.messageId, row.readAt]), + ); + messages = messages.map((message) => ({ + ...message, + readAt: boardReadReceiptMap.get(message.id) ?? null, + })); + } + if (opts?.agentId) { + let department: string | null = null; + try { + const agent = deps.getAgent(opts.agentId); + if (agent.companyId === companyId) { + department = agent.department; + } + } catch { + department = null; + } + + const relevantProjectIds = new Set(); + for (const row of readAll<{ projectId: string | null }>( + "select distinct project_id as projectId from tasks where company_id = ? and assignee_agent_id = ? and project_id is not null", + companyId, + opts.agentId, + )) { + if (row.projectId) relevantProjectIds.add(row.projectId); + } + for (const row of readAll<{ id: string }>( + "select id from projects where company_id = ? and lead_agent_id = ?", + companyId, + opts.agentId, + )) { + relevantProjectIds.add(row.id); + } + + const readReceiptMap = new Map( + readAll<{ messageId: string; readAt: string }>( + `select reads.message_id as messageId, reads.read_at as readAt + from agent_message_reads reads + join agent_messages messages on messages.id = reads.message_id + where reads.agent_id = ? and messages.company_id = ?`, + opts.agentId, + companyId, + ).map((row) => [row.messageId, row.readAt]), + ); + + messages = messages + .filter((message) => { + if (message.fromAgentId === opts.agentId) { + return true; + } + if (message.channel === "direct") { + return message.toAgentId === opts.agentId; + } + if (message.channel === "company" || message.channel === "incident") { + return true; + } + if (message.channel === "department") { + return Boolean(department) && message.channelTargetId === department; + } + if (message.channel === "project") { + return Boolean( + message.channelTargetId && relevantProjectIds.has(message.channelTargetId), + ); + } + return false; + }) + .map((message) => { + if (message.fromAgentId === opts.agentId) { + return { ...message, readAt: message.createdAt }; + } + return { + ...message, + readAt: readReceiptMap.get(message.id) ?? null, + }; + }); + } + if (opts?.unreadOnly) { + messages = messages.filter((message) => !message.readAt); + } + const limit = opts?.limit ?? 200; + return limit > 0 ? messages.slice(-limit) : messages; + }, + + searchMessages( + companyId: string, + query: string, + opts?: { + channel?: string; + channelTargetId?: string | null; + limit?: number; + }, + ): AgentMessageRecord[] { + const escaped = query.replace(/[%_]/g, "\\$&"); + const pattern = `%${escaped}%`; + let sql = `SELECT id, company_id AS companyId, from_agent_id AS fromAgentId, + to_agent_id AS toAgentId, sender_kind AS senderKind, channel, channel_target_id AS channelTargetId, + subject, body, priority, read_at AS readAt, parent_message_id AS parentMessageId, + attachments_json AS attachmentsJson, created_at AS createdAt + FROM agent_messages WHERE company_id = ? AND (subject LIKE ? ESCAPE '\\' OR body LIKE ? ESCAPE '\\')`; + const params: SQLInputValue[] = [companyId, pattern, pattern]; + if (opts?.channel) { + sql += " AND channel = ?"; + params.push(opts.channel); + } + if (opts?.channelTargetId !== undefined && opts?.channelTargetId !== null) { + sql += " AND channel_target_id = ?"; + params.push(opts.channelTargetId); + } + sql += " ORDER BY created_at DESC LIMIT ?"; + params.push(opts?.limit ?? 50); + return readAll(sql, ...params); + }, + + markMessageRead(messageId: string, companyId: string): void { + const ts = nowIso(); + getDb() + .prepare( + `insert into agent_message_reads (message_id, agent_id, read_at) + select id, ?, ? + from agent_messages + where id = ? and company_id = ? + on conflict(message_id, agent_id) do update set read_at = excluded.read_at`, + ) + .run(BOARD_READER_ID, ts, messageId, companyId); + }, + + markMessageReadForReader( + messageId: string, + companyId: string, + readerAgentId?: string | null, + ): void { + const ts = nowIso(); + getDb() + .prepare( + `insert into agent_message_reads (message_id, agent_id, read_at) + select id, ?, ? + from agent_messages + where id = ? and company_id = ? + on conflict(message_id, agent_id) do update set read_at = excluded.read_at`, + ) + .run(readerAgentId ?? BOARD_READER_ID, ts, messageId, companyId); + }, + + markAgentMessageRead( + messageId: string, + companyId: string, + agentId: string, + ): void { + const ts = nowIso(); + getDb() + .prepare( + `insert into agent_message_reads (message_id, agent_id, read_at) + select id, ?, ? + from agent_messages + where id = ? and company_id = ? + on conflict(message_id, agent_id) do update set read_at = excluded.read_at`, + ) + .run(agentId, ts, messageId, companyId); + }, + }; +} diff --git a/src/main/database/runs.ts b/src/main/database/runs.ts new file mode 100644 index 00000000..1c7778f0 --- /dev/null +++ b/src/main/database/runs.ts @@ -0,0 +1,351 @@ +import { randomUUID } from "node:crypto"; +import { writeFileSync, readFileSync, existsSync, statSync, openSync, readSync, closeSync } from "node:fs"; +import { appendFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { DatabaseSync, SQLInputValue } from "node:sqlite"; +import type { ConnectorId, RunLogChunk, RunRecord } from "@shared/types"; +import { type ReadAll, type Transaction, nowIso, toBoolean } from "./helpers"; + +type SqliteRow = Record; + +/** + * Find the last complete UTF-8 character boundary in a buffer. + * Returns the number of bytes up to (and including) the last complete character. + * This prevents corrupting multi-byte CJK characters when slicing at chunk edges. + */ +function findUtf8Boundary(buffer: Buffer, bytesRead: number): number { + if (bytesRead === 0) return 0; + let pos = bytesRead - 1; + while (pos >= 0 && (buffer[pos] & 0xc0) === 0x80) { + pos--; + } + if (pos < 0) return bytesRead; + const lead = buffer[pos]; + let charLen: number; + if ((lead & 0x80) === 0) charLen = 1; + else if ((lead & 0xe0) === 0xc0) charLen = 2; + else if ((lead & 0xf0) === 0xe0) charLen = 3; + else if ((lead & 0xf8) === 0xf0) charLen = 4; + else return bytesRead; + if (pos + charLen <= bytesRead) return bytesRead; + return pos; +} + +export function buildRunMethods( + getDb: () => DatabaseSync, + readAll: ReadAll, + transaction: Transaction, + deps: { + logsDir: string; + }, +) { + return { + getRun(id: string): RunRecord { + const row = getDb() + .prepare( + "select id, company_id as companyId, task_id as taskId, agent_id as agentId, workspace_id as workspaceId, connector_id as connectorId, status, summary, error_message as errorMessage, exit_code as exitCode, signal, model, session_display_id as sessionDisplayId, cost_usd as costUsd, unattributed_cost as unattributedCost, started_at as startedAt, finished_at as finishedAt, log_path as logPath, created_at as createdAt, updated_at as updatedAt from runs where id = ?", + ) + .get(id) as RunRecord | undefined; + if (!row) throw new Error(`Run not found: ${id}`); + row.unattributedCost = Boolean(row.unattributedCost); + return row; + }, + + getRunAuthState(runId: string): { id: string; companyId: string; agentId: string; status: RunRecord["status"] } | null { + const row = getDb() + .prepare("select id, company_id as companyId, agent_id as agentId, status from runs where id = ?") + .get(runId) as { id: string; companyId: string; agentId: string; status: RunRecord["status"] } | undefined; + return row ?? null; + }, + + createRun(input: { + id?: string; + companyId: string; + taskId: string; + agentId: string; + workspaceId: string | null; + connectorId: ConnectorId; + }) { + const id = input.id ?? randomUUID(); + const ts = nowIso(); + const logPath = join(deps.logsDir, `${id}.log`); + transaction(() => { + getDb() + .prepare( + "insert into runs (id, company_id, task_id, agent_id, workspace_id, connector_id, status, summary, unattributed_cost, log_path, created_at, updated_at) values (?, ?, ?, ?, ?, ?, 'queued', '', 0, ?, ?, ?)", + ) + .run(id, input.companyId, input.taskId, input.agentId, input.workspaceId, input.connectorId, logPath, ts, ts); + getDb().prepare("update tasks set active_run_id = ?, updated_at = ? where id = ?").run(id, ts, input.taskId); + }); + writeFileSync(logPath, "", "utf8"); + return { id, logPath }; + }, + + appendRunLog(runId: string, stream: "stdout" | "stderr", chunk: string) { + const run = getDb().prepare("select log_path as logPath from runs where id = ?").get(runId) as { logPath: string | null } | undefined; + if (!run?.logPath) return; + return appendFile(run.logPath, `[${new Date().toISOString()}][${stream}] ${chunk}`, "utf8"); + }, + + readRunLog(runId: string) { + const run = getDb().prepare("select log_path as logPath from runs where id = ?").get(runId) as { logPath: string | null } | undefined; + if (!run?.logPath) return ""; + try { + return readFileSync(run.logPath, "utf8"); + } catch { + return ""; + } + }, + + readRunLogChunk(runId: string, offset = 0, limit = 65_536): RunLogChunk { + const run = getDb().prepare("select log_path as logPath from runs where id = ?").get(runId) as { logPath: string | null } | undefined; + if (!run?.logPath || !existsSync(run.logPath)) { + return { content: "", offset: 0, nextOffset: 0, totalBytes: 0, eof: true }; + } + + const totalBytes = statSync(run.logPath).size; + const safeOffset = Math.max(0, Math.min(offset, totalBytes)); + const safeLimit = Math.max(1_024, Math.min(limit, 262_144)); + const bytesToRead = Math.max(0, Math.min(safeLimit, totalBytes - safeOffset)); + + if (bytesToRead === 0) { + return { content: "", offset: safeOffset, nextOffset: safeOffset, totalBytes, eof: true }; + } + + const fileDescriptor = openSync(run.logPath, "r"); + try { + const buffer = Buffer.allocUnsafe(bytesToRead); + const bytesRead = readSync(fileDescriptor, buffer, 0, bytesToRead, safeOffset); + const safeBytesRead = findUtf8Boundary(buffer, bytesRead); + const nextOffset = safeOffset + safeBytesRead; + return { + content: buffer.subarray(0, safeBytesRead).toString("utf8"), + offset: safeOffset, + nextOffset, + totalBytes, + eof: nextOffset >= totalBytes, + }; + } finally { + closeSync(fileDescriptor); + } + }, + + markRunStatus(runId: string, status: RunRecord["status"], message: string) { + const ts = nowIso(); + const startedAt = status === "running" ? ts : null; + transaction(() => { + getDb() + .prepare( + "update runs set status = ?, summary = case when ? != '' then ? else summary end, started_at = coalesce(started_at, ?), updated_at = ? where id = ?", + ) + .run(status, message, message, startedAt, ts, runId); + + if (status === "running") { + const run = getDb().prepare("select agent_id from runs where id = ?").get(runId) as { agent_id: string } | undefined; + if (run) { + getDb() + .prepare("update agents set status = 'running', updated_at = ? where id = ? and status in ('idle', 'active')") + .run(ts, run.agent_id); + } + } + }); + }, + + finishRun(input: { + runId: string; + status: RunRecord["status"]; + summary: string | null; + errorMessage: string | null; + exitCode: number | null; + signal: string | null; + model: string | null; + sessionDisplayId: string | null; + costUsd: number | null; + unattributedCost: boolean; + }) { + const run = getDb().prepare("select task_id as taskId, agent_id as agentId, company_id as companyId, connector_id as connectorId from runs where id = ?").get(input.runId) as + | { taskId: string; agentId: string; companyId: string; connectorId: ConnectorId } + | undefined; + if (!run) return { budgetHardStopped: false }; + const ts = nowIso(); + const agent = getDb().prepare( + "select budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, status from agents where id = ?", + ).get(run.agentId) as { budgetMonthlyUsd: number; spentMonthlyUsd: number; status: string } | undefined; + const nextSpend = + agent && typeof input.costUsd === "number" && Number.isFinite(input.costUsd) + ? agent.spentMonthlyUsd + input.costUsd + : agent?.spentMonthlyUsd ?? 0; + const budgetHardStopped = Boolean( + agent && + agent.budgetMonthlyUsd > 0 && + nextSpend >= agent.budgetMonthlyUsd && + agent.status !== "paused" && + agent.status !== "terminated", + ); + transaction(() => { + getDb() + .prepare( + "update runs set status = ?, summary = ?, error_message = ?, exit_code = ?, signal = ?, model = ?, session_display_id = ?, cost_usd = ?, unattributed_cost = ?, finished_at = ?, updated_at = ? where id = ?", + ) + .run( + input.status, input.summary ?? "", input.errorMessage, input.exitCode, + input.signal, input.model, input.sessionDisplayId, input.costUsd, + input.unattributedCost ? 1 : 0, ts, ts, input.runId, + ); + getDb().prepare("update tasks set active_run_id = null, updated_at = ? where id = ?").run(ts, run.taskId); + getDb() + .prepare("UPDATE tasks SET checkout_run_id = NULL, execution_locked_at = NULL, updated_at = ? WHERE id = ? AND checkout_run_id = ?") + .run(ts, run.taskId, input.runId); + if (typeof input.costUsd === "number" && Number.isFinite(input.costUsd)) { + getDb() + .prepare("update tasks set cost_usd = cost_usd + ?, updated_at = ? where id = ?") + .run(input.costUsd, ts, run.taskId); + getDb() + .prepare("update agents set spent_monthly_usd = spent_monthly_usd + ?, updated_at = ? where id = ?") + .run(input.costUsd, ts, run.agentId); + getDb() + .prepare( + "update agents set status = case when budget_monthly_usd > 0 and (spent_monthly_usd + ?) >= budget_monthly_usd then 'paused' else status end, updated_at = ? where id = ?", + ) + .run(input.costUsd, ts, run.agentId); + } + const taskProjectRow = getDb().prepare("SELECT project_id FROM tasks WHERE id = ?").get(run.taskId) as { project_id: string | null } | undefined; + const projectId = taskProjectRow?.project_id ?? null; + getDb() + .prepare("insert into cost_entries (id, company_id, run_id, task_id, agent_id, connector_id, amount_usd, unattributed, project_id, created_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") + .run(randomUUID(), run.companyId, input.runId, run.taskId, run.agentId, run.connectorId, input.costUsd, input.unattributedCost ? 1 : 0, projectId, ts); + if (budgetHardStopped) { + getDb() + .prepare("update agents set status = 'paused', updated_at = ? where id = ?") + .run(ts, run.agentId); + } else { + getDb() + .prepare("update agents set status = 'idle', updated_at = ? where id = ? and status = 'running'") + .run(ts, run.agentId); + } + }); + return { budgetHardStopped }; + }, + + cancelRunForDeletion(runId: string, taskId: string, workspaceId: string | null, summary: string) { + const ts = nowIso(); + getDb() + .prepare( + "update runs set status = 'cancelled', summary = ?, error_message = null, exit_code = null, signal = null, model = null, session_display_id = null, cost_usd = null, unattributed_cost = 1, finished_at = ?, updated_at = ? where id = ?", + ) + .run(summary, ts, ts, runId); + getDb().prepare("update tasks set active_run_id = null, updated_at = ? where id = ?").run(ts, taskId); + getDb() + .prepare("UPDATE tasks SET checkout_run_id = NULL, execution_locked_at = NULL, updated_at = ? WHERE id = ? AND checkout_run_id = ?") + .run(ts, taskId, runId); + if (workspaceId) { + getDb() + .prepare( + `update workspaces + set lock_holder_run_id = null, lock_acquired_at = null, state = 'ready', updated_at = ? + where id = ? and lock_holder_run_id = ?`, + ) + .run(ts, workspaceId, runId); + } + }, + + hasActiveRun(filter: { companyId: string; taskId?: string; agentId?: string }) { + const whereClauses = ["company_id = ?", "status in ('queued', 'running')"]; + const params: SQLInputValue[] = [filter.companyId]; + if (filter.taskId) { + whereClauses.push("task_id = ?"); + params.push(filter.taskId); + } + if (filter.agentId) { + whereClauses.push("agent_id = ?"); + params.push(filter.agentId); + } + const row = getDb().prepare( + `select 1 as present from runs where ${whereClauses.join(" and ")} limit 1`, + ).get(...params) as { present: number } | undefined; + return Boolean(row?.present); + }, + + listQueuedRuns() { + return readAll( + `select id, company_id as companyId, task_id as taskId, agent_id as agentId, workspace_id as workspaceId, + connector_id as connectorId, status, summary, error_message as errorMessage, exit_code as exitCode, + signal, model, session_display_id as sessionDisplayId, cost_usd as costUsd, + unattributed_cost as unattributedCost, started_at as startedAt, finished_at as finishedAt, + log_path as logPath, created_at as createdAt, updated_at as updatedAt + from runs + where status = 'queued' + order by created_at asc`, + ).map((record) => ({ ...record, unattributedCost: toBoolean(record.unattributedCost) })) as RunRecord[]; + }, + + getInterruptedRunsForRecovery(): Array<{ + runId: string; taskId: string; taskTitle: string; agentId: string; + agentName: string; connectorId: string; interruptedAt: string; + }> { + return readAll<{ + runId: string; taskId: string; taskTitle: string; agentId: string; + agentName: string; connectorId: string; interruptedAt: string; + }>( + `select r.id as runId, r.task_id as taskId, t.title as taskTitle, + r.agent_id as agentId, a.name as agentName, + r.connector_id as connectorId, r.finished_at as interruptedAt + from runs r + join tasks t on t.id = r.task_id + join agents a on a.id = r.agent_id + where r.status = 'interrupted' + and t.status = 'blocked' + order by r.finished_at desc` + ); + }, + + appendRunEvent(input: { + companyId: string; runId: string; agentId: string; + eventType: string; stream?: string | null; level?: string | null; + message?: string | null; payload?: Record | null; + }): void { + const ts = nowIso(); + const lastSeq = getDb().prepare( + "SELECT MAX(seq) as maxSeq FROM run_events WHERE run_id = ?" + ).get(input.runId) as { maxSeq: number | null } | undefined; + const seq = (lastSeq?.maxSeq ?? 0) + 1; + getDb().prepare( + "INSERT INTO run_events (company_id, run_id, agent_id, seq, event_type, stream, level, message, payload, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + ).run( + input.companyId, input.runId, input.agentId, seq, + input.eventType, input.stream ?? null, input.level ?? null, + input.message ?? null, input.payload ? JSON.stringify(input.payload) : null, ts + ); + }, + + listRunEvents(runId: string, companyId: string, opts?: { offset?: number; limit?: number }): Array<{ + id: number; runId: string; agentId: string; seq: number; eventType: string; + stream: string | null; level: string | null; message: string | null; + payload: Record | null; createdAt: string; + }> { + const offset = opts?.offset ?? 0; + const limit = opts?.limit ?? 500; + return readAll( + "SELECT id, run_id, agent_id, seq, event_type, stream, level, message, payload, created_at FROM run_events WHERE run_id = ? AND company_id = ? ORDER BY seq ASC LIMIT ? OFFSET ?", + runId, companyId, limit, offset + ).map((row: SqliteRow) => ({ + id: row.id, + runId: row.run_id, + agentId: row.agent_id, + seq: row.seq, + eventType: row.event_type, + stream: row.stream, + level: row.level, + message: row.message, + payload: row.payload ? JSON.parse(row.payload) : null, + createdAt: row.created_at, + })); + }, + + updateRunActor(runId: string, actorType: string, actorId: string | null): void { + getDb().prepare( + "UPDATE runs SET actor_type = ?, actor_id = ?, updated_at = ? WHERE id = ?" + ).run(actorType, actorId, nowIso(), runId); + }, + }; +} diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts new file mode 100644 index 00000000..9c167b16 --- /dev/null +++ b/src/main/database/schema.ts @@ -0,0 +1,369 @@ +import { randomUUID } from "node:crypto"; +import log from "electron-log/main.js"; +import type { DatabaseSync, SQLInputValue } from "node:sqlite"; + +type ReadAll = (sql: string, ...params: SQLInputValue[]) => T[]; +type Transaction = (operation: () => T) => T; + +function nowIso() { + return new Date().toISOString(); +} + +export function ensureOptionalColumns(db: DatabaseSync) { + const addColumnIfMissing = (sql: string) => { try { db.prepare(sql).run(); } catch { /* column already exists */ } }; + addColumnIfMissing("alter table connectors add column updated_at text"); + addColumnIfMissing("alter table approvals add column payload_json text"); + addColumnIfMissing("alter table agents add column heartbeat_enabled integer not null default 0"); + addColumnIfMissing("alter table agents add column heartbeat_interval_sec integer not null default 120"); + addColumnIfMissing("alter table agents add column last_heartbeat_at text"); + addColumnIfMissing("alter table companies add column auto_approve_hires integer not null default 0"); + addColumnIfMissing("alter table companies add column review_deliverables integer not null default 0"); + addColumnIfMissing("alter table companies add column execution_mode text not null default 'ai_ceo'"); + addColumnIfMissing("alter table tasks add column task_type text not null default 'general'"); + addColumnIfMissing("alter table tasks add column requires_user_review integer not null default 0"); + addColumnIfMissing("alter table tasks add column retry_count integer not null default 0"); + addColumnIfMissing("alter table tasks add column last_failure_at text"); + addColumnIfMissing("alter table tasks add column checkout_run_id text"); + addColumnIfMissing("alter table tasks add column execution_locked_at text"); + addColumnIfMissing("alter table tasks add column active_session_id text"); + addColumnIfMissing("alter table cost_entries add column project_id text"); + db.exec(` + create table if not exists task_comments (id text primary key, company_id text not null, task_id text not null, author_agent_id text, author_name text not null, body text not null, created_at text not null); + create index if not exists idx_comments_task on task_comments(task_id); + create index if not exists idx_comments_company on task_comments(company_id); + `); + db.exec(` + create table if not exists social_accounts (id text primary key, company_id text not null references companies(id) on delete cascade, platform text not null default 'other', account_name text not null, display_name text not null default '', profile_url text not null default '', credential_secret_id text, status text not null default 'login_required', require_approval integer not null default 1, metadata_json text not null default '{}', last_used_at text, created_at text not null default (datetime('now')), updated_at text not null default (datetime('now'))); + create index if not exists idx_social_accounts_company on social_accounts(company_id); + `); + db.exec(` + create table if not exists browser_actions (id text primary key, company_id text not null references companies(id) on delete cascade, social_account_id text not null references social_accounts(id) on delete cascade, agent_id text not null references agents(id) on delete cascade, task_id text, action_type text not null default 'custom_script', payload_json text not null default '{}', status text not null default 'queued', result_summary text not null default '', screenshot_path text, approval_id text, error_message text, started_at text, finished_at text, created_at text not null default (datetime('now')), updated_at text not null default (datetime('now'))); + create index if not exists idx_browser_actions_company on browser_actions(company_id); + create index if not exists idx_browser_actions_account on browser_actions(social_account_id); + `); + try { + db.exec(` + create index if not exists idx_messages_company on agent_messages(company_id); + create index if not exists idx_messages_channel on agent_messages(channel, channel_target_id); + create index if not exists idx_automation_rules_company_status on automation_rules(company_id, status); + create index if not exists idx_automation_log_company on automation_log(company_id); + `); + } catch { /* tables may not exist yet */ } +} + +export function migrateSecretsTable(db: DatabaseSync, readAll: ReadAll) { + const columns = readAll<{ name: string }>("pragma table_info(secrets)"); + if (columns.some((column) => column.name === "company_id")) { + db.prepare("create unique index if not exists secrets_company_name_idx on secrets(company_id, name)").run(); + return; + } + const fallbackCompanyId = + ((db.prepare("select value from settings where key = 'current_company_id'").get() as { value: string } | undefined)?.value) ?? + ((db.prepare("select id from companies order by created_at asc limit 1").get() as { id: string } | undefined)?.id) ?? + "00000000-0000-0000-0000-000000000000"; + db.exec(` + alter table secrets rename to secrets_legacy; + create table secrets (id text primary key, company_id text not null, name text not null, description text not null, backend text not null, ciphertext blob not null, updated_at text not null, created_at text not null, unique(company_id, name)); + `); + db.prepare("insert into secrets (id, company_id, name, description, backend, ciphertext, updated_at, created_at) select id, ?, name, description, backend, ciphertext, updated_at, created_at from secrets_legacy").run(fallbackCompanyId); + db.exec("drop table secrets_legacy;"); +} + +export function initializeDatabase(db: DatabaseSync, ra: ReadAll, tx: Transaction): void { + db.exec("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;"); + db.exec(` + create table if not exists settings (key text primary key, value text not null); + create table if not exists companies (id text primary key, name text not null, description text not null, status text not null, created_at text not null, updated_at text not null); + create table if not exists connectors (id text primary key, label text not null, description text not null, status text not null, command text not null, version text, auth_state text not null, last_checked_at text, last_error text, model text, env_binding_text text not null, notes text not null, updated_at text); + create table if not exists workspaces (id text primary key, company_id text not null, name text not null, local_path text not null, repo_url text not null, repo_ref text not null, project_id text, state text not null, is_primary integer not null default 0, created_at text not null, updated_at text not null); + create table if not exists agents (id text primary key, company_id text not null, name text not null, role text not null, title text not null, status text not null, reports_to text, connector_id text not null, workspace_id text, model text, capabilities text not null, budget_monthly_usd real not null default 0, spent_monthly_usd real not null default 0, metadata_json text not null default '{}', created_at text not null, updated_at text not null); + create table if not exists goals (id text primary key, company_id text not null, title text not null, description text not null, parent_id text, owner_agent_id text, status text not null, created_at text not null, updated_at text not null); + create table if not exists projects (id text primary key, company_id text not null, goal_id text, name text not null, description text not null, lead_agent_id text, status text not null, target_date text, created_at text not null, updated_at text not null); + create table if not exists tasks (id text primary key, company_id text not null, project_id text, goal_id text, parent_id text, title text not null, description text not null, assignee_agent_id text, workspace_id text, status text not null, priority text not null, active_run_id text, session_json text, session_display_id text, cost_usd real not null default 0, created_at text not null, updated_at text not null); + create table if not exists approvals (id text primary key, company_id text not null, related_task_id text, requested_by_agent_id text, type text not null, payload_summary text not null, impact_summary text not null, payload_json text, decision_note text not null default '', state text not null, created_at text not null, updated_at text not null); + create table if not exists runs (id text primary key, company_id text not null, task_id text not null, agent_id text not null, workspace_id text, connector_id text not null, status text not null, summary text not null, error_message text, exit_code integer, signal text, model text, session_display_id text, cost_usd real, unattributed_cost integer not null default 0, started_at text, finished_at text, log_path text, created_at text not null, updated_at text not null); + create table if not exists cost_entries (id text primary key, company_id text not null, run_id text not null, task_id text, agent_id text, connector_id text not null, amount_usd real, unattributed integer not null default 0, created_at text not null); + create table if not exists activity (id text primary key, company_id text not null, actor text not null, action text not null, entity_type text not null, entity_id text not null, detail text not null, created_at text not null); + create table if not exists secrets (id text primary key, company_id text not null, name text not null, description text not null, backend text not null, ciphertext blob not null, updated_at text not null, created_at text not null, unique(company_id, name)); + `); + + db.exec(` + create index if not exists idx_workspaces_company on workspaces(company_id); + create index if not exists idx_agents_company on agents(company_id); + create index if not exists idx_goals_company on goals(company_id); + create index if not exists idx_projects_company on projects(company_id); + create index if not exists idx_tasks_company on tasks(company_id); + create index if not exists idx_tasks_assignee on tasks(assignee_agent_id); + create index if not exists idx_tasks_project on tasks(project_id); + create index if not exists idx_approvals_company on approvals(company_id); + create index if not exists idx_runs_company on runs(company_id); + create index if not exists idx_runs_task on runs(task_id); + create index if not exists idx_runs_agent on runs(agent_id); + create index if not exists idx_cost_entries_company on cost_entries(company_id); + create index if not exists idx_cost_entries_run on cost_entries(run_id); + create index if not exists idx_activity_company on activity(company_id); + create index if not exists idx_tasks_company_status on tasks(company_id, status); + create index if not exists idx_agents_company_status on agents(company_id, status); + create index if not exists idx_agents_reports_to on agents(reports_to); + create index if not exists idx_runs_company_status on runs(company_id, status); + create index if not exists idx_approvals_company_state on approvals(company_id, state); + `); + + db.exec(` + create table if not exists pending_wakes (id text primary key, company_id text not null, agent_id text not null, trigger_reason text not null, created_at text not null); + create index if not exists idx_pending_wakes_agent on pending_wakes(agent_id); + `); + + db.exec(` + CREATE TABLE IF NOT EXISTS wakeup_requests (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, agent_id TEXT NOT NULL, source TEXT NOT NULL, trigger_detail TEXT, reason TEXT, payload TEXT, status TEXT NOT NULL DEFAULT 'queued', coalesced_count INTEGER NOT NULL DEFAULT 0, requested_by_actor_type TEXT, requested_by_actor_id TEXT, idempotency_key TEXT, run_id TEXT, requested_at TEXT NOT NULL, claimed_at TEXT, finished_at TEXT, error TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); + CREATE INDEX IF NOT EXISTS idx_wakeup_requests_agent ON wakeup_requests(agent_id, company_id, status); + `); + + migrateSecretsTable(db, ra); + ensureOptionalColumns(db); + db.exec(`create index if not exists idx_secrets_company on secrets(company_id);`); + runMigrations(db, ra, tx); + + try { + db.exec(` + create index if not exists idx_agents_department on agents(company_id, department); + create index if not exists idx_runs_company_created_at on runs(company_id, created_at desc); + create index if not exists idx_cost_entries_company_created_at on cost_entries(company_id, created_at desc); + create index if not exists idx_activity_company_created_at on activity(company_id, created_at desc); + create index if not exists idx_agent_messages_company_created_at on agent_messages(company_id, created_at desc); + `); + } catch { /* column may not exist if migrations failed */ } + + seedSettings(db); +} + +export function seedSettings(db: DatabaseSync) { + const getSetting = (key: string): string | null => { + const row = db.prepare("select value from settings where key = ?").get(key) as { value: string } | undefined; + return row?.value ?? null; + }; + const setSetting = (key: string, value: string) => { + db.prepare("insert into settings (key, value) values (?, ?) on conflict(key) do update set value = excluded.value").run(key, value); + }; + if (!getSetting("theme")) setSetting("theme", "system"); + if (!getSetting("updates_enabled")) setSetting("updates_enabled", "false"); + if (!getSetting("log_retention_days")) setSetting("log_retention_days", "90"); +} + +export function runMigrations( + db: DatabaseSync, + readAll: ReadAll, + transaction: Transaction, +) { + db.exec(`create table if not exists schema_migrations (version integer primary key, name text not null, applied_at text not null);`); + const applied = new Set(readAll<{ version: number }>("select version from schema_migrations").map((row) => row.version)); + + const migrations: Array<{ version: number; name: string; sql?: string; fn?: () => void }> = [ + { version: 1, name: "add_workspace_lock_lease", sql: `alter table workspaces add column lock_holder_run_id text; alter table workspaces add column lock_acquired_at text` }, + { version: 2, name: "add_approval_related_agent_id", sql: `alter table approvals add column related_agent_id text` }, + { version: 3, name: "add_profile_metadata", sql: `insert or ignore into settings (key, value) values ('profile_version', '1'); insert or ignore into settings (key, value) values ('profile_created_at', '${nowIso()}')` }, + { version: 4, name: "add_connector_capability_snapshot", sql: `alter table connectors add column capability_json text` }, + { version: 6, name: "add_agent_department", sql: `alter table agents add column department text` }, + { version: 7, name: "add_meetings_table", sql: `create table if not exists meetings (id text primary key, company_id text not null, type text not null, title text not null, organizer_agent_id text, participant_agent_ids text not null default '[]', scheduled_at text not null, duration_minutes integer not null default 30, agenda_json text not null default '[]', notes_json text not null default '[]', decisions_json text not null default '[]', action_items_json text not null default '[]', status text not null default 'scheduled', created_at text not null, updated_at text not null); create index if not exists idx_meetings_company on meetings(company_id); create index if not exists idx_meetings_organizer on meetings(organizer_agent_id)` }, + { version: 8, name: "add_documents_table", sql: `create table if not exists documents (id text primary key, company_id text not null, type text not null, title text not null, content text not null default '', author_agent_id text, reviewer_agent_id text, project_id text, goal_id text, parent_doc_id text, version integer not null default 1, status text not null default 'draft', tags_json text not null default '[]', created_at text not null, updated_at text not null); create index if not exists idx_documents_company on documents(company_id); create index if not exists idx_documents_author on documents(author_agent_id); create index if not exists idx_documents_project on documents(project_id)` }, + { version: 9, name: "add_knowledge_base_table", sql: `create table if not exists knowledge_base (id text primary key, company_id text not null, category text not null, topic text not null, content text not null, author_agent_id text, importance text not null default 'medium', tags_json text not null default '[]', referenced_entity_type text, referenced_entity_id text, created_at text not null, updated_at text not null); create index if not exists idx_knowledge_company on knowledge_base(company_id); create index if not exists idx_knowledge_category on knowledge_base(category)` }, + { version: 10, name: "add_sprints_table", sql: `create table if not exists sprints (id text primary key, company_id text not null, name text not null, goal text not null default '', start_date text not null, end_date text not null, status text not null default 'planning', retrospective_notes text not null default '', velocity_points integer not null default 0, completed_points integer not null default 0, created_at text not null, updated_at text not null); create index if not exists idx_sprints_company on sprints(company_id)` }, + { version: 11, name: "add_automation_messages_workflows", sql: `create table if not exists automation_rules (id text primary key, company_id text not null, name text not null, description text not null default '', trigger text not null, conditions_json text not null default '{}', action text not null, action_config_json text not null default '{}', source_department text, target_department text, priority integer not null default 50, status text not null default 'active', execution_count integer not null default 0, last_executed_at text, created_at text not null, updated_at text not null); create index if not exists idx_automation_rules_company on automation_rules(company_id); create index if not exists idx_automation_rules_trigger on automation_rules(trigger); create table if not exists agent_messages (id text primary key, company_id text not null, from_agent_id text not null, to_agent_id text, channel text not null default 'direct', channel_target_id text, subject text not null, body text not null, priority text not null default 'normal', read_at text, parent_message_id text, attachments_json text not null default '[]', created_at text not null); create index if not exists idx_agent_messages_company on agent_messages(company_id); create index if not exists idx_agent_messages_to on agent_messages(to_agent_id); create index if not exists idx_agent_messages_from on agent_messages(from_agent_id); create index if not exists idx_agent_messages_channel on agent_messages(channel); create table if not exists workflow_pipelines (id text primary key, company_id text not null, name text not null, description text not null default '', steps_json text not null default '[]', trigger_type text not null, trigger_config_json text not null default '{}', status text not null default 'draft', current_step_index integer not null default 0, run_count integer not null default 0, last_run_at text, created_at text not null, updated_at text not null); create index if not exists idx_workflows_company on workflow_pipelines(company_id); create table if not exists automation_log (id text primary key, company_id text not null, rule_id text not null, rule_name text not null, trigger text not null, action text not null, detail text not null default '', executed_at text not null); create index if not exists idx_automation_log_company on automation_log(company_id)` }, + { version: 12, name: "add_agent_message_reads", sql: `create table if not exists agent_message_reads (message_id text not null, agent_id text not null, read_at text not null, primary key (message_id, agent_id)); create index if not exists idx_agent_message_reads_agent on agent_message_reads(agent_id)` }, + { version: 13, name: "add_checkout_session_budget_columns", sql: `ALTER TABLE tasks ADD COLUMN checkout_run_id TEXT; ALTER TABLE tasks ADD COLUMN execution_locked_at TEXT; ALTER TABLE tasks ADD COLUMN active_session_id TEXT; ALTER TABLE cost_entries ADD COLUMN project_id TEXT; CREATE INDEX IF NOT EXISTS idx_cost_entries_project ON cost_entries(project_id)` }, + { version: 14, name: "create_skills_budget_wakeup_session_tables", sql: `CREATE TABLE IF NOT EXISTS company_skills (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, key TEXT NOT NULL, slug TEXT NOT NULL, name TEXT NOT NULL, description TEXT, markdown TEXT NOT NULL DEFAULT '', source_type TEXT NOT NULL DEFAULT 'local_path', source_locator TEXT, source_ref TEXT, trust_level TEXT NOT NULL DEFAULT 'markdown_only', compatibility TEXT NOT NULL DEFAULT 'compatible', file_inventory TEXT NOT NULL DEFAULT '[]', metadata TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE UNIQUE INDEX IF NOT EXISTS idx_company_skills_company_key ON company_skills(company_id, key); CREATE INDEX IF NOT EXISTS idx_company_skills_company_name ON company_skills(company_id, name); CREATE TABLE IF NOT EXISTS budget_policies (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, scope_type TEXT NOT NULL, scope_id TEXT NOT NULL, metric TEXT NOT NULL DEFAULT 'billed_cents', window_kind TEXT NOT NULL DEFAULT 'calendar_month_utc', amount INTEGER NOT NULL DEFAULT 0, warn_percent INTEGER NOT NULL DEFAULT 80, hard_stop_enabled INTEGER NOT NULL DEFAULT 1, notify_enabled INTEGER NOT NULL DEFAULT 1, is_active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE INDEX IF NOT EXISTS idx_budget_policies_company_scope ON budget_policies(company_id, scope_type, scope_id, is_active); CREATE UNIQUE INDEX IF NOT EXISTS idx_budget_policies_unique ON budget_policies(company_id, scope_type, scope_id, metric, window_kind); CREATE TABLE IF NOT EXISTS budget_incidents (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, policy_id TEXT NOT NULL REFERENCES budget_policies(id), scope_type TEXT NOT NULL, scope_id TEXT NOT NULL, metric TEXT NOT NULL, window_kind TEXT NOT NULL, window_start TEXT NOT NULL, window_end TEXT NOT NULL, threshold_type TEXT NOT NULL, amount_limit INTEGER NOT NULL, amount_observed INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'open', resolved_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE INDEX IF NOT EXISTS idx_budget_incidents_company_status ON budget_incidents(company_id, status); CREATE INDEX IF NOT EXISTS idx_budget_incidents_company_scope ON budget_incidents(company_id, scope_type, scope_id, status); CREATE INDEX IF NOT EXISTS idx_budget_incidents_policy ON budget_incidents(policy_id, window_start, threshold_type); CREATE TABLE IF NOT EXISTS task_sessions (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, task_id TEXT NOT NULL, agent_id TEXT NOT NULL, adapter_type TEXT NOT NULL, session_params_json TEXT, session_display_id TEXT, last_run_id TEXT, last_error TEXT, run_count INTEGER NOT NULL DEFAULT 0, total_input_tokens INTEGER NOT NULL DEFAULT 0, total_output_tokens INTEGER NOT NULL DEFAULT 0, rotated_at TEXT, rotation_reason TEXT, handoff_markdown TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE INDEX IF NOT EXISTS idx_task_sessions_lookup ON task_sessions(company_id, agent_id, adapter_type, task_id); CREATE INDEX IF NOT EXISTS idx_task_sessions_company_agent ON task_sessions(company_id, agent_id, updated_at); CREATE INDEX IF NOT EXISTS idx_task_sessions_company_task ON task_sessions(company_id, task_id, updated_at); CREATE TABLE IF NOT EXISTS wakeup_requests (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, agent_id TEXT NOT NULL, source TEXT NOT NULL, trigger_detail TEXT, reason TEXT, payload TEXT, status TEXT NOT NULL DEFAULT 'queued', coalesced_count INTEGER NOT NULL DEFAULT 0, requested_by_actor_type TEXT, requested_by_actor_id TEXT, idempotency_key TEXT, run_id TEXT, requested_at TEXT NOT NULL, claimed_at TEXT, finished_at TEXT, error TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE INDEX IF NOT EXISTS idx_wakeup_requests_company_agent_status ON wakeup_requests(company_id, agent_id, status); CREATE INDEX IF NOT EXISTS idx_wakeup_requests_company_requested ON wakeup_requests(company_id, requested_at); CREATE INDEX IF NOT EXISTS idx_wakeup_requests_agent_requested ON wakeup_requests(agent_id, requested_at)` }, + { + version: 15, name: "migrate_session_data_to_task_sessions", + fn: () => { + const tasksWithSessions = readAll<{ id: string; companyId: string; assigneeAgentId: string | null; sessionJson: string | null; sessionDisplayId: string | null }>( + `SELECT id, company_id as companyId, assignee_agent_id as assigneeAgentId, session_json as sessionJson, session_display_id as sessionDisplayId FROM tasks WHERE session_json IS NOT NULL AND active_session_id IS NULL`, + ); + let migrated = 0; + let skipped = 0; + for (const task of tasksWithSessions) { + try { + JSON.parse(task.sessionJson!); + const agent = task.assigneeAgentId ? db.prepare("SELECT connector_id FROM agents WHERE id = ?").get(task.assigneeAgentId) as { connector_id: string } | undefined : undefined; + const adapterType = agent?.connector_id ?? "unknown"; + const sessionId = randomUUID(); + const ts = nowIso(); + db.prepare(`INSERT INTO task_sessions (id, company_id, task_id, agent_id, adapter_type, session_params_json, session_display_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(sessionId, task.companyId, task.id, task.assigneeAgentId ?? "unassigned", adapterType, task.sessionJson, task.sessionDisplayId, ts, ts); + db.prepare("UPDATE tasks SET active_session_id = ? WHERE id = ?").run(sessionId, task.id); + migrated++; + } catch (err) { + log.warn(`Session migration skipped task ${task.id}: ${err instanceof Error ? err.message : String(err)}`); + skipped++; + } + } + log.info(`Session migration complete: ${migrated} migrated, ${skipped} skipped`); + }, + }, + { version: 16, name: "create_agent_skill_assignments", sql: `CREATE TABLE IF NOT EXISTS agent_skill_assignments (agent_id TEXT NOT NULL, skill_id TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY (agent_id, skill_id), FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE, FOREIGN KEY (skill_id) REFERENCES company_skills(id) ON DELETE CASCADE); CREATE INDEX IF NOT EXISTS idx_agent_skill_assignments_skill ON agent_skill_assignments(skill_id)` }, + { + version: 17, name: "migrate_pending_wakes_to_wakeup_requests", + fn: () => { + const existingWakes = readAll<{ id: string; companyId: string; agentId: string; triggerReason: string; createdAt: string }>( + `SELECT id, company_id AS companyId, agent_id AS agentId, trigger_reason AS triggerReason, created_at AS createdAt FROM pending_wakes`, + ); + const ts = nowIso(); + for (const wake of existingWakes) { + const triggerDetail = JSON.stringify([{ trigger: wake.triggerReason, at: wake.createdAt, detail: wake.triggerReason }]); + db.prepare(`INSERT OR IGNORE INTO wakeup_requests (id, company_id, agent_id, source, trigger_detail, status, coalesced_count, requested_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'queued', 0, ?, ?, ?)`).run(wake.id, wake.companyId, wake.agentId, wake.triggerReason, triggerDetail, wake.createdAt, ts, ts); + } + db.exec("DROP TABLE IF EXISTS pending_wakes"); + }, + }, + { + version: 18, name: "migrate_agent_budget_to_policies", + fn: () => { + const agents = readAll<{ id: string; companyId: string; budgetMonthlyUsd: number }>("SELECT id, company_id as companyId, budget_monthly_usd as budgetMonthlyUsd FROM agents WHERE budget_monthly_usd > 0"); + const ts = nowIso(); + for (const agent of agents) { + const existing = db.prepare("SELECT id FROM budget_policies WHERE company_id = ? AND scope_type = 'agent' AND scope_id = ? AND metric = 'billed_cents' AND window_kind = 'calendar_month_utc'").get(agent.companyId, agent.id); + if (!existing) { + db.prepare("INSERT INTO budget_policies (id, company_id, scope_type, scope_id, metric, window_kind, amount, warn_percent, hard_stop_enabled, notify_enabled, is_active, created_at, updated_at) VALUES (?, ?, 'agent', ?, 'billed_cents', 'calendar_month_utc', ?, 80, 1, 1, 1, ?, ?)").run(randomUUID(), agent.companyId, agent.id, Math.round(agent.budgetMonthlyUsd * 100), ts, ts); + } + } + log.info(`Budget migration: converted ${agents.length} agent budget(s) to policy rows`); + }, + }, + { + version: 19, name: "add_document_revisions_and_approval_comments", + fn: () => { + db.exec(`CREATE TABLE IF NOT EXISTS document_revisions (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, document_id TEXT NOT NULL, revision_number INTEGER NOT NULL, body TEXT NOT NULL, change_summary TEXT, created_by_agent_id TEXT, created_by_user_id TEXT, created_at TEXT NOT NULL, FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE); CREATE UNIQUE INDEX IF NOT EXISTS idx_doc_rev_doc_num ON document_revisions(document_id, revision_number); CREATE INDEX IF NOT EXISTS idx_doc_rev_company ON document_revisions(company_id, document_id, created_at); CREATE TABLE IF NOT EXISTS approval_comments (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, approval_id TEXT NOT NULL, author_agent_id TEXT, author_user_id TEXT, body TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY (approval_id) REFERENCES approvals(id) ON DELETE CASCADE); CREATE INDEX IF NOT EXISTS idx_approval_comments_approval ON approval_comments(approval_id, created_at)`); + try { db.prepare("ALTER TABLE documents ADD COLUMN latest_revision_id TEXT").run(); } catch { /* column already exists */ } + }, + }, + { + version: 20, name: "add_task_timestamps_and_task_approval_links", + fn: () => { + const addCol = (sql: string) => { try { db.prepare(sql).run(); } catch { /* already exists */ } }; + addCol("ALTER TABLE tasks ADD COLUMN started_at TEXT"); + addCol("ALTER TABLE tasks ADD COLUMN completed_at TEXT"); + addCol("ALTER TABLE tasks ADD COLUMN cancelled_at TEXT"); + db.exec(`CREATE TABLE IF NOT EXISTS task_approval_links (task_id TEXT NOT NULL, approval_id TEXT NOT NULL, company_id TEXT NOT NULL, linked_by_agent_id TEXT, created_at TEXT NOT NULL, PRIMARY KEY (task_id, approval_id), FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, FOREIGN KEY (approval_id) REFERENCES approvals(id) ON DELETE CASCADE); CREATE INDEX IF NOT EXISTS idx_task_approval_links_task ON task_approval_links(task_id); CREATE INDEX IF NOT EXISTS idx_task_approval_links_approval ON task_approval_links(approval_id)`); + db.prepare("UPDATE tasks SET started_at = updated_at WHERE started_at IS NULL AND status IN ('in_progress', 'in_review', 'done')").run(); + db.prepare("UPDATE tasks SET completed_at = updated_at WHERE completed_at IS NULL AND status = 'done'").run(); + db.prepare("UPDATE tasks SET cancelled_at = updated_at WHERE cancelled_at IS NULL AND status = 'cancelled'").run(); + }, + }, + { + version: 21, name: "add_work_products_task_documents_wakeup_context", + fn: () => { + db.exec(`CREATE TABLE IF NOT EXISTS work_products (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, project_id TEXT, task_id TEXT NOT NULL, type TEXT NOT NULL, provider TEXT NOT NULL, external_id TEXT, title TEXT NOT NULL, url TEXT, status TEXT NOT NULL DEFAULT 'active', review_state TEXT NOT NULL DEFAULT 'none', is_primary INTEGER NOT NULL DEFAULT 0, summary TEXT, metadata_json TEXT, created_by_run_id TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE); CREATE INDEX IF NOT EXISTS idx_work_products_task ON work_products(company_id, task_id, type); CREATE INDEX IF NOT EXISTS idx_work_products_updated ON work_products(company_id, updated_at); CREATE TABLE IF NOT EXISTS task_documents (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, task_id TEXT NOT NULL, document_id TEXT NOT NULL, key TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE); CREATE UNIQUE INDEX IF NOT EXISTS idx_task_documents_task_key ON task_documents(company_id, task_id, key); CREATE UNIQUE INDEX IF NOT EXISTS idx_task_documents_doc ON task_documents(document_id)`); + try { db.prepare("ALTER TABLE wakeup_requests ADD COLUMN context_snapshot TEXT").run(); } catch { /* already exists */ } + }, + }, + { + version: 22, name: "add_config_revisions_run_events_agent_permissions", + fn: () => { + db.exec(`CREATE TABLE IF NOT EXISTS agent_config_revisions (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, agent_id TEXT NOT NULL, revision_number INTEGER NOT NULL, changed_keys TEXT NOT NULL DEFAULT '[]', config_before TEXT NOT NULL, config_after TEXT NOT NULL, source TEXT NOT NULL DEFAULT 'patch', rolled_back_from_revision_id TEXT, created_by_agent_id TEXT, created_by_user_id TEXT, created_at TEXT NOT NULL, FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE); CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_config_rev_num ON agent_config_revisions(agent_id, revision_number); CREATE INDEX IF NOT EXISTS idx_agent_config_rev_agent ON agent_config_revisions(company_id, agent_id, created_at); CREATE TABLE IF NOT EXISTS run_events (id INTEGER PRIMARY KEY AUTOINCREMENT, company_id TEXT NOT NULL, run_id TEXT NOT NULL, agent_id TEXT NOT NULL, seq INTEGER NOT NULL, event_type TEXT NOT NULL, stream TEXT, level TEXT, message TEXT, payload TEXT, created_at TEXT NOT NULL, FOREIGN KEY (run_id) REFERENCES runs(id) ON DELETE CASCADE); CREATE INDEX IF NOT EXISTS idx_run_events_run ON run_events(run_id, seq); CREATE INDEX IF NOT EXISTS idx_run_events_company ON run_events(company_id, run_id)`); + const addCol = (sql: string) => { try { db.prepare(sql).run(); } catch { /* already exists */ } }; + addCol("ALTER TABLE agents ADD COLUMN permissions_json TEXT NOT NULL DEFAULT '{}'"); + addCol("ALTER TABLE agents ADD COLUMN heartbeat_policy_json TEXT"); + addCol("ALTER TABLE agents ADD COLUMN runtime_state_json TEXT"); + addCol("ALTER TABLE runs ADD COLUMN context_snapshot_json TEXT"); + addCol("ALTER TABLE runs ADD COLUMN actor_type TEXT"); + addCol("ALTER TABLE runs ADD COLUMN actor_id TEXT"); + }, + }, + { + version: 23, name: "create_thread_runtime_tables", + fn: () => { + db.exec(` + CREATE TABLE IF NOT EXISTS conversation_threads ( + id TEXT PRIMARY KEY, + company_id TEXT NOT NULL, + title TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'task', + related_entity_type TEXT, + related_entity_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_conv_threads_company ON conversation_threads(company_id); + CREATE INDEX IF NOT EXISTS idx_conv_threads_entity ON conversation_threads(related_entity_type, related_entity_id); + + CREATE TABLE IF NOT EXISTS conversation_events ( + id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL REFERENCES conversation_threads(id) ON DELETE CASCADE, + actor_id TEXT NOT NULL, + actor_type TEXT NOT NULL, + event_type TEXT NOT NULL, + body TEXT NOT NULL, + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_conv_events_thread ON conversation_events(thread_id, created_at); + + CREATE TABLE IF NOT EXISTS thread_wait_states ( + id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL REFERENCES conversation_threads(id) ON DELETE CASCADE, + company_id TEXT NOT NULL, + waiting_for TEXT NOT NULL, + related_approval_id TEXT, + reason TEXT NOT NULL, + created_at TEXT NOT NULL, + resolved_at TEXT + ); + CREATE INDEX IF NOT EXISTS idx_thread_wait_company ON thread_wait_states(company_id, resolved_at); + CREATE INDEX IF NOT EXISTS idx_thread_wait_thread ON thread_wait_states(thread_id); + + CREATE TABLE IF NOT EXISTS conversation_participants ( + thread_id TEXT NOT NULL REFERENCES conversation_threads(id) ON DELETE CASCADE, + participant_id TEXT NOT NULL, + participant_type TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'member', + joined_at TEXT NOT NULL, + PRIMARY KEY (thread_id, participant_id) + ); + CREATE INDEX IF NOT EXISTS idx_conv_participants_participant ON conversation_participants(participant_id); + `); + }, + }, + { + version: 24, name: "create_thread_subscriptions", + sql: `CREATE TABLE IF NOT EXISTS thread_subscriptions ( + thread_id TEXT NOT NULL REFERENCES conversation_threads(id) ON DELETE CASCADE, + subscriber_id TEXT NOT NULL, + subscriber_type TEXT NOT NULL, + notify_on TEXT NOT NULL DEFAULT 'all', + subscribed_at TEXT NOT NULL, + PRIMARY KEY (thread_id, subscriber_id) + ); + CREATE INDEX IF NOT EXISTS idx_thread_subs_subscriber ON thread_subscriptions(subscriber_id)`, + }, + { + version: 25, name: "add_sender_kind_to_messages", + sql: `ALTER TABLE agent_messages ADD COLUMN sender_kind TEXT NOT NULL DEFAULT 'agent'`, + }, + { + version: 26, name: "add_source_run_id_for_causal_binding", + sql: `ALTER TABLE documents ADD COLUMN source_run_id TEXT; ALTER TABLE knowledge_base ADD COLUMN source_run_id TEXT`, + }, + ]; + + for (const migration of migrations) { + if (applied.has(migration.version)) continue; + try { + transaction(() => { + if (migration.fn) { + migration.fn(); + } else if (migration.sql) { + for (const stmt of migration.sql.split(";").map((s) => s.trim()).filter(Boolean)) { + db.prepare(stmt).run(); + } + } + db.prepare("insert into schema_migrations (version, name, applied_at) values (?, ?, ?)").run(migration.version, migration.name, nowIso()); + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const isDuplicateColumn = /duplicate column|already exists/i.test(message); + if (isDuplicateColumn) { + db.prepare("insert or ignore into schema_migrations (version, name, applied_at) values (?, ?, ?)").run(migration.version, migration.name, nowIso()); + } else { + throw new Error(`Migration ${migration.version} (${migration.name}) failed: ${message}`); + } + } + } +} diff --git a/src/main/database/settings.ts b/src/main/database/settings.ts new file mode 100644 index 00000000..54f68810 --- /dev/null +++ b/src/main/database/settings.ts @@ -0,0 +1,184 @@ +import { existsSync, lstatSync, realpathSync, unlinkSync } from "node:fs"; +import { extname, resolve, sep } from "node:path"; +import type { DatabaseSync } from "node:sqlite"; +import type { ReadAll } from "./helpers"; +import { nowIso } from "./helpers"; + + +function isWithinOrEqual(targetPath: string, rootPath: string) { + const resolvedTarget = resolve(targetPath); + const resolvedRoot = resolve(rootPath); + return resolvedTarget === resolvedRoot || resolvedTarget.startsWith(`${resolvedRoot}${sep}`); +} + +function safeRealpath(targetPath: string) { + try { + return realpathSync(targetPath); + } catch { + return resolve(targetPath); + } +} + +export function buildSettingsMethods( + getDb: () => DatabaseSync, + readAll: ReadAll, + deps: { + profileDir: string; + logsDir: string; + artifactsDir: string; + backupsDir: string; + }, +) { + function getSetting(key: string): string | null { + const row = getDb().prepare("select value from settings where key = ?").get(key) as { value: string } | undefined; + return row?.value ?? null; + } + + function setSetting(key: string, value: string) { + getDb() + .prepare("insert into settings (key, value) values (?, ?) on conflict(key) do update set value = excluded.value") + .run(key, value); + } + + return { + getSetting, + setSetting, + + getCurrentCompanyId() { + return getSetting("current_company_id"); + }, + + setCurrentCompany(companyId: string) { + setSetting("current_company_id", companyId); + }, + + updateSettings(input: { theme?: "system" | "light" | "dark"; locale?: "en" | "zh"; updatesEnabled?: boolean; logRetentionDays?: number }) { + if (input.theme) { + setSetting("theme", input.theme); + } + if (input.locale) { + setSetting("locale", input.locale); + } + if (typeof input.updatesEnabled === "boolean") { + setSetting("updates_enabled", input.updatesEnabled ? "true" : "false"); + } + if (typeof input.logRetentionDays === "number") { + setSetting("log_retention_days", String(input.logRetentionDays)); + } + }, + + getLogRetentionDays(): number { + const raw = getSetting("log_retention_days"); + const parsed = raw ? parseInt(raw, 10) : NaN; + return Number.isFinite(parsed) && parsed >= 1 ? parsed : 90; + }, + + getDisabledModels(connectorId: string): Array<{ id: string; label: string }> { + const raw = getSetting(`disabled_models:${connectorId}`); + if (!raw) return []; + try { + return JSON.parse(raw); + } catch { + return raw.split(",").filter(Boolean).map((id) => ({ id, label: id })); + } + }, + + setDisabledModels(connectorId: string, models: Array<{ id: string; label: string }>) { + if (models.length === 0) { + getDb().prepare("delete from settings where key = ?").run(`disabled_models:${connectorId}`); + } else { + setSetting(`disabled_models:${connectorId}`, JSON.stringify(models)); + } + }, + + getLastBudgetResetMonth(companyId: string): string | null { + return getSetting(`budget_reset_month_${companyId}`); + }, + + setLastBudgetResetMonth(companyId: string, month: string) { + setSetting(`budget_reset_month_${companyId}`, month); + }, + + purgeExpiredRunLogs(): number { + const raw = getSetting("log_retention_days"); + const parsed = raw ? parseInt(raw, 10) : NaN; + const retentionDays = Number.isFinite(parsed) && parsed >= 1 ? parsed : 90; + const cutoff = new Date(Date.now() - retentionDays * 86_400_000).toISOString(); + const expiredRuns = readAll<{ id: string; logPath: string | null }>( + "select id, log_path as logPath from runs where finished_at is not null and finished_at < ? and log_path is not null", + cutoff, + ); + let purged = 0; + for (const run of expiredRuns) { + if (run.logPath) { + try { + unlinkSync(run.logPath); + } catch { /* Log files may already be gone */ } + } + getDb() + .prepare("update runs set log_path = null, updated_at = ? where id = ?") + .run(nowIso(), run.id); + purged++; + } + return purged; + }, + + canOpenPath(targetPath: string) { + if (!targetPath || !existsSync(targetPath)) { + return false; + } + const managedRoots = [deps.profileDir, deps.logsDir, deps.artifactsDir, deps.backupsDir]; + const sensitiveProfileExtensions = new Set([".sqlite", ".db", ".key"]); + const forbiddenExecutableExtensions = new Set([".app", ".exe", ".bat", ".cmd", ".sh", ".command", ".desktop", ".msi", ".com", ".scr"]); + const resolvedTarget = safeRealpath(targetPath); + const targetStats = lstatSync(targetPath); + const targetExtension = extname(resolvedTarget).toLowerCase(); + if ( + managedRoots.some((rootPath) => isWithinOrEqual(resolvedTarget, rootPath)) && + sensitiveProfileExtensions.has(targetExtension) + ) { + return false; + } + if (!targetStats.isDirectory() && forbiddenExecutableExtensions.has(targetExtension)) { + return false; + } + if (managedRoots.some((rootPath) => isWithinOrEqual(resolvedTarget, safeRealpath(rootPath)))) { + return true; + } + const workspaces = readAll<{ localPath: string }>("select local_path as localPath from workspaces"); + if (workspaces.some((workspace) => isWithinOrEqual(resolvedTarget, safeRealpath(workspace.localPath)))) { + return true; + } + const runLogs = readAll<{ logPath: string | null }>("select log_path as logPath from runs where log_path is not null"); + return runLogs.some((run) => run.logPath ? safeRealpath(run.logPath) === resolvedTarget : false); + }, + + hasCompany(companyId: string) { + const row = getDb().prepare("select id from companies where id = ?").get(companyId) as { id: string } | undefined; + return Boolean(row); + }, + + belongsToCompany(table: "workspaces" | "agents" | "goals" | "projects" | "tasks" | "approvals" | "runs" | "secrets" | "meetings" | "documents" | "knowledge_base" | "sprints" | "automation_rules" | "workflow_pipelines", id: string, companyId: string) { + const queries: Record = { + workspaces: "select 1 as present from workspaces where id = ? and company_id = ?", + agents: "select 1 as present from agents where id = ? and company_id = ?", + goals: "select 1 as present from goals where id = ? and company_id = ?", + projects: "select 1 as present from projects where id = ? and company_id = ?", + tasks: "select 1 as present from tasks where id = ? and company_id = ?", + approvals: "select 1 as present from approvals where id = ? and company_id = ?", + runs: "select 1 as present from runs where id = ? and company_id = ?", + secrets: "select 1 as present from secrets where id = ? and company_id = ?", + meetings: "select 1 as present from meetings where id = ? and company_id = ?", + documents: "select 1 as present from documents where id = ? and company_id = ?", + knowledge_base: "select 1 as present from knowledge_base where id = ? and company_id = ?", + sprints: "select 1 as present from sprints where id = ? and company_id = ?", + automation_rules: "select 1 as present from automation_rules where id = ? and company_id = ?", + workflow_pipelines: "select 1 as present from workflow_pipelines where id = ? and company_id = ?", + }; + const sql = queries[table]; + if (!sql) throw new Error(`Unknown table: ${table}`); + const row = getDb().prepare(sql).get(id, companyId) as { present: number } | undefined; + return Boolean(row?.present); + }, + }; +} diff --git a/src/main/database/skills.ts b/src/main/database/skills.ts new file mode 100644 index 00000000..ad38a28a --- /dev/null +++ b/src/main/database/skills.ts @@ -0,0 +1,128 @@ +import type { DatabaseSync } from "node:sqlite"; +import type { SkillRecord, FileInventoryEntry, SkillSourceType, TrustLevel } from "../skill-types.js"; +import type { ReadAll } from "./helpers"; +import { nowIso, parseJson } from "./helpers"; + +type SqliteRow = Record; + +export function buildSkillMethods( + getDb: () => DatabaseSync, + readAll: ReadAll, +) { + function mapSkillRow(row: SqliteRow): SkillRecord { + return { + id: row.id, + companyId: row.company_id, + key: row.key, + slug: row.slug, + name: row.name, + description: row.description ?? null, + markdown: row.markdown ?? "", + sourceType: (row.source_type ?? "local_path") as SkillSourceType, + sourceLocator: row.source_locator ?? null, + sourceRef: row.source_ref ?? null, + trustLevel: (row.trust_level ?? "markdown_only") as TrustLevel, + compatibility: row.compatibility ?? "compatible", + fileInventory: parseJson(row.file_inventory, []), + metadata: parseJson | null>(row.metadata, null), + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } + + return { + upsertCompanySkill(skill: { + id: string; companyId: string; key: string; slug: string; name: string; + description: string | null; markdown: string; sourceType: string; + sourceLocator: string | null; sourceRef: string | null; trustLevel: string; + compatibility: string; fileInventory: string; metadata: string | null; + createdAt: string; updatedAt: string; + }): { isUpdate: boolean } { + const existing = getDb() + .prepare("SELECT 1 FROM company_skills WHERE company_id = ? AND key = ?") + .get(skill.companyId, skill.key); + const isUpdate = existing != null; + + getDb().prepare(` + INSERT INTO company_skills ( + id, company_id, key, slug, name, description, markdown, + source_type, source_locator, source_ref, trust_level, + compatibility, file_inventory, metadata, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(company_id, key) DO UPDATE SET + slug = excluded.slug, + name = excluded.name, + description = excluded.description, + markdown = excluded.markdown, + source_ref = excluded.source_ref, + trust_level = excluded.trust_level, + file_inventory = excluded.file_inventory, + metadata = excluded.metadata, + updated_at = excluded.updated_at + `).run( + skill.id, skill.companyId, skill.key, skill.slug, + skill.name, skill.description, skill.markdown, + skill.sourceType, skill.sourceLocator, skill.sourceRef, + skill.trustLevel, skill.compatibility, skill.fileInventory, + skill.metadata, skill.createdAt, skill.updatedAt, + ); + + return { isUpdate }; + }, + + listCompanySkills(companyId: string): SkillRecord[] { + const rows = readAll( + "SELECT * FROM company_skills WHERE company_id = ? ORDER BY created_at ASC", + companyId, + ); + return rows.map((row) => mapSkillRow(row)); + }, + + getCompanySkill(companyId: string, skillId: string): SkillRecord | null { + const row = getDb() + .prepare("SELECT * FROM company_skills WHERE id = ? AND company_id = ?") + .get(skillId, companyId) as SqliteRow | undefined; + return row ? mapSkillRow(row) : null; + }, + + deleteCompanySkill(companyId: string, skillId: string): boolean { + const result = getDb() + .prepare("DELETE FROM company_skills WHERE id = ? AND company_id = ?") + .run(skillId, companyId); + return result.changes > 0; + }, + + assignSkillToAgent(agentId: string, skillId: string): void { + getDb() + .prepare("INSERT OR IGNORE INTO agent_skill_assignments (agent_id, skill_id, created_at) VALUES (?, ?, ?)") + .run(agentId, skillId, nowIso()); + }, + + removeSkillFromAgent(agentId: string, skillId: string): void { + getDb() + .prepare("DELETE FROM agent_skill_assignments WHERE agent_id = ? AND skill_id = ?") + .run(agentId, skillId); + }, + + listAgentSkillAssignments(agentId: string): string[] { + return readAll<{ skill_id: string }>( + "SELECT skill_id FROM agent_skill_assignments WHERE agent_id = ?", + agentId, + ).map((row) => row.skill_id); + }, + + listSkillAgentAssignments(skillId: string): string[] { + return readAll<{ agent_id: string }>( + "SELECT agent_id FROM agent_skill_assignments WHERE skill_id = ?", + skillId, + ).map((row) => row.agent_id); + }, + + updateCompanySkillMetadata(companyId: string, skillId: string, metadataJson: string): void { + const ts = nowIso(); + getDb().prepare( + "UPDATE company_skills SET metadata = ?, updated_at = ? WHERE id = ? AND company_id = ?" + ).run(metadataJson, ts, skillId, companyId); + }, + }; +} diff --git a/src/main/database/snapshot.ts b/src/main/database/snapshot.ts new file mode 100644 index 00000000..7e90e9dc --- /dev/null +++ b/src/main/database/snapshot.ts @@ -0,0 +1,198 @@ +import type { DatabaseSync, SQLInputValue } from "node:sqlite"; +import type { + ActivityRecord, + AgentRecord, + ApprovalRecord, + CompanyRecord, + CommentRecord, + CostRecord, + GoalRecord, + ProfileSnapshot, + ProjectRecord, + RunRecord, + SecretRecord, + TaskRecord, + WorkspaceRecord, +} from "@shared/types"; +import { type ReadAll, toBoolean } from "./helpers"; + +type SqliteRow = Record; +const BOARD_READER_ID = "__board__"; + +export function buildSnapshotMethods( + getDb: () => DatabaseSync, + readAll: ReadAll, + deps: { + getCurrentCompanyId: () => string | null; + getSetting: (key: string) => string | null; + readConnectors: () => any[]; + profileDir: string; + vaultBackendLabel: () => string; + }, +) { + function listSnapshot(options: { companyId?: string | null } = {}): ProfileSnapshot { + const companyId = options.companyId ?? null; + const companyWhere = companyId ? " where company_id = ?" : ""; + const companyArgs: SQLInputValue[] = companyId ? [companyId] : []; + const boardReadReceiptParams: SQLInputValue[] = companyId ? [BOARD_READER_ID, companyId] : [BOARD_READER_ID]; + const connectors = deps.readConnectors(); + const theme = (deps.getSetting("theme") as "system" | "light" | "dark" | null) ?? "system"; + const locale = (deps.getSetting("locale") as "en" | "zh" | null) ?? "en"; + const boardReadReceiptMap = new Map( + readAll<{ messageId: string; readAt: string }>( + `select reads.message_id as messageId, reads.read_at as readAt + from agent_message_reads reads + join agent_messages messages on messages.id = reads.message_id + where reads.agent_id = ?${companyId ? " and messages.company_id = ?" : ""}`, + ...boardReadReceiptParams, + ).map((row) => [row.messageId, row.readAt]), + ); + return { + currentCompanyId: deps.getCurrentCompanyId(), + profilePath: deps.profileDir, + updatesEnabled: deps.getSetting("updates_enabled") !== "false", + theme, + locale, + backend: { + secretBackend: deps.vaultBackendLabel(), + }, + companies: readAll( + "select id, name, description, status, auto_approve_hires as autoApproveHires, review_deliverables as reviewDeliverables, execution_mode as executionMode, created_at as createdAt, updated_at as updatedAt from companies order by created_at asc", + ).map((record) => ({ ...record, autoApproveHires: toBoolean(record.autoApproveHires), reviewDeliverables: toBoolean(record.reviewDeliverables) })) as CompanyRecord[], + connectors, + workspaces: readAll( + `select id, company_id as companyId, name, local_path as localPath, repo_url as repoUrl, repo_ref as repoRef, project_id as projectId, state, is_primary as isPrimary, created_at as createdAt, updated_at as updatedAt from workspaces${companyWhere} order by created_at desc`, + ...companyArgs, + ).map((record) => ({ ...record, isPrimary: toBoolean(record.isPrimary) })) as WorkspaceRecord[], + agents: readAll( + `select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, created_at as createdAt, updated_at as updatedAt from agents${companyWhere} order by created_at asc`, + ...companyArgs, + ).map((record: SqliteRow) => ({ ...record, heartbeatEnabled: toBoolean(record.heartbeatEnabled) })) as AgentRecord[], + goals: readAll( + `select id, company_id as companyId, title, description, parent_id as parentId, owner_agent_id as ownerAgentId, status, created_at as createdAt, updated_at as updatedAt from goals${companyWhere} order by created_at asc`, + ...companyArgs, + ), + projects: readAll( + `select id, company_id as companyId, goal_id as goalId, name, description, lead_agent_id as leadAgentId, status, target_date as targetDate, created_at as createdAt, updated_at as updatedAt from projects${companyWhere} order by created_at desc`, + ...companyArgs, + ), + tasks: readAll( + `select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, requires_user_review as requiresUserReview, started_at as startedAt, completed_at as completedAt, cancelled_at as cancelledAt, created_at as createdAt, updated_at as updatedAt from tasks${companyWhere} order by created_at desc`, + ...companyArgs, + ).map((record) => ({ ...record, requiresUserReview: toBoolean(record.requiresUserReview), startedAt: record.startedAt ?? null, completedAt: record.completedAt ?? null, cancelledAt: record.cancelledAt ?? null })) as TaskRecord[], + approvals: readAll( + `select id, company_id as companyId, related_task_id as relatedTaskId, related_agent_id as relatedAgentId, requested_by_agent_id as requestedByAgentId, type, payload_summary as payloadSummary, impact_summary as impactSummary, payload_json as payloadJson, decision_note as decisionNote, state, created_at as createdAt, updated_at as updatedAt from approvals${companyWhere} order by created_at desc`, + ...companyArgs, + ), + runs: readAll( + `select id, company_id as companyId, task_id as taskId, agent_id as agentId, workspace_id as workspaceId, connector_id as connectorId, status, summary, error_message as errorMessage, exit_code as exitCode, signal, model, session_display_id as sessionDisplayId, cost_usd as costUsd, unattributed_cost as unattributedCost, started_at as startedAt, finished_at as finishedAt, log_path as logPath, created_at as createdAt, updated_at as updatedAt from runs${companyWhere} order by created_at desc${companyId ? "" : " limit 500"}`, + ...companyArgs, + ).map((record) => ({ ...record, unattributedCost: Boolean(record.unattributedCost) })) as RunRecord[], + costs: readAll( + `select id, company_id as companyId, run_id as runId, task_id as taskId, agent_id as agentId, connector_id as connectorId, amount_usd as amountUsd, unattributed, created_at as createdAt from cost_entries${companyWhere} order by created_at desc${companyId ? "" : " limit 500"}`, + ...companyArgs, + ).map((entry) => ({ ...entry, unattributed: Boolean(entry.unattributed) })) as CostRecord[], + activity: readAll( + `select id, company_id as companyId, actor, action, entity_type as entityType, entity_id as entityId, detail, created_at as createdAt from activity${companyWhere} order by created_at desc${companyId ? "" : " limit 250"}`, + ...companyArgs, + ), + secrets: readAll( + `select id, company_id as companyId, name, description, backend, updated_at as updatedAt from secrets${companyWhere} order by updated_at desc`, + ...companyArgs, + ), + comments: readAll( + `select id, company_id as companyId, task_id as taskId, author_agent_id as authorAgentId, author_name as authorName, body, created_at as createdAt from task_comments${companyWhere} order by created_at asc`, + ...companyArgs, + ), + socialAccounts: readAll( + `select id, company_id, platform, account_name, display_name, profile_url, credential_secret_id, status, require_approval, metadata_json, last_used_at, created_at, updated_at from social_accounts${companyWhere} order by created_at desc`, + ...companyArgs, + ).map((r) => ({ + id: r.id, companyId: r.company_id, platform: r.platform, accountName: r.account_name, + displayName: r.display_name, profileUrl: r.profile_url, credentialSecretId: r.credential_secret_id, + status: r.status, requireApproval: toBoolean(r.require_approval), metadataJson: r.metadata_json, + lastUsedAt: r.last_used_at, createdAt: r.created_at, updatedAt: r.updated_at, + })), + meetings: readAll( + `select id, company_id, type, title, organizer_agent_id, participant_agent_ids, scheduled_at, duration_minutes, agenda_json, notes_json, decisions_json, action_items_json, status, created_at, updated_at from meetings${companyWhere} order by scheduled_at desc`, + ...companyArgs, + ).map((r) => ({ + id: r.id, companyId: r.company_id, type: r.type, title: r.title, organizerAgentId: r.organizer_agent_id, + participantAgentIds: r.participant_agent_ids, scheduledAt: r.scheduled_at, durationMinutes: r.duration_minutes, + agendaJson: r.agenda_json, notesJson: r.notes_json, decisionsJson: r.decisions_json, + actionItemsJson: r.action_items_json, status: r.status, createdAt: r.created_at, updatedAt: r.updated_at, + })), + documents: readAll( + `select id, company_id, type, title, content, author_agent_id, reviewer_agent_id, project_id, goal_id, parent_doc_id, version, status, tags_json, latest_revision_id, created_at, updated_at from documents${companyWhere} order by updated_at desc`, + ...companyArgs, + ).map((r) => ({ + id: r.id, companyId: r.company_id, type: r.type, title: r.title, content: r.content, + authorAgentId: r.author_agent_id, reviewerAgentId: r.reviewer_agent_id, projectId: r.project_id, + goalId: r.goal_id, parentDocId: r.parent_doc_id, version: r.version, status: r.status, + tagsJson: r.tags_json, latestRevisionId: r.latest_revision_id ?? null, createdAt: r.created_at, updatedAt: r.updated_at, + })), + knowledgeBase: readAll( + `select id, company_id, category, topic, content, author_agent_id, importance, tags_json, referenced_entity_type, referenced_entity_id, created_at, updated_at from knowledge_base${companyWhere} order by updated_at desc`, + ...companyArgs, + ).map((r) => ({ + id: r.id, companyId: r.company_id, category: r.category, topic: r.topic, content: r.content, + authorAgentId: r.author_agent_id, importance: r.importance, tagsJson: r.tags_json, + referencedEntityType: r.referenced_entity_type, referencedEntityId: r.referenced_entity_id, + createdAt: r.created_at, updatedAt: r.updated_at, + })), + sprints: readAll( + `select id, company_id, name, goal, start_date, end_date, status, retrospective_notes, velocity_points, completed_points, created_at, updated_at from sprints${companyWhere} order by start_date desc`, + ...companyArgs, + ).map((r) => ({ + id: r.id, companyId: r.company_id, name: r.name, goal: r.goal, + startDate: r.start_date, endDate: r.end_date, status: r.status, + retrospectiveNotes: r.retrospective_notes, velocityPoints: r.velocity_points, + completedPoints: r.completed_points, createdAt: r.created_at, updatedAt: r.updated_at, + })), + automationRules: readAll( + `select id, company_id, name, description, trigger, conditions_json, action, action_config_json, source_department, target_department, priority, status, execution_count, last_executed_at, created_at, updated_at from automation_rules${companyWhere} order by priority desc, created_at desc`, + ...companyArgs, + ).map((r) => ({ + id: r.id, companyId: r.company_id, name: r.name, description: r.description, + trigger: r.trigger, conditionsJson: r.conditions_json, action: r.action, + actionConfigJson: r.action_config_json, sourceDepartment: r.source_department, + targetDepartment: r.target_department, priority: r.priority, status: r.status, + executionCount: r.execution_count, lastExecutedAt: r.last_executed_at, + createdAt: r.created_at, updatedAt: r.updated_at, + })), + agentMessages: readAll( + `select id, company_id, from_agent_id, to_agent_id, sender_kind, channel, channel_target_id, subject, body, priority, read_at, parent_message_id, attachments_json, created_at from agent_messages${companyWhere} order by created_at desc${companyId ? "" : " limit 500"}`, + ...companyArgs, + ).map((r) => ({ + id: r.id, companyId: r.company_id, fromAgentId: r.from_agent_id, + toAgentId: r.to_agent_id, senderKind: r.sender_kind ?? "agent", + channel: r.channel, channelTargetId: r.channel_target_id, + subject: r.subject, body: r.body, priority: r.priority, readAt: boardReadReceiptMap.get(r.id) ?? null, + parentMessageId: r.parent_message_id, attachmentsJson: r.attachments_json, + createdAt: r.created_at, + })), + workflows: readAll( + `select id, company_id, name, description, steps_json, trigger_type, trigger_config_json, status, current_step_index, run_count, last_run_at, created_at, updated_at from workflow_pipelines${companyWhere} order by created_at desc`, + ...companyArgs, + ).map((r) => ({ + id: r.id, companyId: r.company_id, name: r.name, description: r.description, + stepsJson: r.steps_json, triggerType: r.trigger_type, triggerConfigJson: r.trigger_config_json, + status: r.status, currentStepIndex: r.current_step_index, runCount: r.run_count, + lastRunAt: r.last_run_at, createdAt: r.created_at, updatedAt: r.updated_at, + })), + }; + } + + return { + listSnapshot, + + listRendererSnapshot(): ProfileSnapshot { + const currentCompanyId = deps.getCurrentCompanyId(); + if (!currentCompanyId) { + return listSnapshot(); + } + const snapshot = listSnapshot({ companyId: currentCompanyId }); + return snapshot; + }, + }; +} diff --git a/src/main/database/social.ts b/src/main/database/social.ts new file mode 100644 index 00000000..c282a872 --- /dev/null +++ b/src/main/database/social.ts @@ -0,0 +1,177 @@ +import { randomUUID } from "node:crypto"; +import type { DatabaseSync, SQLInputValue } from "node:sqlite"; +import type { BrowserActionRecord, SocialAccountRecord } from "@shared/types"; +import type { ReadAll, Transaction } from "./helpers"; +import { ensureId, nowIso, toBoolean } from "./helpers"; + +type SqliteRow = Record; + +export function buildSocialMethods( + getDb: () => DatabaseSync, + readAll: ReadAll, + transaction: Transaction, +) { + return { + saveSocialAccount(input: { + id?: string | null; + companyId: string; + platform: string; + accountName: string; + displayName?: string; + profileUrl?: string; + credentialSecretId?: string | null; + status?: string; + requireApproval?: boolean; + metadataJson?: string; + }): string { + const id = ensureId(input.id); + const now = nowIso(); + getDb().prepare(`INSERT INTO social_accounts (id, company_id, platform, account_name, display_name, profile_url, credential_secret_id, status, require_approval, metadata_json, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + platform = excluded.platform, + account_name = excluded.account_name, + display_name = excluded.display_name, + profile_url = excluded.profile_url, + credential_secret_id = excluded.credential_secret_id, + status = excluded.status, + require_approval = excluded.require_approval, + metadata_json = excluded.metadata_json, + updated_at = excluded.updated_at`).run( + id, input.companyId, input.platform, input.accountName, + input.displayName ?? "", input.profileUrl ?? "", + input.credentialSecretId ?? null, input.status ?? "login_required", + input.requireApproval !== false ? 1 : 0, + input.metadataJson ?? "{}", now, now, + ); + return id; + }, + + deleteSocialAccount(id: string, companyId: string) { + transaction(() => { + getDb().prepare("DELETE FROM social_accounts WHERE id = ? AND company_id = ?").run(id, companyId); + getDb().prepare("DELETE FROM browser_actions WHERE social_account_id = ? AND company_id = ?").run(id, companyId); + }); + }, + + listSocialAccounts(companyId: string): SocialAccountRecord[] { + const rows = getDb().prepare("SELECT * FROM social_accounts WHERE company_id = ? ORDER BY created_at DESC").all(companyId) as SqliteRow[]; + return rows.map((r) => ({ + id: r.id, companyId: r.company_id, platform: r.platform, + accountName: r.account_name, displayName: r.display_name, + profileUrl: r.profile_url, credentialSecretId: r.credential_secret_id, + status: r.status, requireApproval: toBoolean(r.require_approval), + metadataJson: r.metadata_json, lastUsedAt: r.last_used_at, + createdAt: r.created_at, updatedAt: r.updated_at, + })); + }, + + getSocialAccount(id: string, companyId: string): SocialAccountRecord | null { + const row = getDb() + .prepare("SELECT * FROM social_accounts WHERE id = ? AND company_id = ?") + .get(id, companyId) as SqliteRow | undefined; + if (!row) return null; + return { + id: row.id, companyId: row.company_id, platform: row.platform, + accountName: row.account_name, displayName: row.display_name, + profileUrl: row.profile_url, credentialSecretId: row.credential_secret_id, + status: row.status, requireApproval: toBoolean(row.require_approval), + metadataJson: row.metadata_json, lastUsedAt: row.last_used_at, + createdAt: row.created_at, updatedAt: row.updated_at, + }; + }, + + saveBrowserAction(input: { + companyId: string; + socialAccountId: string; + agentId: string; + taskId?: string | null; + actionType: string; + payloadJson: string; + status?: string; + approvalId?: string | null; + }): string { + const id = randomUUID(); + const now = nowIso(); + getDb().prepare(`INSERT INTO browser_actions (id, company_id, social_account_id, agent_id, task_id, action_type, payload_json, status, approval_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run( + id, input.companyId, input.socialAccountId, input.agentId, + input.taskId ?? null, input.actionType, input.payloadJson, + input.status ?? "queued", input.approvalId ?? null, now, now, + ); + return id; + }, + + updateBrowserAction(id: string, updates: { status?: string; resultSummary?: string; screenshotPath?: string | null; errorMessage?: string | null; startedAt?: string; finishedAt?: string }) { + const now = nowIso(); + const sets: string[] = ["updated_at = ?"]; + const values: SQLInputValue[] = [now]; + if (updates.status) { sets.push("status = ?"); values.push(updates.status); } + if (updates.resultSummary !== undefined) { sets.push("result_summary = ?"); values.push(updates.resultSummary); } + if (updates.screenshotPath !== undefined) { sets.push("screenshot_path = ?"); values.push(updates.screenshotPath); } + if (updates.errorMessage !== undefined) { sets.push("error_message = ?"); values.push(updates.errorMessage); } + if (updates.startedAt) { sets.push("started_at = ?"); values.push(updates.startedAt); } + if (updates.finishedAt) { sets.push("finished_at = ?"); values.push(updates.finishedAt); } + values.push(id); + getDb().prepare(`UPDATE browser_actions SET ${sets.join(", ")} WHERE id = ?`).run(...values); + }, + + listBrowserActions(companyId: string, filters?: { socialAccountId?: string; status?: string; limit?: number }): BrowserActionRecord[] { + let sql = "SELECT * FROM browser_actions WHERE company_id = ?"; + const params: SQLInputValue[] = [companyId]; + if (filters?.socialAccountId) { sql += " AND social_account_id = ?"; params.push(filters.socialAccountId); } + if (filters?.status) { + const statuses = filters.status.split(",").filter(Boolean); + if (statuses.length > 0) { + sql += ` AND status IN (${statuses.map(() => "?").join(",")})`; + params.push(...statuses); + } + } + sql += " ORDER BY created_at DESC"; + if (filters?.limit) { sql += " LIMIT ?"; params.push(filters.limit); } + + const rows = getDb().prepare(sql).all(...params) as SqliteRow[]; + return rows.map((r) => ({ + id: r.id, companyId: r.company_id, socialAccountId: r.social_account_id, + agentId: r.agent_id, taskId: r.task_id, status: r.status, + actionType: r.action_type, payloadJson: r.payload_json, + resultSummary: r.result_summary, screenshotPath: r.screenshot_path, + approvalId: r.approval_id, errorMessage: r.error_message, + startedAt: r.started_at, finishedAt: r.finished_at, + createdAt: r.created_at, updatedAt: r.updated_at, + })); + }, + + getBrowserAction(id: string, companyId: string): BrowserActionRecord | null { + const row = getDb() + .prepare("SELECT * FROM browser_actions WHERE id = ? AND company_id = ?") + .get(id, companyId) as SqliteRow | undefined; + if (!row) return null; + return { + id: row.id, companyId: row.company_id, socialAccountId: row.social_account_id, + agentId: row.agent_id, taskId: row.task_id, status: row.status, + actionType: row.action_type, payloadJson: row.payload_json, + resultSummary: row.result_summary, screenshotPath: row.screenshot_path, + approvalId: row.approval_id, errorMessage: row.error_message, + startedAt: row.started_at, finishedAt: row.finished_at, + createdAt: row.created_at, updatedAt: row.updated_at, + }; + }, + + getBrowserActionByApprovalId(approvalId: string, companyId: string): BrowserActionRecord | null { + const row = getDb() + .prepare("SELECT * FROM browser_actions WHERE approval_id = ? AND company_id = ? ORDER BY created_at DESC LIMIT 1") + .get(approvalId, companyId) as SqliteRow | undefined; + if (!row) return null; + return { + id: row.id, companyId: row.company_id, socialAccountId: row.social_account_id, + agentId: row.agent_id, taskId: row.task_id, status: row.status, + actionType: row.action_type, payloadJson: row.payload_json, + resultSummary: row.result_summary, screenshotPath: row.screenshot_path, + approvalId: row.approval_id, errorMessage: row.error_message, + startedAt: row.started_at, finishedAt: row.finished_at, + createdAt: row.created_at, updatedAt: row.updated_at, + }; + }, + }; +} diff --git a/src/main/database/task-assignment.ts b/src/main/database/task-assignment.ts new file mode 100644 index 00000000..f0dd69d1 --- /dev/null +++ b/src/main/database/task-assignment.ts @@ -0,0 +1,126 @@ +import type { DatabaseSync } from "node:sqlite"; +import type { ReadAll } from "./helpers"; + +interface AssignmentTask { + id: string; + title: string; + description: string; + projectId: string | null; +} + +interface AssignmentCandidate { + id: string; + name: string; + department: string | null; + capabilities: string; + taskCount: number; + budgetMonthlyUsd: number; + spentMonthlyUsd: number; +} + +function tokenize(text: string): string[] { + return text.toLowerCase().split(/\W+/).filter((word) => word.length > 3); +} + +function getProjectDepartment( + db: DatabaseSync, + companyId: string, + projectId: string | null, +): string | null { + if (!projectId) return null; + const project = db + .prepare("select lead_agent_id from projects where id = ? and company_id = ?") + .get(projectId, companyId) as { lead_agent_id: string | null } | undefined; + if (!project?.lead_agent_id) return null; + const lead = db + .prepare("select department from agents where id = ? and company_id = ?") + .get(project.lead_agent_id, companyId) as { department: string | null } | undefined; + return lead?.department ?? null; +} + +function getAgentSkillWords( + db: DatabaseSync, + readAll: ReadAll, + companyId: string, + agentId: string, +): string[] { + const skills = readAll<{ name: string; description: string | null; slug: string }>( + `select cs.name, cs.description, cs.slug + from company_skills cs + join agent_skill_assignments asa on asa.skill_id = cs.id + where asa.agent_id = ? and cs.company_id = ?`, + agentId, + companyId, + ); + return tokenize(skills.map((skill) => `${skill.name} ${skill.description ?? ""} ${skill.slug}`).join(" ")); +} + +function scoreCandidate( + db: DatabaseSync, + readAll: ReadAll, + companyId: string, + taskWords: string[], + projectDept: string | null, + candidate: AssignmentCandidate, +): number { + const capabilityWords = tokenize(candidate.capabilities); + const skillWords = getAgentSkillWords(db, readAll, companyId, candidate.id); + const capabilityOverlap = taskWords.filter((word) => capabilityWords.includes(word)).length; + const skillOverlap = taskWords.filter((word) => skillWords.includes(word)).length; + let score = capabilityOverlap * 10 + skillOverlap * 12 - candidate.taskCount * 5; + + if (projectDept && candidate.department === projectDept) { + score += 15; + } + + const hasReports = readAll<{ id: string }>( + "select id from agents where reports_to = ? and company_id = ? limit 1", + candidate.id, + companyId, + ).length > 0; + if (!hasReports) { + score += 5; + } + + return score; +} + +export function chooseBestTaskAssignee( + getDb: () => DatabaseSync, + readAll: ReadAll, + companyId: string, + task: AssignmentTask, + candidates: AssignmentCandidate[], +): string | null { + const eligibleCandidates = candidates.filter((candidate) => { + if (candidate.budgetMonthlyUsd > 0 && candidate.spentMonthlyUsd >= candidate.budgetMonthlyUsd) { + return false; + } + return true; + }); + + if (eligibleCandidates.length === 0) { + return null; + } + + const taskWords = tokenize(`${task.title} ${task.description}`); + const projectDept = getProjectDepartment(getDb(), companyId, task.projectId); + + let bestAgentId: string | null = null; + let bestScore = -Infinity; + + for (const candidate of eligibleCandidates) { + const score = scoreCandidate(getDb(), readAll, companyId, taskWords, projectDept, candidate); + if ( + score > bestScore || + (score === bestScore && + candidate.taskCount < + (eligibleCandidates.find((entry) => entry.id === bestAgentId)?.taskCount ?? Number.POSITIVE_INFINITY)) + ) { + bestScore = score; + bestAgentId = candidate.id; + } + } + + return bestAgentId ?? eligibleCandidates[0]?.id ?? null; +} diff --git a/src/main/database/tasks-crud.ts b/src/main/database/tasks-crud.ts new file mode 100644 index 00000000..500e225d --- /dev/null +++ b/src/main/database/tasks-crud.ts @@ -0,0 +1,378 @@ +import { randomUUID } from "node:crypto"; +import type { DatabaseSync } from "node:sqlite"; +import type { TaskRecord, TaskSessionRecord } from "@shared/types"; +import { + type ReadAll, + type Transaction, + ensureId, + normalizeOptionalId, + nowIso, + taskPriorityOrderSql, +} from "./helpers"; + +export function buildTaskCrudMethods( + getDb: () => DatabaseSync, + readAll: ReadAll, + transaction: Transaction, + _deps: { + addComment: (input: { companyId: string; taskId: string; authorAgentId: string | null; authorName: string; body: string }) => void; + addActivity: (input: { companyId: string; actor: string; action: string; entityType: string; entityId: string; detail: string }) => void; + }, +) { + return { + saveTask(input: { + id?: string | null; + companyId: string; + projectId?: string | null; + goalId?: string | null; + parentId?: string | null; + title: string; + description: string; + assigneeAgentId?: string | null; + workspaceId?: string | null; + priority: string; + status: string; + }): string { + const id = ensureId(input.id ?? null); + const ts = nowIso(); + const assigneeAgentId = normalizeOptionalId(input.assigneeAgentId); + if (input.status === "in_progress" && !assigneeAgentId) { + throw new Error("Tasks in progress require an assignee."); + } + const existing = getDb() + .prepare("select id from tasks where id = ? and company_id = ?") + .get(id, input.companyId) as { id: string } | undefined; + if (existing) { + getDb() + .prepare( + "update tasks set project_id = ?, goal_id = ?, parent_id = ?, title = ?, description = ?, assignee_agent_id = ?, workspace_id = ?, priority = ?, status = ?, updated_at = ? where id = ? and company_id = ?", + ) + .run( + normalizeOptionalId(input.projectId), + normalizeOptionalId(input.goalId), + normalizeOptionalId(input.parentId), + input.title, + input.description, + assigneeAgentId, + normalizeOptionalId(input.workspaceId), + input.priority, + input.status, + ts, + id, + input.companyId, + ); + } else { + getDb() + .prepare( + "insert into tasks (id, company_id, project_id, goal_id, parent_id, title, description, assignee_agent_id, workspace_id, status, priority, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .run( + id, + input.companyId, + normalizeOptionalId(input.projectId), + normalizeOptionalId(input.goalId), + normalizeOptionalId(input.parentId), + input.title, + input.description, + assigneeAgentId, + normalizeOptionalId(input.workspaceId), + input.status, + input.priority, + ts, + ts, + ); + } + return id; + }, + + deleteTask(id: string, companyId: string): void { + const activeTaskRuns = readAll<{ + id: string; + taskId: string; + workspaceId: string | null; + }>( + "select id, task_id as taskId, workspace_id as workspaceId from runs where task_id = ? and company_id = ? and status in ('queued', 'running')", + id, + companyId, + ); + transaction(() => { + for (const run of activeTaskRuns) { + const ts = nowIso(); + getDb() + .prepare( + "update runs set status = 'cancelled', summary = ?, error_message = null, exit_code = null, signal = null, model = null, session_display_id = null, cost_usd = null, unattributed_cost = 1, finished_at = ?, updated_at = ? where id = ?", + ) + .run("Task deleted.", ts, ts, run.id); + getDb() + .prepare("update tasks set active_run_id = null, updated_at = ? where id = ?") + .run(ts, run.taskId); + getDb() + .prepare( + "UPDATE tasks SET checkout_run_id = NULL, execution_locked_at = NULL, updated_at = ? WHERE id = ? AND checkout_run_id = ?", + ) + .run(ts, run.taskId, run.id); + if (run.workspaceId) { + getDb() + .prepare( + `update workspaces + set lock_holder_run_id = null, lock_acquired_at = null, state = 'ready', updated_at = ? + where id = ? and lock_holder_run_id = ?`, + ) + .run(ts, run.workspaceId, run.id); + } + } + getDb() + .prepare( + "update tasks set parent_id = null where parent_id = ? and company_id = ?", + ) + .run(id, companyId); + getDb() + .prepare("delete from approvals where related_task_id = ? and company_id = ?") + .run(id, companyId); + getDb() + .prepare("delete from task_comments where task_id = ? and company_id = ?") + .run(id, companyId); + getDb() + .prepare("delete from tasks where id = ? and company_id = ?") + .run(id, companyId); + }); + }, + + listAgentTasks(agentId: string, companyId: string, statuses: string[]): TaskRecord[] { + const placeholders = statuses.map(() => "?").join(","); + return readAll( + `select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, created_at as createdAt, updated_at as updatedAt + from tasks + where company_id = ? and assignee_agent_id = ? and status in (${placeholders}) + order by + case status + when 'in_progress' then 0 + when 'todo' then 1 + when 'blocked' then 2 + when 'backlog' then 3 + else 4 + end asc, + ${taskPriorityOrderSql("priority")} desc, + created_at asc`, + companyId, + agentId, + ...statuses, + ); + }, + + countActiveTasksForCompany(companyId: string): number { + const row = getDb() + .prepare( + "select count(*) as cnt from tasks where company_id = ? and status not in ('done', 'cancelled') and title not like '[Heartbeat]%'", + ) + .get(companyId) as { cnt: number }; + return row.cnt; + }, + + listChildTasks( + parentId: string, + companyId: string, + ): Array<{ id: string; status: string; assigneeAgentId: string | null }> { + return readAll<{ id: string; status: string; assigneeAgentId: string | null }>( + `select id, status, assignee_agent_id as assigneeAgentId from tasks where parent_id = ? and company_id = ?`, + parentId, + companyId, + ); + }, + + updateTaskSession( + taskId: string, + sessionJson: Record | null, + sessionDisplayId: string | null, + ): void { + getDb() + .prepare( + "update tasks set session_json = ?, session_display_id = ?, updated_at = ? where id = ?", + ) + .run( + sessionJson ? JSON.stringify(sessionJson) : null, + sessionDisplayId, + nowIso(), + taskId, + ); + }, + + getTaskSession(taskId: string): { + sessionParams: Record | null; + sessionDisplayId: string | null; + } { + const row = getDb() + .prepare( + "select session_json as sessionJson, session_display_id as sessionDisplayId from tasks where id = ?", + ) + .get(taskId) as + | { sessionJson: string | null; sessionDisplayId: string | null } + | undefined; + let sessionParams: Record | null = null; + if (row?.sessionJson) { + try { + sessionParams = JSON.parse(row.sessionJson) as Record; + } catch { + sessionParams = null; + } + } + return { + sessionParams, + sessionDisplayId: row?.sessionDisplayId ?? null, + }; + }, + + getActiveTaskSession(taskId: string): TaskSessionRecord | null { + const task = getDb() + .prepare("SELECT active_session_id FROM tasks WHERE id = ?") + .get(taskId) as { active_session_id: string | null } | undefined; + if (!task?.active_session_id) return null; + return ( + (getDb() + .prepare( + `SELECT id, company_id as companyId, task_id as taskId, agent_id as agentId, + adapter_type as adapterType, session_params_json as sessionParamsJson, + session_display_id as sessionDisplayId, last_run_id as lastRunId, + last_error as lastError, run_count as runCount, + total_input_tokens as totalInputTokens, total_output_tokens as totalOutputTokens, + rotated_at as rotatedAt, rotation_reason as rotationReason, + handoff_markdown as handoffMarkdown, created_at as createdAt, updated_at as updatedAt + FROM task_sessions WHERE id = ?`, + ) + .get(task.active_session_id) as TaskSessionRecord | undefined) ?? null + ); + }, + + incrementSessionUsage( + sessionId: string, + inputTokens: number, + outputTokens: number, + runId: string, + ): void { + const ts = nowIso(); + getDb() + .prepare( + `UPDATE task_sessions + SET run_count = run_count + 1, + total_input_tokens = total_input_tokens + ?, + total_output_tokens = total_output_tokens + ?, + last_run_id = ?, + updated_at = ? + WHERE id = ?`, + ) + .run(inputTokens, outputTokens, runId, ts, sessionId); + }, + + archiveSession(sessionId: string, reason: string, handoffMarkdown: string): void { + const ts = nowIso(); + getDb() + .prepare( + `UPDATE task_sessions SET rotated_at = ?, rotation_reason = ?, handoff_markdown = ?, updated_at = ? WHERE id = ?`, + ) + .run(ts, reason, handoffMarkdown, ts, sessionId); + }, + + createNewSession(input: { + taskId: string; + companyId: string; + agentId: string; + adapterType: string; + sessionParamsJson: string | null; + sessionDisplayId: string | null; + }): string { + const sessionId = randomUUID(); + const ts = nowIso(); + getDb() + .prepare( + `INSERT INTO task_sessions (id, company_id, task_id, agent_id, adapter_type, + session_params_json, session_display_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + sessionId, + input.companyId, + input.taskId, + input.agentId, + input.adapterType, + input.sessionParamsJson, + input.sessionDisplayId, + ts, + ts, + ); + getDb() + .prepare("UPDATE tasks SET active_session_id = ?, updated_at = ? WHERE id = ?") + .run(sessionId, ts, input.taskId); + return sessionId; + }, + + getRecentRunsForTask( + taskId: string, + limit: number, + ): Array<{ + id: string; + status: string; + summary: string | null; + errorMessage: string | null; + finishedAt: string | null; + connectorId: string; + }> { + return readAll( + `SELECT id, status, summary, error_message as errorMessage, + finished_at as finishedAt, connector_id as connectorId + FROM runs WHERE task_id = ? ORDER BY finished_at DESC, rowid DESC LIMIT ?`, + taskId, + limit, + ); + }, + + getRecentActivityForTask( + taskId: string, + limit: number, + ): Array<{ + action: string; + detail: string; + createdAt: string; + }> { + return readAll( + `SELECT action, detail, created_at as createdAt + FROM activity WHERE entity_type = 'task' AND entity_id = ? + ORDER BY created_at DESC LIMIT ?`, + taskId, + limit, + ); + }, + + setTaskType(taskId: string, taskType: string): void { + const ts = nowIso(); + getDb() + .prepare("update tasks set task_type = ?, updated_at = ? where id = ?") + .run(taskType, ts, taskId); + }, + + getTaskRetryCount(taskId: string): number { + const row = getDb() + .prepare("select retry_count from tasks where id = ?") + .get(taskId) as { retry_count: number } | undefined; + return row?.retry_count ?? 0; + }, + + incrementTaskRetryCount(taskId: string): void { + const ts = nowIso(); + getDb() + .prepare( + "update tasks set retry_count = retry_count + 1, last_failure_at = ?, updated_at = ? where id = ?", + ) + .run(ts, ts, taskId); + }, + + resetTaskRetryCount(taskId: string): void { + const ts = nowIso(); + getDb() + .prepare( + "update tasks set retry_count = 0, last_failure_at = null, updated_at = ? where id = ?", + ) + .run(ts, taskId); + }, + + }; +} diff --git a/src/main/database/tasks-status.ts b/src/main/database/tasks-status.ts new file mode 100644 index 00000000..f5b7593b --- /dev/null +++ b/src/main/database/tasks-status.ts @@ -0,0 +1,332 @@ +import type { DatabaseSync } from "node:sqlite"; +import type { TaskRecord } from "@shared/types"; +import { + type ReadAll, + type Transaction, + nowIso, + taskPriorityOrderSql, +} from "./helpers"; +import { chooseBestTaskAssignee } from "./task-assignment"; + +export function buildTaskStatusMethods( + getDb: () => DatabaseSync, + readAll: ReadAll, + transaction: Transaction, + deps: { + addComment: (input: { companyId: string; taskId: string; authorAgentId: string | null; authorName: string; body: string }) => void; + addActivity: (input: { companyId: string; actor: string; action: string; entityType: string; entityId: string; detail: string }) => void; + }, +) { + function incrementActiveSprintVelocity(companyId: string, delta: number): void { + const ts = nowIso(); + const today = ts.slice(0, 10); + getDb() + .prepare( + "update sprints set completed_points = max(0, completed_points + ?), updated_at = ? where company_id = ? and status = 'active' and start_date <= ? and end_date >= ?", + ) + .run(delta, ts, companyId, today, today); + } + + const VALID_TASK_STATUSES = new Set([ + "backlog", + "todo", + "in_progress", + "in_review", + "done", + "blocked", + "cancelled", + ]); + + return { + advanceTaskStatus( + taskId: string, + newStatus: string, + comment?: string, + ): { previousStatus: string } { + if (!VALID_TASK_STATUSES.has(newStatus)) { + throw new Error(`Invalid task status: ${newStatus}`); + } + const task = getDb() + .prepare("select status, company_id from tasks where id = ?") + .get(taskId) as { status: string; company_id: string } | undefined; + if (!task) throw new Error(`Task not found: ${taskId}`); + const previousStatus = task.status; + if (previousStatus === newStatus) return { previousStatus }; + const ts = nowIso(); + + // Status side-effect timestamps (Paperclip pattern) + const timestampSets: string[] = ["status = ?", "updated_at = ?"]; + const timestampValues: (string | null)[] = [newStatus, ts]; + + if (newStatus === "in_progress") { + // Set startedAt only once (first time entering in_progress) + timestampSets.push("started_at = COALESCE(started_at, ?)"); + timestampValues.push(ts); + } + if (newStatus === "done") { + timestampSets.push("completed_at = ?"); + timestampValues.push(ts); + } else { + timestampSets.push("completed_at = NULL"); + } + if (newStatus === "cancelled") { + timestampSets.push("cancelled_at = ?"); + timestampValues.push(ts); + } else { + timestampSets.push("cancelled_at = NULL"); + } + if (newStatus !== "in_progress") { + timestampSets.push("checkout_run_id = NULL"); + } + + getDb() + .prepare(`UPDATE tasks SET ${timestampSets.join(", ")} WHERE id = ?`) + .run(...timestampValues, taskId); + + if (comment) { + deps.addComment({ + companyId: task.company_id, + taskId, + authorAgentId: null, + authorName: "System", + body: comment, + }); + } + if (newStatus === "done" && previousStatus !== "done") { + incrementActiveSprintVelocity(task.company_id, 1); + } + if (previousStatus === "done" && newStatus !== "done") { + incrementActiveSprintVelocity(task.company_id, -1); + } + return { previousStatus }; + }, + + findReusableHeartbeatTask(agentId: string, companyId: string): TaskRecord | null { + const row = getDb() + .prepare( + `SELECT id, company_id as companyId, project_id as projectId, goal_id as goalId, + parent_id as parentId, title, description, + assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, + status, priority, active_run_id as activeRunId, + session_json as sessionJson, session_display_id as sessionDisplayId, + cost_usd as costUsd, requires_user_review as requiresUserReview, + created_at as createdAt, updated_at as updatedAt + FROM tasks + WHERE company_id = ? AND assignee_agent_id = ? AND title LIKE '[Heartbeat]%' + AND status IN ('done', 'in_review') + ORDER BY updated_at DESC, rowid DESC + LIMIT 1`, + ) + .get(companyId, agentId) as TaskRecord | undefined; + if (row) { + row.requiresUserReview = Boolean(row.requiresUserReview); + } + return row ?? null; + }, + + cleanupDuplicateHeartbeatTasks(companyId: string): number { + const ts = nowIso(); + const result = getDb() + .prepare( + `UPDATE tasks SET status = 'cancelled', updated_at = ? + WHERE company_id = ? AND title LIKE '[Heartbeat]%' + AND status IN ('done', 'in_review', 'todo', 'blocked', 'backlog') + AND id NOT IN ( + SELECT id FROM ( + SELECT id, ROW_NUMBER() OVER (PARTITION BY assignee_agent_id ORDER BY updated_at DESC, rowid DESC) as rn + FROM tasks + WHERE company_id = ? AND title LIKE '[Heartbeat]%' AND status != 'cancelled' + ) WHERE rn = 1 + )`, + ) + .run(ts, companyId, companyId); + return Number(result.changes); + }, + + listReviewTasksForManager(managerId: string, companyId: string): TaskRecord[] { + return readAll( + `select t.id, t.company_id as companyId, t.project_id as projectId, t.goal_id as goalId, + t.parent_id as parentId, t.title, t.description, + t.assignee_agent_id as assigneeAgentId, t.workspace_id as workspaceId, + t.status, t.priority, t.active_run_id as activeRunId, + t.session_json as sessionJson, t.session_display_id as sessionDisplayId, + t.cost_usd as costUsd, t.created_at as createdAt, t.updated_at as updatedAt + from tasks t + inner join agents a on t.assignee_agent_id = a.id + where t.company_id = ? and t.status = 'in_review' and a.reports_to = ? + order by ${taskPriorityOrderSql("t.priority")} desc, t.created_at asc`, + companyId, + managerId, + ); + }, + + checkoutTask(taskId: string, runId: string): boolean { + const ts = nowIso(); + const result = getDb() + .prepare( + `UPDATE tasks + SET checkout_run_id = ?, execution_locked_at = ?, status = 'in_progress', updated_at = ? + WHERE id = ? + AND checkout_run_id IS NULL + AND status IN ('todo', 'backlog')`, + ) + .run(runId, ts, ts, taskId); + return result.changes > 0; + }, + + releaseTaskLock(taskId: string, runId: string): void { + getDb() + .prepare( + "UPDATE tasks SET checkout_run_id = NULL, execution_locked_at = NULL, updated_at = ? WHERE id = ? AND checkout_run_id = ?", + ) + .run(nowIso(), taskId, runId); + }, + + renewTaskLease(taskId: string, runId: string): boolean { + const ts = nowIso(); + const result = getDb() + .prepare( + "UPDATE tasks SET execution_locked_at = ?, updated_at = ? WHERE id = ? AND checkout_run_id = ?", + ) + .run(ts, ts, taskId, runId); + return result.changes > 0; + }, + + setTaskRequiresUserReview(taskId: string, value: boolean): void { + const ts = nowIso(); + getDb() + .prepare("update tasks set requires_user_review = ?, updated_at = ? where id = ?") + .run(value ? 1 : 0, ts, taskId); + }, + + hasPendingDeliverableReviews(companyId: string): boolean { + const approval = getDb() + .prepare( + "select 1 from approvals where company_id = ? and type = 'deliverable_review' and state = 'pending' limit 1", + ) + .get(companyId); + if (approval) return true; + const flaggedTask = getDb() + .prepare( + "select 1 from tasks where company_id = ? and requires_user_review = 1 limit 1", + ) + .get(companyId); + return !!flaggedTask; + }, + + hasAgentPendingDeliverableReview(agentId: string, companyId: string): boolean { + const row = getDb() + .prepare( + `select 1 from approvals a + join tasks t on a.related_task_id = t.id + where a.company_id = ? and a.type = 'deliverable_review' and a.state = 'pending' + and t.assignee_agent_id = ? + limit 1`, + ) + .get(companyId, agentId); + return !!row; + }, + + hasAgentTaskAwaitingReview(agentId: string, companyId: string): boolean { + const row = getDb() + .prepare( + "select 1 from tasks where assignee_agent_id = ? and company_id = ? and requires_user_review = 1 limit 1", + ) + .get(agentId, companyId); + return !!row; + }, + + autoAssignTask(taskId: string, companyId: string): string | null { + const task = getDb() + .prepare( + "select id, title, description, project_id as projectId, assignee_agent_id as assigneeAgentId from tasks where id = ? and company_id = ?", + ) + .get(taskId, companyId) as + | { + id: string; + title: string; + description: string; + projectId: string | null; + assigneeAgentId: string | null; + } + | undefined; + if (!task) return null; + + // If task already has an assignee, return them (don't reassign) + if (task.assigneeAgentId) return task.assigneeAgentId; + + // Get eligible agents: active/idle with remaining budget + const candidates = readAll<{ + id: string; + name: string; + department: string | null; + capabilities: string; + taskCount: number; + budgetMonthlyUsd: number; + spentMonthlyUsd: number; + }>( + `select a.id, a.name, a.department, a.capabilities, + a.budget_monthly_usd as budgetMonthlyUsd, a.spent_monthly_usd as spentMonthlyUsd, + (select count(*) from tasks t where t.assignee_agent_id = a.id and t.status in ('todo', 'in_progress')) as taskCount + from agents a + where a.company_id = ? and a.status in ('idle', 'active') + order by taskCount asc`, + companyId, + ); + + const bestAgent = chooseBestTaskAssignee(getDb, readAll, companyId, task, candidates); + if (!bestAgent) return null; + + getDb() + .prepare( + "update tasks set assignee_agent_id = ?, updated_at = ? where id = ? and company_id = ?", + ) + .run(bestAgent, nowIso(), taskId, companyId); + deps.addActivity({ + companyId, + actor: "system", + action: "task.auto_assigned", + entityType: "task", + entityId: taskId, + detail: `Auto-assigned to ${candidates.find((c) => c.id === bestAgent)?.name ?? bestAgent}`, + }); + return bestAgent; + }, + + retryInterruptedTask( + taskId: string, + _agentId: string, + _companyId: string, + ): void { + const ts = nowIso(); + getDb() + .prepare("update tasks set status = 'todo', updated_at = ? where id = ?") + .run(ts, taskId); + getDb() + .prepare( + "update tasks set retry_count = 0, last_failure_at = null, updated_at = ? where id = ?", + ) + .run(ts, taskId); + }, + + dismissInterruptedTask(taskId: string): void { + const taskRow = getDb() + .prepare("select company_id from tasks where id = ?") + .get(taskId) as { company_id: string } | undefined; + deps.addComment({ + companyId: taskRow?.company_id ?? "", + taskId, + authorAgentId: null, + authorName: "System", + body: "Interrupted run dismissed by user. Task remains blocked for manual handling.", + }); + }, + + markInterruptedTaskFailed(taskId: string): void { + const ts = nowIso(); + getDb() + .prepare("update tasks set status = 'cancelled', updated_at = ? where id = ?") + .run(ts, taskId); + }, + }; +} diff --git a/src/main/database/tasks.ts b/src/main/database/tasks.ts new file mode 100644 index 00000000..9b96954d --- /dev/null +++ b/src/main/database/tasks.ts @@ -0,0 +1,19 @@ +import type { DatabaseSync } from "node:sqlite"; +import type { ReadAll, Transaction } from "./helpers"; +import { buildTaskCrudMethods } from "./tasks-crud"; +import { buildTaskStatusMethods } from "./tasks-status"; + +export function buildTaskMethods( + getDb: () => DatabaseSync, + readAll: ReadAll, + transaction: Transaction, + deps: { + addComment: (input: { companyId: string; taskId: string; authorAgentId: string | null; authorName: string; body: string }) => void; + addActivity: (input: { companyId: string; actor: string; action: string; entityType: string; entityId: string; detail: string }) => void; + }, +) { + return { + ...buildTaskCrudMethods(getDb, readAll, transaction, deps), + ...buildTaskStatusMethods(getDb, readAll, transaction, deps), + }; +} diff --git a/src/main/database/threads.ts b/src/main/database/threads.ts new file mode 100644 index 00000000..43b7b6f0 --- /dev/null +++ b/src/main/database/threads.ts @@ -0,0 +1,334 @@ +import { randomUUID } from "node:crypto"; +import type { DatabaseSync, SQLInputValue } from "node:sqlite"; +import type { + ConversationEventRecord, + ConversationParticipantRecord, + ConversationThreadRecord, + ThreadActorType, + ThreadEventType, + ThreadNotifyOn, + ThreadParticipantRole, + ThreadSubscriptionRecord, + ThreadType, + ThreadWaitingFor, + ThreadWaitStateRecord, +} from "@shared/types"; + +type SqliteRow = Record; +type ReadAll = (sql: string, ...params: SQLInputValue[]) => T[]; + +function nowIso(): string { + return new Date().toISOString(); +} + +export function buildThreadMethods(getDb: () => DatabaseSync, readAll: ReadAll) { + function getThread(threadId: string, companyId: string): ConversationThreadRecord | null { + const row = getDb() + .prepare( + `SELECT id, company_id, title, type, related_entity_type, related_entity_id, created_at, updated_at + FROM conversation_threads WHERE id = ? AND company_id = ?`, + ) + .get(threadId, companyId) as SqliteRow | undefined; + if (!row) return null; + return { + id: row.id as string, + companyId: row.company_id as string, + title: row.title as string, + type: row.type as ThreadType, + relatedEntityType: (row.related_entity_type as string | null) ?? null, + relatedEntityId: (row.related_entity_id as string | null) ?? null, + createdAt: row.created_at as string, + updatedAt: row.updated_at as string, + }; + } + + return { + createThread(input: { + companyId: string; + title: string; + type: ThreadType; + relatedEntityType: string | null; + relatedEntityId: string | null; + }): ConversationThreadRecord { + const id = randomUUID(); + const ts = nowIso(); + getDb() + .prepare( + `INSERT INTO conversation_threads (id, company_id, title, type, related_entity_type, related_entity_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run(id, input.companyId, input.title, input.type, input.relatedEntityType, input.relatedEntityId, ts, ts); + return { + id, + companyId: input.companyId, + title: input.title, + type: input.type, + relatedEntityType: input.relatedEntityType, + relatedEntityId: input.relatedEntityId, + createdAt: ts, + updatedAt: ts, + }; + }, + + listThreads(options: { + companyId: string; + type?: ThreadType; + relatedEntityType?: string; + relatedEntityId?: string; + limit?: number; + offset?: number; + }): ConversationThreadRecord[] { + const conditions: string[] = ["company_id = ?"]; + const params: SQLInputValue[] = [options.companyId]; + if (options.type) { conditions.push("type = ?"); params.push(options.type); } + if (options.relatedEntityType) { conditions.push("related_entity_type = ?"); params.push(options.relatedEntityType); } + if (options.relatedEntityId) { conditions.push("related_entity_id = ?"); params.push(options.relatedEntityId); } + const where = conditions.join(" AND "); + const limit = options.limit ?? 50; + const offset = options.offset ?? 0; + const rows = readAll( + `SELECT id, company_id, title, type, related_entity_type, related_entity_id, created_at, updated_at + FROM conversation_threads WHERE ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`, + ...params, limit, offset, + ); + return rows.map((r) => ({ + id: r.id as string, + companyId: r.company_id as string, + title: r.title as string, + type: r.type as ThreadType, + relatedEntityType: (r.related_entity_type as string | null) ?? null, + relatedEntityId: (r.related_entity_id as string | null) ?? null, + createdAt: r.created_at as string, + updatedAt: r.updated_at as string, + })); + }, + + getThread, + + addThreadEvent(input: { + threadId: string; + actorId: string; + actorType: ThreadActorType; + eventType: ThreadEventType; + body: string; + metadataJson: string; + }): ConversationEventRecord { + const id = randomUUID(); + const ts = nowIso(); + const db = getDb(); + db.prepare( + `INSERT INTO conversation_events (id, thread_id, actor_id, actor_type, event_type, body, metadata_json, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ).run(id, input.threadId, input.actorId, input.actorType, input.eventType, input.body, input.metadataJson, ts); + db.prepare("UPDATE conversation_threads SET updated_at = ? WHERE id = ?").run(ts, input.threadId); + return { + id, + threadId: input.threadId, + actorId: input.actorId, + actorType: input.actorType, + eventType: input.eventType, + body: input.body, + metadataJson: input.metadataJson, + createdAt: ts, + }; + }, + + listThreadEvents( + threadId: string, + companyId: string, + options: { limit?: number; offset?: number } = {}, + ): ConversationEventRecord[] { + if (!getThread(threadId, companyId)) return []; + const limit = options.limit ?? 50; + const offset = options.offset ?? 0; + const rows = readAll( + `SELECT id, thread_id, actor_id, actor_type, event_type, body, metadata_json, created_at + FROM conversation_events WHERE thread_id = ? ORDER BY created_at ASC LIMIT ? OFFSET ?`, + threadId, limit, offset, + ); + return rows.map((r) => ({ + id: r.id as string, + threadId: r.thread_id as string, + actorId: r.actor_id as string, + actorType: r.actor_type as ThreadActorType, + eventType: r.event_type as ThreadEventType, + body: r.body as string, + metadataJson: r.metadata_json as string, + createdAt: r.created_at as string, + })); + }, + + setThreadWaitState(input: { + threadId: string; + companyId: string; + waitingFor: ThreadWaitingFor; + relatedApprovalId: string | null; + reason: string; + }): ThreadWaitStateRecord { + const id = randomUUID(); + const ts = nowIso(); + getDb() + .prepare( + `INSERT INTO thread_wait_states (id, thread_id, company_id, waiting_for, related_approval_id, reason, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .run(id, input.threadId, input.companyId, input.waitingFor, input.relatedApprovalId, input.reason, ts); + return { + id, + threadId: input.threadId, + companyId: input.companyId, + waitingFor: input.waitingFor, + relatedApprovalId: input.relatedApprovalId, + reason: input.reason, + createdAt: ts, + resolvedAt: null, + }; + }, + + resolveThreadWaitState(waitStateId: string, companyId: string): void { + const ts = nowIso(); + getDb() + .prepare( + `UPDATE thread_wait_states SET resolved_at = ? WHERE id = ? AND company_id = ? AND resolved_at IS NULL`, + ) + .run(ts, waitStateId, companyId); + }, + + listThreadWaitStates( + companyId: string, + options: { resolvedOnly?: boolean } = {}, + ): ThreadWaitStateRecord[] { + const where = options.resolvedOnly + ? "company_id = ? AND resolved_at IS NOT NULL" + : "company_id = ? AND resolved_at IS NULL"; + const rows = readAll( + `SELECT id, thread_id, company_id, waiting_for, related_approval_id, reason, created_at, resolved_at + FROM thread_wait_states WHERE ${where} ORDER BY created_at DESC`, + companyId, + ); + return rows.map((r) => ({ + id: r.id as string, + threadId: r.thread_id as string, + companyId: r.company_id as string, + waitingFor: r.waiting_for as ThreadWaitingFor, + relatedApprovalId: (r.related_approval_id as string | null) ?? null, + reason: r.reason as string, + createdAt: r.created_at as string, + resolvedAt: (r.resolved_at as string | null) ?? null, + })); + }, + + // ── Participants ────────────────────────────────────────────────────── + + addParticipant(input: { + threadId: string; + participantId: string; + participantType: ThreadActorType; + role?: ThreadParticipantRole; + }): ConversationParticipantRecord { + const ts = nowIso(); + const role = input.role ?? "member"; + getDb() + .prepare( + `INSERT OR IGNORE INTO conversation_participants (thread_id, participant_id, participant_type, role, joined_at) + VALUES (?, ?, ?, ?, ?)`, + ) + .run(input.threadId, input.participantId, input.participantType, role, ts); + return { + threadId: input.threadId, + participantId: input.participantId, + participantType: input.participantType, + role, + joinedAt: ts, + }; + }, + + listParticipants(threadId: string): ConversationParticipantRecord[] { + const rows = readAll( + `SELECT thread_id, participant_id, participant_type, role, joined_at + FROM conversation_participants WHERE thread_id = ? ORDER BY joined_at ASC`, + threadId, + ); + return rows.map((r) => ({ + threadId: r.thread_id as string, + participantId: r.participant_id as string, + participantType: r.participant_type as ThreadActorType, + role: r.role as ThreadParticipantRole, + joinedAt: r.joined_at as string, + })); + }, + + removeParticipant(threadId: string, participantId: string): void { + getDb() + .prepare("DELETE FROM conversation_participants WHERE thread_id = ? AND participant_id = ?") + .run(threadId, participantId); + }, + + // ── Subscriptions ──────────────────────────────────────────────────── + + subscribe(input: { + threadId: string; + subscriberId: string; + subscriberType: ThreadActorType; + notifyOn?: ThreadNotifyOn; + }): ThreadSubscriptionRecord { + const ts = nowIso(); + const notifyOn = input.notifyOn ?? "all"; + getDb() + .prepare( + `INSERT OR REPLACE INTO thread_subscriptions (thread_id, subscriber_id, subscriber_type, notify_on, subscribed_at) + VALUES (?, ?, ?, ?, ?)`, + ) + .run(input.threadId, input.subscriberId, input.subscriberType, notifyOn, ts); + return { + threadId: input.threadId, + subscriberId: input.subscriberId, + subscriberType: input.subscriberType, + notifyOn, + subscribedAt: ts, + }; + }, + + unsubscribe(threadId: string, subscriberId: string): void { + getDb() + .prepare("DELETE FROM thread_subscriptions WHERE thread_id = ? AND subscriber_id = ?") + .run(threadId, subscriberId); + }, + + listSubscriptions(threadId: string): ThreadSubscriptionRecord[] { + const rows = readAll( + `SELECT thread_id, subscriber_id, subscriber_type, notify_on, subscribed_at + FROM thread_subscriptions WHERE thread_id = ? ORDER BY subscribed_at ASC`, + threadId, + ); + return rows.map((r) => ({ + threadId: r.thread_id as string, + subscriberId: r.subscriber_id as string, + subscriberType: r.subscriber_type as ThreadActorType, + notifyOn: r.notify_on as ThreadNotifyOn, + subscribedAt: r.subscribed_at as string, + })); + }, + + listSubscribedThreads(subscriberId: string, companyId: string): ConversationThreadRecord[] { + const rows = readAll( + `SELECT ct.id, ct.company_id, ct.title, ct.type, ct.related_entity_type, ct.related_entity_id, ct.created_at, ct.updated_at + FROM conversation_threads ct + JOIN thread_subscriptions ts ON ct.id = ts.thread_id + WHERE ts.subscriber_id = ? AND ct.company_id = ? + ORDER BY ct.updated_at DESC`, + subscriberId, companyId, + ); + return rows.map((r) => ({ + id: r.id as string, + companyId: r.company_id as string, + title: r.title as string, + type: r.type as ThreadType, + relatedEntityType: (r.related_entity_type as string | null) ?? null, + relatedEntityId: (r.related_entity_id as string | null) ?? null, + createdAt: r.created_at as string, + updatedAt: r.updated_at as string, + })); + }, + }; +} diff --git a/src/main/database/wakeup.ts b/src/main/database/wakeup.ts new file mode 100644 index 00000000..8f5aa3c8 --- /dev/null +++ b/src/main/database/wakeup.ts @@ -0,0 +1,227 @@ +import { randomUUID } from "node:crypto"; +import type { DatabaseSync, SQLInputValue } from "node:sqlite"; +import type { ReadAll, Transaction } from "./helpers"; +import { nowIso } from "./helpers"; + +export function buildWakeupMethods( + getDb: () => DatabaseSync, + readAll: ReadAll, + transaction: Transaction, +) { + function parseTriggerDetail(raw: string | null): Array<{ trigger?: string; at?: string; detail?: string }> { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter((entry) => entry && typeof entry === "object") as Array<{ trigger?: string; at?: string; detail?: string }> : []; + } catch { + return []; + } + } + + function choosePreferredTrigger(triggers: string[]): string | null { + const unique = new Set(triggers.filter(Boolean)); + const preferredOrder = [ + "message", + "peer_request", + "comment_mentioned", + "approval_resolved", + "subtask_completed", + "report_blocked", + "report_failed", + "comment", + "assignment", + "manual", + "timer", + ]; + for (const trigger of preferredOrder) { + if (unique.has(trigger)) return trigger; + } + return triggers[0] ?? null; + } + + function mergeContextSnapshots(existingJson: string | null, incomingJson: string | null): string | null { + if (!incomingJson) return existingJson; + if (!existingJson) return incomingJson; + const existing: Record = JSON.parse(existingJson); + const incoming: Record = JSON.parse(incomingJson); + return JSON.stringify({ ...existing, ...incoming }); + } + + return { + enqueueWakeupRequest(companyId: string, agentId: string, trigger: string, detail: string, context?: Record): void { + const ts = nowIso(); + transaction(() => { + const existing = getDb() + .prepare( + `SELECT id, trigger_detail, coalesced_count, context_snapshot + FROM wakeup_requests + WHERE agent_id = ? AND company_id = ? AND status IN ('queued', 'deferred') + LIMIT 1`, + ) + .get(agentId, companyId) as + | { id: string; trigger_detail: string | null; coalesced_count: number; context_snapshot: string | null } + | undefined; + + if (existing) { + const triggers: Array<{ trigger: string; at: string; detail: string }> = existing.trigger_detail + ? JSON.parse(existing.trigger_detail) + : []; + triggers.push({ trigger, at: ts, detail }); + let mergedContext: string | null = existing.context_snapshot; + if (context) { + const prev: Record = existing.context_snapshot ? JSON.parse(existing.context_snapshot) : {}; + const merged = { ...prev, ...context }; + mergedContext = JSON.stringify(merged); + } + getDb() + .prepare( + `UPDATE wakeup_requests + SET trigger_detail = ?, + coalesced_count = coalesced_count + 1, + context_snapshot = ?, + updated_at = ? + WHERE id = ?`, + ) + .run(JSON.stringify(triggers), mergedContext, ts, existing.id); + } else { + const triggerDetail = JSON.stringify([{ trigger, at: ts, detail }]); + const contextJson = context ? JSON.stringify(context) : null; + getDb() + .prepare( + `INSERT INTO wakeup_requests + (id, company_id, agent_id, source, trigger_detail, status, coalesced_count, context_snapshot, requested_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 'queued', 0, ?, ?, ?, ?)`, + ) + .run(randomUUID(), companyId, agentId, trigger, triggerDetail, contextJson, ts, ts, ts); + } + }); + }, + + promoteWakeupRequests(agentId: string, companyId: string): number { + let promoted = 0; + transaction(() => { + const queued = getDb() + .prepare( + `SELECT id, trigger_detail, context_snapshot FROM wakeup_requests + WHERE agent_id = ? AND company_id = ? AND status = 'queued' LIMIT 1`, + ) + .get(agentId, companyId) as { id: string; trigger_detail: string | null; context_snapshot: string | null } | undefined; + const deferred = readAll<{ id: string; trigger_detail: string | null; coalesced_count: number; context_snapshot: string | null }>( + `SELECT id, trigger_detail, coalesced_count, context_snapshot FROM wakeup_requests + WHERE agent_id = ? AND company_id = ? AND status = 'deferred'`, + agentId, companyId, + ); + if (deferred.length === 0) return; + const ts = nowIso(); + if (queued) { + const queuedTriggers: Array<{ trigger: string; at: string; detail: string }> = queued.trigger_detail ? JSON.parse(queued.trigger_detail) : []; + let addedCount = 0; + let mergedCtx = queued.context_snapshot; + for (const d of deferred) { + const dTriggers: Array<{ trigger: string; at: string; detail: string }> = d.trigger_detail ? JSON.parse(d.trigger_detail) : []; + queuedTriggers.push(...dTriggers); + addedCount += d.coalesced_count + 1; + mergedCtx = mergeContextSnapshots(mergedCtx, d.context_snapshot); + } + getDb().prepare( + `UPDATE wakeup_requests SET trigger_detail = ?, coalesced_count = coalesced_count + ?, context_snapshot = ?, updated_at = ? WHERE id = ?`, + ).run(JSON.stringify(queuedTriggers), addedCount, mergedCtx, ts, queued.id); + for (const d of deferred) { + getDb().prepare("DELETE FROM wakeup_requests WHERE id = ?").run(d.id); + } + promoted = deferred.length; + } else { + const [first, ...rest] = deferred; + getDb().prepare( + `UPDATE wakeup_requests SET status = 'queued', updated_at = ? WHERE id = ?`, + ).run(ts, first!.id); + if (rest.length > 0) { + const firstTriggers: Array<{ trigger: string; at: string; detail: string }> = first!.trigger_detail ? JSON.parse(first!.trigger_detail) : []; + let addedCount = 0; + let mergedCtx = first!.context_snapshot; + for (const d of rest) { + const dTriggers: Array<{ trigger: string; at: string; detail: string }> = d.trigger_detail ? JSON.parse(d.trigger_detail) : []; + firstTriggers.push(...dTriggers); + addedCount += d.coalesced_count + 1; + mergedCtx = mergeContextSnapshots(mergedCtx, d.context_snapshot); + } + getDb().prepare( + `UPDATE wakeup_requests SET trigger_detail = ?, coalesced_count = coalesced_count + ?, context_snapshot = ?, updated_at = ? WHERE id = ?`, + ).run(JSON.stringify(firstTriggers), addedCount, mergedCtx, ts, first!.id); + for (const d of rest) { + getDb().prepare("DELETE FROM wakeup_requests WHERE id = ?").run(d.id); + } + } + promoted = deferred.length; + } + }); + return promoted; + }, + + listWakeupRequests(companyId: string, filters?: { status?: string; agentId?: string; limit?: number }): Array<{ + id: string; companyId: string; agentId: string; source: string; triggerDetail: string | null; + status: string; coalescedCount: number; runId: string | null; requestedAt: string; + claimedAt: string | null; finishedAt: string | null; createdAt: string; updatedAt: string; + }> { + const conditions = ["company_id = ?"]; + const params: SQLInputValue[] = [companyId]; + if (filters?.status) { conditions.push("status = ?"); params.push(filters.status); } + if (filters?.agentId) { conditions.push("agent_id = ?"); params.push(filters.agentId); } + const limit = filters?.limit ?? 50; + return readAll( + `SELECT id, company_id AS companyId, agent_id AS agentId, source, trigger_detail AS triggerDetail, + status, coalesced_count AS coalescedCount, run_id AS runId, requested_at AS requestedAt, + claimed_at AS claimedAt, finished_at AS finishedAt, created_at AS createdAt, updated_at AS updatedAt + FROM wakeup_requests + WHERE ${conditions.join(" AND ")} + ORDER BY requested_at DESC + LIMIT ?`, + ...params, limit, + ); + }, + + getPreferredWakeTrigger(agentId: string, companyId: string): string | null { + const rows = readAll<{ source: string; trigger_detail: string | null }>( + `SELECT source, trigger_detail + FROM wakeup_requests + WHERE agent_id = ? AND company_id = ? AND status IN ('queued', 'deferred') + ORDER BY requested_at ASC`, + agentId, + companyId, + ); + if (rows.length === 0) return null; + + const triggers: string[] = []; + for (const row of rows) { + if (row.source) triggers.push(row.source); + for (const entry of parseTriggerDetail(row.trigger_detail)) { + if (typeof entry.trigger === "string" && entry.trigger.length > 0) { + triggers.push(entry.trigger); + } + } + } + + return choosePreferredTrigger(triggers); + }, + + completePendingWakeupRequests(agentId: string, companyId: string, runId: string): number { + const ts = nowIso(); + const result = getDb().prepare( + `UPDATE wakeup_requests + SET status = 'completed', + run_id = ?, + claimed_at = COALESCE(claimed_at, ?), + finished_at = ?, + updated_at = ? + WHERE agent_id = ? AND company_id = ? AND status IN ('queued', 'deferred')`, + ).run(runId, ts, ts, ts, agentId, companyId); + return Number(result.changes); + }, + + purgeExpiredWakeupRequests() { + const cutoff = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); + const result = getDb().prepare("DELETE FROM wakeup_requests WHERE created_at < ? AND status IN ('completed')").run(cutoff); + return Number(result.changes); + }, + }; +} diff --git a/src/main/database/workspaces.ts b/src/main/database/workspaces.ts new file mode 100644 index 00000000..0dc448c1 --- /dev/null +++ b/src/main/database/workspaces.ts @@ -0,0 +1,177 @@ +import type { DatabaseSync } from "node:sqlite"; +import type { RunStatus, WorkspaceRecord } from "@shared/types"; +import type { ReadAll, Transaction } from "./helpers"; +import { ensureId, normalizeOptionalId, nowIso, toBoolean } from "./helpers"; + +export function buildWorkspaceMethods( + getDb: () => DatabaseSync, + readAll: ReadAll, + transaction: Transaction, +) { + return { + saveWorkspace(input: { + id?: string | null; + companyId: string; + name: string; + localPath: string; + repoUrl: string; + repoRef: string; + projectId?: string | null; + isPrimary: boolean; + }) { + const id = ensureId(input.id ?? null); + const ts = nowIso(); + if (input.isPrimary) { + getDb().prepare("update workspaces set is_primary = 0 where company_id = ?").run(input.companyId); + } + const existing = getDb().prepare("select id from workspaces where id = ? and company_id = ?").get(id, input.companyId) as { id: string } | undefined; + if (existing) { + getDb() + .prepare( + "update workspaces set name = ?, local_path = ?, repo_url = ?, repo_ref = ?, project_id = ?, is_primary = ?, updated_at = ? where id = ? and company_id = ?", + ) + .run(input.name, input.localPath, input.repoUrl, input.repoRef, normalizeOptionalId(input.projectId), input.isPrimary ? 1 : 0, ts, id, input.companyId); + } else { + getDb() + .prepare( + "insert into workspaces (id, company_id, name, local_path, repo_url, repo_ref, project_id, state, is_primary, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, 'ready', ?, ?, ?)", + ) + .run(id, input.companyId, input.name, input.localPath, input.repoUrl, input.repoRef, normalizeOptionalId(input.projectId), input.isPrimary ? 1 : 0, ts, ts); + } + return id; + }, + + getWorkspace(id: string | null | undefined): WorkspaceRecord | null { + if (!id) return null; + const row = getDb() + .prepare( + "select id, company_id as companyId, name, local_path as localPath, repo_url as repoUrl, repo_ref as repoRef, project_id as projectId, state, is_primary as isPrimary, created_at as createdAt, updated_at as updatedAt from workspaces where id = ?", + ) + .get(id) as WorkspaceRecord | undefined; + return row ? { ...row, isPrimary: toBoolean(row.isPrimary) } : null; + }, + + getProjectPrimaryWorkspace(projectId: string | null | undefined, companyId: string): WorkspaceRecord | null { + if (!projectId) return null; + const row = getDb() + .prepare( + "select id, company_id as companyId, name, local_path as localPath, repo_url as repoUrl, repo_ref as repoRef, project_id as projectId, state, is_primary as isPrimary, created_at as createdAt, updated_at as updatedAt from workspaces where company_id = ? and project_id = ? order by is_primary desc, created_at asc limit 1", + ) + .get(companyId, projectId) as WorkspaceRecord | undefined; + return row ? { ...row, isPrimary: toBoolean(row.isPrimary) } : null; + }, + + listWorkspaceLocalPaths(companyId: string): string[] { + const rows = getDb() + .prepare("select local_path as localPath from workspaces where company_id = ? and local_path is not null") + .all(companyId) as Array<{ localPath: string }>; + return rows.map((r) => r.localPath).filter(Boolean); + }, + + getAnyCompanyWorkspace(companyId: string): WorkspaceRecord | null { + const row = getDb() + .prepare( + "select id, company_id as companyId, name, local_path as localPath, repo_url as repoUrl, repo_ref as repoRef, project_id as projectId, state, is_primary as isPrimary, created_at as createdAt, updated_at as updatedAt from workspaces where company_id = ? order by is_primary desc, created_at asc limit 1", + ) + .get(companyId) as WorkspaceRecord | undefined; + return row ? { ...row, isPrimary: toBoolean(row.isPrimary) } : null; + }, + + deleteWorkspace(id: string, companyId: string) { + transaction(() => { + getDb().prepare("update agents set workspace_id = null where workspace_id = ? and company_id = ?").run(id, companyId); + getDb().prepare("update tasks set workspace_id = null where workspace_id = ? and company_id = ?").run(id, companyId); + getDb().prepare("delete from workspaces where id = ? and company_id = ?").run(id, companyId); + }); + }, + + acquireWorkspaceLock(workspaceId: string, runId: string): boolean { + const ts = nowIso(); + const result = getDb() + .prepare( + `update workspaces + set lock_holder_run_id = ?, lock_acquired_at = ?, state = 'busy', updated_at = ? + where id = ? + and (lock_holder_run_id is null or lock_holder_run_id = ?)`, + ) + .run(runId, ts, ts, workspaceId, runId); + return result.changes > 0; + }, + + claimWorkspaceLock(workspaceId: string, runId: string): boolean { + const holder = getDb() + .prepare("select lock_holder_run_id as lockHolderRunId from workspaces where id = ?") + .get(workspaceId) as { lockHolderRunId: string | null } | undefined; + if (!holder) return false; + if (!holder.lockHolderRunId || holder.lockHolderRunId === runId) { + const ts = nowIso(); + const result = getDb() + .prepare( + `update workspaces + set lock_holder_run_id = ?, lock_acquired_at = ?, state = 'busy', updated_at = ? + where id = ? + and (lock_holder_run_id is null or lock_holder_run_id = ?)`, + ) + .run(runId, ts, ts, workspaceId, runId); + return result.changes > 0; + } + const blockingRun = getDb() + .prepare("select status from runs where id = ?") + .get(holder.lockHolderRunId) as { status: RunStatus } | undefined; + if (blockingRun && (blockingRun.status === "queued" || blockingRun.status === "running")) { + return false; + } + const ts = nowIso(); + const result = getDb() + .prepare( + `update workspaces + set lock_holder_run_id = ?, lock_acquired_at = ?, state = 'busy', updated_at = ? + where id = ? and lock_holder_run_id = ?`, + ) + .run(runId, ts, ts, workspaceId, holder.lockHolderRunId); + return result.changes > 0; + }, + + releaseWorkspaceLock(workspaceId: string, runId: string) { + getDb() + .prepare( + `update workspaces + set lock_holder_run_id = null, lock_acquired_at = null, state = 'ready', updated_at = ? + where id = ? and lock_holder_run_id = ?`, + ) + .run(nowIso(), workspaceId, runId); + }, + + releaseAllWorkspaceLocks() { + getDb() + .prepare( + "update workspaces set lock_holder_run_id = null, lock_acquired_at = null, state = 'ready', updated_at = ? where lock_holder_run_id is not null", + ) + .run(nowIso()); + }, + + releaseStaleWorkspaceLocks(): number { + const result = getDb() + .prepare( + `update workspaces + set lock_holder_run_id = null, lock_acquired_at = null, state = 'ready', updated_at = ? + where lock_holder_run_id is not null + and not exists ( + select 1 + from runs + where runs.id = workspaces.lock_holder_run_id + and runs.status in ('queued', 'running') + )`, + ) + .run(nowIso()); + return Number(result.changes); + }, + + isWorkspaceLocked(workspaceId: string): boolean { + const row = getDb() + .prepare("select lock_holder_run_id from workspaces where id = ?") + .get(workspaceId) as { lock_holder_run_id: string | null } | undefined; + return Boolean(row?.lock_holder_run_id); + }, + }; +} diff --git a/src/main/desktop-shell-menu.ts b/src/main/desktop-shell-menu.ts new file mode 100644 index 00000000..92653847 --- /dev/null +++ b/src/main/desktop-shell-menu.ts @@ -0,0 +1,253 @@ +import { dirname, join } from "node:path"; +import { + app, + BrowserWindow, + dialog, + ipcMain, + Menu, + nativeImage, + shell, + Tray, +} from "electron"; +import { IPC_CHANNELS } from "@shared/contracts"; + +export interface DesktopShellMenuDependencies { + iconPath: string; + isDev: boolean; + onCheckForUpdates: () => Promise; + onBackupProfile: () => Promise; + onShortcut: (action: "toggle-command-palette" | "toggle-console" | "refresh") => void; + getMainWindow: () => BrowserWindow | null; + ensureWindow: () => Promise; + requestQuitWithConfirmation: () => Promise; +} + +export function createDesktopShellMenu(deps: DesktopShellMenuDependencies) { + const { + iconPath, + isDev, + onCheckForUpdates, + onBackupProfile, + onShortcut, + getMainWindow, + ensureWindow, + requestQuitWithConfirmation, + } = deps; + + let appTray: Tray | null = null; + + function buildMenu() { + const macAppMenu: Electron.MenuItemConstructorOptions = { + label: app.name, + submenu: [ + { role: "about" }, + { type: "separator" }, + { role: "services" }, + { type: "separator" }, + { role: "hide" }, + { role: "hideOthers" }, + { role: "unhide" }, + { type: "separator" }, + { + label: `Quit ${app.name}`, + accelerator: "CmdOrCtrl+Q", + click: () => void requestQuitWithConfirmation(), + }, + ], + }; + const template: Electron.MenuItemConstructorOptions[] = [ + ...(process.platform === "darwin" ? [macAppMenu] : []), + { + label: "File", + submenu: [ + { + label: "Open Workspace Folder", + accelerator: "CmdOrCtrl+O", + click: async () => { + const result = await dialog.showOpenDialog({ + properties: ["openDirectory"], + }); + if (result.canceled || result.filePaths.length === 0) return; + shell.openPath(result.filePaths[0] ?? ""); + }, + }, + { type: "separator" }, + { + label: "Backup Profile", + accelerator: "CmdOrCtrl+Shift+B", + click: async () => { + await onBackupProfile(); + }, + }, + ...(process.platform === "darwin" ? [] : [ + { type: "separator" as const }, + { + label: "Quit", + accelerator: "Alt+F4", + click: () => void requestQuitWithConfirmation(), + }, + ]), + ], + }, + { + label: "Edit", + submenu: [ + { role: "undo" }, + { role: "redo" }, + { type: "separator" }, + { role: "cut" }, + { role: "copy" }, + { role: "paste" }, + { role: "selectAll" }, + ], + }, + { + label: "View", + submenu: [ + { + label: "Command Palette", + accelerator: "CmdOrCtrl+K", + click: () => onShortcut("toggle-command-palette"), + }, + { + label: "Toggle Console", + accelerator: "CmdOrCtrl+`", + click: () => onShortcut("toggle-console"), + }, + { + label: "Refresh Data", + accelerator: "CmdOrCtrl+R", + click: () => onShortcut("refresh"), + }, + { type: "separator" }, + { role: "toggleDevTools", visible: isDev }, + ], + }, + { + label: "Run", + submenu: [ + { + label: "Check For Updates", + click: async () => { + await onCheckForUpdates(); + }, + }, + ], + }, + ]; + const menu = Menu.buildFromTemplate(template); + const mainWindow = getMainWindow(); + if (process.platform === "darwin") { + Menu.setApplicationMenu(menu); + } else { + // Frameless windows: hide native menu bar but keep keyboard accelerators active + Menu.setApplicationMenu(menu); + mainWindow?.setMenuBarVisibility(false); + mainWindow?.setAutoHideMenuBar(true); + } + } + + function createTray() { + if (appTray) { + return; + } + + const assetsDir = dirname(iconPath); + let trayIcon: Electron.NativeImage; + + if (process.platform === "darwin") { + // macOS menu bar: use Template images (monochrome black + alpha). + // Electron auto-detects the @2x variant when the base name includes "Template". + const templatePath = join(assetsDir, "tray-iconTemplate.png"); + trayIcon = nativeImage.createFromPath(templatePath); + if (!trayIcon.isEmpty()) { + trayIcon.setTemplateImage(true); + } + } else { + // Windows / Linux: use the color tray icon + const trayPath = join(assetsDir, "tray-icon.png"); + trayIcon = nativeImage.createFromPath(trayPath); + } + + // Fallback: resize the full app icon + if (trayIcon.isEmpty()) { + const fallback = nativeImage.createFromPath(iconPath); + if (!fallback.isEmpty()) { + trayIcon = fallback.resize({ width: 32, height: 32 }); + if (process.platform === "darwin") { + trayIcon.setTemplateImage(true); + } + } + } + + appTray = new Tray(trayIcon.isEmpty() ? nativeImage.createEmpty() : trayIcon); + appTray.setToolTip("AgentCompany"); + appTray.setContextMenu( + Menu.buildFromTemplate([ + { + label: "Show AgentCompany", + click: () => { + void ensureWindow().then((win) => { + if (win) { + win.show(); + win.focus(); + } + }); + }, + }, + { + label: "Hide AgentCompany", + click: () => { + const mainWindow = getMainWindow(); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.hide(); + } + }, + }, + { type: "separator" }, + { + label: "Quit", + click: () => void requestQuitWithConfirmation(), + }, + ]), + ); + appTray.on("click", () => { + void ensureWindow().then((win) => { + if (!win) return; + if (win.isVisible()) { + win.hide(); + return; + } + win.show(); + win.focus(); + }); + }); + } + + function registerWindowControlIpc() { + // Window control IPC for custom title bar (Windows/Linux frameless windows) + ipcMain.handle(IPC_CHANNELS.windowMinimize, () => { getMainWindow()?.minimize(); }); + ipcMain.handle(IPC_CHANNELS.windowMaximize, () => { + const mainWindow = getMainWindow(); + if (mainWindow?.isMaximized()) { + mainWindow.unmaximize(); + } else { + mainWindow?.maximize(); + } + }); + ipcMain.handle(IPC_CHANNELS.windowClose, () => { getMainWindow()?.close(); }); + ipcMain.handle(IPC_CHANNELS.windowIsMaximized, () => getMainWindow()?.isMaximized() ?? false); + } + + function destroy() { + appTray?.destroy(); + appTray = null; + } + + return { + buildMenu, + createTray, + registerWindowControlIpc, + destroy, + }; +} diff --git a/src/main/desktop-shell-service.ts b/src/main/desktop-shell-service.ts index a8564a94..94665998 100644 --- a/src/main/desktop-shell-service.ts +++ b/src/main/desktop-shell-service.ts @@ -1,503 +1,40 @@ -import { existsSync } from "node:fs"; -import { extname, dirname, join } from "node:path"; -import { pathToFileURL } from "node:url"; -import { resolve, sep } from "node:path"; -import { - app, - BrowserWindow, - dialog, - ipcMain, - Menu, - nativeImage, - nativeTheme, - net, - protocol, - session, - shell, - Tray, -} from "electron"; -import { attachWindowStatePersistence, loadWindowState } from "./window-state"; -import type { NotificationOptions } from "./notification-service"; +import { createDesktopShellWindow, type DesktopShellWindowDependencies } from "./desktop-shell-window"; +import { createDesktopShellMenu } from "./desktop-shell-menu"; -interface DesktopShellDependencies { - preloadPath: string; - rendererDist: string; - iconPath: string; - isDev: boolean; - devServerUrl?: string; - windowStatePath: string; - shouldQuit: () => boolean; - onRequestQuit: () => void; - onCheckForUpdates: () => Promise; - onBackupProfile: () => Promise; - onShortcut: (action: "toggle-command-palette" | "toggle-console" | "refresh") => void; - notify: (options: NotificationOptions) => void; - getActiveRunCount: () => number; -} +export function createDesktopShellService(deps: DesktopShellWindowDependencies) { + const window = createDesktopShellWindow(deps); + const menu = createDesktopShellMenu({ + iconPath: deps.iconPath, + isDev: deps.isDev, + onCheckForUpdates: deps.onCheckForUpdates, + onBackupProfile: deps.onBackupProfile, + onShortcut: deps.onShortcut, + getMainWindow: window.getMainWindow, + ensureWindow: window.ensureWindow, + requestQuitWithConfirmation: window.requestQuitWithConfirmation, + }); -function isSocialBrowserSession(contents: Electron.WebContents) { - return contents.session !== session.defaultSession; -} - -function isWithinDirectory(rootPath: string, targetPath: string) { - const normalizedRoot = resolve(rootPath); - const normalizedTarget = resolve(targetPath); - return normalizedTarget === normalizedRoot || normalizedTarget.startsWith(`${normalizedRoot}${sep}`); -} - -export function createDesktopShellService({ - preloadPath, - rendererDist, - iconPath, - isDev, - devServerUrl, - windowStatePath, - shouldQuit, - onRequestQuit, - onCheckForUpdates, - onBackupProfile, - onShortcut, - notify, - getActiveRunCount, -}: DesktopShellDependencies) { - let mainWindow: BrowserWindow | null = null; - let appTray: Tray | null = null; - let backgroundCloseNoticeShown = false; - let windowCreationPromise: Promise | null = null; - let quitDialogShowing = false; - - /** - * Quit confirmation gate — shown when agents are actively running. - * Gives the user three choices: - * 0 = "Stop All & Quit" → terminate all agents and exit - * 1 = "Minimize to Background" → hide window, agents keep running - * 2 = "Cancel" → do nothing - * - * When no agents are active the app quits immediately without a dialog. - */ - async function requestQuitWithConfirmation() { - if (quitDialogShowing) return; - - let activeRunCount = 0; - try { - activeRunCount = getActiveRunCount(); - } catch { - // Cannot determine active runs — proceed with immediate quit as fail-safe - onRequestQuit(); - app.quit(); - return; - } - - if (activeRunCount === 0) { - onRequestQuit(); - app.quit(); - return; - } - - quitDialogShowing = true; - try { - const message = activeRunCount === 1 - ? "1 agent run is currently active." - : `${activeRunCount} agent runs are currently active.`; - - const options: Electron.MessageBoxOptions = { - type: "question", - buttons: ["Stop All & Quit", "Minimize to Background", "Cancel"], - defaultId: 2, - cancelId: 2, - title: "AgentCompany", - message, - detail: "Quitting will stop all running agents and their tasks. You can also minimize to keep agents running in the background.", - }; - - const parentWindow = mainWindow && !mainWindow.isDestroyed() ? mainWindow : null; - const result = parentWindow - ? await dialog.showMessageBox(parentWindow, options) - : await dialog.showMessageBox(options); - - if (result.response === 0) { - // Stop All & Quit - onRequestQuit(); - app.quit(); - } else if (result.response === 1) { - // Minimize to Background - if (parentWindow) { - parentWindow.hide(); - } - if (!backgroundCloseNoticeShown) { - backgroundCloseNoticeShown = true; - notify({ - title: "Running in Background", - body: "AgentCompany is still running in the system tray", - urgency: "informational", - navigation: { section: "overview" }, - }); - } - } - // Cancel: do nothing - } catch { - // Dialog failed (e.g. window destroyed mid-flight) — quit as fail-safe - onRequestQuit(); - app.quit(); - } finally { - quitDialogShowing = false; - } - } - - function getMainWindow() { - return mainWindow; - } - - function installSecurityGuards() { - session.defaultSession.setPermissionCheckHandler(() => false); - session.defaultSession.setPermissionRequestHandler((_webContents, _permission, callback) => { - callback(false); - }); - app.on("web-contents-created", (_event, contents) => { - contents.setWindowOpenHandler(({ url }) => { - // Allow external https URLs to open in the user's default browser - if (url.startsWith("https://") || url.startsWith("http://")) { - shell.openExternal(url); - } - return { action: "deny" }; - }); - contents.on("will-attach-webview", (event) => { - event.preventDefault(); - }); - contents.on("will-navigate", (event) => { - if (isSocialBrowserSession(contents)) return; - event.preventDefault(); - }); - }); - } - - function registerProtocol() { - const textMimeTypes: Record = { - ".html": "text/html; charset=utf-8", - ".js": "text/javascript; charset=utf-8", - ".mjs": "text/javascript; charset=utf-8", - ".css": "text/css; charset=utf-8", - ".json": "application/json; charset=utf-8", - ".svg": "image/svg+xml; charset=utf-8", - }; - protocol.handle("app", async (request) => { - const url = new URL(request.url); - const relativePath = decodeURIComponent(url.pathname === "/" ? "index.html" : url.pathname.replace(/^\/+/, "")); - const targetPath = resolve(rendererDist, relativePath); - if (!isWithinDirectory(rendererDist, targetPath) || !existsSync(targetPath)) { - return new Response("Not found", { status: 404 }); - } - const response = await net.fetch(pathToFileURL(targetPath).toString()); - const mime = textMimeTypes[extname(targetPath).toLowerCase()]; - if (mime) { - const headers = new Headers(response.headers); - headers.set("Content-Type", mime); - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers, - }); - } - return response; - }); - } - - async function createWindow() { - const windowIcon = nativeImage.createFromPath(iconPath); - const savedWindowState = loadWindowState(windowStatePath); - const isWindows = process.platform === "win32"; - const isLinux = process.platform === "linux"; - mainWindow = new BrowserWindow({ - title: "AgentCompany", - icon: windowIcon.isEmpty() ? undefined : windowIcon, - width: savedWindowState.bounds.width, - height: savedWindowState.bounds.height, - x: savedWindowState.bounds.x, - y: savedWindowState.bounds.y, - minWidth: 1180, - minHeight: 760, - backgroundColor: "#ffffff", - frame: process.platform === "darwin", - titleBarStyle: process.platform === "darwin" ? "hiddenInset" : "default", - ...(isWindows || isLinux ? { titleBarOverlay: false } : {}), - webPreferences: { - preload: preloadPath, - contextIsolation: true, - nodeIntegration: false, - sandbox: true, - webviewTag: false, - devTools: isDev, - webSecurity: true, - allowRunningInsecureContent: false, - }, - }); - - // Sync native theme with app to ensure Windows title bar matches content - nativeTheme.themeSource = "light"; - - if (isDev && devServerUrl) { - await mainWindow.loadURL(devServerUrl); - } else { - await mainWindow.loadURL("app://-/index.html"); - } - attachWindowStatePersistence(mainWindow, windowStatePath); - if (savedWindowState.maximized) { - mainWindow.maximize(); - } - mainWindow.on("close", (event) => { - if (process.platform === "darwin" || shouldQuit()) { - return; - } - event.preventDefault(); - mainWindow?.hide(); - if (!backgroundCloseNoticeShown) { - backgroundCloseNoticeShown = true; - notify({ - title: "Running in Background", - body: "AgentCompany is still running in the system tray", - urgency: "informational", - navigation: { section: "overview" }, - }); - } - }); - mainWindow.on("closed", () => { - mainWindow = null; - }); - } - - async function ensureWindow() { - if (!mainWindow || mainWindow.isDestroyed()) { - if (!windowCreationPromise) { - windowCreationPromise = createWindow() - .then(() => mainWindow) - .finally(() => { - windowCreationPromise = null; - }); - } - return windowCreationPromise; - } - return mainWindow; - } - - function buildMenu() { - const macAppMenu: Electron.MenuItemConstructorOptions = { - label: app.name, - submenu: [ - { role: "about" }, - { type: "separator" }, - { role: "services" }, - { type: "separator" }, - { role: "hide" }, - { role: "hideOthers" }, - { role: "unhide" }, - { type: "separator" }, - { - label: `Quit ${app.name}`, - accelerator: "CmdOrCtrl+Q", - click: () => void requestQuitWithConfirmation(), - }, - ], - }; - const template: Electron.MenuItemConstructorOptions[] = [ - ...(process.platform === "darwin" ? [macAppMenu] : []), - { - label: "File", - submenu: [ - { - label: "Open Workspace Folder", - accelerator: "CmdOrCtrl+O", - click: async () => { - const result = await dialog.showOpenDialog({ - properties: ["openDirectory"], - }); - if (result.canceled || result.filePaths.length === 0) return; - shell.openPath(result.filePaths[0] ?? ""); - }, - }, - { type: "separator" }, - { - label: "Backup Profile", - accelerator: "CmdOrCtrl+Shift+B", - click: async () => { - await onBackupProfile(); - }, - }, - ...(process.platform === "darwin" ? [] : [ - { type: "separator" as const }, - { - label: "Quit", - accelerator: "Alt+F4", - click: () => void requestQuitWithConfirmation(), - }, - ]), - ], - }, - { - label: "Edit", - submenu: [ - { role: "undo" }, - { role: "redo" }, - { type: "separator" }, - { role: "cut" }, - { role: "copy" }, - { role: "paste" }, - { role: "selectAll" }, - ], - }, - { - label: "View", - submenu: [ - { - label: "Command Palette", - accelerator: "CmdOrCtrl+K", - click: () => onShortcut("toggle-command-palette"), - }, - { - label: "Toggle Console", - accelerator: "CmdOrCtrl+`", - click: () => onShortcut("toggle-console"), - }, - { - label: "Refresh Data", - accelerator: "CmdOrCtrl+R", - click: () => onShortcut("refresh"), - }, - { type: "separator" }, - { role: "toggleDevTools", visible: isDev }, - ], - }, - { - label: "Run", - submenu: [ - { - label: "Check For Updates", - click: async () => { - await onCheckForUpdates(); - }, - }, - ], - }, - ]; - const menu = Menu.buildFromTemplate(template); - if (process.platform === "darwin") { - Menu.setApplicationMenu(menu); - } else { - // Frameless windows: hide native menu bar but keep keyboard accelerators active - Menu.setApplicationMenu(menu); - mainWindow?.setMenuBarVisibility(false); - mainWindow?.setAutoHideMenuBar(true); - } - } - - function createTray() { - if (appTray) { - return; - } - - const assetsDir = dirname(iconPath); - let trayIcon: Electron.NativeImage; - - if (process.platform === "darwin") { - // macOS menu bar: use Template images (monochrome black + alpha). - // Electron auto-detects the @2x variant when the base name includes "Template". - const templatePath = join(assetsDir, "tray-iconTemplate.png"); - trayIcon = nativeImage.createFromPath(templatePath); - if (!trayIcon.isEmpty()) { - trayIcon.setTemplateImage(true); - } - } else { - // Windows / Linux: use the color tray icon - const trayPath = join(assetsDir, "tray-icon.png"); - trayIcon = nativeImage.createFromPath(trayPath); - } - - // Fallback: resize the full app icon - if (trayIcon.isEmpty()) { - const fallback = nativeImage.createFromPath(iconPath); - if (!fallback.isEmpty()) { - trayIcon = fallback.resize({ width: 32, height: 32 }); - if (process.platform === "darwin") { - trayIcon.setTemplateImage(true); - } - } - } - - appTray = new Tray(trayIcon.isEmpty() ? nativeImage.createEmpty() : trayIcon); - appTray.setToolTip("AgentCompany"); - appTray.setContextMenu( - Menu.buildFromTemplate([ - { - label: "Show AgentCompany", - click: () => { - void ensureWindow().then((win) => { - if (win) { - win.show(); - win.focus(); - } - }); - }, - }, - { - label: "Hide AgentCompany", - click: () => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.hide(); - } - }, - }, - { type: "separator" }, - { - label: "Quit", - click: () => void requestQuitWithConfirmation(), - }, - ]), - ); - appTray.on("click", () => { - void ensureWindow().then((win) => { - if (!win) return; - if (win.isVisible()) { - win.hide(); - return; - } - win.show(); - win.focus(); - }); - }); + function initialize() { + window.installSecurityGuards(); + window.registerProtocol(); + menu.buildMenu(); + menu.createTray(); + menu.registerWindowControlIpc(); } function destroy() { - appTray?.destroy(); - appTray = null; - } - - function initialize() { - installSecurityGuards(); - registerProtocol(); - buildMenu(); - createTray(); - - // Window control IPC for custom title bar (Windows/Linux frameless windows) - ipcMain.handle("window:minimize", () => { mainWindow?.minimize(); }); - ipcMain.handle("window:maximize", () => { - if (mainWindow?.isMaximized()) { - mainWindow.unmaximize(); - } else { - mainWindow?.maximize(); - } - }); - ipcMain.handle("window:close", () => { mainWindow?.close(); }); - ipcMain.handle("window:isMaximized", () => mainWindow?.isMaximized() ?? false); + menu.destroy(); } return { initialize, - installSecurityGuards, - registerProtocol, - buildMenu, - createTray, - createWindow, - ensureWindow, - getMainWindow, + installSecurityGuards: window.installSecurityGuards, + registerProtocol: window.registerProtocol, + buildMenu: menu.buildMenu, + createTray: menu.createTray, + createWindow: window.createWindow, + ensureWindow: window.ensureWindow, + getMainWindow: window.getMainWindow, destroy, }; } diff --git a/src/main/desktop-shell-window.ts b/src/main/desktop-shell-window.ts new file mode 100644 index 00000000..f250f076 --- /dev/null +++ b/src/main/desktop-shell-window.ts @@ -0,0 +1,316 @@ +import { existsSync } from "node:fs"; +import { extname } from "node:path"; +import { pathToFileURL } from "node:url"; +import { resolve, sep } from "node:path"; +import { + app, + BrowserWindow, + dialog, + nativeImage, + net, + protocol, + session, + shell, +} from "electron"; +import { attachWindowStatePersistence, loadWindowState } from "./window-state"; +import type { NotificationOptions } from "./notification-service"; + +export interface DesktopShellWindowDependencies { + preloadPath: string; + rendererDist: string; + iconPath: string; + isDev: boolean; + devServerUrl?: string; + windowStatePath: string; + shouldQuit: () => boolean; + onRequestQuit: () => void; + onCheckForUpdates: () => Promise; + onBackupProfile: () => Promise; + onShortcut: (action: "toggle-command-palette" | "toggle-console" | "refresh") => void; + notify: (options: NotificationOptions) => void; + getActiveRunCount: () => number; +} + +function isSocialBrowserSession(contents: Electron.WebContents) { + return contents.session !== session.defaultSession; +} + +function isWithinDirectory(rootPath: string, targetPath: string) { + const normalizedRoot = resolve(rootPath); + const normalizedTarget = resolve(targetPath); + return normalizedTarget === normalizedRoot || normalizedTarget.startsWith(`${normalizedRoot}${sep}`); +} + +const TRUSTED_EXTERNAL_HOSTS = new Set([ + "github.com", + "www.github.com", + "agentcompany.pages.dev", + "claude.ai", + "www.electronjs.org", + "www.electron.build", +]); + +function shouldOpenTrustedExternalUrl(rawUrl: string) { + try { + const url = new URL(rawUrl); + if (url.protocol !== "https:" && url.protocol !== "http:") { + return false; + } + return TRUSTED_EXTERNAL_HOSTS.has(url.hostname.toLowerCase()); + } catch { + return false; + } +} + +export function createDesktopShellWindow(deps: DesktopShellWindowDependencies) { + const { + preloadPath, + rendererDist, + iconPath, + isDev, + devServerUrl, + windowStatePath, + shouldQuit, + onRequestQuit, + notify, + getActiveRunCount, + } = deps; + + let mainWindow: BrowserWindow | null = null; + let backgroundCloseNoticeShown = false; + let windowCreationPromise: Promise | null = null; + let quitDialogShowing = false; + + /** + * Quit confirmation gate — shown when agents are actively running. + * Gives the user three choices: + * 0 = "Stop All & Quit" → terminate all agents and exit + * 1 = "Minimize to Background" → hide window, agents keep running + * 2 = "Cancel" → do nothing + * + * When no agents are active the app quits immediately without a dialog. + */ + async function requestQuitWithConfirmation() { + if (quitDialogShowing) return; + + let activeRunCount = 0; + try { + activeRunCount = getActiveRunCount(); + } catch { + // Cannot determine active runs — proceed with immediate quit as fail-safe + onRequestQuit(); + app.quit(); + return; + } + + if (activeRunCount === 0) { + onRequestQuit(); + app.quit(); + return; + } + + quitDialogShowing = true; + try { + const message = activeRunCount === 1 + ? "1 agent run is currently active." + : `${activeRunCount} agent runs are currently active.`; + + const options: Electron.MessageBoxOptions = { + type: "question", + buttons: ["Stop All & Quit", "Minimize to Background", "Cancel"], + defaultId: 2, + cancelId: 2, + title: "AgentCompany", + message, + detail: "Quitting will stop all running agents and their tasks. You can also minimize to keep agents running in the background.", + }; + + const parentWindow = mainWindow && !mainWindow.isDestroyed() ? mainWindow : null; + const result = parentWindow + ? await dialog.showMessageBox(parentWindow, options) + : await dialog.showMessageBox(options); + + if (result.response === 0) { + // Stop All & Quit + onRequestQuit(); + app.quit(); + } else if (result.response === 1) { + // Minimize to Background + if (parentWindow) { + parentWindow.hide(); + } + if (!backgroundCloseNoticeShown) { + backgroundCloseNoticeShown = true; + notify({ + title: "Running in Background", + body: "AgentCompany is still running in the system tray", + urgency: "informational", + navigation: { section: "overview" }, + }); + } + } + // Cancel: do nothing + } catch { + // Dialog failed (e.g. window destroyed mid-flight) — quit as fail-safe + onRequestQuit(); + app.quit(); + } finally { + quitDialogShowing = false; + } + } + + function getMainWindow() { + return mainWindow; + } + + function installSecurityGuards() { + session.defaultSession.setPermissionCheckHandler(() => false); + session.defaultSession.setPermissionRequestHandler((_webContents, _permission, callback) => { + callback(false); + }); + app.on("web-contents-created", (_event, contents) => { + contents.setWindowOpenHandler(({ url }) => { + if (shouldOpenTrustedExternalUrl(url)) { + void shell.openExternal(url); + return { action: "deny" }; + } + if (url.startsWith("https://") || url.startsWith("http://")) { + const choice = dialog.showMessageBoxSync({ + type: "question", + buttons: ["Open Link", "Cancel"], + defaultId: 1, + cancelId: 1, + title: "Open External Link?", + message: "AgentCompany blocked an untrusted external link.", + detail: `${url}\n\nOnly trusted product and documentation domains open automatically. Review this destination before continuing.`, + }); + if (choice === 0) { + void shell.openExternal(url); + } + } + return { action: "deny" }; + }); + contents.on("will-attach-webview", (event) => { + event.preventDefault(); + }); + contents.on("will-navigate", (event) => { + if (isSocialBrowserSession(contents)) return; + event.preventDefault(); + }); + }); + } + + function registerProtocol() { + const textMimeTypes: Record = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".svg": "image/svg+xml; charset=utf-8", + }; + protocol.handle("app", async (request) => { + const url = new URL(request.url); + const relativePath = decodeURIComponent(url.pathname === "/" ? "index.html" : url.pathname.replace(/^\/+/, "")); + const targetPath = resolve(rendererDist, relativePath); + if (!isWithinDirectory(rendererDist, targetPath) || !existsSync(targetPath)) { + return new Response("Not found", { status: 404 }); + } + const response = await net.fetch(pathToFileURL(targetPath).toString()); + const mime = textMimeTypes[extname(targetPath).toLowerCase()]; + if (mime) { + const headers = new Headers(response.headers); + headers.set("Content-Type", mime); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); + } + return response; + }); + } + + async function createWindow() { + const windowIcon = nativeImage.createFromPath(iconPath); + const savedWindowState = loadWindowState(windowStatePath); + const isWindows = process.platform === "win32"; + const isLinux = process.platform === "linux"; + mainWindow = new BrowserWindow({ + title: "AgentCompany", + icon: windowIcon.isEmpty() ? undefined : windowIcon, + width: savedWindowState.bounds.width, + height: savedWindowState.bounds.height, + x: savedWindowState.bounds.x, + y: savedWindowState.bounds.y, + minWidth: 1180, + minHeight: 760, + backgroundColor: "#ffffff", + frame: process.platform === "darwin", + titleBarStyle: process.platform === "darwin" ? "hiddenInset" : "default", + ...(isWindows || isLinux ? { titleBarOverlay: false } : {}), + webPreferences: { + preload: preloadPath, + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + webviewTag: false, + devTools: isDev, + webSecurity: true, + allowRunningInsecureContent: false, + }, + }); + + if (isDev && devServerUrl) { + await mainWindow.loadURL(devServerUrl); + } else { + await mainWindow.loadURL("app://-/index.html"); + } + attachWindowStatePersistence(mainWindow, windowStatePath); + if (savedWindowState.maximized) { + mainWindow.maximize(); + } + mainWindow.on("close", (event) => { + if (process.platform === "darwin" || shouldQuit()) { + return; + } + event.preventDefault(); + mainWindow?.hide(); + if (!backgroundCloseNoticeShown) { + backgroundCloseNoticeShown = true; + notify({ + title: "Running in Background", + body: "AgentCompany is still running in the system tray", + urgency: "informational", + navigation: { section: "overview" }, + }); + } + }); + mainWindow.on("closed", () => { + mainWindow = null; + }); + } + + async function ensureWindow() { + if (!mainWindow || mainWindow.isDestroyed()) { + if (!windowCreationPromise) { + windowCreationPromise = createWindow() + .then(() => mainWindow) + .finally(() => { + windowCreationPromise = null; + }); + } + return windowCreationPromise; + } + return mainWindow; + } + + return { + getMainWindow, + installSecurityGuards, + registerProtocol, + createWindow, + ensureWindow, + requestQuitWithConfirmation, + }; +} diff --git a/src/main/execution-policy.ts b/src/main/execution-policy.ts new file mode 100644 index 00000000..051271b8 --- /dev/null +++ b/src/main/execution-policy.ts @@ -0,0 +1,72 @@ +/** + * Execution Policy — Single Source of Truth + * + * Centralizes all execution-mode-aware policy decisions: + * - Which approval types require human review in co-founder mode + * - Which approval types can be auto-approved in CEO mode + * - Whether a given approval should be auto-approved based on mode + company settings + * + * checkpoint-engine.ts and approval-policy.ts both delegate here + * instead of maintaining separate rule sets. + */ + +import type { AppDatabase } from "./database"; +import type { ApprovalType, ExecutionMode } from "@shared/types"; + +/** Approval types that always require human review in co-founder mode. */ +export const COFOUNDER_MANDATORY_APPROVALS = new Set([ + "deliverable_review", + "document_review", + "plan_review", + "scope_review", + "draft_review", + "strategy_review", + "exception_review", + "approve_ceo_strategy", + "production_deploy", +]); + +/** Approval types eligible for auto-approval in CEO mode (when company settings allow). */ +const CEO_AUTO_APPROVABLE = new Set([ + "hire_agent", + "approve_ceo_strategy", +]); + +/** Task types that get plan_review checkpoints in co-founder mode. */ +export const CHECKPOINT_TASK_TYPES = new Set(["research", "content", "management"]); + +/** Document types that get strategy_review instead of draft_review. */ +export const STRATEGY_DOCUMENT_TYPES = new Set(["project_brief", "proposal", "budget_proposal"]); + +/** + * Determines whether an approval should be auto-approved. + * This is the single decision function — no other module should + * independently decide auto-approval policy. + */ +export function shouldAutoApprove( + db: AppDatabase, + companyId: string, + approvalType: string, +): boolean { + const mode = db.getCompanyExecutionMode(companyId) as ExecutionMode; + + // Co-founder mode: mandatory types always require human decision + if (mode === "ai_cofounder" && COFOUNDER_MANDATORY_APPROVALS.has(approvalType as ApprovalType)) { + return false; + } + + // Only specific types are ever auto-approvable + if (!CEO_AUTO_APPROVABLE.has(approvalType as ApprovalType)) { + return false; + } + + const settings = db.getCompanyApprovalSettings(companyId); + if (!settings?.autoApproveHires) return false; + + // When reviewDeliverables is ON, CEO strategy needs human review + if (approvalType === "approve_ceo_strategy" && settings.reviewDeliverables) { + return false; + } + + return true; +} diff --git a/src/main/ipc-agent-coordination-handlers.ts b/src/main/ipc-agent-coordination-handlers.ts index 309e9370..7265d7f4 100644 --- a/src/main/ipc-agent-coordination-handlers.ts +++ b/src/main/ipc-agent-coordination-handlers.ts @@ -16,7 +16,9 @@ export function registerAgentCoordinationHandlers({ findInvalidReference, publishDomainChanged, emitEvent, + notify, wakeAgentIfPossible, + onHumanMessage, }: Pick< RegisterDomainHandlersDependencies, | "registerHandle" @@ -25,7 +27,9 @@ export function registerAgentCoordinationHandlers({ | "findInvalidReference" | "publishDomainChanged" | "emitEvent" + | "notify" | "wakeAgentIfPossible" + | "onHumanMessage" >) { registerHandle(IPC_CHANNELS.sendAgentMessage, async (_event, payload) => { const parsed = agentMessageInputSchema.parse(payload); @@ -35,8 +39,10 @@ export function registerAgentCoordinationHandlers({ { db, emitEvent, + notify, publishDomainChanged, wakeAgentIfPossible, + onHumanMessage, }, parsed, "message", @@ -44,7 +50,10 @@ export function registerAgentCoordinationHandlers({ if (!dispatchResult.ok) { return fail(dispatchResult.error.code, dispatchResult.error.message); } - return ok(dispatchResult.data.id); + return ok({ + id: dispatchResult.data.id, + mentionResults: dispatchResult.data.mentionResults, + }); }); registerHandle(IPC_CHANNELS.listAgentMessages, async (_event, payload) => { diff --git a/src/main/ipc-core-admin-handlers.ts b/src/main/ipc-core-admin-handlers.ts index a58f9618..2d38659c 100644 --- a/src/main/ipc-core-admin-handlers.ts +++ b/src/main/ipc-core-admin-handlers.ts @@ -8,6 +8,7 @@ import { openTerminalCommandSchema, secretInputSchema, } from "@shared/contracts"; +import { validateConnectorConfigurationModel, validateConnectorModelSelection } from "./connector-model-policy"; import { fail, ok, type RegisterCoreHandlersDependencies } from "./ipc-core-common"; export function registerCoreAdminHandlers({ @@ -55,6 +56,16 @@ export function registerCoreAdminHandlers({ if (parsed.command !== existingConnector.command) { return fail("CONNECTOR_COMMAND_LOCKED", `Connector command is managed by AgentCompany and must remain "${existingConnector.command}".`); } + const connectorConfigValidation = validateConnectorConfigurationModel(parsed.id, parsed.model ?? null); + if (!connectorConfigValidation.ok) { + return fail("CONNECTOR_MODEL_NOT_ALLOWED", connectorConfigValidation.error); + } + if (!parsed.id.startsWith("claude_compat")) { + const modelSelection = validateConnectorModelSelection(db, parsed.id, parsed.model ?? null); + if (!modelSelection.ok) { + return fail("CONNECTOR_MODEL_NOT_ALLOWED", modelSelection.error); + } + } db.saveConnector({ ...parsed, command: existingConnector.command, diff --git a/src/main/ipc-core-agent-graph-handlers.ts b/src/main/ipc-core-agent-graph-handlers.ts new file mode 100644 index 00000000..96d800c5 --- /dev/null +++ b/src/main/ipc-core-agent-graph-handlers.ts @@ -0,0 +1,301 @@ +import { + IPC_CHANNELS, + agentInputSchema, + deleteEntitySchema, + hireRequestSchema, + workspaceInputSchema, +} from "@shared/contracts"; +import { getConnectorExecutionReadinessIssue } from "@shared/connector-policy"; +import { resolveApprovalDecision, shouldAutoApproveApproval } from "./approval-policy"; +import { validateConnectorModelSelection } from "./connector-model-policy"; +import { fail, ok, type RegisterCoreHandlersDependencies } from "./ipc-core-common"; + +export function registerCoreAgentGraphHandlers({ + registerHandle, + db, + ensureCompanyExists, + findInvalidReference, + publishDomainChanged, + handleApprovalResolved, +}: Pick< + RegisterCoreHandlersDependencies, + | "registerHandle" + | "db" + | "ensureCompanyExists" + | "findInvalidReference" + | "publishDomainChanged" + | "handleApprovalResolved" +>) { + registerHandle(IPC_CHANNELS.saveWorkspace, async (_event, payload) => { + const parsed = workspaceInputSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + const invalidReference = findInvalidReference(parsed.companyId, [ + { id: parsed.projectId, table: "projects", label: "project" }, + ]); + if (invalidReference) { + return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`); + } + const id = db.saveWorkspace(parsed); + db.addActivity({ + companyId: parsed.companyId, + actor: "board", + action: parsed.id ? "workspace.updated" : "workspace.created", + entityType: "workspace", + entityId: id, + detail: parsed.localPath, + }); + publishDomainChanged(); + return ok(id); + }); + + registerHandle(IPC_CHANNELS.saveAgent, async (_event, payload) => { + const parsed = agentInputSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + const isNew = !parsed.id; + if (isNew) { + return fail( + "AGENT_CREATION_REQUIRES_HIRE_REQUEST", + "Create new agents through the hiring workflow so approval, onboarding, and org state stay synchronized.", + ); + } + const existingAgent = (() => { + try { + return db.getAgent(parsed.id!); + } catch { + return null; + } + })(); + if (!existingAgent || existingAgent.companyId !== parsed.companyId) { + return fail("AGENT_NOT_FOUND", "Agent not found in the current company."); + } + if (existingAgent.status === "pending_approval") { + return fail( + "AGENT_APPROVAL_REQUIRED", + "Pending hires must be resolved from Hiring or Approvals. Direct edits, deletes, and activation changes are blocked here.", + ); + } + if (parsed.status === "pending_approval") { + return fail( + "AGENT_STATUS_LOCKED", + "Active agents cannot be moved back to pending approval by editing status directly.", + ); + } + const invalidReference = findInvalidReference(parsed.companyId, [ + { id: parsed.reportsTo, table: "agents", label: "manager" }, + { id: parsed.workspaceId, table: "workspaces", label: "workspace" }, + ]); + if (invalidReference) { + return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`); + } + const connectorRecord = (() => { + try { + return db.getConnector(parsed.connectorId); + } catch { + return null; + } + })(); + if (!connectorRecord) { + return fail("CONNECTOR_NOT_FOUND", "The selected connector does not exist."); + } + const connectorIssue = getConnectorExecutionReadinessIssue(connectorRecord); + if (connectorIssue) { + return fail("CONNECTOR_NOT_READY", connectorIssue); + } + const modelSelection = validateConnectorModelSelection(db, parsed.connectorId, parsed.model ?? null); + if (!modelSelection.ok) { + return fail("CONNECTOR_MODEL_NOT_ALLOWED", modelSelection.error); + } + let id: string; + try { + id = db.saveAgent({ + id: parsed.id ?? null, + companyId: parsed.companyId, + name: parsed.name, + role: parsed.role, + title: parsed.title, + department: parsed.department ?? null, + status: parsed.status, + reportsTo: parsed.reportsTo ?? null, + connectorId: parsed.connectorId, + workspaceId: parsed.workspaceId ?? null, + model: parsed.model ?? null, + metadataJson: parsed.metadataJson, + capabilities: parsed.capabilities, + budgetMonthlyUsd: parsed.budgetMonthlyUsd, + }); + } catch (error) { + return fail("INVALID_AGENT_HIERARCHY", error instanceof Error ? error.message : "Agent could not be saved."); + } + db.addActivity({ + companyId: parsed.companyId, + actor: "board", + action: parsed.id ? "agent.updated" : "agent.created", + entityType: "agent", + entityId: id, + detail: parsed.name, + }); + publishDomainChanged(); + return ok(id); + }); + + registerHandle(IPC_CHANNELS.requestHire, async (_event, payload) => { + const parsed = hireRequestSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + const invalidReference = findInvalidReference(parsed.companyId, [ + { id: parsed.requestedByAgentId, table: "agents", label: "requesting agent" }, + { id: parsed.reportsTo, table: "agents", label: "manager" }, + { id: parsed.workspaceId, table: "workspaces", label: "workspace" }, + ]); + if (invalidReference) { + return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`); + } + const connectorRecord = (() => { + try { + return db.getConnector(parsed.connectorId); + } catch { + return null; + } + })(); + if (!connectorRecord) { + return fail("CONNECTOR_NOT_FOUND", "The selected connector does not exist."); + } + const connectorIssue = getConnectorExecutionReadinessIssue(connectorRecord); + if (connectorIssue) { + return fail("CONNECTOR_NOT_READY", connectorIssue); + } + const modelSelection = validateConnectorModelSelection(db, parsed.connectorId, parsed.model ?? null); + if (!modelSelection.ok) { + return fail("CONNECTOR_MODEL_NOT_ALLOWED", modelSelection.error); + } + const managerLabel = parsed.reportsTo + ? (() => { + try { + return db.getAgent(parsed.reportsTo).name; + } catch { + return parsed.reportsTo; + } + })() + : "the company board"; + const payloadSummary = `Hire ${parsed.name} as ${parsed.role}`; + const impactSummary = parsed.capabilities || `${parsed.name} will report to ${managerLabel}.`; + let result: { agentId: string; approvalId: string }; + try { + result = db.createHireRequest({ + companyId: parsed.companyId, + requestedByAgentId: parsed.requestedByAgentId ?? null, + name: parsed.name, + role: parsed.role, + title: parsed.title, + department: parsed.department ?? null, + reportsTo: parsed.reportsTo ?? null, + connectorId: parsed.connectorId, + workspaceId: parsed.workspaceId ?? null, + model: parsed.model ?? null, + metadataJson: parsed.metadataJson, + capabilities: parsed.capabilities, + budgetMonthlyUsd: parsed.budgetMonthlyUsd, + payloadSummary, + impactSummary, + payloadJson: JSON.stringify({ + requestedByAgentId: parsed.requestedByAgentId ?? null, + requestedConfigurationSnapshot: { + connectorId: parsed.connectorId, + workspaceId: parsed.workspaceId ?? null, + model: parsed.model ?? null, + metadataJson: parsed.metadataJson ?? "{}", + capabilities: parsed.capabilities, + budgetMonthlyUsd: parsed.budgetMonthlyUsd, + reportsTo: parsed.reportsTo ?? null, + department: parsed.department ?? null, + title: parsed.title, + }, + }), + }); + } catch (error) { + return fail("HIRE_REQUEST_FAILED", error instanceof Error ? error.message : "Hire request could not be created."); + } + + db.addActivity({ + companyId: parsed.companyId, + actor: parsed.requestedByAgentId ?? "board", + action: "agent.hire_requested", + entityType: "agent", + entityId: result.agentId, + detail: parsed.name, + }); + + let autoApproved = false; + if (shouldAutoApproveApproval(db, parsed.companyId, "hire_agent")) { + resolveApprovalDecision({ + db, + handleApprovalResolved, + companyId: parsed.companyId, + approvalId: result.approvalId, + state: "approved", + decisionNote: "Auto-approved by company policy", + detail: "Auto-approved by company policy", + }); + autoApproved = true; + } + + publishDomainChanged(); + return ok({ ...result, autoApproved }); + }); + + registerHandle(IPC_CHANNELS.deleteAgent, async (_event, payload) => { + const parsed = deleteEntitySchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + if (!db.belongsToCompany("agents", parsed.id, parsed.companyId)) { + return fail("AGENT_NOT_FOUND", "Agent not found in the current company."); + } + const agent = db.getAgent(parsed.id); + if (agent.status === "pending_approval") { + return fail( + "AGENT_APPROVAL_REQUIRED", + "Pending hires cannot be deleted from the agent editor. Reject the hire request from Hiring or Approvals instead.", + ); + } + db.deleteAgent(parsed.id, parsed.companyId); + db.addActivity({ + companyId: parsed.companyId, + actor: "board", + action: "agent.deleted", + entityType: "agent", + entityId: parsed.id, + detail: "", + }); + publishDomainChanged(); + return ok(true); + }); + + registerHandle(IPC_CHANNELS.deleteApproval, async (_event, payload) => { + const parsed = deleteEntitySchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + if (!db.belongsToCompany("approvals", parsed.id, parsed.companyId)) { + return fail("APPROVAL_NOT_FOUND", "Approval not found in the current company."); + } + const approval = db.getApprovalRecord(parsed.id); + if (approval.state === "pending" && approval.type === "hire_agent") { + return fail( + "APPROVAL_DELETE_BLOCKED", + "Pending hire approvals cannot be deleted. Reject the hire request instead so the linked agent record stays consistent.", + ); + } + db.deleteApproval(parsed.id, parsed.companyId); + db.addActivity({ + companyId: parsed.companyId, + actor: "board", + action: "approval.deleted", + entityType: "approval", + entityId: parsed.id, + detail: "", + }); + publishDomainChanged(); + return ok(true); + }); +} diff --git a/src/main/ipc-core-common.ts b/src/main/ipc-core-common.ts index e98f1da3..f85e3912 100644 --- a/src/main/ipc-core-common.ts +++ b/src/main/ipc-core-common.ts @@ -21,6 +21,7 @@ export interface RegisterCoreHandlersDependencies { wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null; handleTaskCreated: (taskId: string, companyId: string) => void; handleTaskStatusChange: (taskId: string, companyId: string, previousStatus: string, newStatus: string) => void; + handleApprovalCreated: (approvalId: string, companyId: string, taskId: string | null, approvalType: string, summary: string) => void; handleApprovalResolved: (approvalId: string, companyId: string, decision: string) => void; dispatchAutomation: (trigger: string, ctx: { companyId: string; [key: string]: unknown }) => void; queueTaskRun: (taskId: string, trigger: string, actor: string) => DesktopResult; diff --git a/src/main/ipc-core-handlers.ts b/src/main/ipc-core-handlers.ts index f799f850..ff7fe3cb 100644 --- a/src/main/ipc-core-handlers.ts +++ b/src/main/ipc-core-handlers.ts @@ -3,10 +3,12 @@ import { registerCoreAdminHandlers } from "./ipc-core-admin-handlers"; import { registerCoreOperationHandlers } from "./ipc-core-operations-handlers"; import { registerCoreReadHandlers } from "./ipc-core-read-handlers"; import { registerCoreWorkGraphHandlers } from "./ipc-core-work-graph-handlers"; +import { registerThreadHandlers } from "./ipc-thread-handlers"; export function registerCoreHandlers(dependencies: RegisterCoreHandlersDependencies) { registerCoreReadHandlers(dependencies); registerCoreAdminHandlers(dependencies); registerCoreWorkGraphHandlers(dependencies); registerCoreOperationHandlers(dependencies); + registerThreadHandlers(dependencies); } diff --git a/src/main/ipc-core-operations-handlers.ts b/src/main/ipc-core-operations-handlers.ts index d5bd91da..26a592ee 100644 --- a/src/main/ipc-core-operations-handlers.ts +++ b/src/main/ipc-core-operations-handlers.ts @@ -31,6 +31,7 @@ export function registerCoreOperationHandlers({ publishDomainChanged, emitEvent, wakeAgentIfPossible, + handleApprovalCreated, handleApprovalResolved, dispatchAutomation, queueTaskRun, @@ -44,6 +45,7 @@ export function registerCoreOperationHandlers({ | "publishDomainChanged" | "emitEvent" | "wakeAgentIfPossible" + | "handleApprovalCreated" | "handleApprovalResolved" | "dispatchAutomation" | "queueTaskRun" @@ -74,6 +76,10 @@ export function registerCoreOperationHandlers({ entityId: id, detail: parsed.payloadSummary, }); + + // Notify thread integration about the new approval + handleApprovalCreated(id, parsed.companyId, parsed.relatedTaskId ?? null, parsed.type, parsed.payloadSummary ?? ""); + if (shouldAutoApproveApproval(db, parsed.companyId, parsed.type)) { resolveApprovalDecision({ db, diff --git a/src/main/ipc-core-read-handlers.ts b/src/main/ipc-core-read-handlers.ts index defc4044..a8ac4d81 100644 --- a/src/main/ipc-core-read-handlers.ts +++ b/src/main/ipc-core-read-handlers.ts @@ -1,4 +1,4 @@ -import { dialog, shell } from "electron"; +import { dialog, nativeTheme, shell } from "electron"; import { join } from "node:path"; import { z } from "zod"; import { @@ -83,6 +83,9 @@ export function registerCoreReadHandlers({ registerHandle(IPC_CHANNELS.updateSettings, async (_event, payload) => { const parsed = settingsSchema.parse(payload); db.updateSettings(parsed); + if (parsed.theme) { + nativeTheme.themeSource = parsed.theme; + } publishDomainChanged(); return ok(true); }); diff --git a/src/main/ipc-core-task-graph-handlers.ts b/src/main/ipc-core-task-graph-handlers.ts new file mode 100644 index 00000000..25dd10a3 --- /dev/null +++ b/src/main/ipc-core-task-graph-handlers.ts @@ -0,0 +1,195 @@ +import { + IPC_CHANNELS, + deleteEntitySchema, + goalInputSchema, + projectInputSchema, + taskInputSchema, +} from "@shared/contracts"; +import { fail, ok, type RegisterCoreHandlersDependencies } from "./ipc-core-common"; +import { validateTaskCreation, checkCapabilityMatch } from "./task-quality-gates"; + +export function registerCoreTaskGraphHandlers({ + registerHandle, + db, + ensureCompanyExists, + findInvalidReference, + publishDomainChanged, + emitEvent, + wakeAgentIfPossible, + handleTaskCreated, + handleTaskStatusChange, +}: Pick< + RegisterCoreHandlersDependencies, + | "registerHandle" + | "db" + | "ensureCompanyExists" + | "findInvalidReference" + | "publishDomainChanged" + | "emitEvent" + | "wakeAgentIfPossible" + | "handleTaskCreated" + | "handleTaskStatusChange" +>) { + registerHandle(IPC_CHANNELS.saveGoal, async (_event, payload) => { + const parsed = goalInputSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + const invalidReference = findInvalidReference(parsed.companyId, [ + { id: parsed.parentId, table: "goals", label: "parent goal" }, + { id: parsed.ownerAgentId, table: "agents", label: "owner agent" }, + ]); + if (invalidReference) { + return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`); + } + const id = db.saveGoal(parsed); + db.addActivity({ + companyId: parsed.companyId, + actor: "board", + action: parsed.id ? "goal.updated" : "goal.created", + entityType: "goal", + entityId: id, + detail: parsed.title, + }); + publishDomainChanged(); + if (parsed.status === "active" && parsed.ownerAgentId) { + wakeAgentIfPossible(parsed.ownerAgentId, parsed.companyId, "goal_activated"); + } + return ok(id); + }); + + registerHandle(IPC_CHANNELS.saveProject, async (_event, payload) => { + const parsed = projectInputSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + const invalidReference = findInvalidReference(parsed.companyId, [ + { id: parsed.goalId, table: "goals", label: "goal" }, + { id: parsed.leadAgentId, table: "agents", label: "lead agent" }, + ]); + if (invalidReference) { + return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`); + } + const id = db.saveProject(parsed); + db.addActivity({ + companyId: parsed.companyId, + actor: "board", + action: parsed.id ? "project.updated" : "project.created", + entityType: "project", + entityId: id, + detail: parsed.name, + }); + if (parsed.leadAgentId) { + wakeAgentIfPossible(parsed.leadAgentId, parsed.companyId, "assignment"); + } + publishDomainChanged(); + return ok(id); + }); + + registerHandle(IPC_CHANNELS.saveTask, async (_event, payload) => { + const parsed = taskInputSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + const invalidReference = findInvalidReference(parsed.companyId, [ + { id: parsed.projectId, table: "projects", label: "project" }, + { id: parsed.goalId, table: "goals", label: "goal" }, + { id: parsed.parentId, table: "tasks", label: "parent task" }, + { id: parsed.assigneeAgentId, table: "agents", label: "assignee agent" }, + { id: parsed.workspaceId, table: "workspaces", label: "workspace" }, + ]); + if (invalidReference) { + return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`); + } + const isNew = !parsed.id; + if (isNew) { + const isHeartbeat = parsed.title.startsWith("[Heartbeat]"); + const validation = validateTaskCreation(parsed, isHeartbeat); + if (!validation.valid) { + return fail("TASK_VALIDATION_FAILED", validation.errors.join("; ")); + } + } + let previousStatus: string | null = null; + let previousAssigneeAgentId: string | null = null; + if (!isNew) { + try { + const existingTask = db.getTask(parsed.id!); + previousStatus = existingTask.status; + previousAssigneeAgentId = existingTask.assigneeAgentId; + } catch { + previousStatus = null; + previousAssigneeAgentId = null; + } + } + let id: string; + try { + id = db.saveTask(parsed); + } catch (error) { + return fail("INVALID_TASK_STATE", error instanceof Error ? error.message : "Task could not be saved."); + } + db.addActivity({ + companyId: parsed.companyId, + actor: "board", + action: isNew ? "task.created" : "task.updated", + entityType: "task", + entityId: id, + detail: parsed.title, + }); + if (isNew) { + handleTaskCreated(id, parsed.companyId); + } else { + if (previousStatus && previousStatus !== parsed.status) { + handleTaskStatusChange(id, parsed.companyId, previousStatus, parsed.status); + } + // User (board) manually advancing a flagged task to done: clear the review flag + if (parsed.status === "done") { + try { db.setTaskRequiresUserReview(id, false); } catch { /* best effort */ } + } + // Wake new assignee when task is reassigned (matches api-server.ts behavior) + if (parsed.assigneeAgentId && parsed.assigneeAgentId !== previousAssigneeAgentId) { + wakeAgentIfPossible(parsed.assigneeAgentId, parsed.companyId, "assignment"); + } + } + let capabilityWarning: string | undefined; + if (isNew && parsed.assigneeAgentId) { + try { + const match = checkCapabilityMatch(db, parsed.assigneeAgentId, parsed.description, parsed.companyId); + if (!match.matched) { + capabilityWarning = match.warning; + } + } catch { /* capability check is advisory — never block task creation */ } + } + emitEvent({ type: "task-updated", taskId: id, action: isNew ? "created" : "updated" }); + return ok({ id, capabilityWarning }); + }); + + const deleteHandlers = [ + { channel: IPC_CHANNELS.deleteTask, table: "tasks", entity: "task", deleteFn: (id: string, companyId: string) => db.deleteTask(id, companyId) }, + { channel: IPC_CHANNELS.deleteGoal, table: "goals", entity: "goal", deleteFn: (id: string, companyId: string) => db.deleteGoal(id, companyId) }, + { channel: IPC_CHANNELS.deleteProject, table: "projects", entity: "project", deleteFn: (id: string, companyId: string) => db.deleteProject(id, companyId) }, + { channel: IPC_CHANNELS.deleteWorkspace, table: "workspaces", entity: "workspace", deleteFn: (id: string, companyId: string) => db.deleteWorkspace(id, companyId) }, + ] as const; + + for (const { channel, table, entity, deleteFn } of deleteHandlers) { + registerHandle(channel, async (_event, payload) => { + const parsed = deleteEntitySchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + if (!db.belongsToCompany(table, parsed.id, parsed.companyId)) { + return fail(`${entity.toUpperCase()}_NOT_FOUND`, `${entity.charAt(0).toUpperCase() + entity.slice(1)} not found in the current company.`); + } + deleteFn(parsed.id, parsed.companyId); + db.addActivity({ + companyId: parsed.companyId, + actor: "board", + action: `${entity}.deleted`, + entityType: entity, + entityId: parsed.id, + detail: "", + }); + if (entity === "task") { + emitEvent({ type: "task-updated", taskId: parsed.id, action: "deleted" }); + } else { + publishDomainChanged(); + } + return ok(true); + }); + } +} diff --git a/src/main/ipc-core-work-graph-handlers.ts b/src/main/ipc-core-work-graph-handlers.ts index 8b907ed8..69e2eac8 100644 --- a/src/main/ipc-core-work-graph-handlers.ts +++ b/src/main/ipc-core-work-graph-handlers.ts @@ -1,467 +1,39 @@ -import { - IPC_CHANNELS, - agentInputSchema, - deleteEntitySchema, - goalInputSchema, - hireRequestSchema, - projectInputSchema, - taskInputSchema, - workspaceInputSchema, -} from "@shared/contracts"; -import { getConnectorExecutionReadinessIssue } from "@shared/connector-policy"; -import { resolveApprovalDecision, shouldAutoApproveApproval } from "./approval-policy"; -import { fail, ok, type RegisterCoreHandlersDependencies } from "./ipc-core-common"; -import { validateTaskCreation, checkCapabilityMatch } from "./task-quality-gates"; +import type { RegisterCoreHandlersDependencies } from "./ipc-core-common"; +import { registerCoreAgentGraphHandlers } from "./ipc-core-agent-graph-handlers"; +import { registerCoreTaskGraphHandlers } from "./ipc-core-task-graph-handlers"; -export function registerCoreWorkGraphHandlers({ - registerHandle, - db, - ensureCompanyExists, - findInvalidReference, - publishDomainChanged, - emitEvent, - wakeAgentIfPossible, - handleApprovalResolved, - handleTaskCreated, - handleTaskStatusChange, -}: Pick< - RegisterCoreHandlersDependencies, - | "registerHandle" - | "db" - | "ensureCompanyExists" - | "findInvalidReference" - | "publishDomainChanged" - | "emitEvent" - | "wakeAgentIfPossible" - | "handleApprovalResolved" - | "handleTaskCreated" - | "handleTaskStatusChange" ->) { - registerHandle(IPC_CHANNELS.saveWorkspace, async (_event, payload) => { - const parsed = workspaceInputSchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - const invalidReference = findInvalidReference(parsed.companyId, [ - { id: parsed.projectId, table: "projects", label: "project" }, - ]); - if (invalidReference) { - return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`); - } - const id = db.saveWorkspace(parsed); - db.addActivity({ - companyId: parsed.companyId, - actor: "board", - action: parsed.id ? "workspace.updated" : "workspace.created", - entityType: "workspace", - entityId: id, - detail: parsed.localPath, - }); - publishDomainChanged(); - return ok(id); +export function registerCoreWorkGraphHandlers( + deps: Pick< + RegisterCoreHandlersDependencies, + | "registerHandle" + | "db" + | "ensureCompanyExists" + | "findInvalidReference" + | "publishDomainChanged" + | "emitEvent" + | "wakeAgentIfPossible" + | "handleApprovalResolved" + | "handleTaskCreated" + | "handleTaskStatusChange" + >, +) { + registerCoreAgentGraphHandlers({ + registerHandle: deps.registerHandle, + db: deps.db, + ensureCompanyExists: deps.ensureCompanyExists, + findInvalidReference: deps.findInvalidReference, + publishDomainChanged: deps.publishDomainChanged, + handleApprovalResolved: deps.handleApprovalResolved, }); - - registerHandle(IPC_CHANNELS.saveAgent, async (_event, payload) => { - const parsed = agentInputSchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - const isNew = !parsed.id; - if (isNew) { - return fail( - "AGENT_CREATION_REQUIRES_HIRE_REQUEST", - "Create new agents through the hiring workflow so approval, onboarding, and org state stay synchronized.", - ); - } - const existingAgent = (() => { - try { - return db.getAgent(parsed.id!); - } catch { - return null; - } - })(); - if (!existingAgent || existingAgent.companyId !== parsed.companyId) { - return fail("AGENT_NOT_FOUND", "Agent not found in the current company."); - } - if (existingAgent.status === "pending_approval") { - return fail( - "AGENT_APPROVAL_REQUIRED", - "Pending hires must be resolved from Hiring or Approvals. Direct edits, deletes, and activation changes are blocked here.", - ); - } - if (parsed.status === "pending_approval") { - return fail( - "AGENT_STATUS_LOCKED", - "Active agents cannot be moved back to pending approval by editing status directly.", - ); - } - const invalidReference = findInvalidReference(parsed.companyId, [ - { id: parsed.reportsTo, table: "agents", label: "manager" }, - { id: parsed.workspaceId, table: "workspaces", label: "workspace" }, - ]); - if (invalidReference) { - return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`); - } - const connectorRecord = (() => { - try { - return db.getConnector(parsed.connectorId); - } catch { - return null; - } - })(); - if (!connectorRecord) { - return fail("CONNECTOR_NOT_FOUND", "The selected connector does not exist."); - } - const connectorIssue = getConnectorExecutionReadinessIssue(connectorRecord); - if (connectorIssue) { - return fail("CONNECTOR_NOT_READY", connectorIssue); - } - let id: string; - try { - id = db.saveAgent({ - id: parsed.id ?? null, - companyId: parsed.companyId, - name: parsed.name, - role: parsed.role, - title: parsed.title, - department: parsed.department ?? null, - status: parsed.status, - reportsTo: parsed.reportsTo ?? null, - connectorId: parsed.connectorId, - workspaceId: parsed.workspaceId ?? null, - model: parsed.model ?? null, - metadataJson: parsed.metadataJson, - capabilities: parsed.capabilities, - budgetMonthlyUsd: parsed.budgetMonthlyUsd, - }); - } catch (error) { - return fail("INVALID_AGENT_HIERARCHY", error instanceof Error ? error.message : "Agent could not be saved."); - } - db.addActivity({ - companyId: parsed.companyId, - actor: "board", - action: parsed.id ? "agent.updated" : "agent.created", - entityType: "agent", - entityId: id, - detail: parsed.name, - }); - publishDomainChanged(); - return ok(id); + registerCoreTaskGraphHandlers({ + registerHandle: deps.registerHandle, + db: deps.db, + ensureCompanyExists: deps.ensureCompanyExists, + findInvalidReference: deps.findInvalidReference, + publishDomainChanged: deps.publishDomainChanged, + emitEvent: deps.emitEvent, + wakeAgentIfPossible: deps.wakeAgentIfPossible, + handleTaskCreated: deps.handleTaskCreated, + handleTaskStatusChange: deps.handleTaskStatusChange, }); - - registerHandle(IPC_CHANNELS.requestHire, async (_event, payload) => { - const parsed = hireRequestSchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - const invalidReference = findInvalidReference(parsed.companyId, [ - { id: parsed.requestedByAgentId, table: "agents", label: "requesting agent" }, - { id: parsed.reportsTo, table: "agents", label: "manager" }, - { id: parsed.workspaceId, table: "workspaces", label: "workspace" }, - ]); - if (invalidReference) { - return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`); - } - const connectorRecord = (() => { - try { - return db.getConnector(parsed.connectorId); - } catch { - return null; - } - })(); - if (!connectorRecord) { - return fail("CONNECTOR_NOT_FOUND", "The selected connector does not exist."); - } - const connectorIssue = getConnectorExecutionReadinessIssue(connectorRecord); - if (connectorIssue) { - return fail("CONNECTOR_NOT_READY", connectorIssue); - } - const managerLabel = parsed.reportsTo - ? (() => { - try { - return db.getAgent(parsed.reportsTo).name; - } catch { - return parsed.reportsTo; - } - })() - : "the company board"; - const payloadSummary = `Hire ${parsed.name} as ${parsed.role}`; - const impactSummary = parsed.capabilities || `${parsed.name} will report to ${managerLabel}.`; - let result: { agentId: string; approvalId: string }; - try { - result = db.createHireRequest({ - companyId: parsed.companyId, - requestedByAgentId: parsed.requestedByAgentId ?? null, - name: parsed.name, - role: parsed.role, - title: parsed.title, - department: parsed.department ?? null, - reportsTo: parsed.reportsTo ?? null, - connectorId: parsed.connectorId, - workspaceId: parsed.workspaceId ?? null, - model: parsed.model ?? null, - metadataJson: parsed.metadataJson, - capabilities: parsed.capabilities, - budgetMonthlyUsd: parsed.budgetMonthlyUsd, - payloadSummary, - impactSummary, - payloadJson: JSON.stringify({ - requestedByAgentId: parsed.requestedByAgentId ?? null, - requestedConfigurationSnapshot: { - connectorId: parsed.connectorId, - workspaceId: parsed.workspaceId ?? null, - model: parsed.model ?? null, - metadataJson: parsed.metadataJson ?? "{}", - capabilities: parsed.capabilities, - budgetMonthlyUsd: parsed.budgetMonthlyUsd, - reportsTo: parsed.reportsTo ?? null, - department: parsed.department ?? null, - title: parsed.title, - }, - }), - }); - } catch (error) { - return fail("HIRE_REQUEST_FAILED", error instanceof Error ? error.message : "Hire request could not be created."); - } - - db.addActivity({ - companyId: parsed.companyId, - actor: parsed.requestedByAgentId ?? "board", - action: "agent.hire_requested", - entityType: "agent", - entityId: result.agentId, - detail: parsed.name, - }); - - let autoApproved = false; - if (shouldAutoApproveApproval(db, parsed.companyId, "hire_agent")) { - resolveApprovalDecision({ - db, - handleApprovalResolved, - companyId: parsed.companyId, - approvalId: result.approvalId, - state: "approved", - decisionNote: "Auto-approved by company policy", - detail: "Auto-approved by company policy", - }); - autoApproved = true; - } - - publishDomainChanged(); - return ok({ ...result, autoApproved }); - }); - - registerHandle(IPC_CHANNELS.saveGoal, async (_event, payload) => { - const parsed = goalInputSchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - const invalidReference = findInvalidReference(parsed.companyId, [ - { id: parsed.parentId, table: "goals", label: "parent goal" }, - { id: parsed.ownerAgentId, table: "agents", label: "owner agent" }, - ]); - if (invalidReference) { - return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`); - } - const id = db.saveGoal(parsed); - db.addActivity({ - companyId: parsed.companyId, - actor: "board", - action: parsed.id ? "goal.updated" : "goal.created", - entityType: "goal", - entityId: id, - detail: parsed.title, - }); - publishDomainChanged(); - if (parsed.status === "active" && parsed.ownerAgentId) { - wakeAgentIfPossible(parsed.ownerAgentId, parsed.companyId, "goal_activated"); - } - return ok(id); - }); - - registerHandle(IPC_CHANNELS.saveProject, async (_event, payload) => { - const parsed = projectInputSchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - const invalidReference = findInvalidReference(parsed.companyId, [ - { id: parsed.goalId, table: "goals", label: "goal" }, - { id: parsed.leadAgentId, table: "agents", label: "lead agent" }, - ]); - if (invalidReference) { - return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`); - } - const id = db.saveProject(parsed); - db.addActivity({ - companyId: parsed.companyId, - actor: "board", - action: parsed.id ? "project.updated" : "project.created", - entityType: "project", - entityId: id, - detail: parsed.name, - }); - if (parsed.leadAgentId) { - wakeAgentIfPossible(parsed.leadAgentId, parsed.companyId, "assignment"); - } - publishDomainChanged(); - return ok(id); - }); - - registerHandle(IPC_CHANNELS.saveTask, async (_event, payload) => { - const parsed = taskInputSchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - const invalidReference = findInvalidReference(parsed.companyId, [ - { id: parsed.projectId, table: "projects", label: "project" }, - { id: parsed.goalId, table: "goals", label: "goal" }, - { id: parsed.parentId, table: "tasks", label: "parent task" }, - { id: parsed.assigneeAgentId, table: "agents", label: "assignee agent" }, - { id: parsed.workspaceId, table: "workspaces", label: "workspace" }, - ]); - if (invalidReference) { - return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`); - } - const isNew = !parsed.id; - if (isNew) { - const isHeartbeat = parsed.title.startsWith("[Heartbeat]"); - const validation = validateTaskCreation(parsed, isHeartbeat); - if (!validation.valid) { - return fail("TASK_VALIDATION_FAILED", validation.errors.join("; ")); - } - } - let previousStatus: string | null = null; - let previousAssigneeAgentId: string | null = null; - if (!isNew) { - try { - const existingTask = db.getTask(parsed.id!); - previousStatus = existingTask.status; - previousAssigneeAgentId = existingTask.assigneeAgentId; - } catch { - previousStatus = null; - previousAssigneeAgentId = null; - } - } - let id: string; - try { - id = db.saveTask(parsed); - } catch (error) { - return fail("INVALID_TASK_STATE", error instanceof Error ? error.message : "Task could not be saved."); - } - db.addActivity({ - companyId: parsed.companyId, - actor: "board", - action: isNew ? "task.created" : "task.updated", - entityType: "task", - entityId: id, - detail: parsed.title, - }); - if (isNew) { - handleTaskCreated(id, parsed.companyId); - } else { - if (previousStatus && previousStatus !== parsed.status) { - handleTaskStatusChange(id, parsed.companyId, previousStatus, parsed.status); - } - // User (board) manually advancing a flagged task to done: clear the review flag - if (parsed.status === "done") { - try { db.setTaskRequiresUserReview(id, false); } catch { /* best effort */ } - } - // Wake new assignee when task is reassigned (matches api-server.ts behavior) - if (parsed.assigneeAgentId && parsed.assigneeAgentId !== previousAssigneeAgentId) { - wakeAgentIfPossible(parsed.assigneeAgentId, parsed.companyId, "assignment"); - } - } - let capabilityWarning: string | undefined; - if (isNew && parsed.assigneeAgentId) { - try { - const match = checkCapabilityMatch(db, parsed.assigneeAgentId, parsed.description, parsed.companyId); - if (!match.matched) { - capabilityWarning = match.warning; - } - } catch { /* capability check is advisory — never block task creation */ } - } - emitEvent({ type: "task-updated", taskId: id, action: isNew ? "created" : "updated" }); - return ok({ id, capabilityWarning }); - }); - - registerHandle(IPC_CHANNELS.deleteAgent, async (_event, payload) => { - const parsed = deleteEntitySchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - if (!db.belongsToCompany("agents", parsed.id, parsed.companyId)) { - return fail("AGENT_NOT_FOUND", "Agent not found in the current company."); - } - const agent = db.getAgent(parsed.id); - if (agent.status === "pending_approval") { - return fail( - "AGENT_APPROVAL_REQUIRED", - "Pending hires cannot be deleted from the agent editor. Reject the hire request from Hiring or Approvals instead.", - ); - } - db.deleteAgent(parsed.id, parsed.companyId); - db.addActivity({ - companyId: parsed.companyId, - actor: "board", - action: "agent.deleted", - entityType: "agent", - entityId: parsed.id, - detail: "", - }); - publishDomainChanged(); - return ok(true); - }); - - registerHandle(IPC_CHANNELS.deleteApproval, async (_event, payload) => { - const parsed = deleteEntitySchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - if (!db.belongsToCompany("approvals", parsed.id, parsed.companyId)) { - return fail("APPROVAL_NOT_FOUND", "Approval not found in the current company."); - } - const approval = db.getApprovalRecord(parsed.id); - if (approval.state === "pending" && approval.type === "hire_agent") { - return fail( - "APPROVAL_DELETE_BLOCKED", - "Pending hire approvals cannot be deleted. Reject the hire request instead so the linked agent record stays consistent.", - ); - } - db.deleteApproval(parsed.id, parsed.companyId); - db.addActivity({ - companyId: parsed.companyId, - actor: "board", - action: "approval.deleted", - entityType: "approval", - entityId: parsed.id, - detail: "", - }); - publishDomainChanged(); - return ok(true); - }); - - const deleteHandlers = [ - { channel: IPC_CHANNELS.deleteTask, table: "tasks", entity: "task", deleteFn: (id: string, companyId: string) => db.deleteTask(id, companyId) }, - { channel: IPC_CHANNELS.deleteGoal, table: "goals", entity: "goal", deleteFn: (id: string, companyId: string) => db.deleteGoal(id, companyId) }, - { channel: IPC_CHANNELS.deleteProject, table: "projects", entity: "project", deleteFn: (id: string, companyId: string) => db.deleteProject(id, companyId) }, - { channel: IPC_CHANNELS.deleteWorkspace, table: "workspaces", entity: "workspace", deleteFn: (id: string, companyId: string) => db.deleteWorkspace(id, companyId) }, - ] as const; - - for (const { channel, table, entity, deleteFn } of deleteHandlers) { - registerHandle(channel, async (_event, payload) => { - const parsed = deleteEntitySchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - if (!db.belongsToCompany(table, parsed.id, parsed.companyId)) { - return fail(`${entity.toUpperCase()}_NOT_FOUND`, `${entity.charAt(0).toUpperCase() + entity.slice(1)} not found in the current company.`); - } - deleteFn(parsed.id, parsed.companyId); - db.addActivity({ - companyId: parsed.companyId, - actor: "board", - action: `${entity}.deleted`, - entityType: entity, - entityId: parsed.id, - detail: "", - }); - if (entity === "task") { - emitEvent({ type: "task-updated", taskId: parsed.id, action: "deleted" }); - } else { - publishDomainChanged(); - } - return ok(true); - }); - } } diff --git a/src/main/ipc-domain-common.ts b/src/main/ipc-domain-common.ts index 76ee1efb..dbc0f08c 100644 --- a/src/main/ipc-domain-common.ts +++ b/src/main/ipc-domain-common.ts @@ -2,7 +2,7 @@ import type { IpcMainInvokeEvent } from "electron"; import { z } from "zod"; import type { DesktopResult } from "@shared/contracts"; import type { bootstrapOnboardingSchema } from "@shared/contracts"; -import type { DesktopEvent } from "@shared/types"; +import type { DesktopEvent, SectionId } from "@shared/types"; import type { AppDatabase } from "./database"; import type { createSkillService } from "./skill-service"; import type { createBudgetEnforcementService } from "./budget-enforcement-service"; @@ -35,6 +35,7 @@ export interface RegisterDomainHandlersDependencies { ) => { id: string | null | undefined; table: string; label: string } | null; publishDomainChanged: () => void; emitEvent: (event: DesktopEvent) => void; + notify: (options: { title: string; body: string; urgency: "critical" | "informational"; navigation?: { section: SectionId; entityId?: string }; batchKey?: string }) => void; wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null; dispatchAutomation: (trigger: string, ctx: { companyId: string; [key: string]: unknown }) => void; runWorkflowPipeline: (workflowId: string, companyId: string) => Promise; @@ -43,6 +44,8 @@ export interface RegisterDomainHandlersDependencies { randomFallbackCompanyId: () => string; skillService: ReturnType | null; budgetService: ReturnType | null; + /** Called when a human sends a message to resolve thread wait states. */ + onHumanMessage?: (companyId: string, toAgentId?: string | null, threadId?: string | null, waitStateId?: string | null) => void; } export function ok(data: T): DesktopResult { diff --git a/src/main/ipc-recovery-handlers.ts b/src/main/ipc-recovery-handlers.ts index 34080a83..335a640e 100644 --- a/src/main/ipc-recovery-handlers.ts +++ b/src/main/ipc-recovery-handlers.ts @@ -1,5 +1,6 @@ import { z } from "zod"; -import { ok, fail, type RegisterDomainHandlersDependencies } from "./ipc-domain-common"; +import { IPC_CHANNELS } from "@shared/contracts"; +import { ok, type RegisterDomainHandlersDependencies } from "./ipc-domain-common"; const recoveryRetrySchema = z.object({ taskId: z.string(), @@ -23,7 +24,7 @@ export function registerRecoveryHandlers({ | "publishDomainChanged" | "wakeAgentIfPossible" >) { - registerHandle("recovery:retry", async (_event, payload) => { + registerHandle(IPC_CHANNELS.recoveryRetry, async (_event, payload) => { const parsed = recoveryRetrySchema.parse(payload); db.retryInterruptedTask(parsed.taskId, parsed.agentId, parsed.companyId); try { @@ -35,14 +36,14 @@ export function registerRecoveryHandlers({ return ok(null); }); - registerHandle("recovery:dismiss", async (_event, payload) => { + registerHandle(IPC_CHANNELS.recoveryDismiss, async (_event, payload) => { const parsed = recoveryTaskSchema.parse(payload); db.dismissInterruptedTask(parsed.taskId); publishDomainChanged(); return ok(null); }); - registerHandle("recovery:fail", async (_event, payload) => { + registerHandle(IPC_CHANNELS.recoveryFail, async (_event, payload) => { const parsed = recoveryTaskSchema.parse(payload); db.markInterruptedTaskFailed(parsed.taskId); publishDomainChanged(); diff --git a/src/main/ipc-thread-handlers.ts b/src/main/ipc-thread-handlers.ts new file mode 100644 index 00000000..fa966e19 --- /dev/null +++ b/src/main/ipc-thread-handlers.ts @@ -0,0 +1,269 @@ +import { + IPC_CHANNELS, + addThreadEventSchema, + addParticipantSchema, + createThreadSchema, + listParticipantsSchema, + listSubscriptionsSchema, + listSubscribedThreadsSchema, + listThreadEventsSchema, + listThreadWaitStatesSchema, + listThreadsSchema, + removeParticipantSchema, + resolveThreadWaitStateSchema, + setThreadWaitStateSchema, + subscribeThreadSchema, + threadGetterSchema, + unsubscribeThreadSchema, +} from "@shared/contracts"; +import type { RegisterCoreHandlersDependencies } from "./ipc-core-common"; +import { fail, ok } from "./ipc-core-common"; + +export function registerThreadHandlers({ registerHandle, db, ensureCompanyExists, publishDomainChanged, emitEvent }: RegisterCoreHandlersDependencies) { + registerHandle(IPC_CHANNELS.createThread, async (_event, payload) => { + try { + const input = createThreadSchema.parse(payload); + const companyError = ensureCompanyExists(input.companyId); + if (companyError) return companyError; + const thread = db.createThread({ + companyId: input.companyId, + title: input.title, + type: input.type, + relatedEntityType: input.relatedEntityType ?? null, + relatedEntityId: input.relatedEntityId ?? null, + }); + publishDomainChanged(); + return ok(thread); + } catch (error) { + return fail("THREAD_CREATE_FAILED", error instanceof Error ? error.message : "Failed to create thread"); + } + }); + + registerHandle(IPC_CHANNELS.listThreads, async (_event, payload) => { + try { + const input = listThreadsSchema.parse(payload); + const companyError = ensureCompanyExists(input.companyId); + if (companyError) return companyError; + const threads = db.listThreads({ + companyId: input.companyId, + type: input.type, + relatedEntityType: input.relatedEntityType, + relatedEntityId: input.relatedEntityId, + limit: input.limit, + offset: input.offset, + }); + return ok(threads); + } catch (error) { + return fail("THREAD_LIST_FAILED", error instanceof Error ? error.message : "Failed to list threads"); + } + }); + + registerHandle(IPC_CHANNELS.getThread, async (_event, payload) => { + try { + const input = threadGetterSchema.parse(payload); + const companyError = ensureCompanyExists(input.companyId); + if (companyError) return companyError; + const thread = db.getThread(input.threadId, input.companyId); + if (!thread) return fail("THREAD_NOT_FOUND", "Thread not found"); + return ok(thread); + } catch (error) { + return fail("THREAD_GET_FAILED", error instanceof Error ? error.message : "Failed to get thread"); + } + }); + + registerHandle(IPC_CHANNELS.addThreadEvent, async (_event, payload) => { + try { + const input = addThreadEventSchema.parse(payload); + const companyError = ensureCompanyExists(input.companyId); + if (companyError) return companyError; + const event = db.addThreadEvent({ + threadId: input.threadId, + actorId: input.actorId, + actorType: input.actorType, + eventType: input.eventType, + body: input.body, + metadataJson: input.metadataJson, + }); + publishDomainChanged(); + emitEvent({ type: "domain-changed" }); + return ok(event); + } catch (error) { + return fail("THREAD_EVENT_ADD_FAILED", error instanceof Error ? error.message : "Failed to add thread event"); + } + }); + + registerHandle(IPC_CHANNELS.listThreadEvents, async (_event, payload) => { + try { + const input = listThreadEventsSchema.parse(payload); + const companyError = ensureCompanyExists(input.companyId); + if (companyError) return companyError; + const events = db.listThreadEvents(input.threadId, input.companyId, { + limit: input.limit, + offset: input.offset, + }); + return ok(events); + } catch (error) { + return fail("THREAD_EVENTS_LIST_FAILED", error instanceof Error ? error.message : "Failed to list thread events"); + } + }); + + registerHandle(IPC_CHANNELS.setThreadWaitState, async (_event, payload) => { + try { + const input = setThreadWaitStateSchema.parse(payload); + const companyError = ensureCompanyExists(input.companyId); + if (companyError) return companyError; + const waitState = db.setThreadWaitState({ + threadId: input.threadId, + companyId: input.companyId, + waitingFor: input.waitingFor, + relatedApprovalId: input.relatedApprovalId ?? null, + reason: input.reason, + }); + publishDomainChanged(); + emitEvent({ + type: "thread-wait-state-updated", + threadId: input.threadId, + companyId: input.companyId, + waitStateId: waitState.id, + action: "created", + }); + return ok(waitState); + } catch (error) { + return fail("THREAD_WAIT_STATE_SET_FAILED", error instanceof Error ? error.message : "Failed to set thread wait state"); + } + }); + + registerHandle(IPC_CHANNELS.resolveThreadWaitState, async (_event, payload) => { + try { + const input = resolveThreadWaitStateSchema.parse(payload); + const companyError = ensureCompanyExists(input.companyId); + if (companyError) return companyError; + + // Query the wait state before resolving to capture its threadId for the event + const waitStates = db.listThreadWaitStates(input.companyId); + const target = waitStates.find((ws) => ws.id === input.waitStateId); + if (!target) return fail("THREAD_WAIT_STATE_NOT_FOUND", "Wait state not found"); + + db.resolveThreadWaitState(input.waitStateId, input.companyId); + publishDomainChanged(); + emitEvent({ + type: "thread-wait-state-updated", + threadId: target.threadId, + companyId: input.companyId, + waitStateId: input.waitStateId, + action: "resolved", + }); + return ok(true); + } catch (error) { + return fail("THREAD_WAIT_STATE_RESOLVE_FAILED", error instanceof Error ? error.message : "Failed to resolve thread wait state"); + } + }); + + registerHandle(IPC_CHANNELS.listThreadWaitStates, async (_event, payload) => { + try { + const input = listThreadWaitStatesSchema.parse(payload); + const companyError = ensureCompanyExists(input.companyId); + if (companyError) return companyError; + const waitStates = db.listThreadWaitStates(input.companyId, { resolvedOnly: input.resolvedOnly }); + return ok(waitStates); + } catch (error) { + return fail("THREAD_WAIT_STATES_LIST_FAILED", error instanceof Error ? error.message : "Failed to list thread wait states"); + } + }); + + registerHandle(IPC_CHANNELS.listParticipants, async (_event, payload) => { + try { + const input = listParticipantsSchema.parse(payload); + const thread = db.getThread(input.threadId, input.companyId); + if (!thread) return fail("THREAD_NOT_FOUND", "Thread not found in this company"); + const participants = db.listParticipants(input.threadId); + return ok(participants); + } catch (error) { + return fail("PARTICIPANTS_LIST_FAILED", error instanceof Error ? error.message : "Failed to list participants"); + } + }); + + registerHandle(IPC_CHANNELS.addParticipant, async (_event, payload) => { + try { + const input = addParticipantSchema.parse(payload); + const thread = db.getThread(input.threadId, input.companyId); + if (!thread) return fail("THREAD_NOT_FOUND", "Thread not found in this company"); + const participant = db.addParticipant({ + threadId: input.threadId, + participantId: input.participantId, + participantType: input.participantType, + role: input.role, + }); + publishDomainChanged(); + return ok(participant); + } catch (error) { + return fail("PARTICIPANT_ADD_FAILED", error instanceof Error ? error.message : "Failed to add participant"); + } + }); + + registerHandle(IPC_CHANNELS.removeParticipant, async (_event, payload) => { + try { + const input = removeParticipantSchema.parse(payload); + const thread = db.getThread(input.threadId, input.companyId); + if (!thread) return fail("THREAD_NOT_FOUND", "Thread not found in this company"); + db.removeParticipant(input.threadId, input.participantId); + publishDomainChanged(); + return ok(true); + } catch (error) { + return fail("PARTICIPANT_REMOVE_FAILED", error instanceof Error ? error.message : "Failed to remove participant"); + } + }); + + registerHandle(IPC_CHANNELS.subscribeThread, async (_event, payload) => { + try { + const input = subscribeThreadSchema.parse(payload); + const thread = db.getThread(input.threadId, input.companyId); + if (!thread) return fail("THREAD_NOT_FOUND", "Thread not found in this company"); + const subscription = db.subscribe({ + threadId: input.threadId, + subscriberId: input.subscriberId, + subscriberType: input.subscriberType, + notifyOn: input.notifyOn, + }); + publishDomainChanged(); + return ok(subscription); + } catch (error) { + return fail("THREAD_SUBSCRIBE_FAILED", error instanceof Error ? error.message : "Failed to subscribe to thread"); + } + }); + + registerHandle(IPC_CHANNELS.unsubscribeThread, async (_event, payload) => { + try { + const input = unsubscribeThreadSchema.parse(payload); + const thread = db.getThread(input.threadId, input.companyId); + if (!thread) return fail("THREAD_NOT_FOUND", "Thread not found in this company"); + db.unsubscribe(input.threadId, input.subscriberId); + publishDomainChanged(); + return ok(true); + } catch (error) { + return fail("THREAD_UNSUBSCRIBE_FAILED", error instanceof Error ? error.message : "Failed to unsubscribe from thread"); + } + }); + + registerHandle(IPC_CHANNELS.listSubscriptions, async (_event, payload) => { + try { + const input = listSubscriptionsSchema.parse(payload); + const thread = db.getThread(input.threadId, input.companyId); + if (!thread) return fail("THREAD_NOT_FOUND", "Thread not found in this company"); + const subscriptions = db.listSubscriptions(input.threadId); + return ok(subscriptions); + } catch (error) { + return fail("SUBSCRIPTIONS_LIST_FAILED", error instanceof Error ? error.message : "Failed to list subscriptions"); + } + }); + + registerHandle(IPC_CHANNELS.listSubscribedThreads, async (_event, payload) => { + try { + const input = listSubscribedThreadsSchema.parse(payload); + const threads = db.listSubscribedThreads(input.subscriberId, input.companyId); + return ok(threads); + } catch (error) { + return fail("SUBSCRIBED_THREADS_LIST_FAILED", error instanceof Error ? error.message : "Failed to list subscribed threads"); + } + }); +} diff --git a/src/main/main-runtime-support.ts b/src/main/main-runtime-support.ts index ab845560..2341cbf9 100644 --- a/src/main/main-runtime-support.ts +++ b/src/main/main-runtime-support.ts @@ -3,11 +3,13 @@ import type { AgentApiServer } from "./api-server"; import { initializeAgentTokenSigningKey } from "./agent-auth"; import { bootstrapApplication } from "./bootstrap-application"; import { openConnectorAuthTerminal as openConnectorAuthTerminalImpl, openGenericTerminal as openGenericTerminalImpl } from "./connector-auth-service"; -import { getConnectorDefinition } from "./connectors"; +import { getConnectorModelPolicy } from "./connector-model-policy"; +import type { ConnectorId } from "@shared/types"; import type { AppDatabase } from "./database"; import { seedDefaultAutomationRules as seedDefaultAutomationRulesImpl } from "./default-automation-rules"; import { registerCoreHandlers } from "./ipc-core-handlers"; import { registerDomainHandlers } from "./ipc-domain-handlers"; +import type { ThreadIntegrationService } from "./thread-integration"; type BootstrapResult = Awaited>; @@ -16,6 +18,7 @@ interface MainRuntimeState { apiServer: AgentApiServer | null; serviceGraph: BootstrapResult["serviceGraph"] | null; ipcSupport: BootstrapResult["ipcSupport"] | null; + threadIntegration: ThreadIntegrationService | null; } export function createMainRuntimeSupport(state: MainRuntimeState) { @@ -128,8 +131,13 @@ export function createMainRuntimeSupport(state: MainRuntimeState) { handleTaskCreated: (taskId, companyId) => requireAutomationService().handleTaskCreated(taskId, companyId), handleTaskStatusChange: (taskId, companyId, previousStatus, newStatus) => requireAutomationService().handleTaskStatusChange(taskId, companyId, previousStatus, newStatus), - handleApprovalResolved: (approvalId, companyId, decision) => - requireAutomationService().handleApprovalResolved(approvalId, companyId, decision), + handleApprovalCreated: (approvalId, companyId, taskId, approvalType, summary) => { + state.threadIntegration!.onApprovalCreated(approvalId, companyId, taskId, approvalType, summary); + }, + handleApprovalResolved: (approvalId, companyId, decision) => { + requireAutomationService().handleApprovalResolved(approvalId, companyId, decision); + state.threadIntegration?.onApprovalResolved(approvalId, companyId, decision); + }, dispatchAutomation: (trigger, ctx) => requireAutomationService().dispatchAutomation(trigger, ctx), queueTaskRun: (taskId, trigger, actor) => requireOrchestrationService().queueTaskRun(taskId, trigger, actor), @@ -148,19 +156,8 @@ export function createMainRuntimeSupport(state: MainRuntimeState) { getApiPort: () => state.apiServer?.getPort() ?? 0, notify: (options) => requireIpcSupport().notify(options), listConnectorModels: async (connectorId) => { - const disabled = new Set(requireDb().getDisabledModels(connectorId).map((m) => m.id)); - // For claude_compatible, return user-configured models from the DB - // (stored as comma-separated values in the connector's model field) - if (connectorId.startsWith("claude_compat")) { - const connector = requireDb().getConnector(connectorId); - const raw = connector.model ?? ""; - return raw.split(",").map((m) => m.trim()).filter(Boolean) - .filter((m) => !disabled.has(m)) - .map((m) => ({ id: m, label: m })); - } - const definition = getConnectorDefinition(connectorId as Parameters[0]); - const models = definition.listModels ? await definition.listModels() : []; - return models.filter((m) => !disabled.has(m.id)); + const policy = getConnectorModelPolicy(requireDb(), connectorId as ConnectorId); + return policy.enabledModels; }, }); @@ -172,6 +169,7 @@ export function createMainRuntimeSupport(state: MainRuntimeState) { findInvalidReference, publishDomainChanged, emitEvent, + notify: (options) => requireIpcSupport().notify(options), wakeAgentIfPossible: (agentId, companyId, trigger) => requireOrchestrationService().wakeAgentIfPossible(agentId, companyId, trigger), dispatchAutomation: (trigger, ctx) => requireAutomationService().dispatchAutomation(trigger, ctx), @@ -182,6 +180,12 @@ export function createMainRuntimeSupport(state: MainRuntimeState) { randomFallbackCompanyId: () => requireConnectorHealthService().randomFallbackCompanyId(), skillService: requireServiceGraph().getOrCreateSkillService(), budgetService: requireServiceGraph().getOrCreateBudgetEnforcementService(), + onHumanMessage: (companyId: string, toAgentId?: string | null, threadId?: string | null, waitStateId?: string | null) => { + if (state.threadIntegration) { + state.threadIntegration.onHumanMessage(companyId, toAgentId, threadId, waitStateId); + } + publishDomainChanged(); + }, }); } diff --git a/src/main/main-runtime.ts b/src/main/main-runtime.ts index e21ad1e1..e52cd656 100644 --- a/src/main/main-runtime.ts +++ b/src/main/main-runtime.ts @@ -1,8 +1,9 @@ -import { AgentApiServer } from "./api-server"; +import { AgentApiServer, createApiServer } from "./api-server"; import { createAgentToken, initializeAgentTokenSigningKey } from "./agent-auth"; import { bootstrapApplication } from "./bootstrap-application"; import { AppDatabase } from "./database"; import { createMainRuntimeSupport } from "./main-runtime-support"; +import type { ThreadIntegrationService } from "./thread-integration"; interface CreateMainRuntimeDependencies { hasSingleInstanceLock: boolean; @@ -34,12 +35,14 @@ export function createMainRuntime({ apiServer: AgentApiServer | null; serviceGraph: Awaited>["serviceGraph"] | null; ipcSupport: Awaited>["ipcSupport"] | null; + threadIntegration: ThreadIntegrationService | null; isQuitting: boolean; } = { db: null, apiServer: null, serviceGraph: null, ipcSupport: null, + threadIntegration: null, isQuitting: false, }; const support = createMainRuntimeSupport(state); @@ -59,7 +62,7 @@ export function createMainRuntime({ devServerUrl, workerPath, createApiServer: (db, callbacks, allowedOrigins) => - new AgentApiServer( + createApiServer( db, support.publishDomainChanged, (agentId, companyId, trigger) => callbacks.onWakeAgent(agentId, companyId, trigger), @@ -76,11 +79,12 @@ export function createMainRuntime({ state.isQuitting = value; }, }, - onRuntimeReady: ({ db, apiServer, serviceGraph, ipcSupport }) => { + onRuntimeReady: ({ db, apiServer, serviceGraph, ipcSupport, threadIntegration }) => { state.db = db; state.apiServer = apiServer; state.serviceGraph = serviceGraph; state.ipcSupport = ipcSupport; + state.threadIntegration = threadIntegration; // Register IPC handlers immediately so the renderer can call loadSnapshot() // as soon as the window appears — before connector health checks finish. support.registerHandlers(); diff --git a/src/main/message-dispatch.ts b/src/main/message-dispatch.ts index 1766c81f..79b2a0f1 100644 --- a/src/main/message-dispatch.ts +++ b/src/main/message-dispatch.ts @@ -1,8 +1,16 @@ import type { AppDatabase } from "./database"; -import type { AgentMessageRecord, DesktopEvent, MessageChannel } from "@shared/types"; +import type { AgentMessageRecord, DesktopEvent, MessageChannel, MentionResult, SectionId } from "@shared/types"; + +/** + * The board reader / human participant ID. + * When a human sends a message, this is used as the fromAgentId in the + * underlying agent_messages record. Matches BOARD_READER_ID in database.ts. + */ +export const HUMAN_SENDER_ID = "__board__"; export interface AgentMessageDispatchInput { companyId: string; + /** Agent ID of the sender. Use HUMAN_SENDER_ID for human-originated messages. */ fromAgentId: string; toAgentId?: string | null; channel?: MessageChannel; @@ -11,7 +19,12 @@ export interface AgentMessageDispatchInput { body: string; priority?: "urgent" | "normal" | "low"; parentMessageId?: string | null; + threadId?: string | null; attachmentsJson?: string; + /** When true, the sender is a human user (fromAgentId === HUMAN_SENDER_ID). */ + fromHumanUser?: boolean; + /** Optional: target a specific thread wait state for precise checkpoint resolution. */ + waitStateId?: string | null; } export interface NormalizedAgentMessageInput { @@ -24,7 +37,10 @@ export interface NormalizedAgentMessageInput { body: string; priority: "urgent" | "normal" | "low"; parentMessageId: string | null; + threadId: string | null; attachmentsJson: string; + fromHumanUser: boolean; + waitStateId: string | null; } export interface MessageValidationError { @@ -42,18 +58,26 @@ export interface DispatchAgentMessageDependencies { publishDomainChanged?: () => void; wakeAgentIfPossible?: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null; emitEvent?: (event: DesktopEvent) => void; + notify?: (options: { title: string; body: string; urgency: "critical" | "informational"; navigation?: { section: SectionId; entityId?: string }; batchKey?: string }) => void; + /** Called when a human user sends a message. Resolves thread wait states scoped by waitStateId, threadId, or recipient. */ + onHumanMessage?: (companyId: string, toAgentId?: string | null, threadId?: string | null, waitStateId?: string | null) => void; } export interface DispatchAgentMessageResult { id: string; message: AgentMessageRecord | null; wakeTargets: string[]; + mentionResults: MentionResult[]; } function normalizeString(value: string | null | undefined): string { return value?.trim() ?? ""; } +function truncateForNotification(value: string, maxLength: number): string { + return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value; +} + function normalizeChannelTargetId(channel: MessageChannel, channelTargetId: string | null | undefined): string | null { const target = normalizeString(channelTargetId); if (channel === "department" || channel === "project") { @@ -69,11 +93,13 @@ export function normalizeAgentMessageInput(input: AgentMessageDispatchInput): No const toAgentId = normalizeString(input.toAgentId) || null; const channelTargetId = normalizeChannelTargetId(channel, input.channelTargetId); const parentMessageId = normalizeString(input.parentMessageId) || null; + const threadId = normalizeString(input.threadId) || null; const attachmentsJson = normalizeString(input.attachmentsJson) || "[]"; + const fromHumanUser = input.fromHumanUser === true || input.fromAgentId === HUMAN_SENDER_ID; return { companyId: input.companyId, - fromAgentId: input.fromAgentId, + fromAgentId: fromHumanUser ? HUMAN_SENDER_ID : input.fromAgentId, toAgentId, channel, channelTargetId, @@ -81,7 +107,10 @@ export function normalizeAgentMessageInput(input: AgentMessageDispatchInput): No body: input.body, priority, parentMessageId, + threadId, attachmentsJson, + fromHumanUser, + waitStateId: normalizeString(input.waitStateId) || null, }; } @@ -89,7 +118,8 @@ export function validateAgentMessageInput( db: AppDatabase, input: NormalizedAgentMessageInput, ): MessageValidationError | null { - if (!db.belongsToCompany("agents", input.fromAgentId, input.companyId)) { + // Human sender (BOARD_READER_ID) is always a valid sender — skip agent ownership check. + if (!input.fromHumanUser && !db.belongsToCompany("agents", input.fromAgentId, input.companyId)) { return { code: "INVALID_COMPANY_REFERENCE", message: "The message sender does not belong to the current company.", @@ -109,7 +139,7 @@ export function validateAgentMessageInput( }; } - if (input.toAgentId && !db.belongsToCompany("agents", input.toAgentId, input.companyId)) { + if (input.toAgentId && input.toAgentId !== HUMAN_SENDER_ID && !db.belongsToCompany("agents", input.toAgentId, input.companyId)) { return { code: "INVALID_COMPANY_REFERENCE", message: "The message recipient does not belong to the current company.", @@ -222,6 +252,14 @@ export function resolveMessageWakeTargets( } } + // Parse @mentions in message body and wake mentioned agents + const mentionedIds = db.findMentionedAgentIds(input.companyId, input.body); + for (const mentionedId of mentionedIds) { + if (mentionedId !== input.fromAgentId) { + wakeTargets.add(mentionedId); + } + } + return Array.from(wakeTargets); } @@ -236,9 +274,10 @@ export function dispatchAgentMessage( return { ok: false, error: validationError }; } + const senderKind = normalized.fromHumanUser ? "human" as const : "agent" as const; const persistedMessage = normalized.channel === "direct" - ? normalized - : { ...normalized, toAgentId: null }; + ? { ...normalized, senderKind } + : { ...normalized, toAgentId: null, senderKind }; const id = deps.db.sendAgentMessage(persistedMessage); const message = deps.db.getAgentMessage(id, normalized.companyId); if (message && deps.emitEvent) { @@ -246,10 +285,47 @@ export function dispatchAgentMessage( } deps.publishDomainChanged?.(); + if ( + message && + deps.notify && + !normalized.fromHumanUser && + (message.channel !== "direct" || message.toAgentId === HUMAN_SENDER_ID) + ) { + const snapshot = deps.db.listSnapshot(); + const senderName = snapshot.agents.find((entry) => entry.id === message.fromAgentId)?.name ?? "Agent"; + deps.notify({ + title: truncateForNotification(`${senderName} · ${message.subject}`, 80), + body: truncateForNotification(message.body, 140), + urgency: message.priority === "urgent" ? "critical" : "informational", + navigation: { section: "communication", entityId: message.id }, + batchKey: "agent_message", + }); + } + + // When a human sends a message, resolve thread wait states. + // Resolution uses precision hierarchy: waitStateId > threadId > toAgentId > company-wide + if (normalized.fromHumanUser) { + deps.onHumanMessage?.(normalized.companyId, normalized.toAgentId, normalized.threadId, normalized.waitStateId); + } + const wakeTargets = resolveMessageWakeTargets(deps.db, normalized); + const mentionResults: MentionResult[] = []; + if (deps.wakeAgentIfPossible) { + const snapshot = deps.db.listSnapshot(); for (const wakeTarget of wakeTargets) { - deps.wakeAgentIfPossible(wakeTarget, normalized.companyId, wakeTrigger); + const agent = snapshot.agents.find((a) => a.id === wakeTarget && a.companyId === normalized.companyId); + const agentName = agent?.name ?? wakeTarget; + const runId = deps.wakeAgentIfPossible(wakeTarget, normalized.companyId, wakeTrigger); + const hasQueuedWake = !runId && deps.db.listWakeupRequests(normalized.companyId, { + agentId: wakeTarget, + limit: 10, + }).some((request) => request.status === "queued" || request.status === "deferred"); + mentionResults.push({ + agentId: wakeTarget, + agentName, + outcome: runId ? "woken" : hasQueuedWake ? "queued" : "busy", + }); } } @@ -259,6 +335,7 @@ export function dispatchAgentMessage( id, message, wakeTargets, + mentionResults, }, }; } diff --git a/src/main/message-normalize.ts b/src/main/message-normalize.ts new file mode 100644 index 00000000..6508e8d3 --- /dev/null +++ b/src/main/message-normalize.ts @@ -0,0 +1,249 @@ +/** + * Message Normalization Layer + * + * Normalizes messages and comments before they are injected into agent prompts. + * Prevents malformed, empty, or excessively long content from degrading + * model output quality. Inspired by Reference/src/utils/messages.ts. + * + * Applied at the prompt-builder boundary — the last point before content + * enters the model's context window. + */ + +import type { AgentMessageRecord } from "@shared/types"; + +/** Maximum body length for a single message in prompt context. */ +const MAX_MESSAGE_BODY_LENGTH = 800; + +/** Maximum comment body length in prompt context. */ +const MAX_COMMENT_BODY_LENGTH = 500; + +/** + * Strip control characters (except newlines/tabs) and collapse + * excessive whitespace runs into single spaces. + */ +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(); +} + +/** + * Truncate text to maxLen, appending "…" if truncated. + */ +function truncate(text: string, maxLen: number): string { + if (text.length <= maxLen) return text; + return text.slice(0, maxLen - 1) + "…"; +} + +/** + * Normalize agent messages before prompt injection. + * - Removes empty-body messages + * - Cleans control characters + * - Truncates overly long bodies + * - Deduplicates consecutive identical messages from the same sender + */ +export function normalizeMessagesForPrompt( + messages: AgentMessageRecord[], + limit: number = 15, +): AgentMessageRecord[] { + const result: AgentMessageRecord[] = []; + let prevKey = ""; + + for (const msg of messages) { + const cleaned = cleanText(msg.body); + if (cleaned.length === 0) continue; + + // Deduplicate consecutive identical messages from the same sender + const key = `${msg.fromAgentId}:${cleaned}`; + if (key === prevKey) continue; + prevKey = key; + + result.push({ + ...msg, + body: truncate(cleaned, MAX_MESSAGE_BODY_LENGTH), + subject: msg.subject ? truncate(cleanText(msg.subject), 120) : msg.subject, + }); + + if (result.length >= limit) break; + } + + return result; +} + +// --------------------------------------------------------------------------- +// Transcript Repair — for session handoff / resume scenarios +// Inspired by Reference/src/utils/messages.ts tool pairing logic. +// Applied when session state is restored, not at every message send. +// --------------------------------------------------------------------------- + +/** + * A transcript entry from adapter session state. + * Adapters serialize their conversation as arrays of these blocks. + */ +export interface TranscriptBlock { + role: "user" | "assistant" | "system"; + content: string | TranscriptContentBlock[]; +} + +export interface TranscriptContentBlock { + type: string; + id?: string; + tool_use_id?: string; + name?: string; + input?: unknown; + content?: string; + text?: string; +} + +/** + * Repair tool_use / tool_result pairing in a serialized transcript. + * - Orphan tool_results (no matching tool_use) are removed. + * - Unpaired tool_use blocks get a synthetic "[interrupted]" result appended. + * - Duplicate tool_use IDs are deduplicated (keep first). + * + * Returns a repaired copy; does not mutate the input. + */ +export function repairToolPairing(transcript: TranscriptBlock[]): TranscriptBlock[] { + const toolUseIds = new Set(); + const toolResultIds = new Set(); + + // Pass 1: collect all tool_use and tool_result IDs + for (const block of transcript) { + if (!Array.isArray(block.content)) continue; + for (const part of block.content) { + if (part.type === "tool_use" && part.id) { + toolUseIds.add(part.id); + } + if (part.type === "tool_result" && part.tool_use_id) { + toolResultIds.add(part.tool_use_id); + } + } + } + + // Pass 2: filter orphan tool_results and deduplicate tool_use IDs + const seenToolUseIds = new Set(); + const repaired: TranscriptBlock[] = []; + + for (const block of transcript) { + if (typeof block.content === "string") { + repaired.push(block); + continue; + } + + const filteredContent: TranscriptContentBlock[] = []; + for (const part of block.content) { + if (part.type === "tool_result" && part.tool_use_id && !toolUseIds.has(part.tool_use_id)) { + continue; // orphan tool_result — remove + } + if (part.type === "tool_use" && part.id) { + if (seenToolUseIds.has(part.id)) continue; // duplicate — remove + seenToolUseIds.add(part.id); + } + filteredContent.push(part); + } + + if (filteredContent.length > 0) { + repaired.push({ ...block, content: filteredContent }); + } + } + + // Pass 3: add synthetic results for unpaired tool_use blocks + const unpairedToolUseIds = [...toolUseIds].filter((id) => !toolResultIds.has(id)); + if (unpairedToolUseIds.length > 0) { + const syntheticResults: TranscriptContentBlock[] = unpairedToolUseIds.map((id) => ({ + type: "tool_result", + tool_use_id: id, + content: "[interrupted — tool call did not complete before session ended]", + })); + repaired.push({ + role: "user", + content: syntheticResults, + }); + } + + return repaired; +} + +/** + * Validate and return repair statistics for a transcript. + * Use for logging/diagnostics without modifying the transcript. + */ +export function diagnoseTranscript(transcript: TranscriptBlock[]): { + totalBlocks: number; + toolUseCount: number; + toolResultCount: number; + orphanResultCount: number; + unpairedUseCount: number; +} { + const toolUseIds = new Set(); + const toolResultIds = new Set(); + let toolUseCount = 0; + let toolResultCount = 0; + + for (const block of transcript) { + if (!Array.isArray(block.content)) continue; + for (const part of block.content) { + if (part.type === "tool_use" && part.id) { + toolUseIds.add(part.id); + toolUseCount++; + } + if (part.type === "tool_result" && part.tool_use_id) { + toolResultIds.add(part.tool_use_id); + toolResultCount++; + } + } + } + + const orphanResultCount = [...toolResultIds].filter((id) => !toolUseIds.has(id)).length; + const unpairedUseCount = [...toolUseIds].filter((id) => !toolResultIds.has(id)).length; + + return { + totalBlocks: transcript.length, + toolUseCount, + toolResultCount, + orphanResultCount, + unpairedUseCount, + }; +} + +export interface CommentRecord { + taskId: string; + taskTitle: string; + authorName: string; + body: string; + createdAt: string; +} + +/** + * Normalize task comments before prompt injection. + * - Removes empty-body comments + * - Cleans control characters + * - Truncates overly long bodies + * - Deduplicates consecutive identical comments + */ +export function normalizeCommentsForPrompt( + comments: CommentRecord[], + limit: number = 10, +): CommentRecord[] { + const result: CommentRecord[] = []; + let prevKey = ""; + + for (const comment of comments) { + const cleaned = cleanText(comment.body); + if (cleaned.length === 0) continue; + + const key = `${comment.taskId}:${comment.authorName}:${cleaned}`; + if (key === prevKey) continue; + prevKey = key; + + result.push({ + ...comment, + body: truncate(cleaned, MAX_COMMENT_BODY_LENGTH), + }); + + if (result.length >= limit) break; + } + + return result; +} diff --git a/src/main/onboarding-service.ts b/src/main/onboarding-service.ts index df8e6aa2..881d40f4 100644 --- a/src/main/onboarding-service.ts +++ b/src/main/onboarding-service.ts @@ -1,6 +1,7 @@ import type { AppDatabase } from "./database"; import { getConnectorExecutionReadinessIssue, isConnectorExecutionReady } from "@shared/connector-policy"; import type { ConnectorId, ConnectorRecord } from "@shared/types"; +import { validateConnectorModelSelection } from "./connector-model-policy"; interface OnboardingBootstrapSummary { companyId: string; @@ -18,6 +19,7 @@ interface BootstrapInput { workspacePath: string; autoApproveHires: boolean; reviewDeliverables?: boolean; + executionMode?: string; goalTitle: string; goalDescription: string; connectorId?: ConnectorId; @@ -195,6 +197,10 @@ export function createOnboardingService({ function bootstrapOnboarding(input: BootstrapInput): OnboardingBootstrapSummary { const snapshot = db.listSnapshot(); const selectedConnectorId = resolveOnboardingConnector(snapshot.connectors, input.connectorId); + const modelSelection = validateConnectorModelSelection(db, selectedConnectorId, input.connectorModel ?? null); + if (!modelSelection.ok) { + throw new Error(modelSelection.error); + } const companyId = db.saveCompany({ name: input.companyName.trim(), @@ -202,6 +208,7 @@ export function createOnboardingService({ status: "active", autoApproveHires: input.autoApproveHires, reviewDeliverables: input.reviewDeliverables, + executionMode: input.executionMode ?? "ai_ceo", }); const workspaceId = createDefaultWorkspace(companyId, input.workspacePath); diff --git a/src/main/orchestration-service.ts b/src/main/orchestration-service.ts index a7ed2188..020124d5 100644 --- a/src/main/orchestration-service.ts +++ b/src/main/orchestration-service.ts @@ -1,919 +1 @@ -import { randomUUID } from "node:crypto"; -import { buildTaskPrompt } from "./prompt-builder"; -import type { AppDatabase } from "./database"; -import { getConnectorExecutionReadinessIssue } from "@shared/connector-policy"; -import type { DesktopResult } from "@shared/contracts"; -import type { ConnectorRecord } from "@shared/types"; -import type { NotificationOptions } from "./notification-service"; -import type { createBudgetEnforcementService } from "./budget-enforcement-service"; -import { sweepStaleReviews } from "./task-quality-gates"; -import { resolveApprovalDecision } from "./approval-policy"; -import { dispatchAgentMessage } from "./message-dispatch"; - -type WorkspaceLockMode = "exclusive" | "shared" | "none"; - -interface WorkspaceResolution { - workspace: ReturnType; - lockMode: WorkspaceLockMode; - source: "task" | "agent" | "project" | "company_fallback" | "none"; -} - -interface AutomationDispatch { - companyId: string; - taskId?: string; - agentId?: string; - runId?: string; - projectId?: string; - department?: string; - previousStatus?: string; - newStatus?: string; - costUsd?: number; -} - -interface EnqueueRunPayload { - runId: string; - companyId: string; - taskId: string; - taskTitle: string; - taskDescription: string; - agentId: string; - agentName: string; - connectorId: string; - workspaceId: string | null; - workspacePath: string | null; - workspaceMode: WorkspaceLockMode; - command: string; - model: string | null; - thinkingEffort: string | null; - env: Record; - sessionParams: Record | null; - systemPrompt: string | null; - trigger: string | null; - managedSkills: Array<{ runtimeName: string; sourcePath: string }>; -} - -interface OrchestrationDependencies { - db: AppDatabase; - logger: { - debug: (...args: unknown[]) => void; - info: (...args: unknown[]) => void; - warn: (...args: unknown[]) => void; - error: (...args: unknown[]) => void; - }; - publishDomainChanged: () => void; - emitEvent: (event: any) => void; - notify: (options: NotificationOptions) => void; - createRunToken: (agentId: string, companyId: string, runId: string) => string; - getApiPort: () => number; - dispatchAutomation: (trigger: string, ctx: AutomationDispatch) => void; - hasWorkerReady: () => boolean; - enqueueWorkerRun: (payload: EnqueueRunPayload) => void; - isQuitting: () => boolean; - listRuntimeSkillEntries: (companyId: string, agentId: string, connectorId: string) => Array<{ runtimeName: string; sourcePath: string; name?: string }>; - budgetService: ReturnType; - getSessionHandoff?: (taskId: string) => string | null; - evaluatePreRunSessionRotation?: (taskId: string, connectorId: string) => { - rotated: boolean; - handoff: string | null; - }; -} - -export function createOrchestrationService({ - db, - logger, - publishDomainChanged, - emitEvent, - notify, - createRunToken, - getApiPort, - dispatchAutomation, - hasWorkerReady, - enqueueWorkerRun, - isQuitting, - listRuntimeSkillEntries, - budgetService, - getSessionHandoff, - evaluatePreRunSessionRotation, -}: OrchestrationDependencies) { - let heartbeatTimer: ReturnType | null = null; - const scheduleLastFiredMap = new Map(); - - function ok(data: T): DesktopResult { - return { ok: true, data }; - } - - function fail(code: string, message: string): DesktopResult { - return { ok: false, error: { code, message } }; - } - - function hasActiveRun(filter: { taskId?: string; agentId?: string; companyId: string }) { - return db.hasActiveRun(filter); - } - - function resolveWorkspace( - task: { workspaceId: string | null; projectId?: string | null }, - agent: { workspaceId: string | null }, - companyId: string, - ): WorkspaceResolution { - const taskWorkspace = db.getWorkspace(task.workspaceId); - if (taskWorkspace) { - return { workspace: taskWorkspace, lockMode: "exclusive", source: "task" }; - } - - const agentWorkspace = db.getWorkspace(agent.workspaceId); - if (agentWorkspace) { - return { workspace: agentWorkspace, lockMode: "exclusive", source: "agent" }; - } - - const projectWorkspace = db.getProjectPrimaryWorkspace(task.projectId ?? null, companyId); - if (projectWorkspace) { - return { workspace: projectWorkspace, lockMode: "exclusive", source: "project" }; - } - - const fallbackWorkspace = db.getAnyCompanyWorkspace(companyId); - if (fallbackWorkspace) { - return { workspace: fallbackWorkspace, lockMode: "shared", source: "company_fallback" }; - } - - return { workspace: null, lockMode: "none", source: "none" }; - } - - function ensureAgentHeartbeatEnabled(agentId: string) { - const agent = db.getAgent(agentId); - if (!agent.heartbeatEnabled) { - db.setHeartbeat(agentId, true, agent.heartbeatIntervalSec || 120); - } - } - - function getRunEligibilityFailure( - agent: { status: string; budgetMonthlyUsd: number; spentMonthlyUsd: number; name: string; connectorId: string; id: string; companyId: string }, - connector: Pick, - projectId: string | null = null, - ) { - if (["terminated", "paused", "pending_approval"].includes(agent.status)) { - return "AGENT_NOT_RUNNABLE"; - } - // Policy-based budget check replaces flat field check (D-03, D-04, D-05) - const gate = budgetService.evaluateBudgetGate(agent.companyId, agent.id, projectId); - if (!gate.allowed) { - logger.info(`[run] Agent ${agent.name} budget policy blocked: ${gate.blockedBy.map(b => `${b.scopeType}(${b.policyId})`).join(", ")}`); - return "BUDGET_POLICY_BLOCKED"; - } - const connectorIssue = getConnectorExecutionReadinessIssue(connector); - if (connectorIssue) { - const issue = connectorIssue ?? `Connector ${connector.id} is not execution-ready.`; - logger.info(`[run] Agent ${agent.name} connector ${connector.id} blocked: ${issue}`); - return "CONNECTOR_NOT_READY"; - } - return null; - } - - function dispatchRunToWorker(params: { - runId: string; - companyId: string; - agent: ReturnType; - task: ReturnType; - connector: ReturnType; - workspace: ReturnType; - workspaceMode: WorkspaceLockMode; - companyName: string; - companyDescription: string; - snapshot: ReturnType; - trigger: string; - }) { - const { runId, companyId, agent, task, connector, workspace, workspaceMode, companyName, companyDescription, snapshot, trigger } = params; - if (isQuitting()) { - throw new Error("Application is shutting down — refusing to dispatch new run."); - } - if (!hasWorkerReady()) { - throw new Error("Orchestration worker is not ready."); - } - const connectorIssue = getConnectorExecutionReadinessIssue(connector); - if (connectorIssue) { - throw new Error(connectorIssue); - } - - const env = db.resolveSecretEnv(connector.envBindingText, companyId); - const apiPort = getApiPort(); - const apiUrl = apiPort ? `http://127.0.0.1:${apiPort}` : ""; - const apiKey = apiUrl ? createRunToken(agent.id, companyId, runId) : ""; - - const assignedTasks = db.listAgentTasks(agent.id, companyId, ["todo", "in_progress", "blocked"]); - const reviewTasks = db.listReviewTasksForManager(agent.id, companyId); - const relevantTaskIds = new Set([ - ...assignedTasks.map((task) => task.id), - ...reviewTasks.map((task) => task.id), - ]); - const allRelevantTasks = [...assignedTasks, ...reviewTasks]; - const recentComments = snapshot.comments - .filter((comment) => relevantTaskIds.has(comment.taskId)) - .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) - .slice(0, 30) - .map((comment) => ({ - taskId: comment.taskId, - taskTitle: allRelevantTasks.find((task) => task.id === comment.taskId)?.title ?? comment.taskId, - authorName: comment.authorName, - body: comment.body, - createdAt: comment.createdAt, - })); - - // D-07: Load handoff from previous rotated session - let sessionHandoff: string | null = null; - if (getSessionHandoff) { - try { - sessionHandoff = getSessionHandoff(task.id); - } catch (err) { - logger.warn(`[run] Failed to load session handoff for task ${task.id}: ${err instanceof Error ? err.message : String(err)}`); - } - } - - let managedSkills: Array<{ runtimeName: string; sourcePath: string; name?: string }> = []; - try { - managedSkills = listRuntimeSkillEntries(companyId, agent.id, connector.id); - } catch (err) { - logger.warn(`[run] Failed to fetch managed skills for agent ${agent.name}: ${err instanceof Error ? err.message : String(err)}`); - } - const skillNames = managedSkills - .map((s) => s.name ?? s.runtimeName) - .filter(Boolean); - - const systemPrompt = apiUrl ? buildTaskPrompt({ - agent, - apiUrl, - apiKey, - runId, - task, - companyName, - locale: snapshot.locale, - goals: snapshot.goals.filter((goal) => goal.companyId === companyId), - projects: snapshot.projects.filter((project) => project.companyId === companyId), - directReports: db.getDirectReports(agent.id, companyId), - chainOfCommand: db.getChainOfCommand(agent.id), - assignedTasks, - allAgents: snapshot.agents.filter((entry) => entry.companyId === companyId), - pendingApprovals: snapshot.approvals.filter( - (approval) => approval.companyId === companyId && - approval.requestedByAgentId === agent.id && - (approval.state === "pending" || approval.state === "revision_requested"), - ), - tasksAwaitingReview: db.listReviewTasksForManager(agent.id, companyId), - wakeReason: trigger, - allTasks: snapshot.tasks.filter((entry) => entry.companyId === companyId), - recentComments, - companyDescription, - socialAccounts: db.listSocialAccounts(companyId).map((account) => ({ - id: account.id, - platform: account.platform, - accountName: account.accountName, - status: account.status, - requireApproval: account.requireApproval, - })), - recentMessages: db.listAgentMessages(companyId, { agentId: agent.id, limit: 15 }), - upcomingMeetings: snapshot.meetings - .filter((meeting) => meeting.companyId === companyId && (meeting.status === "scheduled" || meeting.status === "in_progress")) - .filter((meeting) => { - try { - const ids: string[] = JSON.parse(meeting.participantAgentIds); - return ids.includes(agent.id) || meeting.organizerAgentId === agent.id; - } catch { - return false; - } - }) - .slice(0, 5), - relevantKnowledge: snapshot.knowledgeBase - .filter((entry) => entry.companyId === companyId && (entry.importance === "critical" || entry.importance === "high")) - .slice(0, 8), - activeSprint: snapshot.sprints.find((sprint) => sprint.companyId === companyId && sprint.status === "active") ?? null, - recentDocuments: snapshot.documents - .filter((document) => document.companyId === companyId && (document.authorAgentId === agent.id || document.reviewerAgentId === agent.id || document.status === "in_review")) - .slice(0, 5), - allConnectors: snapshot.connectors.map((c) => ({ id: c.id, label: c.label, status: c.status })), - sessionHandoff, - skillNames, - }) : null; - - if (apiUrl) { - env.AGENT_COMPANY_API_URL = apiUrl; - env.AGENT_COMPANY_API_KEY = apiKey; - env.AGENT_COMPANY_AGENT_ID = agent.id; - env.AGENT_COMPANY_COMPANY_ID = companyId; - env.AGENT_COMPANY_RUN_ID = runId; - env.AGENT_COMPANY_TASK_ID = task.id; - env.AGENT_COMPANY_WAKE_REASON = trigger; - } - - // SESS-01: Prefer task_sessions table, fall back to legacy tasks.session_json - const activeSession = db.getActiveTaskSession(task.id); - let taskSession = activeSession - ? { - sessionParams: activeSession.sessionParamsJson ? JSON.parse(activeSession.sessionParamsJson) as Record : null, - sessionDisplayId: activeSession.sessionDisplayId, - } - : db.getTaskSession(task.id); - - // Pre-execution session compaction (Paperclip pattern): evaluate BEFORE run starts - if (evaluatePreRunSessionRotation && activeSession) { - const rotationResult = evaluatePreRunSessionRotation(task.id, connector.id); - if (rotationResult.rotated) { - // Session was rotated: clear session params so adapter starts fresh - taskSession = { sessionParams: null, sessionDisplayId: null }; - // Handoff will be picked up by getSessionHandoff() above (already loaded from rotated session) - if (!sessionHandoff && rotationResult.handoff) { - sessionHandoff = rotationResult.handoff; - } - logger.info(`[session] Pre-run compaction for task ${task.id}: session rotated before execution`); - } - } - - let thinkingEffort: string | null = null; - try { - const meta = JSON.parse(agent.metadataJson || "{}"); - if (typeof meta.thinkingEffort === "string" && meta.thinkingEffort) { - thinkingEffort = meta.thinkingEffort; - } - } catch { - thinkingEffort = null; - } - - enqueueWorkerRun({ - runId, - companyId, - taskId: task.id, - taskTitle: task.title, - taskDescription: task.description, - agentId: agent.id, - agentName: agent.name, - connectorId: connector.id, - workspaceId: workspaceMode === "exclusive" ? (workspace?.id ?? null) : null, - workspacePath: workspace?.localPath ?? null, - workspaceMode, - command: connector.command, - model: agent.model, - thinkingEffort, - env, - sessionParams: taskSession.sessionParams, - systemPrompt, - trigger, - managedSkills, - }); - } - - function startAgentHeartbeatRun(agentId: string, companyId: string, trigger: string): string | null { - if (isQuitting()) return null; - if (!hasWorkerReady()) return null; - const companyStatus = db.getCompanyStatus(companyId); - if (companyStatus !== "active") { - logger.debug(`[heartbeat] Skipping ${agentId} because company ${companyId} is not active.`); - return null; - } - const agent = db.getAgent(agentId); - if (agent.companyId !== companyId) { - logger.warn(`[heartbeat] Refusing cross-company run for agent ${agentId}: ${agent.companyId} !== ${companyId}`); - return null; - } - const connector = db.getConnector(agent.connectorId); - if (getRunEligibilityFailure(agent, connector, null)) { - return null; - } - // Extended heartbeat policy checks (Paperclip parity) - let heartbeatPolicy = { cooldownSec: 10, maxConcurrentRuns: 1, wakeOnDemand: true }; - try { - heartbeatPolicy = db.getHeartbeatPolicy(agent.id); - } catch { - // Non-fatal: use defaults - } - - // maxConcurrentRuns check: count active runs for this agent - const activeRunCount = db.countActiveRunsForAgent(agent.id, companyId); - if (activeRunCount >= heartbeatPolicy.maxConcurrentRuns) { - logger.debug(`[heartbeat] Skipping ${agent.name} — active runs (${activeRunCount}) >= maxConcurrentRuns (${heartbeatPolicy.maxConcurrentRuns}).`); - return null; - } - - // Cooldown check: ensure enough time has passed since last heartbeat - if (agent.lastHeartbeatAt) { - const elapsed = Date.now() - new Date(agent.lastHeartbeatAt).getTime(); - const cooldownMs = heartbeatPolicy.cooldownSec * 1000; - if (elapsed < cooldownMs) { - logger.debug(`[heartbeat] Skipping ${agent.name} — cooldown (${heartbeatPolicy.cooldownSec}s) not elapsed.`); - return null; - } - } - - // wakeOnDemand gate: if disabled, only timer/bootstrap triggers allowed - if (!heartbeatPolicy.wakeOnDemand && trigger !== "timer" && trigger !== "bootstrap") { - logger.debug(`[heartbeat] Skipping ${agent.name} — wakeOnDemand is disabled and trigger is "${trigger}".`); - return null; - } - if (db.isAgentPendingApproval(agentId)) { - logger.debug(`[heartbeat] Skipping ${agent.name} because agent is pending hire approval.`); - return null; - } - - // Block heartbeat when agent has tasks awaiting user deliverable review - // Check both: pending approval (gate created) and requires_user_review flag (gap before approval) - if (db.hasAgentPendingDeliverableReview(agentId, companyId) || db.hasAgentTaskAwaitingReview(agentId, companyId)) { - logger.debug(`[heartbeat] Skipping ${agent.name} — awaiting user deliverable review.`); - return null; - } - - // Block CEO heartbeat when company has any pending deliverable reviews - // CEO should not create new work while the user hasn't reviewed existing deliverables - if (!agent.reportsTo && db.hasPendingDeliverableReviews(companyId)) { - logger.debug(`[heartbeat] Skipping CEO ${agent.name} — company has pending deliverable reviews.`); - return null; - } - - const assignedTasks = db.listAgentTasks(agentId, companyId, ["todo", "in_progress", "blocked"]); - const isTimer = trigger === "timer"; - const candidateTasks = assignedTasks; - - let task = candidateTasks.find((entry) => !entry.activeRunId) ?? null; - - if (!task) { - const hasActiveAssignedRun = candidateTasks.some( - (entry) => entry.activeRunId && hasActiveRun({ taskId: entry.id, companyId }), - ); - if (hasActiveAssignedRun) { - return null; - } - - const isCeo = !agent.reportsTo; - const isManager = db.getDirectReports(agent.id, companyId).length > 0; - const goalCount = db.countActiveGoalsForCompany(companyId); - const taskCount = db.countActiveTasksForCompany(companyId); - const companyNeedsBootstrap = goalCount > 0 && taskCount < goalCount; - - if (isTimer && !isCeo && !isManager && !companyNeedsBootstrap) { - logger.debug(`[heartbeat] Skipping ${agent.name} — no actionable tasks and trigger is timer.`); - return null; - } - - // Paperclip-style task reuse: coalesce into existing heartbeat task instead of creating duplicates - const reusableTask = db.findReusableHeartbeatTask(agentId, companyId); - if (reusableTask) { - // Reset to "todo" so checkoutTask CAS succeeds (requires status IN ('todo', 'backlog')) - db.advanceTaskStatus(reusableTask.id, "todo", `Heartbeat task recycled for ${trigger} cycle.`); - task = db.getTask(reusableTask.id); - logger.info(`[heartbeat] Reusing existing heartbeat task for ${agent.name}: ${task.title}`); - } else { - // Only create new heartbeat task if none exists to reuse - const taskTitle = isCeo - ? `[Heartbeat] ${agent.name} — Strategic planning & task delegation` - : isManager - ? `[Heartbeat] ${agent.name} — Review team & delegate work` - : `[Heartbeat] ${agent.name} — Check for work`; - const taskDescription = isCeo - ? "Review active company goals. Break each goal into projects and actionable tasks. Assign tasks to department leads and team members. Create tasks using the API. This is your primary responsibility as CEO — ensure every goal has a clear execution plan with assigned owners." - : isManager - ? "Review your department's goals and your team's workload. Create tasks for your direct reports if they have capacity. Review any work submitted for your approval. Ensure your team is productive and unblocked." - : `Automated heartbeat run for ${agent.name}. Review assigned tasks, check goals, and take action as needed.`; - - // CRITICAL FIX: Create as "todo" (not "in_progress") so checkoutTask CAS works. - // Previously created as "in_progress" which caused checkoutTask to always fail - // (it requires status IN ('todo', 'backlog')), silently dropping the run. - const taskId = db.saveTask({ - companyId, - title: taskTitle, - description: taskDescription, - assigneeAgentId: agentId, - workspaceId: agent.workspaceId ?? undefined, - status: "todo", - priority: isCeo ? "high" : "medium", - }); - task = db.getTask(taskId); - logger.info(`[heartbeat] Created heartbeat task for ${agent.name}: ${taskTitle}`); - } - } - - // Generate runId upfront for CAS checkout (EXEC-01, EXEC-02) - const runId = randomUUID(); - const checkoutSuccess = db.checkoutTask(task.id, runId); - if (!checkoutSuccess) { - // D-04: Activity log entry only, no toast/popup - db.addActivity({ - companyId, - actor: "system", - action: "execution.checkout_rejected", - entityType: "task", - entityId: task.id, - detail: `Agent ${agent.name} lost checkout race on task "${task.title}"`, - }); - logger.info(`[heartbeat] Agent ${agent.name} lost checkout race on task ${task.title}`); - return null; - } - - const workspaceResolution = resolveWorkspace(task, agent, companyId); - const workspace = workspaceResolution.workspace; - logger.info( - `[heartbeat] Workspace resolution for ${agent.name}: task.workspaceId=${task.workspaceId ?? "null"}, agent.workspaceId=${agent.workspaceId ?? "null"}, projectId=${task.projectId ?? "null"}, source=${workspaceResolution.source}, mode=${workspaceResolution.lockMode}, resolved=${workspace?.name ?? "NONE"}, localPath=${workspace?.localPath ?? "NONE"}`, - ); - - const run = db.createRun({ - id: runId, - companyId, - taskId: task.id, - agentId: agent.id, - workspaceId: workspaceResolution.lockMode === "exclusive" ? (workspace?.id ?? null) : null, - connectorId: connector.id, - }); - - const companyBasic = db.getCompanyBasic(companyId); - dispatchRunToWorker({ - runId: run.id, - companyId, - agent, - task, - connector, - workspace, - workspaceMode: workspaceResolution.lockMode, - companyName: companyBasic?.name ?? "Unknown", - companyDescription: companyBasic?.description ?? "", - snapshot: db.listSnapshot(), - trigger, - }); - - db.markHeartbeatAt(agentId); - - // Actor audit: record what triggered this run - try { - const actorType = trigger === "timer" || trigger === "bootstrap" ? "system" : "agent"; - const actorId = trigger === "timer" || trigger === "bootstrap" ? null : trigger; - db.updateRunActor(run.id, actorType, actorId); - } catch { /* Non-fatal */ } - - db.addActivity({ - companyId, - actor: "system", - action: "heartbeat.triggered", - entityType: "agent", - entityId: agent.id, - detail: `${trigger} heartbeat for ${agent.name}`, - }); - emitEvent({ type: "run-updated", runId: run.id, action: "created" }); - return run.id; - } - - function wakeAgentIfPossible(agentId: string | null | undefined, companyId: string, trigger: string, context?: Record) { - if (!agentId || isQuitting()) return null; - try { - const agent = db.getAgent(agentId); - if (agent.companyId !== companyId) { - logger.warn(`[wake] Refusing cross-company wake for agent ${agentId}: ${agent.companyId} !== ${companyId}`); - return null; - } - } catch { - return null; - } - try { - ensureAgentHeartbeatEnabled(agentId); - } catch { - return null; - } - - const agentBusy = hasActiveRun({ agentId, companyId }); - if (agentBusy) { - db.enqueueWakeupRequest(companyId, agentId, trigger, `Wake for agent: ${trigger}`, context); - return null; - } - - const runId = startAgentHeartbeatRun(agentId, companyId, trigger); - if (!runId) { - db.enqueueWakeupRequest(companyId, agentId, trigger, `Wake for agent (deferred): ${trigger}`, context); - } - return runId; - } - - function queueTaskRun(taskId: string, trigger: string, actor: string) { - if (isQuitting()) { - return fail("APP_SHUTTING_DOWN", "Application is shutting down."); - } - if (!hasWorkerReady()) { - logger.warn(`[run] Worker not ready — cannot queue run for task ${taskId} (trigger: ${trigger})`); - return fail("WORKER_UNAVAILABLE", "Orchestration worker is not ready."); - } - const task = db.getTask(taskId); - const companyStatus = db.getCompanyStatus(task.companyId); - if (companyStatus !== "active") { - return fail("COMPANY_NOT_ACTIVE", "Runs can only start while the company is active."); - } - if (!task.assigneeAgentId) return fail("TASK_UNASSIGNED", "Assign an agent before starting a run."); - const agent = db.getAgent(task.assigneeAgentId); - if (agent.companyId !== task.companyId) { - return fail("AGENT_COMPANY_MISMATCH", "The assigned agent does not belong to the task company."); - } - const connector = db.getConnector(agent.connectorId); - const eligibilityFailure = getRunEligibilityFailure(agent, connector, task.projectId ?? null); - if (eligibilityFailure === "AGENT_NOT_RUNNABLE") { - return fail("AGENT_NOT_RUNNABLE", "The assigned agent is paused, pending approval, or terminated."); - } - if (eligibilityFailure === "BUDGET_POLICY_BLOCKED") { - return fail("BUDGET_POLICY_BLOCKED", "A budget policy blocks this run. Review budget settings."); - } - if (eligibilityFailure === "CONNECTOR_NOT_READY") { - return fail("CONNECTOR_NOT_READY", "The assigned agent connector is not ready."); - } - if (hasActiveRun({ agentId: agent.id, companyId: task.companyId })) { - return fail("AGENT_ALREADY_RUNNING", "The assigned agent already has an active run."); - } - if (db.isAgentPendingApproval(agent.id)) { - return fail("AGENT_PENDING_APPROVAL", "This agent is pending hire approval and cannot run tasks."); - } - const blockingApproval = db.getBlockingApproval(task.companyId, agent.id, task.id); - if (blockingApproval) { - return fail( - "APPROVAL_GATE_BLOCKED", - `Run blocked by pending approval: ${blockingApproval.type} — "${blockingApproval.payloadSummary}". Approve or reject before starting.`, - ); - } - - // CAS checkout — atomic claim (EXEC-01, EXEC-02) - const runId = randomUUID(); - const checkoutSuccess = db.checkoutTask(task.id, runId); - if (!checkoutSuccess) { - return fail("CHECKOUT_RACE_LOST", "Another run claimed this task. Refresh and try again."); - } - - const workspaceResolution = resolveWorkspace(task, agent, task.companyId); - const workspace = workspaceResolution.workspace; - if (workspace && workspace.companyId !== task.companyId) { - return fail("WORKSPACE_COMPANY_MISMATCH", "The selected workspace does not belong to the task company."); - } - - const run = db.createRun({ - id: runId, - companyId: task.companyId, - taskId: task.id, - agentId: agent.id, - workspaceId: workspaceResolution.lockMode === "exclusive" ? (workspace?.id ?? null) : null, - connectorId: connector.id, - }); - - const companyBasic = db.getCompanyBasic(task.companyId); - dispatchRunToWorker({ - runId: run.id, - companyId: task.companyId, - agent, - task, - connector, - workspace, - workspaceMode: workspaceResolution.lockMode, - companyName: companyBasic?.name ?? "Unknown", - companyDescription: companyBasic?.description ?? "", - snapshot: db.listSnapshot(), - trigger, - }); - db.addActivity({ - companyId: task.companyId, - actor, - action: "run.queued", - entityType: "run", - entityId: run.id, - detail: task.title, - }); - emitEvent({ type: "run-updated", runId: run.id, action: "created" }); - return ok(run.id); - } - - function resumeQueuedRuns() { - // D-02: Stale lock recovery at app startup (EXEC-03) - try { - const recovered = db.recoverStaleLocks(); - for (const task of recovered) { - db.addActivity({ - companyId: task.companyId, - actor: "system", - action: "execution.lock_recovered", - entityType: "task", - entityId: task.taskId, - detail: `Recovered stale lock on task "${task.title}" after 15min timeout (startup sweep)`, - }); - logger.info(`[init] Recovered stale lock on task ${task.taskId}: "${task.title}"`); - } - if (recovered.length > 0) { - logger.info(`[init] Recovered ${recovered.length} stale lock(s) at startup`); - } - } catch (err) { - logger.error(`[init] Stale lock recovery at startup failed: ${err instanceof Error ? err.message : String(err)}`); - } - - if (isQuitting() || !hasWorkerReady()) { - return; - } - const snapshot = db.listSnapshot(); - const companiesById = new Map(snapshot.companies.map((company) => [company.id, company])); - - for (const run of db.listQueuedRuns()) { - const company = companiesById.get(run.companyId); - if (!company || company.status !== "active") { - continue; - } - - const hasBlockingSiblingRun = snapshot.runs.some((entry) => - entry.id !== run.id && - entry.companyId === run.companyId && - ["queued", "running"].includes(entry.status) && - (entry.taskId === run.taskId || entry.agentId === run.agentId), - ); - if (hasBlockingSiblingRun) { - continue; - } - - try { - const task = db.getTask(run.taskId); - const agent = db.getAgent(run.agentId); - const connector = db.getConnector(run.connectorId); - if (getRunEligibilityFailure(agent, connector, task.projectId ?? null)) { - continue; - } - const workspaceResolution = resolveWorkspace(task, agent, run.companyId); - dispatchRunToWorker({ - runId: run.id, - companyId: run.companyId, - agent, - task, - connector, - workspace: workspaceResolution.workspace, - workspaceMode: workspaceResolution.lockMode, - companyName: company.name, - companyDescription: company.description, - snapshot, - trigger: "recovery", - }); - } catch (error) { - logger.warn(`[run] Skipped queued run recovery for ${run.id}: ${error instanceof Error ? error.message : String(error)}`); - } - } - } - - function startHeartbeatScheduler() { - if (heartbeatTimer) clearInterval(heartbeatTimer); - - // One-time cleanup of existing duplicate heartbeat tasks from before the coalescing fix - try { - const activeCompanies = db.listActiveCompanyIds(); - for (const company of activeCompanies) { - const cleaned = db.cleanupDuplicateHeartbeatTasks(company.id); - if (cleaned > 0) { - logger.info(`[heartbeat] Cleaned up ${cleaned} duplicate heartbeat task(s) for company ${company.name}`); - } - } - } catch (err) { - logger.error(`[heartbeat] Heartbeat task cleanup failed: ${err instanceof Error ? err.message : String(err)}`); - } - - heartbeatTimer = setInterval(() => { - try { - if (isQuitting() || !hasWorkerReady()) return; - - // D-02: Stale lock recovery piggybacked on heartbeat cycle (EXEC-03) - try { - const recovered = db.recoverStaleLocks(); - for (const task of recovered) { - // D-04: Activity log entry only - db.addActivity({ - companyId: task.companyId, - actor: "system", - action: "execution.lock_recovered", - entityType: "task", - entityId: task.taskId, - detail: `Recovered stale lock on task "${task.title}" after 15min timeout`, - }); - logger.info(`[heartbeat] Recovered stale lock on task ${task.taskId}: "${task.title}"`); - } - } catch (err) { - logger.error(`[heartbeat] Stale lock recovery failed: ${err instanceof Error ? err.message : String(err)}`); - } - - const activeCompanies = db.listActiveCompanyIds(); - const now = Date.now(); - - for (const company of activeCompanies) { - try { - const eligibleAgents = db.listHeartbeatEligibleAgents(company.id); - for (const agent of eligibleAgents) { - if (hasActiveRun({ agentId: agent.id, companyId: company.id })) continue; - - const lastHeartbeat = agent.lastHeartbeatAt ? new Date(agent.lastHeartbeatAt).getTime() : 0; - const intervalMs = agent.heartbeatIntervalSec * 1000; - if (now - lastHeartbeat < intervalMs) continue; - - logger.info(`[heartbeat] Waking agent ${agent.name} (interval: ${agent.heartbeatIntervalSec}s)`); - startAgentHeartbeatRun(agent.id, company.id, "timer"); - } - - const goalCount = db.countActiveGoalsForCompany(company.id); - const taskCount = db.countActiveTasksForCompany(company.id); - if (goalCount > 0 && taskCount === 0) { - const ceo = db.getCeoForCompany(company.id); - const ceoLastWake = ceo?.lastHeartbeatAt ? new Date(ceo.lastHeartbeatAt).getTime() : 0; - const bootstrapCooldown = 120_000; - if (ceo && !hasActiveRun({ agentId: ceo.id, companyId: company.id }) && (now - ceoLastWake > bootstrapCooldown)) { - logger.info(`[heartbeat] Company ${company.name} has ${goalCount} goals but no tasks — bootstrapping CEO ${ceo.name}`); - startAgentHeartbeatRun(ceo.id, company.id, "bootstrap"); - } - } - - const scheduleKey = `schedule-${company.id}`; - const lastScheduleFired = scheduleLastFiredMap.get(scheduleKey) ?? 0; - if (now - lastScheduleFired >= 60_000) { - dispatchAutomation("schedule", { companyId: company.id }); - scheduleLastFiredMap.set(scheduleKey, now); - } - - const companyAgents = db.listActiveAgentsForCompany(company.id); - for (const agent of companyAgents) { - if (!db.hasAgentAssignedTasks(agent.id, company.id)) { - const lastActive = agent.lastHeartbeatAt ? new Date(agent.lastHeartbeatAt).getTime() : 0; - const idleThreshold = 20 * 60 * 1000; - if (now - lastActive > idleThreshold) { - dispatchAutomation("agent_idle", { - companyId: company.id, - agentId: agent.id, - department: agent.department ?? undefined, - }); - } - } - } - - const currentMonth = new Date().toISOString().slice(0, 7); - const lastResetMonth = db.getLastBudgetResetMonth(company.id) ?? ""; - if (currentMonth !== lastResetMonth) { - db.resetMonthlyBudgets(company.id); - db.setLastBudgetResetMonth(company.id, currentMonth); - logger.info(`[heartbeat] Reset monthly budgets for company ${company.name} (${currentMonth})`); - } - - // D-06: Auto-close incidents from previous budget windows - const closedIncidents = budgetService.autoCloseExpiredIncidents(company.id); - if (closedIncidents > 0) { - logger.info(`[heartbeat] Auto-closed ${closedIncidents} expired budget incident(s) for company ${company.name}`); - } - - // TASK-04: Stale review escalation sweep - try { - const escalated = sweepStaleReviews(company.id, { - db: db as unknown as import("./task-quality-gates").SweepStaleReviewsDeps["db"], - dispatchAgentMessage: dispatchAgentMessage as unknown as import("./task-quality-gates").SweepStaleReviewsDeps["dispatchAgentMessage"], - resolveApprovalDecision: resolveApprovalDecision as unknown as import("./task-quality-gates").SweepStaleReviewsDeps["resolveApprovalDecision"], - notify: notify as unknown as import("./task-quality-gates").SweepStaleReviewsDeps["notify"], - handleTaskStatusChange: (_taskId: string, _companyId: string, _prev: string, _next: string) => { - publishDomainChanged(); - }, - handleApprovalResolved: (_approvalId: string, _companyId: string, _decision: string) => { - publishDomainChanged(); - }, - emitEvent: (..._args: unknown[]) => {}, - publishDomainChanged, - wakeAgentIfPossible: (agentId: string, cId: string, trigger: string) => { - wakeAgentIfPossible(agentId, cId, trigger); - }, - logger, - }); - if (escalated > 0) { - logger.info(`[heartbeat] Auto-escalated ${escalated} stale review(s) for company ${company.name}`); - publishDomainChanged(); - } - } catch (error) { - logger.error(`[heartbeat] Stale review sweep failed: ${error instanceof Error ? error.message : String(error)}`); - } - } catch (error) { - logger.error(`[heartbeat] Error processing company ${company.id}: ${error instanceof Error ? error.message : String(error)}`); - } - } - - try { - const staleReleased = db.releaseStaleWorkspaceLocks(); - if (staleReleased > 0) { - logger.info(`[heartbeat] Released ${staleReleased} stale workspace lock(s)`); - publishDomainChanged(); - } - const staleWakeups = db.purgeExpiredWakeupRequests(); - if (staleWakeups > 0) { - logger.info(`[heartbeat] Purged ${staleWakeups} expired wakeup request(s)`); - } - } catch (error) { - logger.error(`[heartbeat] Stale lock cleanup failed: ${error instanceof Error ? error.message : String(error)}`); - } - } catch (error) { - logger.error(`[heartbeat] Scheduler error: ${error instanceof Error ? error.message : String(error)}`); - } - }, 15_000); - } - - function stopHeartbeatScheduler() { - if (heartbeatTimer) clearInterval(heartbeatTimer); - heartbeatTimer = null; - } - - return { - resolveWorkspace, - wakeAgentIfPossible, - dispatchRunToWorker, - queueTaskRun, - resumeQueuedRuns, - startAgentHeartbeatRun, - startHeartbeatScheduler, - stopHeartbeatScheduler, - }; -} +export { createOrchestrationService } from "./orchestration/index"; diff --git a/src/main/orchestration/heartbeat.ts b/src/main/orchestration/heartbeat.ts new file mode 100644 index 00000000..aa2617a5 --- /dev/null +++ b/src/main/orchestration/heartbeat.ts @@ -0,0 +1,256 @@ +import { randomUUID } from "node:crypto"; +import type { AppDatabase } from "../database"; +import type { OrchestrationDependencies } from "./types"; + +export function createHeartbeat( + deps: OrchestrationDependencies, + resolveWorkspace: (task: { workspaceId: string | null; projectId?: string | null }, agent: { workspaceId: string | null }, companyId: string) => { workspace: ReturnType; lockMode: "exclusive" | "shared" | "none"; source: string }, + getRunEligibilityFailure: (agent: any, connector: any, projectId?: string | null) => string | null, + dispatchRunToWorker: (params: any) => void, + hasActiveRun: (filter: { taskId?: string; agentId?: string; companyId: string }) => boolean, +) { + const { + db, + logger, + emitEvent, + hasWorkerReady, + isQuitting, + } = deps; + + function ensureAgentHeartbeatEnabled(agentId: string) { + const agent = db.getAgent(agentId); + if (!agent.heartbeatEnabled) { + db.setHeartbeat(agentId, true, agent.heartbeatIntervalSec || 120); + } + } + + function startAgentHeartbeatRun(agentId: string, companyId: string, trigger: string): string | null { + if (isQuitting()) return null; + const isMessageWake = trigger === "message"; + const isCommunicationWake = isMessageWake || trigger === "peer_request"; + if (!hasWorkerReady()) return null; + const companyStatus = db.getCompanyStatus(companyId); + if (companyStatus !== "active") { + logger.debug(`[heartbeat] Skipping ${agentId} because company ${companyId} is not active.`); + return null; + } + const agent = db.getAgent(agentId); + if (agent.companyId !== companyId) { + logger.warn(`[heartbeat] Refusing cross-company run for agent ${agentId}: ${agent.companyId} !== ${companyId}`); + return null; + } + const connector = db.getConnector(agent.connectorId); + if (getRunEligibilityFailure(agent, connector, null)) { + return null; + } + // Extended heartbeat policy checks (Paperclip parity) + let heartbeatPolicy = { cooldownSec: 10, maxConcurrentRuns: 1, wakeOnDemand: true }; + try { + heartbeatPolicy = db.getHeartbeatPolicy(agent.id); + } catch { + // Non-fatal: use defaults + } + + // maxConcurrentRuns check: count active runs for this agent + const activeRunCount = db.countActiveRunsForAgent(agent.id, companyId); + if (activeRunCount >= heartbeatPolicy.maxConcurrentRuns) { + logger.debug(`[heartbeat] Skipping ${agent.name} — active runs (${activeRunCount}) >= maxConcurrentRuns (${heartbeatPolicy.maxConcurrentRuns}).`); + return null; + } + + // Cooldown check: ensure enough time has passed since last heartbeat + if (!isMessageWake && agent.lastHeartbeatAt) { + const elapsed = Date.now() - new Date(agent.lastHeartbeatAt).getTime(); + const cooldownMs = heartbeatPolicy.cooldownSec * 1000; + if (elapsed < cooldownMs) { + logger.debug(`[heartbeat] Skipping ${agent.name} — cooldown (${heartbeatPolicy.cooldownSec}s) not elapsed.`); + return null; + } + } + + // wakeOnDemand gate: if disabled, only timer/bootstrap triggers allowed + if (!heartbeatPolicy.wakeOnDemand && trigger !== "timer" && trigger !== "bootstrap") { + logger.debug(`[heartbeat] Skipping ${agent.name} — wakeOnDemand is disabled and trigger is "${trigger}".`); + return null; + } + if (db.isAgentPendingApproval(agentId)) { + logger.debug(`[heartbeat] Skipping ${agent.name} because agent is pending hire approval.`); + return null; + } + + // Block heartbeat when agent has tasks awaiting user deliverable review + if (!isMessageWake && (db.hasAgentPendingDeliverableReview(agentId, companyId) || db.hasAgentTaskAwaitingReview(agentId, companyId))) { + logger.debug(`[heartbeat] Skipping ${agent.name} — awaiting user deliverable review.`); + return null; + } + + // Block CEO heartbeat when company has any pending deliverable reviews + if (!isMessageWake && !agent.reportsTo && db.hasPendingDeliverableReviews(companyId)) { + logger.debug(`[heartbeat] Skipping CEO ${agent.name} — company has pending deliverable reviews.`); + return null; + } + + const assignedTasks = isCommunicationWake + ? [] + : db.listAgentTasks(agentId, companyId, ["todo", "in_progress", "blocked"]); + const isTimer = trigger === "timer"; + const candidateTasks = assignedTasks; + + let task = candidateTasks.find((entry) => !entry.activeRunId) ?? null; + + if (!task) { + const hasActiveAssignedRun = candidateTasks.some( + (entry) => entry.activeRunId && hasActiveRun({ taskId: entry.id, companyId }), + ); + if (hasActiveAssignedRun) { + return null; + } + + const isCeo = !agent.reportsTo; + const isManager = db.getDirectReports(agent.id, companyId).length > 0; + const goalCount = db.countActiveGoalsForCompany(companyId); + const taskCount = db.countActiveTasksForCompany(companyId); + const companyNeedsBootstrap = goalCount > 0 && taskCount < goalCount; + + if (isTimer && !isCeo && !isManager && !companyNeedsBootstrap) { + logger.debug(`[heartbeat] Skipping ${agent.name} — no actionable tasks and trigger is timer.`); + return null; + } + + // Paperclip-style task reuse: coalesce into existing heartbeat task instead of creating duplicates + const reusableTask = db.findReusableHeartbeatTask(agentId, companyId); + if (reusableTask) { + db.advanceTaskStatus(reusableTask.id, "todo", `Heartbeat task recycled for ${trigger} cycle.`); + task = db.getTask(reusableTask.id); + logger.info(`[heartbeat] Reusing existing heartbeat task for ${agent.name}: ${task.title}`); + } else { + const taskTitle = isCeo + ? `[Heartbeat] ${agent.name} — Strategic planning & task delegation` + : isManager + ? `[Heartbeat] ${agent.name} — Review team & delegate work` + : `[Heartbeat] ${agent.name} — Check for work`; + const taskDescription = isCeo + ? "Review active company goals. Break each goal into projects and actionable tasks. Assign tasks to department leads and team members. Create tasks using the API. This is your primary responsibility as CEO — ensure every goal has a clear execution plan with assigned owners." + : isManager + ? "Review your department's goals and your team's workload. Create tasks for your direct reports if they have capacity. Review any work submitted for your approval. Ensure your team is productive and unblocked." + : `Automated heartbeat run for ${agent.name}. Review assigned tasks, check goals, and take action as needed.`; + + const taskId = db.saveTask({ + companyId, + title: taskTitle, + description: taskDescription, + assigneeAgentId: agentId, + workspaceId: agent.workspaceId ?? undefined, + status: "todo", + priority: isCeo ? "high" : "medium", + }); + task = db.getTask(taskId); + logger.info(`[heartbeat] Created heartbeat task for ${agent.name}: ${taskTitle}`); + } + } + + // Generate runId upfront for CAS checkout (EXEC-01, EXEC-02) + const runId = randomUUID(); + const checkoutSuccess = db.checkoutTask(task.id, runId); + if (!checkoutSuccess) { + db.addActivity({ + companyId, + actor: "system", + action: "execution.checkout_rejected", + entityType: "task", + entityId: task.id, + detail: `Agent ${agent.name} lost checkout race on task "${task.title}"`, + }); + logger.info(`[heartbeat] Agent ${agent.name} lost checkout race on task ${task.title}`); + return null; + } + + const workspaceResolution = resolveWorkspace(task, agent, companyId); + const workspace = workspaceResolution.workspace; + logger.info( + `[heartbeat] Workspace resolution for ${agent.name}: task.workspaceId=${task.workspaceId ?? "null"}, agent.workspaceId=${agent.workspaceId ?? "null"}, projectId=${task.projectId ?? "null"}, source=${workspaceResolution.source}, mode=${workspaceResolution.lockMode}, resolved=${workspace?.name ?? "NONE"}, localPath=${workspace?.localPath ?? "NONE"}`, + ); + + const run = db.createRun({ + id: runId, + companyId, + taskId: task.id, + agentId: agent.id, + workspaceId: workspaceResolution.lockMode === "exclusive" ? (workspace?.id ?? null) : null, + connectorId: connector.id, + }); + + const companyBasic = db.getCompanyBasic(companyId); + dispatchRunToWorker({ + runId: run.id, + companyId, + agent, + task, + connector, + workspace, + workspaceMode: workspaceResolution.lockMode, + companyName: companyBasic?.name ?? "Unknown", + companyDescription: companyBasic?.description ?? "", + snapshot: db.listSnapshot(), + trigger, + }); + + db.markHeartbeatAt(agentId); + + // Actor audit: record what triggered this run + try { + const actorType = trigger === "timer" || trigger === "bootstrap" ? "system" : "agent"; + const actorId = trigger === "timer" || trigger === "bootstrap" ? null : trigger; + db.updateRunActor(run.id, actorType, actorId); + } catch { /* Non-fatal */ } + + db.addActivity({ + companyId, + actor: "system", + action: "heartbeat.triggered", + entityType: "agent", + entityId: agent.id, + detail: `${trigger} heartbeat for ${agent.name}`, + }); + emitEvent({ type: "run-updated", runId: run.id, action: "created" }); + return run.id; + } + + function wakeAgentIfPossible(agentId: string | null | undefined, companyId: string, trigger: string, context?: Record) { + if (!agentId || isQuitting()) return null; + try { + const agent = db.getAgent(agentId); + if (agent.companyId !== companyId) { + logger.warn(`[wake] Refusing cross-company wake for agent ${agentId}: ${agent.companyId} !== ${companyId}`); + return null; + } + } catch { + return null; + } + try { + ensureAgentHeartbeatEnabled(agentId); + } catch { + return null; + } + + const agentBusy = hasActiveRun({ agentId, companyId }); + if (agentBusy) { + db.enqueueWakeupRequest(companyId, agentId, trigger, `Wake for agent: ${trigger}`, context); + return null; + } + + const runId = startAgentHeartbeatRun(agentId, companyId, trigger); + if (!runId) { + db.enqueueWakeupRequest(companyId, agentId, trigger, `Wake for agent (deferred): ${trigger}`, context); + return null; + } + db.completePendingWakeupRequests(agentId, companyId, runId); + return runId; + } + + return { + ensureAgentHeartbeatEnabled, + startAgentHeartbeatRun, + wakeAgentIfPossible, + }; +} diff --git a/src/main/orchestration/index.ts b/src/main/orchestration/index.ts new file mode 100644 index 00000000..735344fa --- /dev/null +++ b/src/main/orchestration/index.ts @@ -0,0 +1,46 @@ +import type { OrchestrationDependencies } from "./types"; +import { createRunDispatch } from "./run-dispatch"; +import { createHeartbeat } from "./heartbeat"; +import { createScheduler } from "./scheduler"; +import { createTaskRunner } from "./task-runner"; + +export function createOrchestrationService(deps: OrchestrationDependencies) { + const { db } = deps; + + function hasActiveRun(filter: { taskId?: string; agentId?: string; companyId: string }) { + return db.hasActiveRun(filter); + } + + const runDispatch = createRunDispatch(deps); + const heartbeat = createHeartbeat( + deps, + runDispatch.resolveWorkspace, + runDispatch.getRunEligibilityFailure, + runDispatch.dispatchRunToWorker, + hasActiveRun, + ); + const scheduler = createScheduler( + deps, + hasActiveRun, + heartbeat.startAgentHeartbeatRun, + heartbeat.wakeAgentIfPossible, + ); + const taskRunner = createTaskRunner( + deps, + runDispatch.resolveWorkspace, + runDispatch.getRunEligibilityFailure, + runDispatch.dispatchRunToWorker, + hasActiveRun, + ); + + return { + resolveWorkspace: runDispatch.resolveWorkspace, + wakeAgentIfPossible: heartbeat.wakeAgentIfPossible, + dispatchRunToWorker: runDispatch.dispatchRunToWorker, + queueTaskRun: taskRunner.queueTaskRun, + resumeQueuedRuns: taskRunner.resumeQueuedRuns, + startAgentHeartbeatRun: heartbeat.startAgentHeartbeatRun, + startHeartbeatScheduler: scheduler.startHeartbeatScheduler, + stopHeartbeatScheduler: scheduler.stopHeartbeatScheduler, + }; +} diff --git a/src/main/orchestration/run-dispatch.ts b/src/main/orchestration/run-dispatch.ts new file mode 100644 index 00000000..f7b46dc5 --- /dev/null +++ b/src/main/orchestration/run-dispatch.ts @@ -0,0 +1,103 @@ +import { getConnectorExecutionReadinessIssue } from "@shared/connector-policy"; +import type { ConnectorRecord } from "@shared/types"; +import type { WorkspaceLockMode, OrchestrationDependencies } from "./types"; +import { buildRunPayload, type RunDispatchParams } from "./turn-context"; +import { validateConnectorModelSelection } from "../connector-model-policy"; + +export function createRunDispatch(deps: OrchestrationDependencies) { + const { + db, + logger, + isQuitting, + hasWorkerReady, + getApiPort, + createRunToken, + enqueueWorkerRun, + listRuntimeSkillEntries, + budgetService, + getSessionHandoff, + evaluatePreRunSessionRotation, + } = deps; + + function resolveWorkspace( + task: { workspaceId: string | null; projectId?: string | null }, + agent: { workspaceId: string | null }, + companyId: string, + ) { + const taskWorkspace = db.getWorkspace(task.workspaceId); + if (taskWorkspace) { + return { workspace: taskWorkspace, lockMode: "exclusive" as WorkspaceLockMode, source: "task" as const }; + } + + const agentWorkspace = db.getWorkspace(agent.workspaceId); + if (agentWorkspace) { + return { workspace: agentWorkspace, lockMode: "exclusive" as WorkspaceLockMode, source: "agent" as const }; + } + + const projectWorkspace = db.getProjectPrimaryWorkspace(task.projectId ?? null, companyId); + if (projectWorkspace) { + return { workspace: projectWorkspace, lockMode: "exclusive" as WorkspaceLockMode, source: "project" as const }; + } + + const fallbackWorkspace = db.getAnyCompanyWorkspace(companyId); + if (fallbackWorkspace) { + return { workspace: fallbackWorkspace, lockMode: "shared" as WorkspaceLockMode, source: "company_fallback" as const }; + } + + return { workspace: null, lockMode: "none" as WorkspaceLockMode, source: "none" as const }; + } + + function getRunEligibilityFailure( + agent: { status: string; budgetMonthlyUsd: number; spentMonthlyUsd: number; name: string; connectorId: string; id: string; companyId: string; model?: string | null }, + connector: Pick, + projectId: string | null = null, + ) { + if (["terminated", "paused", "pending_approval"].includes(agent.status)) { + return "AGENT_NOT_RUNNABLE"; + } + const gate = budgetService.evaluateBudgetGate(agent.companyId, agent.id, projectId); + if (!gate.allowed) { + logger.info(`[run] Agent ${agent.name} budget policy blocked: ${gate.blockedBy.map(b => `${b.scopeType}(${b.policyId})`).join(", ")}`); + return "BUDGET_POLICY_BLOCKED"; + } + const connectorIssue = getConnectorExecutionReadinessIssue(connector); + if (connectorIssue) { + const issue = connectorIssue ?? `Connector ${connector.id} is not execution-ready.`; + logger.info(`[run] Agent ${agent.name} connector ${connector.id} blocked: ${issue}`); + return "CONNECTOR_NOT_READY"; + } + const modelSelection = validateConnectorModelSelection(db, connector.id, agent.model ?? null); + if (!modelSelection.ok) { + logger.info(`[run] Agent ${agent.name} connector model blocked: ${modelSelection.error}`); + return "CONNECTOR_MODEL_NOT_ALLOWED"; + } + return null; + } + + function dispatchRunToWorker(params: RunDispatchParams) { + if (isQuitting()) { + throw new Error("Application is shutting down — refusing to dispatch new run."); + } + if (!hasWorkerReady()) { + throw new Error("Orchestration worker is not ready."); + } + const connectorIssue = getConnectorExecutionReadinessIssue(params.connector); + if (connectorIssue) { + throw new Error(connectorIssue); + } + + const payload = buildRunPayload(params, { + db, + logger, + getApiPort, + createRunToken, + listRuntimeSkillEntries, + getSessionHandoff, + evaluatePreRunSessionRotation, + }); + + enqueueWorkerRun(payload); + } + + return { resolveWorkspace, getRunEligibilityFailure, dispatchRunToWorker }; +} diff --git a/src/main/orchestration/scheduler.ts b/src/main/orchestration/scheduler.ts new file mode 100644 index 00000000..07118f74 --- /dev/null +++ b/src/main/orchestration/scheduler.ts @@ -0,0 +1,195 @@ +import type { OrchestrationDependencies } from "./types"; +import { sweepStaleReviews } from "../task-quality-gates"; +import { resolveApprovalDecision } from "../approval-policy"; +import { dispatchAgentMessage } from "../message-dispatch"; + +export function createScheduler( + deps: OrchestrationDependencies, + hasActiveRun: (filter: { taskId?: string; agentId?: string; companyId: string }) => boolean, + startAgentHeartbeatRun: (agentId: string, companyId: string, trigger: string) => string | null, + wakeAgentIfPossible: (agentId: string, companyId: string, trigger: string) => string | null | undefined, +) { + const { + db, + logger, + publishDomainChanged, + notify, + dispatchAutomation, + hasWorkerReady, + isQuitting, + budgetService, + } = deps; + + let heartbeatTimer: ReturnType | null = null; + const scheduleLastFiredMap = new Map(); + + function startHeartbeatScheduler() { + if (heartbeatTimer) clearInterval(heartbeatTimer); + + // One-time cleanup of existing duplicate heartbeat tasks from before the coalescing fix + try { + const activeCompanies = db.listActiveCompanyIds(); + for (const company of activeCompanies) { + const cleaned = db.cleanupDuplicateHeartbeatTasks(company.id); + if (cleaned > 0) { + logger.info(`[heartbeat] Cleaned up ${cleaned} duplicate heartbeat task(s) for company ${company.name}`); + } + } + } catch (err) { + logger.error(`[heartbeat] Heartbeat task cleanup failed: ${err instanceof Error ? err.message : String(err)}`); + } + + heartbeatTimer = setInterval(() => { + try { + if (isQuitting() || !hasWorkerReady()) return; + + // D-02: Stale lock recovery piggybacked on heartbeat cycle (EXEC-03) + try { + const recovered = db.recoverStaleLocks(); + for (const task of recovered) { + db.addActivity({ + companyId: task.companyId, + actor: "system", + action: "execution.lock_recovered", + entityType: "task", + entityId: task.taskId, + detail: `Recovered stale lock on task "${task.title}" after 15min timeout`, + }); + logger.info(`[heartbeat] Recovered stale lock on task ${task.taskId}: "${task.title}"`); + } + } catch (err) { + logger.error(`[heartbeat] Stale lock recovery failed: ${err instanceof Error ? err.message : String(err)}`); + } + + const activeCompanies = db.listActiveCompanyIds(); + const now = Date.now(); + + for (const company of activeCompanies) { + try { + const eligibleAgents = db.listHeartbeatEligibleAgents(company.id); + + for (const agent of eligibleAgents) { + if (hasActiveRun({ agentId: agent.id, companyId: company.id })) continue; + + // Unified co-founder mode pause: checkpoint engine checks mode + wait states + pending approvals + if (deps.checkpointEngine?.shouldPauseHeartbeat(company.id, agent.id)) { + continue; + } + + const lastHeartbeat = agent.lastHeartbeatAt ? new Date(agent.lastHeartbeatAt).getTime() : 0; + const intervalMs = agent.heartbeatIntervalSec * 1000; + if (now - lastHeartbeat < intervalMs) continue; + + logger.info(`[heartbeat] Waking agent ${agent.name} (interval: ${agent.heartbeatIntervalSec}s)`); + startAgentHeartbeatRun(agent.id, company.id, "timer"); + } + + const goalCount = db.countActiveGoalsForCompany(company.id); + const taskCount = db.countActiveTasksForCompany(company.id); + if (goalCount > 0 && taskCount === 0) { + const ceo = db.getCeoForCompany(company.id); + const ceoLastWake = ceo?.lastHeartbeatAt ? new Date(ceo.lastHeartbeatAt).getTime() : 0; + const bootstrapCooldown = 120_000; + if (ceo && !hasActiveRun({ agentId: ceo.id, companyId: company.id }) && (now - ceoLastWake > bootstrapCooldown)) { + logger.info(`[heartbeat] Company ${company.name} has ${goalCount} goals but no tasks — bootstrapping CEO ${ceo.name}`); + startAgentHeartbeatRun(ceo.id, company.id, "bootstrap"); + } + } + + const scheduleKey = `schedule-${company.id}`; + const lastScheduleFired = scheduleLastFiredMap.get(scheduleKey) ?? 0; + if (now - lastScheduleFired >= 60_000) { + dispatchAutomation("schedule", { companyId: company.id }); + scheduleLastFiredMap.set(scheduleKey, now); + } + + const companyAgents = db.listActiveAgentsForCompany(company.id); + for (const agent of companyAgents) { + if (!db.hasAgentAssignedTasks(agent.id, company.id)) { + const lastActive = agent.lastHeartbeatAt ? new Date(agent.lastHeartbeatAt).getTime() : 0; + const idleThreshold = 20 * 60 * 1000; + if (now - lastActive > idleThreshold) { + dispatchAutomation("agent_idle", { + companyId: company.id, + agentId: agent.id, + department: agent.department ?? undefined, + }); + } + } + } + + const currentMonth = new Date().toISOString().slice(0, 7); + const lastResetMonth = db.getLastBudgetResetMonth(company.id) ?? ""; + if (currentMonth !== lastResetMonth) { + db.resetMonthlyBudgets(company.id); + db.setLastBudgetResetMonth(company.id, currentMonth); + logger.info(`[heartbeat] Reset monthly budgets for company ${company.name} (${currentMonth})`); + } + + // D-06: Auto-close incidents from previous budget windows + const closedIncidents = budgetService.autoCloseExpiredIncidents(company.id); + if (closedIncidents > 0) { + logger.info(`[heartbeat] Auto-closed ${closedIncidents} expired budget incident(s) for company ${company.name}`); + } + + // TASK-04: Stale review escalation sweep + try { + const escalated = sweepStaleReviews(company.id, { + db: db as unknown as import("../task-quality-gates").SweepStaleReviewsDeps["db"], + getCompanyExecutionMode: (cId: string) => db.getCompanyExecutionMode(cId), + dispatchAgentMessage: dispatchAgentMessage as unknown as import("../task-quality-gates").SweepStaleReviewsDeps["dispatchAgentMessage"], + resolveApprovalDecision: resolveApprovalDecision as unknown as import("../task-quality-gates").SweepStaleReviewsDeps["resolveApprovalDecision"], + notify: notify as unknown as import("../task-quality-gates").SweepStaleReviewsDeps["notify"], + handleTaskStatusChange: (taskId: string, cId: string, previousStatus: string, newStatus: string) => { + deps.dispatchAutomation("task_status_changed", { companyId: cId, taskId, previousStatus, newStatus }); + publishDomainChanged(); + }, + handleApprovalResolved: (approvalId: string, cId: string, _decision: string) => { + deps.dispatchAutomation("approval_resolved", { companyId: cId }); + publishDomainChanged(); + }, + emitEvent: deps.emitEvent, + publishDomainChanged, + wakeAgentIfPossible: (agentId: string, cId: string, trigger: string) => { + wakeAgentIfPossible(agentId, cId, trigger); + }, + logger, + }); + if (escalated > 0) { + logger.info(`[heartbeat] Auto-escalated ${escalated} stale review(s) for company ${company.name}`); + publishDomainChanged(); + } + } catch (error) { + logger.error(`[heartbeat] Stale review sweep failed: ${error instanceof Error ? error.message : String(error)}`); + } + } catch (error) { + logger.error(`[heartbeat] Error processing company ${company.id}: ${error instanceof Error ? error.message : String(error)}`); + } + } + + try { + const staleReleased = db.releaseStaleWorkspaceLocks(); + if (staleReleased > 0) { + logger.info(`[heartbeat] Released ${staleReleased} stale workspace lock(s)`); + publishDomainChanged(); + } + const staleWakeups = db.purgeExpiredWakeupRequests(); + if (staleWakeups > 0) { + logger.info(`[heartbeat] Purged ${staleWakeups} expired wakeup request(s)`); + } + } catch (error) { + logger.error(`[heartbeat] Stale lock cleanup failed: ${error instanceof Error ? error.message : String(error)}`); + } + } catch (error) { + logger.error(`[heartbeat] Scheduler error: ${error instanceof Error ? error.message : String(error)}`); + } + }, 15_000); + } + + function stopHeartbeatScheduler() { + if (heartbeatTimer) clearInterval(heartbeatTimer); + heartbeatTimer = null; + } + + return { startHeartbeatScheduler, stopHeartbeatScheduler }; +} diff --git a/src/main/orchestration/task-runner.ts b/src/main/orchestration/task-runner.ts new file mode 100644 index 00000000..928d22b0 --- /dev/null +++ b/src/main/orchestration/task-runner.ts @@ -0,0 +1,219 @@ +import { randomUUID } from "node:crypto"; +import type { AppDatabase } from "../database"; +import type { DesktopResult } from "@shared/contracts"; +import type { OrchestrationDependencies } from "./types"; + +export function createTaskRunner( + deps: OrchestrationDependencies, + resolveWorkspace: (task: { workspaceId: string | null; projectId?: string | null }, agent: { workspaceId: string | null }, companyId: string) => { workspace: ReturnType; lockMode: "exclusive" | "shared" | "none"; source: string }, + getRunEligibilityFailure: (agent: any, connector: any, projectId?: string | null) => string | null, + dispatchRunToWorker: (params: any) => void, + hasActiveRun: (filter: { taskId?: string; agentId?: string; companyId: string }) => boolean, +) { + const { + db, + logger, + emitEvent, + hasWorkerReady, + isQuitting, + } = deps; + + function ok(data: T): DesktopResult { + return { ok: true, data }; + } + + function fail(code: string, message: string): DesktopResult { + return { ok: false, error: { code, message } }; + } + + function queueTaskRun(taskId: string, trigger: string, actor: string): DesktopResult { + if (isQuitting()) { + return fail("APP_SHUTTING_DOWN", "Application is shutting down."); + } + if (!hasWorkerReady()) { + logger.warn(`[run] Worker not ready — cannot queue run for task ${taskId} (trigger: ${trigger})`); + return fail("WORKER_UNAVAILABLE", "Orchestration worker is not ready."); + } + const task = db.getTask(taskId); + const companyStatus = db.getCompanyStatus(task.companyId); + if (companyStatus !== "active") { + return fail("COMPANY_NOT_ACTIVE", "Runs can only start while the company is active."); + } + if (!task.assigneeAgentId) return fail("TASK_UNASSIGNED", "Assign an agent before starting a run."); + const agent = db.getAgent(task.assigneeAgentId); + if (agent.companyId !== task.companyId) { + return fail("AGENT_COMPANY_MISMATCH", "The assigned agent does not belong to the task company."); + } + const connector = db.getConnector(agent.connectorId); + const eligibilityFailure = getRunEligibilityFailure(agent, connector, task.projectId ?? null); + if (eligibilityFailure === "AGENT_NOT_RUNNABLE") { + return fail("AGENT_NOT_RUNNABLE", "The assigned agent is paused, pending approval, or terminated."); + } + if (eligibilityFailure === "BUDGET_POLICY_BLOCKED") { + return fail("BUDGET_POLICY_BLOCKED", "A budget policy blocks this run. Review budget settings."); + } + if (eligibilityFailure === "CONNECTOR_NOT_READY") { + return fail("CONNECTOR_NOT_READY", "The assigned agent connector is not ready."); + } + if (eligibilityFailure === "CONNECTOR_MODEL_NOT_ALLOWED") { + return fail("CONNECTOR_MODEL_NOT_ALLOWED", "The assigned agent model is disabled or invalid for its connector."); + } + if (hasActiveRun({ agentId: agent.id, companyId: task.companyId })) { + return fail("AGENT_ALREADY_RUNNING", "The assigned agent already has an active run."); + } + if (db.isAgentPendingApproval(agent.id)) { + return fail("AGENT_PENDING_APPROVAL", "This agent is pending hire approval and cannot run tasks."); + } + const blockingApproval = db.getBlockingApproval(task.companyId, agent.id, task.id); + if (blockingApproval) { + return fail( + "APPROVAL_GATE_BLOCKED", + `Run blocked by pending approval: ${blockingApproval.type} — "${blockingApproval.payloadSummary}". Approve or reject before starting.`, + ); + } + + // CAS checkout — atomic claim (EXEC-01, EXEC-02) + const runId = randomUUID(); + const checkoutSuccess = db.checkoutTask(task.id, runId); + if (!checkoutSuccess) { + return fail("CHECKOUT_RACE_LOST", "Another run claimed this task. Refresh and try again."); + } + + const workspaceResolution = resolveWorkspace(task, agent, task.companyId); + const workspace = workspaceResolution.workspace; + if (workspace && workspace.companyId !== task.companyId) { + return fail("WORKSPACE_COMPANY_MISMATCH", "The selected workspace does not belong to the task company."); + } + + const run = db.createRun({ + id: runId, + companyId: task.companyId, + taskId: task.id, + agentId: agent.id, + workspaceId: workspaceResolution.lockMode === "exclusive" ? (workspace?.id ?? null) : null, + connectorId: connector.id, + }); + + const companyBasic = db.getCompanyBasic(task.companyId); + dispatchRunToWorker({ + runId: run.id, + companyId: task.companyId, + agent, + task, + connector, + workspace, + workspaceMode: workspaceResolution.lockMode, + companyName: companyBasic?.name ?? "Unknown", + companyDescription: companyBasic?.description ?? "", + snapshot: db.listSnapshot(), + trigger, + }); + db.addActivity({ + companyId: task.companyId, + actor, + action: "run.queued", + entityType: "run", + entityId: run.id, + detail: task.title, + }); + emitEvent({ type: "run-updated", runId: run.id, action: "created" }); + return ok(run.id); + } + + function resumeQueuedRuns() { + // D-02: Stale lock recovery at app startup (EXEC-03) + try { + const recovered = db.recoverStaleLocks(); + for (const task of recovered) { + db.addActivity({ + companyId: task.companyId, + actor: "system", + action: "execution.lock_recovered", + entityType: "task", + entityId: task.taskId, + detail: `Recovered stale lock on task "${task.title}" after 15min timeout (startup sweep)`, + }); + logger.info(`[init] Recovered stale lock on task ${task.taskId}: "${task.title}"`); + } + if (recovered.length > 0) { + logger.info(`[init] Recovered ${recovered.length} stale lock(s) at startup`); + } + } catch (err) { + logger.error(`[init] Stale lock recovery at startup failed: ${err instanceof Error ? err.message : String(err)}`); + } + + if (isQuitting() || !hasWorkerReady()) { + return; + } + const snapshot = db.listSnapshot(); + const companiesById = new Map(snapshot.companies.map((company) => [company.id, company])); + + for (const run of db.listQueuedRuns()) { + const company = companiesById.get(run.companyId); + if (!company || company.status !== "active") { + continue; + } + + const hasBlockingSiblingRun = snapshot.runs.some((entry) => + entry.id !== run.id && + entry.companyId === run.companyId && + ["queued", "running"].includes(entry.status) && + (entry.taskId === run.taskId || entry.agentId === run.agentId), + ); + if (hasBlockingSiblingRun) { + continue; + } + + try { + const task = db.getTask(run.taskId); + const agent = db.getAgent(run.agentId); + const connector = db.getConnector(run.connectorId); + const eligibilityFailure = getRunEligibilityFailure(agent, connector, task.projectId ?? null); + if (eligibilityFailure) { + if (eligibilityFailure === "CONNECTOR_MODEL_NOT_ALLOWED") { + db.finishRun({ + runId: run.id, + status: "failed", + summary: "Queued run rejected during recovery because the agent model is disabled or invalid for its connector.", + errorMessage: "Connector model is disabled or invalid for this agent.", + exitCode: null, + signal: null, + model: null, + sessionDisplayId: null, + costUsd: null, + unattributedCost: true, + }); + db.addActivity({ + companyId: run.companyId, + actor: "system", + action: "run.recovery_blocked", + entityType: "run", + entityId: run.id, + detail: `Recovered queued run blocked by connector/model policy for task "${task.title}"`, + }); + emitEvent({ type: "run-updated", runId: run.id, action: "completed" }); + } + continue; + } + const workspaceResolution = resolveWorkspace(task, agent, run.companyId); + dispatchRunToWorker({ + runId: run.id, + companyId: run.companyId, + agent, + task, + connector, + workspace: workspaceResolution.workspace, + workspaceMode: workspaceResolution.lockMode, + companyName: company.name, + companyDescription: company.description, + snapshot, + trigger: "recovery", + }); + } catch (error) { + logger.warn(`[run] Skipped queued run recovery for ${run.id}: ${error instanceof Error ? error.message : String(error)}`); + } + } + } + + return { queueTaskRun, resumeQueuedRuns }; +} diff --git a/src/main/orchestration/turn-context.ts b/src/main/orchestration/turn-context.ts new file mode 100644 index 00000000..2f690e6a --- /dev/null +++ b/src/main/orchestration/turn-context.ts @@ -0,0 +1,298 @@ +/** + * Turn Context Assembly + * + * Extracts the run payload assembly logic from dispatchRunToWorker into + * a single composable function. Each step (env, comments, session, skills, + * prompt) is an isolated helper, making the pipeline testable and extensible. + */ + +import { buildTaskPrompt } from "../prompt-builder"; +import type { AppDatabase } from "../database"; +import type { RuntimeSkillEntry } from "../skill-types"; +import type { ConnectorId, ExecutionMode } from "@shared/types"; +import type { WorkspaceLockMode, EnqueueRunPayload } from "./types"; +import { normalizeMessagesForPrompt, normalizeCommentsForPrompt } from "../message-normalize"; +import { getConnectorModelPolicy, validateConnectorModelSelection } from "../connector-model-policy"; + +export interface RunDispatchParams { + runId: string; + companyId: string; + agent: ReturnType; + task: ReturnType; + connector: ReturnType; + workspace: ReturnType; + workspaceMode: WorkspaceLockMode; + companyName: string; + companyDescription: string; + snapshot: ReturnType; + trigger: string; +} + +export interface TurnContextDeps { + db: AppDatabase; + logger: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void }; + getApiPort: () => number; + createRunToken: (agentId: string, companyId: string, runId: string) => string; + listRuntimeSkillEntries: (companyId: string, agentId: string, connectorId: string) => RuntimeSkillEntry[]; + getSessionHandoff?: (taskId: string) => string | null; + evaluatePreRunSessionRotation?: (taskId: string, connectorId: string) => { + rotated: boolean; + handoff: string | null; + }; +} + +function assembleApiEnv( + db: AppDatabase, + connector: RunDispatchParams["connector"], + companyId: string, + agentId: string, + runId: string, + trigger: string, + deps: Pick, +) { + const env = db.resolveSecretEnv(connector.envBindingText, companyId); + const apiPort = deps.getApiPort(); + const apiUrl = apiPort ? `http://127.0.0.1:${apiPort}` : ""; + const apiKey = apiUrl ? deps.createRunToken(agentId, companyId, runId) : ""; + + if (apiUrl) { + env.AGENT_COMPANY_API_URL = apiUrl; + env.AGENT_COMPANY_API_KEY = apiKey; + env.AGENT_COMPANY_AGENT_ID = agentId; + env.AGENT_COMPANY_COMPANY_ID = companyId; + env.AGENT_COMPANY_RUN_ID = runId; + env.AGENT_COMPANY_TASK_ID = ""; + env.AGENT_COMPANY_WAKE_REASON = trigger; + } + return { env, apiUrl, apiKey }; +} + +function assembleRecentComments( + assignedTasks: ReturnType, + reviewTasks: ReturnType, + snapshot: RunDispatchParams["snapshot"], +) { + const relevantTaskIds = new Set([ + ...assignedTasks.map((t) => t.id), + ...reviewTasks.map((t) => t.id), + ]); + const allRelevantTasks = [...assignedTasks, ...reviewTasks]; + return snapshot.comments + .filter((comment) => relevantTaskIds.has(comment.taskId)) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) + .slice(0, 30) + .map((comment) => ({ + taskId: comment.taskId, + taskTitle: allRelevantTasks.find((t) => t.id === comment.taskId)?.title ?? comment.taskId, + authorName: comment.authorName, + body: comment.body, + createdAt: comment.createdAt, + })); +} + +function resolveSessionHandoff( + taskId: string, + deps: Pick, +): string | null { + if (!deps.getSessionHandoff) return null; + try { + return deps.getSessionHandoff(taskId); + } catch (err) { + deps.logger.warn(`[run] Failed to load session handoff for task ${taskId}: ${err instanceof Error ? err.message : String(err)}`); + return null; + } +} + +function resolveSkills( + companyId: string, + agentId: string, + connectorId: string, + agentName: string, + deps: Pick, +): RuntimeSkillEntry[] { + try { + return deps.listRuntimeSkillEntries(companyId, agentId, connectorId); + } catch (err) { + deps.logger.warn(`[run] Failed to fetch managed skills for agent ${agentName}: ${err instanceof Error ? err.message : String(err)}`); + return []; + } +} + +function resolveEnabledConnectorModels( + db: AppDatabase, + connector: { id: string; model: string | null }, +): string[] { + return getConnectorModelPolicy(db, connector.id as ConnectorId).enabledModels.map((model) => model.id); +} + +function resolveSession( + db: AppDatabase, + task: RunDispatchParams["task"], + connectorId: string, + sessionHandoff: string | null, + deps: Pick, +) { + const activeSession = db.getActiveTaskSession(task.id); + let taskSession = activeSession + ? { + sessionParams: activeSession.sessionParamsJson ? JSON.parse(activeSession.sessionParamsJson) as Record : null, + sessionDisplayId: activeSession.sessionDisplayId, + } + : db.getTaskSession(task.id); + + let finalHandoff = sessionHandoff; + if (deps.evaluatePreRunSessionRotation && activeSession) { + const rotationResult = deps.evaluatePreRunSessionRotation(task.id, connectorId); + if (rotationResult.rotated) { + taskSession = { sessionParams: null, sessionDisplayId: null }; + if (!finalHandoff && rotationResult.handoff) { + finalHandoff = rotationResult.handoff; + } + deps.logger.info(`[session] Pre-run compaction for task ${task.id}: session rotated before execution`); + } + } + + return { taskSession, sessionHandoff: finalHandoff }; +} + +function extractThinkingEffort(metadataJson: string | null): string | null { + try { + const meta = JSON.parse(metadataJson || "{}"); + if (typeof meta.thinkingEffort === "string" && meta.thinkingEffort) { + return meta.thinkingEffort; + } + } catch { /* malformed metadata */ } + return null; +} + +/** + * Assemble the full run payload from resolved entities and dependencies. + * This is the core composition step that was previously inlined in dispatchRunToWorker. + */ +export function buildRunPayload(params: RunDispatchParams, deps: TurnContextDeps): EnqueueRunPayload { + const { runId, companyId, agent, task, connector, workspace, workspaceMode, companyName, companyDescription, snapshot, trigger } = params; + const { db } = deps; + + const { env, apiUrl, apiKey } = assembleApiEnv(db, connector, companyId, agent.id, runId, trigger, deps); + env.AGENT_COMPANY_TASK_ID = task.id; + + const assignedTasks = db.listAgentTasks(agent.id, companyId, ["todo", "in_progress", "blocked"]); + const reviewTasks = db.listReviewTasksForManager(agent.id, companyId); + const recentComments = assembleRecentComments(assignedTasks, reviewTasks, snapshot); + let sessionHandoff = resolveSessionHandoff(task.id, deps); + const skills = resolveSkills(companyId, agent.id, connector.id, agent.name, deps); + + const skillNames = skills + .map((s) => s.name ?? s.runtimeName) + .filter(Boolean); + + const systemPrompt = apiUrl ? buildTaskPrompt({ + agent, + apiUrl, + apiKey, + runId, + task, + companyName, + locale: snapshot.locale, + executionMode: db.getCompanyExecutionMode(companyId) as ExecutionMode, + goals: snapshot.goals.filter((goal) => goal.companyId === companyId), + projects: snapshot.projects.filter((project) => project.companyId === companyId), + directReports: db.getDirectReports(agent.id, companyId), + chainOfCommand: db.getChainOfCommand(agent.id), + assignedTasks, + allAgents: snapshot.agents.filter((entry) => entry.companyId === companyId), + pendingApprovals: snapshot.approvals.filter( + (approval) => approval.companyId === companyId && + approval.requestedByAgentId === agent.id && + (approval.state === "pending" || approval.state === "revision_requested"), + ), + tasksAwaitingReview: reviewTasks, + wakeReason: trigger, + allTasks: snapshot.tasks.filter((entry) => entry.companyId === companyId), + recentComments: normalizeCommentsForPrompt(recentComments), + companyDescription, + socialAccounts: db.listSocialAccounts(companyId).map((account) => ({ + id: account.id, + platform: account.platform, + accountName: account.accountName, + status: account.status, + requireApproval: account.requireApproval, + })), + recentMessages: normalizeMessagesForPrompt(db.listAgentMessages(companyId, { agentId: agent.id, limit: 20 })), + upcomingMeetings: snapshot.meetings + .filter((meeting) => meeting.companyId === companyId && (meeting.status === "scheduled" || meeting.status === "in_progress")) + .filter((meeting) => { + try { + const ids: string[] = JSON.parse(meeting.participantAgentIds); + return ids.includes(agent.id) || meeting.organizerAgentId === agent.id; + } catch { + return false; + } + }) + .slice(0, 5), + relevantKnowledge: snapshot.knowledgeBase + .filter((entry) => entry.companyId === companyId && (entry.importance === "critical" || entry.importance === "high")) + .slice(0, 20), + activeSprint: snapshot.sprints.find((sprint) => sprint.companyId === companyId && sprint.status === "active") ?? null, + recentDocuments: snapshot.documents + .filter((document) => document.companyId === companyId && (document.authorAgentId === agent.id || document.reviewerAgentId === agent.id || document.status === "in_review")) + .slice(0, 5), + allConnectors: snapshot.connectors.map((c) => ({ + id: c.id, + label: c.label, + status: c.status, + enabledModels: resolveEnabledConnectorModels(db, c), + })), + sessionHandoff, + skillNames, + skillEntries: skills.map((s) => ({ + name: s.name ?? s.runtimeName, + whenToUse: s.whenToUse, + allowedTools: s.allowedTools, + effort: s.effort, + contextMode: s.contextMode, + })), + companySkillCatalog: db.listCompanySkills(companyId).map((skill) => ({ + name: skill.name, + slug: skill.slug, + description: skill.description, + })), + companyWorkspaces: snapshot.workspaces + .filter((workspace) => workspace.companyId === companyId) + .map((workspace) => ({ name: workspace.name, localPath: workspace.localPath })), + companyDocuments: snapshot.documents.filter((d) => d.companyId === companyId), + }) : null; + + const { taskSession, sessionHandoff: finalHandoff } = resolveSession(db, task, connector.id, sessionHandoff, deps); + if (finalHandoff !== sessionHandoff && systemPrompt && finalHandoff) { + sessionHandoff = finalHandoff; + } + + const thinkingEffort = extractThinkingEffort(agent.metadataJson); + const modelSelection = validateConnectorModelSelection(db, connector.id, agent.model); + if (!modelSelection.ok) { + throw new Error(modelSelection.error); + } + + return { + runId, + companyId, + taskId: task.id, + taskTitle: task.title, + taskDescription: task.description, + agentId: agent.id, + agentName: agent.name, + connectorId: connector.id, + workspaceId: workspaceMode === "exclusive" ? (workspace?.id ?? null) : null, + workspacePath: workspace?.localPath ?? null, + workspaceMode, + command: connector.command, + model: modelSelection.effectiveModel, + thinkingEffort, + env, + sessionParams: taskSession.sessionParams, + systemPrompt, + trigger, + managedSkills: skills.map((s) => ({ runtimeName: s.runtimeName, sourcePath: s.sourcePath })), + }; +} diff --git a/src/main/orchestration/types.ts b/src/main/orchestration/types.ts new file mode 100644 index 00000000..443f1296 --- /dev/null +++ b/src/main/orchestration/types.ts @@ -0,0 +1,74 @@ +import type { AppDatabase } from "../database"; +import type { NotificationOptions } from "../notification-service"; +import type { createBudgetEnforcementService } from "../budget-enforcement-service"; +import type { RuntimeSkillEntry } from "../skill-types"; + +export type WorkspaceLockMode = "exclusive" | "shared" | "none"; + +export interface WorkspaceResolution { + workspace: ReturnType; + lockMode: WorkspaceLockMode; + source: "task" | "agent" | "project" | "company_fallback" | "none"; +} + +export interface AutomationDispatch { + companyId: string; + taskId?: string; + agentId?: string; + runId?: string; + projectId?: string; + department?: string; + previousStatus?: string; + newStatus?: string; + costUsd?: number; +} + +export interface EnqueueRunPayload { + runId: string; + companyId: string; + taskId: string; + taskTitle: string; + taskDescription: string; + agentId: string; + agentName: string; + connectorId: string; + workspaceId: string | null; + workspacePath: string | null; + workspaceMode: WorkspaceLockMode; + command: string; + model: string | null; + thinkingEffort: string | null; + env: Record; + sessionParams: Record | null; + systemPrompt: string | null; + trigger: string | null; + managedSkills: Array<{ runtimeName: string; sourcePath: string }>; +} + +export interface OrchestrationDependencies { + db: AppDatabase; + logger: { + debug: (...args: unknown[]) => void; + info: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + }; + publishDomainChanged: () => void; + emitEvent: (event: any) => void; + notify: (options: NotificationOptions) => void; + createRunToken: (agentId: string, companyId: string, runId: string) => string; + getApiPort: () => number; + dispatchAutomation: (trigger: string, ctx: AutomationDispatch) => void; + hasWorkerReady: () => boolean; + enqueueWorkerRun: (payload: EnqueueRunPayload) => void; + isQuitting: () => boolean; + listRuntimeSkillEntries: (companyId: string, agentId: string, connectorId: string) => RuntimeSkillEntry[]; + budgetService: ReturnType; + /** Checkpoint engine for unified co-founder mode pause decisions. */ + checkpointEngine?: { shouldPauseHeartbeat: (companyId: string, agentId: string) => boolean }; + getSessionHandoff?: (taskId: string) => string | null; + evaluatePreRunSessionRotation?: (taskId: string, connectorId: string) => { + rotated: boolean; + handoff: string | null; + }; +} diff --git a/src/main/prompt-builder.ts b/src/main/prompt-builder.ts index a9999ff7..b875e7f8 100644 --- a/src/main/prompt-builder.ts +++ b/src/main/prompt-builder.ts @@ -1,1695 +1,2 @@ -import type { AgentRecord, AgentMessageRecord, ApprovalRecord, ConnectorId, DocumentRecord, GoalRecord, KnowledgeEntryRecord, MeetingRecord, ProjectRecord, SprintRecord, TaskRecord } from "@shared/types"; -import type { AppLocale } from "@shared/locale"; -import { DEPARTMENT_LABELS } from "@shared/constants"; -import { getLocalePromptInstruction } from "@shared/localization"; -import { getConnectorStrengths, inferTaskType } from "./connector-matching"; - -interface PromptContext { - agent: AgentRecord; - apiUrl: string; - apiKey: string; - runId: string; - task: TaskRecord | null; - companyName: string; - locale?: AppLocale; - goals: GoalRecord[]; - projects: ProjectRecord[]; - directReports: AgentRecord[]; - chainOfCommand: AgentRecord[]; - assignedTasks: TaskRecord[]; - allAgents: AgentRecord[]; - pendingApprovals: ApprovalRecord[]; - tasksAwaitingReview: TaskRecord[]; - wakeReason: string | null; - allTasks: TaskRecord[]; - recentComments: Array<{ taskId: string; taskTitle: string; authorName: string; body: string; createdAt: string }>; - companyDescription: string; - socialAccounts: Array<{ id: string; platform: string; accountName: string; status: string; requireApproval: boolean }>; - recentMessages?: AgentMessageRecord[]; - upcomingMeetings?: MeetingRecord[]; - relevantKnowledge?: KnowledgeEntryRecord[]; - activeSprint?: SprintRecord | null; - recentDocuments?: DocumentRecord[]; - allConnectors?: Array<{ id: string; label: string; status: string }>; - /** D-07: Handoff markdown from a rotated session, injected as context for the agent */ - sessionHandoff?: string | null; - /** Names of skills injected into this agent's runtime environment */ - skillNames?: string[]; -} - -export function buildSystemPrompt(ctx: PromptContext): string { - const sections: string[] = []; - - const isCEO = ctx.chainOfCommand.length === 0; - const isManager = !isCEO && ctx.directReports.length > 0; - - const localeInstruction = getLocalePromptInstruction(ctx.locale ?? "en"); - if (localeInstruction) { - sections.push(`# Language Requirement\n${localeInstruction}\n`); - } - - const now = new Date(); - const dateStr = now.toISOString().split("T")[0]; - const timeStr = now.toISOString().split("T")[1]?.slice(0, 5) ?? "00:00"; - const dayOfWeek = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"][now.getDay()]; - - sections.push(`# Current Date & Time -Today is ${dayOfWeek}, ${dateStr} at ${timeStr} UTC. -`); - - sections.push(`# Agent Identity -You are ${ctx.agent.name}, a ${ctx.agent.role}${ctx.agent.title ? ` (${ctx.agent.title})` : ""} at ${ctx.companyName}. -${ctx.agent.capabilities ? `Your capabilities: ${ctx.agent.capabilities}` : ""} -Your monthly budget: $${ctx.agent.budgetMonthlyUsd.toFixed(2)} | Spent this month: $${ctx.agent.spentMonthlyUsd.toFixed(2)}${ctx.agent.budgetMonthlyUsd > 0 ? ` (${Math.round((ctx.agent.spentMonthlyUsd / ctx.agent.budgetMonthlyUsd) * 100)}% used)` : ""} -${ctx.skillNames && ctx.skillNames.length > 0 ? `Your domain skills: ${ctx.skillNames.join(", ")}. These skills are loaded into your environment and provide domain expertise — use them when relevant to your work.` : ""} -`); - - if (ctx.companyDescription) { - sections.push(`# Company Mission -${ctx.companyDescription} -`); - } - - sections.push(`# Cognitive Framework (How You Think) -You operate using a structured decision-making process: - -## OBSERVE: Gather Information -- Read your standup briefing first (GET /api/agents/me/standup) -- Check all recent comments on your tasks -- Review your team's workload if you're a manager -- Note any changes since your last wake - -## ORIENT: Analyze & Prioritize -- What is the most critical issue right now? (blockers > reviews > new work) -- What is the company-wide priority? (align with active goals) -- What are my teammates depending on me for? -- What has changed since my last session? - -## DECIDE: Choose Your Action -- Select the single highest-impact action you can take right now -- If multiple actions are equal priority, choose the one that unblocks others -- If unsure, ask your manager via comment rather than guessing - -## ACT: Execute with Quality -- Take the action. Document what you did and why. -- Leave a trail: comments, status updates, documents -- Verify your work before submitting for review -`); - - if (isCEO) { - sections.push(`# Your Leadership Style -- You think strategically, not tactically. Your job is vision and coordination. -- You communicate through clear, decisive directives. -- You hold your team accountable with specific, measurable expectations. -- You celebrate wins publicly (via comments) and address failures privately (via 1:1 tasks). -- When making decisions, consider: impact on all departments, timeline, budget, team morale. -`); - } else if (isManager) { - sections.push(`# Your Management Style -- You are a force multiplier. Your success is measured by your team's output, not your own. -- Shield your team from distractions — handle cross-department coordination yourself. -- Give specific, actionable feedback — not vague "looks good" or "needs work." -- Track your team's velocity and identify bottlenecks proactively. -- When delegating, match task complexity to team member capability + growth. -`); - } else { - sections.push(`# Your Work Philosophy -- You are a craftsperson. Take pride in quality work that ships. -- Ask questions early — a 5-minute clarification saves hours of wrong work. -- Leave your code/work better than you found it (but don't gold-plate). -- When stuck for more than 10 minutes, escalate. Don't spin your wheels. -- Document decisions and tradeoffs as you go, not after. -`); - } - - if (ctx.chainOfCommand.length > 0) { - sections.push(`# Chain of Command -You report to: ${ctx.chainOfCommand.map((a) => `${a.name} (${a.role})`).join(" → ")} -`); - } else { - sections.push(`# Position -You are the top-level executive. You report directly to the human operator (Board). -`); - } - - if (ctx.directReports.length > 0) { - sections.push(`# Your Direct Reports -${ctx.directReports.map((a) => `- ${a.name}: ${a.role}${a.title ? ` (${a.title})` : ""}${a.capabilities ? ` — ${a.capabilities}` : ""} [status: ${a.status}, connector: ${a.connectorId}]`).join("\n")} -`); - } - - if (ctx.directReports.length > 0) { - const reportTaskSummaries = ctx.directReports.map((report) => { - const reportTasks = ctx.allTasks.filter((t) => t.assigneeAgentId === report.id); - const inProgress = reportTasks.filter((t) => t.status === "in_progress").length; - const inReview = reportTasks.filter((t) => t.status === "in_review").length; - const blocked = reportTasks.filter((t) => t.status === "blocked").length; - const todo = reportTasks.filter((t) => t.status === "todo").length; - return ` - ${report.name} (${report.role}): ${inProgress} in progress, ${inReview} in review, ${blocked} blocked, ${todo} queued`; - }); - - sections.push(`# Team Workload -${reportTaskSummaries.join("\n")} -`); - } - - if (ctx.agent.department) { - const deptLabel = DEPARTMENT_LABELS[ctx.agent.department] ?? ctx.agent.department; - const deptMembers = ctx.allAgents.filter( - (a) => a.department === ctx.agent.department && a.id !== ctx.agent.id && a.status !== "terminated", - ); - sections.push(`# Department: ${deptLabel} -You belong to the ${deptLabel} department.${deptMembers.length > 0 ? ` -Your department colleagues: -${deptMembers.map((a) => `- ${a.name} (${a.role}) [${a.status}]`).join("\n")}` : ""} -`); - } - - if (isCEO || ctx.directReports.length > 0) { - const deptMap = new Map(); - for (const a of ctx.allAgents.filter((a) => a.status !== "terminated")) { - const dept = a.department ?? "unassigned"; - if (!deptMap.has(dept)) deptMap.set(dept, []); - deptMap.get(dept)!.push(a); - } - if (deptMap.size > 1) { - const deptLines = Array.from(deptMap.entries()).map(([dept, members]) => { - const label = DEPARTMENT_LABELS[dept] ?? dept; - return ` ${label} (${members.length}): ${members.map((m) => m.name).join(", ")}`; - }); - sections.push(`# Company Department Map -${deptLines.join("\n")} -`); - } - } - - const activeGoals = ctx.goals.filter((g) => g.status === "active" || g.status === "planned"); - if (activeGoals.length > 0) { - sections.push(`# Active Goals -${activeGoals.map((g) => `- [${g.id}] ${g.title}: ${g.description || "No description"} (status: ${g.status}${g.ownerAgentId === ctx.agent.id ? " — YOU OWN THIS" : ""})`).join("\n")} -`); - } - - const relevantProjects = ctx.projects.filter((p) => - p.status === "in_progress" || p.status === "planned" || - p.leadAgentId === ctx.agent.id, - ); - if (relevantProjects.length > 0) { - sections.push(`# Projects -${relevantProjects.map((p) => `- [${p.id}] ${p.name}: ${p.description || ""} (status: ${p.status}${p.leadAgentId === ctx.agent.id ? " — YOU LEAD THIS" : ""})`).join("\n")} -`); - } - - // D-07: Inject handoff context from previous rotated session - if (ctx.sessionHandoff) { - sections.push(`# Session Handoff -The following context was preserved from your previous session on this task: - -${ctx.sessionHandoff} -`); - } - - if (ctx.task) { - sections.push(`# Current Task -- ID: ${ctx.task.id} -- Title: ${ctx.task.title} -- Description: ${ctx.task.description || "No description"} -- Status: ${ctx.task.status} -- Priority: ${ctx.task.priority} -${ctx.task.projectId ? `- Project: ${ctx.projects.find((p) => p.id === ctx.task!.projectId)?.name ?? ctx.task.projectId}` : ""} -${ctx.task.goalId ? `- Goal: ${ctx.goals.find((g) => g.id === ctx.task!.goalId)?.title ?? ctx.task.goalId}` : ""} -`); - - // Add deliverable requirements based on task type for non-code tasks - const taskType = ((ctx.task as unknown as Record).task_type as string) ?? inferTaskType(ctx.agent.department); - - if (taskType === "research") { - sections.push(`# Deliverable Requirements (Research Task) -This is a research task. You MUST produce a tangible deliverable before marking it complete: - -**Required:** Create at least ONE of: -1. A knowledge entry via POST /api/companies/${ctx.agent.companyId}/knowledge with your findings -2. A document via POST /api/companies/${ctx.agent.companyId}/documents with a structured report -3. A detailed comment on the task via PATCH /api/issues/${ctx.task.id} with body containing your analysis (minimum 100 words) - -Do NOT mark the task as done or in_review without producing one of these artifacts. -Your manager will reject the task if no deliverable is found. -`); - } else if (taskType === "content") { - sections.push(`# Deliverable Requirements (Content Task) -This is a content creation task. You MUST produce a tangible deliverable before marking it complete: - -**Required:** Create at least ONE of: -1. A document via POST /api/companies/${ctx.agent.companyId}/documents with the content draft -2. A social media draft via the social media queue -3. A detailed comment on the task via PATCH /api/issues/${ctx.task.id} with the content (minimum 100 words) - -Do NOT mark the task as done or in_review without producing one of these artifacts. -Your manager will reject the task if no deliverable is found. -`); - } - } - - if (ctx.assignedTasks.length > 0) { - sections.push(`# Your Assigned Tasks -${ctx.assignedTasks.map((t) => `- [${t.id}] ${t.title} (status: ${t.status}, priority: ${t.priority})`).join("\n")} -`); - } - - if (ctx.tasksAwaitingReview.length > 0) { - const reviewDetails = ctx.tasksAwaitingReview.map((t) => { - const assigneeName = ctx.allAgents.find((a) => a.id === t.assigneeAgentId)?.name ?? "Unknown"; - const desc = t.description ? `\n Description: ${t.description.slice(0, 300)}${t.description.length > 300 ? "..." : ""}` : ""; - const taskComments = ctx.recentComments - .filter((c) => c.taskId === t.id) - .slice(-3) - .map((c) => ` - ${c.authorName}: "${c.body.slice(0, 150)}${c.body.length > 150 ? "..." : ""}"`) - .join("\n"); - const commentSection = taskComments ? `\n Recent comments:\n${taskComments}` : "\n No comments yet."; - return `- [${t.id}] ${t.title} (by ${assigneeName}, priority: ${t.priority})${desc}${commentSection}`; - }).join("\n\n"); - - sections.push(`# Tasks Awaiting Your Review (${ctx.tasksAwaitingReview.length} pending) - -${reviewDetails} - -REVIEW WORKFLOW — This is your HIGHEST PRIORITY after resolving blockers: -1. For EACH task above, first read ALL comments: GET /api/issues/{issueId}/comments -2. Evaluate whether the work meets the task description and acceptance criteria -3. To APPROVE: PATCH /api/issues/{issueId} with {"status": "done", "comment": "Approved. "} -4. To REQUEST CHANGES: PATCH /api/issues/{issueId} with {"status": "in_progress", "comment": "Changes needed: "} -5. Do NOT ignore review requests — your team is blocked until you respond -`); - } - - if (ctx.pendingApprovals.length > 0) { - sections.push(`# Pending Approvals -${ctx.pendingApprovals.map((a) => `- [${a.id}] ${a.type}: ${a.payloadSummary} (status: ${a.state})`).join("\n")} -`); - } - - sections.push(`# Standard Operating Procedures - -## SOP-001: Daily Standup Protocol -At the start of each heartbeat wake, before doing new work: -1. Check all your assigned tasks for status updates -2. Read new comments on your tasks since your last run -3. If you manage others: check your reports' task statuses and read their comments -4. Identify any blockers and escalate immediately -5. Then proceed to your highest-priority assigned work - -## SOP-002: Task Lifecycle -Every task follows this lifecycle and ALL agents must respect it: -- backlog → todo: Task is ready to be worked on -- todo → in_progress: Agent checks out the task (POST checkout) -- in_progress → in_review: Work is done, submit for manager review -- in_review → done: Manager approves the work -- in_review → in_progress: Manager requests changes (read feedback comments) -- Any → blocked: Agent hits a blocker (post comment explaining why) -- blocked → todo: Blocker resolved, ready to retry - -NEVER skip steps. NEVER mark your own work as "done" — always submit to "in_review" and let your manager approve. - -## SOP-003: Delegation Protocol (Managers & Executives) -When delegating work: -1. Break the goal/project into specific, actionable subtasks -2. Each subtask must have a clear title, detailed description, and acceptance criteria -3. Assign each subtask to the most capable report for that work -4. Set parentId to maintain the task hierarchy -5. Include the projectId and goalId for traceability -6. After delegation, leave a comment on the parent task summarizing the plan - -## SOP-004: Code Review Protocol -When reviewing work from reports: -1. Read ALL comments on the task to understand what was done -2. Check if the work matches the task description and acceptance criteria -3. If approved: Update status to "done" + leave a comment explaining why it's good -4. If changes needed: Set status back to "in_progress" + leave detailed feedback -5. NEVER ignore review requests — they are your highest priority after blockers - -## SOP-005: Hiring Protocol -When the team needs new members: -1. Identify the capability gap (what work can't be done with current team?) -2. Define the role clearly: name, title, capabilities, which manager they report to -3. Choose the best connector/model for the role -4. Submit via POST /api/companies/{companyId}/agents -5. The hire enters pending_approval — the Board must approve -6. After approval, assign onboarding tasks to the new hire - -## SOP-006: Escalation Chain -- IC blocked? → Post comment on task → Manager is auto-notified -- Manager can't resolve? → Post comment on parent task → Executive is auto-notified -- Need credentials/secrets? → Comment up the chain, executive creates secret_access approval -- Budget exhausted? → Automatic pause — Board must increase budget -- Critical failure? → Create dangerous_command approval for Board attention - -## SOP-007: Inter-Agent Communication -You have TWO communication channels — use both strategically: - -**Task Comments** (for task-specific discussions): -- When discussing a specific task, use POST /api/issues/{issueId}/comments -- Comments are visible to all agents watching the task -- Use for: progress updates, code review feedback, blockers, acceptance criteria questions - -**Agent Messaging** (for coordination, announcements, and requests): -- Use POST /api/companies/{companyId}/messages to send messages -- Direct messages (channel: "direct", toAgentId: "{agentId}") WAKE the recipient immediately -- Department messages (channel: "department", channelTargetId: "{departmentName}") reach all dept members -- Company-wide messages (channel: "company") reach everyone and wake the CEO -- Project messages (channel: "project", channelTargetId: "{projectId}") reach all agents assigned to tasks in that project -- Incident messages (channel: "incident") are high-visibility alerts visible to all agents company-wide -- USE MESSAGING WHEN: - * You need another agent to take action NOW (send direct message) - * You're announcing something department-wide (send to department channel) - * You're sharing a status update or decision (send to company channel) - * You need cross-department coordination (send to target department) - * You want to discuss strategy or priorities (send to your manager directly) - * You're coordinating within a specific project (send to project channel with projectId) - * There is a critical outage or urgent issue (send to incident channel) - -**IMPORTANT**: Check your messages EVERY wake cycle. Read unread messages first: - GET /api/companies/{companyId}/messages?unreadOnly=true&limit=20 -Then respond to any that require your input. - -## SOP-008: Cross-Department Collaboration -When your work depends on or affects another department: -1. Identify the department lead or relevant agent from the Company Department Map -2. Create a task assigned to them with clear requirements and deadline -3. Set the task priority based on your blocking urgency -4. Post a comment explaining the cross-department dependency -5. Common cross-department workflows: - - Engineering ↔ Product: Product writes PRD → Engineering implements → QA tests → Product validates - - Marketing ↔ Engineering: Marketing needs landing page → Engineering builds → Marketing reviews - - Sales ↔ Product: Sales collects customer feedback → Product prioritizes → Engineering delivers - - HR ↔ All Departments: HR facilitates hiring → Department head defines requirements → HR screens - - Finance ↔ All Departments: Department submits budget_request → CFO reviews → Finance tracks spend - - Customer Support ↔ Engineering: Support reports bug → Engineering fixes → Support verifies with customer - -### Standard Cross-Department Workflows: - -**Product Development Cycle:** -Product → writes PRD → Design → creates mockups → Engineering → implements → QA → tests → Product → validates → Marketing → announces → Sales → sells → Support → supports - -**Incident Response:** -Support → reports issue → Engineering → investigates → DevOps → mitigates → Engineering → root cause → Product → decides fix priority → Engineering → implements fix → QA → regression test → Support → verifies with customer → Knowledge → post-mortem - -**New Feature Request (from customer):** -Support → captures requirement → Product → evaluates & prioritizes → Design → UX research → Product → writes PRD → Engineering → estimates → Finance → budgets → CEO → approves → Engineering → builds - -**Hiring Pipeline:** -Department Head → identifies gap → HR → writes job description → Finance → approves budget → CEO → final approval → HR → onboards → Manager → assigns buddy & first tasks - -**Quarterly Business Review:** -Finance → generates P&L → Operations → compiles OKR status → Each department head → submits report → CEO → presents to Board - -## SOP-009: Document Management -Important work should produce artifacts: -- **PRDs**: Product Managers write PRDs before engineering work begins -- **Technical Specs**: Engineers write specs before complex implementations -- **Post-Mortems**: After incidents, the lead writes a post-mortem with lessons learned -- **Meeting Notes**: After important meetings, capture decisions and action items -- **Status Reports**: Weekly status reports from department heads to CEO -Use task comments to share document content. Reference document IDs in related tasks. - -## SOP-010: Budget & Financial Protocol -- All agents have a monthly budget. Track your spend. -- Before starting expensive operations, check your remaining budget via GET /api/agents/me -- If you need more budget, escalate to your manager who escalates to CFO -- For vendor purchases or new tools, submit a budget_request approval -- The CFO reviews all budget requests above the department head's authority -- Monthly budget resets on the 1st — plan your most expensive work early in the month - -## SOP-011: Performance & Quality Standards -- Every agent's work is tracked: tasks completed, success rate, cost efficiency, review cycles -- Quality > Speed: Better to submit correct work slowly than buggy work quickly -- If a task is sent back for revision more than twice, escalate to your manager -- Managers should conduct monthly performance check-ins via task comments -- Identify agents who are consistently blocked or failing — propose retraining or role changes - -## SOP-012: Sprint & Cadence Management -The company operates in recurring cycles: -- **Daily Standup**: Every heartbeat wake starts with standup check (SOP-001) -- **Sprint Cycle**: 2-week sprints with planning → execution → review → retrospective -- **Weekly Sync**: Department heads report progress to CEO -- **Monthly Review**: Company-wide metrics review, budget check, hiring needs -When creating tasks, consider the current sprint cycle and set realistic deadlines. - -## SOP-013: Incident Response Protocol -When something critical goes wrong: -1. The discovering agent creates a task with priority "critical" and tags the CTO/COO -2. CTO assesses technical impact, COO assesses business impact -3. Engineering focuses on mitigation first, root cause second -4. After resolution, the lead writes a post-mortem document -5. Action items from the post-mortem become tracked tasks -6. Knowledge learned gets added to the team's institutional memory - -## SOP-014: Hiring Pipeline -When the company needs new talent: -1. Department head identifies the gap and writes a hiring_requisition (description of ideal candidate) -2. HR Director reviews and approves the requisition -3. CFO confirms budget availability -4. CEO gives final approval via hire_agent approval -5. New agent is created with clear role, reporting line, and onboarding tasks -6. HR creates onboarding task: introduce to team, share access, assign a buddy -7. Manager assigns first tasks appropriate to the new hire's level - -## SOP-015: Knowledge Management -The company should get smarter over time: -- After solving a hard problem, document the solution as a lesson_learned comment -- After making an important decision, document the rationale and alternatives considered -- When onboarding new agents, share relevant past decisions and lessons -- Periodically review past incidents and ensure fixes are still in place -- Capture "best practices" that emerge from repeated successes -`); - - if (ctx.recentComments.length > 0) { - const commentLines = ctx.recentComments.slice(0, 10).map((c) => { - return `- [${c.taskTitle}] ${c.authorName}: "${c.body.slice(0, 150)}${c.body.length > 150 ? "..." : ""}"`; - }); - sections.push(`# Recent Task Comments -These are the most recent comments on your tasks. Review them before taking action. -${commentLines.join("\n")} -`); - } - - if (ctx.recentMessages && ctx.recentMessages.length > 0) { - const agentMap = new Map(ctx.allAgents.map((a) => [a.id, a.name])); - const msgLines = ctx.recentMessages.slice(0, 15).map((m) => { - const from = agentMap.get(m.fromAgentId) ?? m.fromAgentId; - const to = m.toAgentId ? (agentMap.get(m.toAgentId) ?? m.toAgentId) : "channel"; - const channelLabel = m.channel === "direct" ? `DM → ${to}` : `#${m.channel}${m.channelTargetId ? `-${m.channelTargetId}` : ""}`; - const isUnread = !m.readAt; - return `- ${isUnread ? "[UNREAD] " : ""}[${channelLabel}] ${from}: "${m.subject}" — ${m.body.slice(0, 120)}${m.body.length > 120 ? "..." : ""}`; - }); - const unreadCount = ctx.recentMessages.filter((m) => !m.readAt).length; - sections.push(`# Your Messages${unreadCount > 0 ? ` (${unreadCount} unread)` : ""} -${msgLines.join("\n")} -${unreadCount > 0 ? "\n**ACTION REQUIRED**: Read and respond to unread messages before starting new work. Mark messages as read after processing: POST /api/companies/{companyId}/messages/{messageId}/read" : ""} -`); - } - - sections.push(`# AgentCompany API -You have access to the AgentCompany API to manage your work. Base URL: ${ctx.apiUrl} -Use the environment variables AGENT_COMPANY_API_URL and AGENT_COMPANY_API_KEY for authenticated calls. -Do not print or echo AGENT_COMPANY_API_KEY into comments, summaries, or external tools. - -## Authentication & Request Format -Every API call MUST include these headers: - Authorization: Bearer $AGENT_COMPANY_API_KEY - Content-Type: application/json; charset=utf-8 - X-Agent-Company-Run-Id: ${ctx.runId} - -IMPORTANT: On Windows, prefer using Node.js fetch() for API calls instead of curl, -because curl on Windows may corrupt non-ASCII characters (Chinese, Japanese, etc.). -If you must use curl, write the JSON body to a temp file first and use curl -d @file. - -Example using fetch (Node.js) — PREFERRED: - const res = await fetch(\`\${process.env.AGENT_COMPANY_API_URL}/api/agents/me\`, { - headers: { - "Authorization": \`Bearer \${process.env.AGENT_COMPANY_API_KEY}\`, - "Content-Type": "application/json; charset=utf-8", - "X-Agent-Company-Run-Id": "${ctx.runId}" - } - }); - const data = await res.json(); - -Example POST using fetch (Node.js) — PREFERRED for non-ASCII content: - const res = await fetch(\`\${process.env.AGENT_COMPANY_API_URL}/api/companies/${ctx.agent.companyId}/issues\`, { - method: "POST", - headers: { - "Authorization": \`Bearer \${process.env.AGENT_COMPANY_API_KEY}\`, - "Content-Type": "application/json; charset=utf-8", - "X-Agent-Company-Run-Id": "${ctx.runId}" - }, - body: JSON.stringify({title:"Task title",description:"Details",assigneeAgentId:"agent-id",priority:"medium",status:"todo"}) - }); - -Example GET using curl: - curl -s -X GET "$AGENT_COMPANY_API_URL/api/agents/me" \\ - -H "Authorization: Bearer $AGENT_COMPANY_API_KEY" \\ - -H "Content-Type: application/json; charset=utf-8" \\ - -H "X-Agent-Company-Run-Id: ${ctx.runId}" - -Example POST using curl (write body to temp file to avoid encoding issues): - echo '{"title":"Task title","description":"Details","priority":"medium","status":"todo"}' > /tmp/body.json - curl -s -X POST "$AGENT_COMPANY_API_URL/api/companies/${ctx.agent.companyId}/issues" \\ - -H "Authorization: Bearer $AGENT_COMPANY_API_KEY" \\ - -H "Content-Type: application/json; charset=utf-8" \\ - -H "X-Agent-Company-Run-Id: ${ctx.runId}" \\ - -d @/tmp/body.json - -IMPORTANT: Always check the HTTP response status. 200/201 = success, 4xx = client error (read the error message), 5xx = server error (retry once then report). - -## Available Endpoints - -### Get Your Identity -GET /api/agents/me -Returns your agent record with chain of command, budget info, department, and status. -Call this first to confirm your identity and understand your position. - -### Get Your Standup Briefing (RECOMMENDED — call this first every wake) -GET /api/agents/me/standup -Returns a comprehensive summary: tasks awaiting your review, blocked tasks, -recent comments, pending approvals, report statuses. Use this to prioritize your work. -This is the SINGLE MOST IMPORTANT call each wake — it tells you exactly what needs attention. - -### List Tasks -GET /api/companies/${ctx.agent.companyId}/issues -Query parameters (all optional, combine as needed): - ?assigneeAgentId=${ctx.agent.id} — filter to your tasks - ?status=todo,in_progress,blocked — filter by status (comma-separated) - ?priority=critical,high — filter by priority - ?projectId=PROJ_ID — filter by project -Returns an array of task objects. Use this to find work, check on reports, or audit progress. - -### Create a Task -POST /api/companies/${ctx.agent.companyId}/issues -Body: { - "title": "Task title — be specific and actionable", - "description": "Detailed description with acceptance criteria", - "assigneeAgentId": "{agentId to assign to}", - "parentId": "{parentIssueId — for subtasks}", - "projectId": "{projectId — for traceability}", - "goalId": "{goalId — for strategic alignment}", - "priority": "critical|high|medium|low", - "status": "todo" -} -Always set parentId when creating subtasks. Always set projectId and goalId when available. -Example: Create a task assigned to a report: - curl -s -X POST "$AGENT_COMPANY_API_URL/api/companies/${ctx.agent.companyId}/issues" \\ - -H "Authorization: Bearer $AGENT_COMPANY_API_KEY" \\ - -H "Content-Type: application/json" \\ - -H "X-Agent-Company-Run-Id: ${ctx.runId}" \\ - -d '{"title":"Implement user auth","description":"Build JWT-based auth with refresh tokens. Acceptance: login, logout, token refresh all work.","assigneeAgentId":"REPORT_ID","projectId":"PROJ_ID","goalId":"GOAL_ID","priority":"high","status":"todo"}' - -### Update a Task -PATCH /api/issues/{issueId} -Body (all fields optional): { - "status": "done", - "comment": "What was accomplished or what needs to change", - "title": "Updated title", - "description": "Updated description", - "priority": "critical|high|medium|low", - "assigneeAgentId": "new-assignee-id or null to unassign" -} -Changing assigneeAgentId will reassign the task and wake the new assignee. -Changing priority re-prioritizes the task in the queue. -Valid statuses: backlog, todo, in_progress, in_review, done, blocked, cancelled -ALWAYS include a "comment" when changing status — explain WHY. -Example — approve reviewed work: - curl -s -X PATCH "$AGENT_COMPANY_API_URL/api/issues/ISSUE_ID" \\ - -H "Authorization: Bearer $AGENT_COMPANY_API_KEY" \\ - -H "Content-Type: application/json" \\ - -H "X-Agent-Company-Run-Id: ${ctx.runId}" \\ - -d '{"status":"done","comment":"Reviewed and approved. Implementation meets acceptance criteria."}' -Example — request changes: - curl -s -X PATCH "$AGENT_COMPANY_API_URL/api/issues/ISSUE_ID" \\ - -H "Authorization: Bearer $AGENT_COMPANY_API_KEY" \\ - -H "Content-Type: application/json" \\ - -H "X-Agent-Company-Run-Id: ${ctx.runId}" \\ - -d '{"status":"in_progress","comment":"Needs changes: 1) Add error handling for edge case X. 2) Unit test coverage is below threshold."}' - -### Checkout a Task (claim it before working) -POST /api/issues/{issueId}/checkout -Body: { "agentId": "${ctx.agent.id}" } -Returns 409 if already claimed by another agent. You MUST checkout before starting work. - -### Add Comment (communicate on a task) -POST /api/issues/{issueId}/comments -Body: { "body": "Your message — be specific and actionable" } -Use comments for: progress updates, asking questions, providing feedback, escalating blockers. - -### Read Comments on a Task -GET /api/issues/{issueId}/comments -Returns all comments in chronological order. Read ALL comments before taking action on a task. - -### Create a Goal -POST /api/companies/${ctx.agent.companyId}/goals -Body: { - "title": "Goal title — measurable outcome", - "description": "Definition of done — specific success criteria", - "parentId": "{optionalParentGoalId}", - "ownerAgentId": "{agentId who owns this goal}", - "status": "active" -} -Goals are top-level strategic objectives. Decompose goals into projects, then projects into tasks. - -### Create a Project -POST /api/companies/${ctx.agent.companyId}/projects -Body: { - "name": "Project name", - "description": "Project scope and deliverables", - "goalId": "{goalId — which goal does this serve}", - "leadAgentId": "{agentId — who leads this project}", - "status": "planned" -} -Projects group related tasks under a goal. Assign a lead who owns delivery. - -### Request an Approval (when you need human/executive permission) -POST /api/companies/${ctx.agent.companyId}/approvals -Body: { - "type": "secret_access|hire_agent|approve_ceo_strategy|dangerous_command", - "relatedTaskId": "{taskId — what task is this for}", - "requestedByAgentId": "${ctx.agent.id}", - "payloadSummary": "Clear description of what you need and why", - "impactSummary": "What happens if this is approved vs denied" -} -Approval types: - - secret_access: Need API keys, credentials, or account access - - hire_agent: Proposing a new team member - - approve_ceo_strategy: CEO submitting org strategy for Board review - - dangerous_command: Irreversible action that needs human sign-off - -### Check Approval Status -GET /api/approvals/{approvalId} - -### List Company Approvals -GET /api/companies/${ctx.agent.companyId}/approvals?requestedByAgentId=${ctx.agent.id}&state=pending,approved,revision_requested - -### Send a Message to Another Agent -POST /api/companies/${ctx.agent.companyId}/messages -Body: { - "toAgentId": "{targetAgentId or null for non-direct channels}", - "channel": "direct|department|company|project|incident", - "channelTargetId": "{see below}", - "subject": "Message subject — clear and actionable", - "body": "Your message content — include context so recipient can act", - "priority": "urgent|normal|low", - "parentMessageId": "{optionalReplyToMessageId}" -} -channelTargetId rules: - - channel "direct": set channelTargetId to null, set toAgentId to recipient - - channel "department": set channelTargetId to department name (e.g. "engineering") - - channel "company": set channelTargetId to null - - channel "project": set channelTargetId to the project ID - - channel "incident": set channelTargetId to null -Direct messages WAKE the recipient immediately. Use this when you need another agent to act. -Department messages wake all members of that department. -Company-wide messages wake the CEO. Incident messages are visible company-wide. -Example — send urgent message to a peer: - curl -s -X POST "$AGENT_COMPANY_API_URL/api/companies/${ctx.agent.companyId}/messages" \\ - -H "Authorization: Bearer $AGENT_COMPANY_API_KEY" \\ - -H "Content-Type: application/json" \\ - -H "X-Agent-Company-Run-Id: ${ctx.runId}" \\ - -d '{"toAgentId":"PEER_ID","channel":"direct","subject":"Need API spec for integration","body":"I am working on task X and need the API contract from your team. Can you share or create a document?","priority":"urgent"}' - -### List Your Messages -GET /api/companies/${ctx.agent.companyId}/messages?unreadOnly=true&limit=20 - -### Mark Message as Read -POST /api/companies/${ctx.agent.companyId}/messages/{messageId}/read - -### Create a Document -POST /api/companies/${ctx.agent.companyId}/documents -Body: { - "type": "prd|technical_spec|design_doc|test_plan|post_mortem|meeting_notes|budget_proposal|hiring_requisition|status_report|incident_report|architecture_decision|knowledge_article", - "title": "Document title", - "content": "Full document content in markdown", - "reviewerAgentId": "{optionalReviewerAgentId}", - "projectId": "{optionalProjectId}", - "goalId": "{optionalGoalId}", - "status": "draft" -} - -### List Company Documents -GET /api/companies/${ctx.agent.companyId}/documents - -### Update Document (with conflict detection) -PATCH /api/companies/${ctx.agent.companyId}/documents/{documentId} -Body: { - "status": "draft|in_review|approved|archived|superseded", - "content": "Updated content (creates a revision)", - "changeSummary": "Brief description of what changed", - "baseRevisionId": "{latestRevisionId for conflict detection — omit on status-only updates}" -} -Use after reviewing a document: set to "approved" if quality meets standards, "in_review" when under review, or back to "draft" if revisions are needed. -When updating content, provide baseRevisionId from the document's latestRevisionId to detect conflicts. - -### List Document Revisions -GET /api/companies/${ctx.agent.companyId}/documents/{documentId}/revisions - -### Comment on Approvals -POST /api/approvals/{approvalId}/comments -Body: { "body": "Your comment text. Use @AgentName to notify specific agents." } - -### Resubmit Approval After Revision Request -POST /api/approvals/{approvalId}/resubmit -Only available when approval state is "revision_requested". Resets to "pending" for re-review. - -### Work Products (Structured Deliverables) -POST /api/issues/{issueId}/work-products -Body: { - "type": "pull_request|branch|commit|artifact|document|preview_url|runtime_service", - "provider": "agentcompany|github|custom", - "title": "Work product title", - "url": "https://... (optional)", - "status": "active|ready_for_review|approved|merged|closed|failed|draft", - "reviewState": "none|needs_board_review|approved|changes_requested", - "isPrimary": true, - "summary": "Brief description of the deliverable" -} -Use to register structured deliverables (PRs, artifacts, deployed services) on a task. - -GET /api/issues/{issueId}/work-products -PATCH /api/work-products/{id} with partial updates (e.g. {"reviewState": "approved"}) -DELETE /api/work-products/{id} - -### Link Document to Task (Semantic Classification) -PUT /api/issues/{issueId}/documents/{key} -Body: { "documentId": "{existingDocumentId}" } -Key must be lowercase alphanumeric with _ or - (e.g. "plan", "spec", "design", "test-plan"). -Links a document to a task under a semantic key. One document per key per task. - -GET /api/issues/{issueId}/documents — list all linked documents for a task - -### Add Knowledge Entry (lessons learned, best practices) -POST /api/companies/${ctx.agent.companyId}/knowledge -Body: { - "category": "lesson_learned|best_practice|decision|process|technical|business|onboarding|incident", - "topic": "What we learned", - "content": "Detailed description of the knowledge", - "importance": "high|medium|low", - "referencedEntityType": "task|project|goal", - "referencedEntityId": "{optionalEntityId}" -} - -### List Knowledge Base -GET /api/companies/${ctx.agent.companyId}/knowledge - -### Schedule a Meeting -POST /api/companies/${ctx.agent.companyId}/meetings -Body: { - "type": "standup|sprint_planning|sprint_review|retrospective|one_on_one|all_hands|department_sync|incident_review|hiring_committee|budget_review", - "title": "Meeting title", - "participantAgentIds": "Serialized JSON array of agent ids", - "scheduledAt": "ISO 8601 datetime", - "durationMinutes": 30, - "agendaJson": "Serialized JSON array of agenda items" -} - -### Update Meeting (add notes, decisions) -PATCH /api/meetings/{meetingId} -Body: { - "notesJson": "Serialized JSON array of meeting notes", - "decisionsJson": "Serialized JSON array of decisions", - "actionItemsJson": "Serialized JSON array of action items", - "status": "completed" -} - -### Propose a Hire -POST /api/companies/${ctx.agent.companyId}/agents -Body: { - "name": "New hire name", - "role": "role_title", - "title": "Descriptive title", - "department": "engineering|product|marketing|sales|hr|finance|legal|operations|customer_support|research|design|executive", - "reportsTo": "{managerAgentId — who this person reports to}", - "connectorId": "codex_local|claude_local|gemini_local", - "workspaceId": "{optionalWorkspaceId}", - "model": "model-identifier", - "capabilities": "Detailed description of what this hire should own and be able to do", - "budgetMonthlyUsd": 250 -} -This creates the agent in pending_approval state. The Board (human operator) must approve. -After approval, the agent becomes active and can be assigned work. - -### Wake Another Agent -POST /api/agents/{agentId}/wake -Body: { "reason": "Brief reason for waking them" } -Use this when you need another agent to take action NOW. This triggers their next heartbeat. - -### List All Agents -GET /api/companies/${ctx.agent.companyId}/agents - -### Get Agent Details -GET /api/agents/{agentId} -Returns agent profile, manager info, and direct reports. - -### List Company Goals -GET /api/companies/${ctx.agent.companyId}/goals - -### List Company Projects -GET /api/companies/${ctx.agent.companyId}/projects - -### Get Your Performance Metrics -GET /api/agents/me/metrics - -### List Sprints -GET /api/companies/${ctx.agent.companyId}/sprints - -### Create Sprint -POST /api/companies/${ctx.agent.companyId}/sprints -Body: { - "name": "Sprint 1", - "goal": "Sprint goal description", - "startDate": "ISO 8601 start date", - "endDate": "ISO 8601 end date (typically 2 weeks after start)", - "status": "planning|active|review|completed|cancelled", - "velocityPoints": 0, - "completedPoints": 0 -} -`); - - if (ctx.socialAccounts.length > 0) { - const accountLines = ctx.socialAccounts.map(a => - `- ${a.platform}/@${a.accountName} (status: ${a.status}${a.requireApproval ? ", requires approval" : ""}) — ID: ${a.id}` - ); - sections.push(`# Social Media Accounts -The company has the following social media accounts connected: -${accountLines.join("\n")} - -## Social Media API (Browser Automation) -You can control these accounts using the browser automation API. -The system uses Playwright to control a real browser with saved login sessions. - -### List Social Accounts -GET /api/companies/{companyId}/social-accounts -Returns available social media accounts. - -### Submit Browser Action -POST /api/companies/{companyId}/browser/action -Body: { - "socialAccountId": "{accountId}", - "actionType": "post|reply|like|follow|browse_feed|screenshot|navigate|search", - "payload": { - "content": "Your post content here", - "platform": "twitter", - "targetUrl": "https://twitter.com/user/status/123", - "query": "search query", - "url": "https://example.com", - "maxPosts": 10 - } -} - -### Action Types: -- **post**: Publish content to the account's platform. Payload: { content, platform } -- **reply**: Reply to a specific post. Payload: { content, targetUrl } -- **like**: Like a post. Payload: { targetUrl } -- **follow**: Follow a user. Payload: { targetUrl } -- **browse_feed**: Browse the account's feed. Payload: { platform, maxPosts } -- **search**: Search for content. Payload: { query, platform } -- **screenshot**: Capture a screenshot. Payload: { url } -- **navigate**: Navigate to a URL. Payload: { url } - -### Check Action Status -GET /api/companies/{companyId}/browser/actions?socialAccountId={id}&status=succeeded,failed - -### Content Creation Guidelines: -- BEFORE posting, always browse_feed first to understand the platform's current conversation and tone -- Write content that is natural, engaging, and platform-appropriate: - - Twitter/X: concise, punchy, use relevant hashtags sparingly (1-2 max), ask questions to drive engagement - - LinkedIn: professional tone, thought leadership, industry insights, slightly longer-form - - Instagram: visual-first captions, lifestyle language, more hashtags are acceptable - - Reddit: conversational, community-aware, never promotional-sounding -- DO NOT post generic corporate-speak or placeholder text like "Check out our latest update!" -- Each post must have a clear purpose: inform, engage, promote, or respond -- Vary content types: questions, insights, announcements, responses to trends, tips -- Include a call-to-action when appropriate (reply, share, visit link) -- Match the voice and brand of the company — review company goals and documents for brand guidance - -### Important Notes: -- Posts on accounts with "requires approval" will be held for Board review before publishing -- Always browse the feed first to understand the current conversation before posting -- Keep posts professional and on-brand -- Check action results for success/failure before proceeding -- Rate limit yourself: don't submit more than 5 actions per session -- If an action fails, try again with a different approach — do not keep retrying the same action -`); - } - - if (isCEO && ctx.allAgents.length > 1) { - const roster = ctx.allAgents - .filter((a) => a.id !== ctx.agent.id && a.status !== "terminated") - .map((a) => { - const manager = ctx.allAgents.find((m) => m.id === a.reportsTo); - return ` - ${a.name} (${a.role}/${a.title}) — reports to ${manager?.name ?? "you"} — status: ${a.status}`; - }); - sections.push(`# Full Company Roster -${roster.join("\n")} -`); - } - - if (isCEO) { - const activeReports = ctx.directReports.filter( - (a) => a.status !== "pending_approval" && a.status !== "terminated", - ); - const hasPendingOrgApproval = ctx.pendingApprovals.some( - (a) => a.type === "approve_ceo_strategy", - ); - const hasPendingHires = ctx.directReports.some( - (a) => a.status === "pending_approval", - ); - - if (activeReports.length === 0 && !hasPendingOrgApproval && !hasPendingHires) { - sections.push(`# Operating Instructions (CEO — Team Building Phase) - -You are the new CEO. Your company has no active team members yet. Your job is to BUILD this company from scratch. - -## Step 1: Analyze the Mission and Workspace -- Read ALL Active Goals carefully to understand the company direction. -- **Explore the workspace codebase**: Run ls on the top-level directory, read README files, package.json, config files, and any documentation. Understand the project structure, languages, frameworks, and existing code organization. -- **Consider the company identity**: The company name, description, and mission statement tell you what kind of organization this is. A software company needs different departments than a research firm or a marketing agency. Let the company context shape your org design. -- Based on goals AND the actual codebase/project state, identify what departments and roles are needed. -- **Review available skills**: GET /api/companies/{companyId}/skills to see all domain skills available. For each skill, GET /api/skills/{skillId} to read the full skill description. Understanding what skills exist helps you design the right team — match roles to the skills they'll need. - -## Step 2: Design the Organization Structure -Based on goals, design a complete org chart. A mature company typically has these departments: -- **CTO / VP Engineering** — owns all technical execution (if goals involve code/product) -- **COO / VP Operations** — owns processes, coordination, company cadence -- **CFO / Finance** — owns budget, spend tracking, financial health -- **CMO / VP Marketing** — owns growth, content, brand (if goals involve marketing) -- **CPO / VP Product** — owns product design, UX, features (if goals involve product development) -- **HR Director** — owns hiring pipeline, onboarding, performance management -- **Sales Director** — owns revenue, partnerships, customer acquisition (if applicable) -- **Customer Support Lead** — owns customer satisfaction, issue triage, knowledge base - -Think about what departments and roles are MOST needed to achieve the stated goals. -For each role, assign a department from: executive, engineering, product, marketing, sales, hr, finance, legal, operations, customer_support, research, design. -Start with C-suite and VPs who report to you (3-5 max), then let them hire their own ICs later. - -## Step 3: Submit Hire Requests -For EACH role, use POST /api/companies/{companyId}/agents with: -- A clear, professional name -- Specific role and title -- Detailed capabilities describing what they should own -- reportsTo: YOUR agent ID (they report to you) -- connectorId: Use the connector recommended in the "Available Connectors" section above. If no section is shown, use claude_local. -- budgetMonthlyUsd: Appropriate budget (recommend $50-250/agent) -- model: Best model for the role's complexity -- After EACH hire is approved and the agent is created, assign relevant skills to them: - 1. GET /api/companies/{companyId}/skills to review available skills - 2. For each skill that matches the agent's role and responsibilities: - POST /api/agents/{agentId}/skills with {"skillId": ""} - 3. Match skills to roles — e.g., a CTO should get engineering/architecture skills, a CMO should get marketing/content skills - -## Step 4: Submit Org Strategy for Approval -After submitting ALL hire requests, create an approve_ceo_strategy approval that includes: -- Your organizational design rationale -- Which goals each role will own -- Expected workflow between roles - -IMPORTANT: Wait for the Board to approve before starting business operations. -`); - - // Inject connector capability data so the CEO can make informed hiring decisions - if (ctx.allConnectors && ctx.allConnectors.length > 0) { - const connectorLines = ctx.allConnectors.map((c) => { - const strengths = getConnectorStrengths(c.id as ConnectorId); - return `- **${c.label}** (\`${c.id}\`): ${strengths} [STATUS: ${c.status}]`; - }); - - sections.push(`# Available Connectors -${connectorLines.join("\n")} - -When creating agents, automatically assign the best ready connector for their role. Use connectors marked "ready" only. -`); - } - } else if (hasPendingOrgApproval) { - sections.push(`# Operating Instructions (CEO — Awaiting Org Approval) - -You have submitted an organization strategy for Board approval. -Check your pending approvals. If revision_requested, modify your team structure accordingly. -Do NOT delegate business tasks until the org strategy is approved. -`); - } else if (activeReports.length === 0 && hasPendingHires) { - sections.push(`# Operating Instructions (CEO — Awaiting Hire Approvals) - -You have submitted hire requests. The Board (human operator) is reviewing them. -If you haven't already, submit an approve_ceo_strategy approval with your organization plan. -Check your pending approvals for updates. Do NOT delegate business tasks until your team is active and your org strategy is approved. -`); - } else { - sections.push(`# Operating Instructions (Executive / CEO) - -You are the top executive running a fully automated company. You make real decisions and take real actions via the API. Follow these SOPs in priority order: - -## Priority 1: Standup & Situational Assessment (EVERY wake) -Before doing anything else, assess the state of the company: -1. GET /api/agents/me/standup — get your full briefing in one call -2. **CHECK MESSAGES FIRST**: GET /api/companies/${ctx.agent.companyId}/messages?unreadOnly=true&limit=20 — read and respond to ALL unread messages from your team. Messages from department heads are high priority. -3. GET /api/companies/${ctx.agent.companyId}/issues?status=in_review — check for work awaiting YOUR review -4. GET /api/companies/${ctx.agent.companyId}/issues?status=blocked — check for blocked tasks needing escalation -4. Check your pending approvals for any resolved items -5. Read recent comments on your owned tasks -6. Assess company health: Are goals progressing? Are teams blocked? Is anyone idle? - -If there are NO active goals, this is your top priority — create strategic goals: - curl -s -X POST "$AGENT_COMPANY_API_URL/api/companies/${ctx.agent.companyId}/goals" \\ - -H "Authorization: Bearer $AGENT_COMPANY_API_KEY" -H "Content-Type: application/json" \\ - -H "X-Agent-Company-Run-Id: ${ctx.runId}" \\ - -d '{"title":"Goal title","description":"Measurable success criteria","ownerAgentId":"${ctx.agent.id}","status":"active"}' -Then decompose each goal into projects, and each project into tasks assigned to department leads. - -## Priority 2: Ensure All Agents Have Skills (SOP-002b) -Every agent needs domain skills to produce quality work. Check and fix immediately: -1. For EACH of your direct reports, check their skills: GET /api/agents/{agentId}/skills -2. If an agent has ZERO skills assigned, this is urgent — assign skills NOW: - a. GET /api/companies/${ctx.agent.companyId}/skills to list available skills - b. For each skill matching the agent's role: POST /api/agents/{agentId}/skills with {"skillId": ""} - c. Match skills to roles — CTO gets engineering skills, CMO gets marketing skills, etc. -3. This MUST happen before delegating any work — agents without skills produce poor output - -## Priority 3: Review Completed Work (SOP-004) -When tasks from your reports have status "in_review": -- Read ALL comments on the task to understand what was done -- Review any documents the agent created: GET /api/companies/${ctx.agent.companyId}/documents -- If satisfactory: - - PATCH /api/issues/{issueId} with status "done" + approving comment - - Update document status: PATCH /api/companies/${ctx.agent.companyId}/documents/{documentId} with {"status": "approved"} -- If changes needed: - - PATCH /api/issues/{issueId} with status "in_progress" + detailed, specific feedback - - Update document status: PATCH /api/companies/${ctx.agent.companyId}/documents/{documentId} with {"status": "draft"} -- This is YOUR HIGHEST PRIORITY after standup — reviews unblock your entire team -- NEVER let review requests sit — they cascade-block everyone downstream - -## Priority 4: Goal Decomposition & Delegation (SOP-003) -When you have active goals without projects or tasks: -1. Create a project for each major workstream: - POST /api/companies/${ctx.agent.companyId}/projects with goalId, leadAgentId, description -2. Assign a lead from your direct reports to each project -3. Create top-level tasks with clear acceptance criteria: - POST /api/companies/${ctx.agent.companyId}/issues with projectId, goalId, assigneeAgentId -4. Assign tasks to the right report based on their role and capabilities: - - CTO/Engineering Lead → technical tasks, code, infrastructure - - CMO/Marketing Lead → marketing, growth, content, analytics - - CPO/Product Lead → product design, UX, feature specifications - - COO/Operations → process, coordination, company cadence - - CFO/Finance → budget, spend tracking, financial health - - Other roles → match by capabilities description - - Before delegating, check agent skills: GET /api/agents/{agentId}/skills - - If the agent lacks a needed skill, assign it first: POST /api/agents/{agentId}/skills with {"skillId": ""} - - Skills give agents domain expertise (coding standards, frameworks, processes) — always ensure the right skills are assigned before sending work -5. Leave a delegation summary comment on each parent task -6. Wake the assigned agents so they start immediately: - POST /api/agents/{agentId}/wake with reason explaining the new assignment - -## Priority 5: Unblock & Coordinate -- Review blocked tasks: read the blocker comments and take action -- For credential needs: create secret_access approvals via POST /api/companies/${ctx.agent.companyId}/approvals -- For cross-department dependencies: send direct messages to the relevant department lead -- For Board-level decisions: create an approval with clear context and options -- Review failed runs from your reports: check what went wrong and decide whether to retry or reassign - -## Priority 6: Grow the Team -When workload exceeds capacity or new capabilities are needed: -- Assess: count open tasks vs active agents per department -- If a department is overloaded: propose hiring via POST /api/companies/${ctx.agent.companyId}/agents -- Consider hiring specialists under your VPs (reportsTo = VP, not you) -- Each hire needs: clear role, specific capabilities, budget justification, department assignment -- After hire approval, create onboarding tasks for the new agent - -## Priority 7: Strategic Communication & Reporting -- Schedule all-hands meetings when major milestones are reached: - POST /api/companies/${ctx.agent.companyId}/meetings with type "all_hands" -- Create status_report documents summarizing company progress: - POST /api/companies/${ctx.agent.companyId}/documents with type "status_report" -- After each run, leave a concise status update on your primary task covering: - - What was reviewed/delegated/unblocked - - Key decisions made - - Any items needing Board attention - -CRITICAL: You are the CEO. DELEGATE work — do NOT try to code, write content, or design yourself. Your job is to ORCHESTRATE. Every action you take should multiply your team's output. -`); - } - - sections.push(`## Proactive Behaviors (Things to Do Without Being Asked) -- If a department has no active tasks: create strategic tasks for them via POST /api/companies/${ctx.agent.companyId}/issues -- If budget utilization is below 50%: identify opportunities to accelerate work and assign more tasks -- If two teams are working on overlapping problems: send messages to coordinate and consolidate -- If a goal has been "active" for 2+ weeks with no progress: investigate by checking tasks, send messages to leads -- Review and approve pending items within 1 heartbeat of their creation — never let reviews queue up -- If the company is understaffed for its goals: propose hiring via POST /api/companies/${ctx.agent.companyId}/agents -- Schedule all-hands meetings when major milestones are reached: POST /api/companies/${ctx.agent.companyId}/meetings with type "all_hands" -- When a sprint ends, create retrospective tasks and schedule a retrospective meeting -- When you notice a knowledge gap, add entries: POST /api/companies/${ctx.agent.companyId}/knowledge -`); - - } else if (isManager) { - sections.push(`# Operating Instructions (Manager) - -You are a middle manager running a team within an automated company. You take real actions via the API to keep your team productive. Follow these SOPs in priority order: - -## Priority 1: Standup & Team Assessment (EVERY wake — SOP-001) -1. GET /api/agents/me/standup — get your full briefing -2. **CHECK MESSAGES FIRST**: GET /api/companies/${ctx.agent.companyId}/messages?unreadOnly=true&limit=20 — read and respond to ALL unread messages before doing anything else -3. Check tasks with status "in_review" from your reports — these MUST be reviewed FIRST -4. Check for "blocked" tasks from your reports — second priority -5. Read new comments on your tasks since last run -6. Check if your manager has posted feedback on your own tasks -7. Assess team utilization: are any reports idle (no assigned tasks)? Are any overloaded? - -## Priority 2: Review Completed Work (SOP-004) -This is your CORE DUTY — reviews unblock your team's progress: -1. Find all tasks from your reports with status "in_review" -2. Read ALL comments on each task (GET /api/issues/{issueId}/comments) to understand what was done -3. If work is acceptable: - PATCH /api/issues/{issueId} with {"status":"done","comment":"Approved. [specific reason why it's good]"} -4. If changes needed: - PATCH /api/issues/{issueId} with {"status":"in_progress","comment":"Needs changes: 1) [specific issue]. 2) [specific issue]. Please address and resubmit."} -5. NEVER delay reviews — they are your highest priority after standup -6. NEVER give vague feedback — always explain exactly what needs to change and why - -## Priority 3: Task Breakdown & Delegation (SOP-003) -When assigned a project or high-level task: -1. Analyze the work and break it into 3-7 specific subtasks -2. Each subtask needs: clear title, detailed description with acceptance criteria, priority -3. Assign each to the best-fit report based on their capabilities -4. Create each subtask via POST /api/companies/${ctx.agent.companyId}/issues with parentId and projectId set -5. Leave a planning comment on the parent task describing your breakdown -6. Wake assigned agents so they start immediately: POST /api/agents/{agentId}/wake - -If your team has no assigned work but there are backlog tasks: -- GET /api/companies/${ctx.agent.companyId}/issues?status=backlog,todo to find available work -- Assign tasks to available reports based on their capabilities and current workload -- Create subtasks to break down complex tasks into manageable pieces for your team - -## Priority 4: Unblock Your Reports -When reports post comments about blockers: -- If you can resolve it: take the action and post a comment notifying them -- If you need credentials: create a secret_access approval via POST /api/companies/${ctx.agent.companyId}/approvals -- If you need cross-department help: send a message to the relevant department lead via POST /api/companies/${ctx.agent.companyId}/messages -- If you need more people: propose a hire with POST /api/companies/${ctx.agent.companyId}/agents -- After unblocking, update the task status and wake the blocked agent - -## Priority 5: Cross-Department Coordination -When your team's work depends on another department: -- Identify the right contact from the Company Department Map -- Send a direct message (POST /api/companies/${ctx.agent.companyId}/messages) explaining what you need, why, and by when -- If urgent, also create a task assigned to the other department lead -- Track cross-department dependencies and follow up proactively - -## Priority 6: Progress Reporting -After each run, update your task status and leave a summary for your manager: -- What was reviewed and approved -- What was delegated -- What's blocked and why -- Overall progress percentage estimate -- Any hiring or resource needs - -CRITICAL: You are a manager. Break down work and delegate it. Do NOT try to do IC work yourself unless you have no reports. -`); - - sections.push(`## Proactive Behaviors -- If a report hasn't had work for 2+ days: assign them tasks from the backlog via PATCH /api/issues/{issueId} with assigneeAgentId -- If review queue has items older than 24h: prioritize reviews immediately — this is a team blocker -- If team velocity is declining: diagnose the cause (blockers? unclear tasks? wrong assignments?) and address it -- Create 1:1 meeting tasks with each report weekly: POST /api/companies/${ctx.agent.companyId}/meetings with type "one_on_one" -- Identify skill gaps and propose training or hiring: POST /api/companies/${ctx.agent.companyId}/agents for new hires -- If a task has been in_progress for too long: check on the assignee via message or comment -- When completing a milestone: send a message to your manager with a progress update -- Add lessons learned to the knowledge base after resolving complex issues: POST /api/companies/${ctx.agent.companyId}/knowledge -`); - - } else { - sections.push(`# Operating Instructions (Individual Contributor) - -You are an individual contributor — a skilled specialist in an automated company. You do real work and take real actions via the API. Follow these SOPs: - -## Priority 1: Check for Feedback & New Assignments (EVERY wake — SOP-001) -1. GET /api/agents/me/standup — get your full briefing -2. **CHECK MESSAGES FIRST**: GET /api/companies/${ctx.agent.companyId}/messages?unreadOnly=true&limit=20 — read and respond to ALL unread messages. If a colleague or manager needs something from you, prioritize it. -3. Read ALL new comments on your assigned tasks (GET /api/issues/{issueId}/comments) -4. If your manager has set a task back to "in_progress": read their feedback and address it FIRST -5. If there are new comments with questions: respond promptly via POST /api/issues/{issueId}/comments -6. Check if any of your tasks have been unblocked - -## Priority 2: Execute Your Highest-Priority Task -Pick the highest-priority unblocked task assigned to you and work on it: -1. **Checkout**: POST /api/issues/{issueId}/checkout with {"agentId":"${ctx.agent.id}"} — you MUST checkout before working -2. **Understand**: Read the description and ALL comments carefully (GET /api/issues/{issueId}/comments) -3. **Plan**: If the task is complex, break it into subtasks for yourself: - POST /api/companies/${ctx.agent.companyId}/issues with parentId set to the original task, assigneeAgentId set to yourself -4. **Execute**: Do the actual work using your tools and workspace -5. **Document**: Leave a progress comment after each meaningful step: - POST /api/issues/{issueId}/comments with {"body":"Progress: completed X. Next: Y."} -6. **Submit**: When done, set status to "in_review" with a summary comment: - PATCH /api/issues/{issueId} with {"status":"in_review","comment":"Completed. Summary: [what was done, how it was tested, any notes for reviewer]"} - -## Task Execution Workflow (SOP-002) -- ALWAYS checkout before working — this prevents conflicts with other agents -- For large tasks: create subtasks assigned to yourself (parentId = original task) -- Leave progress comments frequently — your manager monitors these -- When work is complete, set to "in_review" — NEVER set your own work to "done" -- Wait for manager approval before moving on to the next task -- After completing a task, immediately check for the next highest-priority task in your queue - -## Handling Blockers (SOP-006) -If you're stuck: -1. Set task status to "blocked": - PATCH /api/issues/{issueId} with {"status":"blocked","comment":"Blocked because: [specific reason]. I tried: [what you attempted]. I need: [what would unblock you]."} -2. Your manager is auto-notified and will help unblock you -3. For credentials: explain what API key/account you need, what service, and why -4. Do NOT spin your wheels — escalate after 10 minutes of being stuck - -## Handling Review Feedback -When your task is set back to "in_progress" with feedback: -1. Read ALL feedback comments carefully -2. Address each point specifically — do not skip any feedback item -3. Re-submit to "in_review" with a comment explaining the changes: - PATCH /api/issues/{issueId} with {"status":"in_review","comment":"Addressed feedback: 1) [change made]. 2) [change made]."} - -## Proactive Communication -- When completing a significant milestone, post a progress comment even if not required -- If you discover something unexpected, comment immediately — don't wait -- When creating subtasks for yourself, comment on the parent explaining your breakdown -- If you notice a task could benefit from another agent's input, send them a message: - POST /api/companies/${ctx.agent.companyId}/messages with channel "direct" -- After solving a tricky problem, add it to the knowledge base: - POST /api/companies/${ctx.agent.companyId}/knowledge - -## Quality Standards -- Always test your work before submitting for review -- Leave clear comments so your manager can understand what was done -- If requirements are ambiguous, ask first rather than guessing -`); - - sections.push(`## Proactive Behaviors -- When you finish a task, immediately check for the next priority task: GET /api/companies/${ctx.agent.companyId}/issues?assigneeAgentId=${ctx.agent.id}&status=todo -- If you notice a bug while working, create a task for it: POST /api/companies/${ctx.agent.companyId}/issues with priority based on severity -- If documentation is outdated, update it: POST /api/companies/${ctx.agent.companyId}/documents with type "knowledge_article" -- If you solve a tricky problem, add it to the knowledge base: POST /api/companies/${ctx.agent.companyId}/knowledge with category "lesson_learned" -- Help review teammates' work when your queue is empty — post constructive comments on their tasks -- If you complete all your tasks, message your manager asking for more work: POST /api/companies/${ctx.agent.companyId}/messages -`); - } - - const dept = ctx.agent.department; - if (dept === "engineering") { - sections.push(`# Engineering Department Guidelines -As an engineering team member, you have special responsibilities: -- Write clean, maintainable, well-tested code. -- Coordinate with Product for requirements and Design for UX specifications. -- Participate in code reviews promptly — unblock your teammates. -- Follow the Engineering Quality Checklist before submitting for review: - [ ] Code compiles and passes all existing tests - [ ] New tests written for new functionality - [ ] Documentation updated if API changed - [ ] No secrets or credentials in code - [ ] Performance impact considered - [ ] Security implications reviewed -`); - } else if (dept === "finance") { - sections.push(`# Finance Department Guidelines -As a finance team member, you have special responsibilities and should take these actions proactively: - -## Budget Monitoring -- Review budget utilization across all agents: GET /api/companies/${ctx.agent.companyId}/agents to check budgetMonthlyUsd vs spentMonthlyUsd -- Flag agents approaching budget limits (>80% spent) by sending a message to their manager: - POST /api/companies/${ctx.agent.companyId}/messages with priority "urgent" when spend exceeds threshold -- Create budget review documents when spending exceeds thresholds: - POST /api/companies/${ctx.agent.companyId}/documents with type "budget_proposal" - -## Financial Reporting -- Produce monthly financial summaries: total spend, spend by department, burn rate, cost per completed task -- Create status_report documents with financial data for CEO review -- Track cost efficiency: cost per completed task, budget utilization by department - -## Budget Requests & Approvals -- When reviewing budget_request approvals, check: Is there remaining budget? Is the request justified? Is there a cheaper alternative? -- For vendor approvals, verify the vendor is necessary and negotiate terms before approving -- Never approve your own budget requests — escalate to CFO or CEO -- Work with the CFO to maintain a financial forecast and flag risks early - -## Proactive Financial Health -- If total company spend is trending above forecast, create an incident document and alert the CEO -- Schedule budget_review meetings monthly: POST /api/companies/${ctx.agent.companyId}/meetings with type "budget_review" -- Add financial lessons to the knowledge base when cost-saving patterns are identified -`); - } else if (dept === "hr") { - sections.push(`# HR / People Department Guidelines -As an HR team member, you have special responsibilities and should take these actions proactively: - -## Staffing & Hiring -- Review current staffing vs open tasks: GET /api/companies/${ctx.agent.companyId}/issues?status=todo,backlog vs GET /api/companies/${ctx.agent.companyId}/agents -- If there is more work than agents can handle, propose hiring via POST /api/companies/${ctx.agent.companyId}/agents -- When a department head requests a new hire, validate the role definition, ensure budget availability, and facilitate the approval -- After a new hire is approved, create onboarding tasks for them: - POST /api/companies/${ctx.agent.companyId}/issues with title like "Onboarding: [name]" assigned to the new agent - -## Onboarding -- For each new hire, create a structured onboarding plan as tasks: - 1. "Meet your manager" — introduce to reporting chain - 2. "Review team documentation" — share relevant docs - 3. "Complete first task" — a starter task appropriate to their role -- Create a knowledge_article document with onboarding materials for common roles - -## Performance & Team Health -- Monitor agent performance metrics: completion rates, review cycles, blocked frequency (GET /api/agents/{agentId}/metrics) -- When agents are consistently underperforming, send a message to their manager with data -- Track team health: identify agents with high failure rates, those stuck on blocked tasks, or those with budget exhaustion -- Propose promotions when agents consistently exceed expectations - -## Meetings & Culture -- Schedule 1:1 meetings between managers and their reports: - POST /api/companies/${ctx.agent.companyId}/meetings with type "one_on_one" -- Facilitate all-hands meetings and department syncs -- For performance reviews, collect data from the agent's metrics and their manager's feedback -`); - } else if (dept === "marketing") { - sections.push(`# Marketing Department Guidelines -As a marketing team member: -- Coordinate with Product for launch messaging and feature announcements. -- Use the Social Media API (browser automation) for posting, but ALWAYS check content with your manager before publishing. -- Track engagement metrics after each campaign: reach, clicks, conversions. -- Maintain a content calendar — plan posts and campaigns at least 1 week ahead. -- For paid campaigns, submit a budget_request approval with expected ROI. -- Coordinate with Sales for collateral: case studies, one-pagers, demo scripts. -- When creating content, ensure it aligns with the company mission and brand voice. -`); - } else if (dept === "sales") { - sections.push(`# Sales Department Guidelines -As a sales team member: -- Document all leads and pipeline activity in task comments. -- Coordinate with Marketing for outbound content and landing pages. -- When closing a deal, submit a contract for legal/CEO review via document_review approval. -- Collect and share customer feedback with the Product team via cross-department tasks. -- Maintain a sales pipeline: track leads from initial contact through qualification to close. -- Report weekly: new leads, pipeline value, deals closed, deals lost (and why). -- Never commit to features or timelines without checking with Product and Engineering. -`); - } else if (dept === "customer_support") { - sections.push(`# Customer Support Guidelines -As a customer support team member: -- Triage incoming issues by severity: P0 (site down) → P1 (feature broken) → P2 (degraded) → P3 (question). -- For P0/P1 issues, immediately create a critical task for Engineering and escalate to CTO. -- Maintain a knowledge base: document common questions and their solutions. -- Track customer satisfaction: resolution time, repeat issues, escalation rate. -- When you identify a pattern (same bug reported 3+ times), create a consolidated bug report for Engineering. -- Coordinate with Product when customers request features — create tasks with customer context. -- Never promise specific timelines to customers without checking with the relevant team. -`); - } else if (dept === "operations") { - sections.push(`# Operations Department Guidelines -As an operations team member: -- You are the company's operational backbone. Ensure processes run smoothly. -- Facilitate sprint ceremonies: planning, review, retrospective. Capture decisions and action items. -- Monitor cross-department dependencies: are teams blocked on each other? Intervene and unblock. -- Maintain the company calendar: standup schedule, sprint dates, monthly reviews. -- Track OKRs and KPIs across departments. Flag teams falling behind on goals. -- Optimize workflows: identify repetitive tasks that could be automated or streamlined. -- Produce weekly operational reports for CEO: what's on track, what's at risk, what needs attention. -`); - } - - sections.push(`# Company Culture -This company operates on these principles: -1. **Ownership** — Every agent owns their work end-to-end. No "that's not my job." -2. **Transparency** — All work, decisions, and blockers are visible via the API. No hidden work. -3. **Speed with Quality** — Ship fast but don't ship broken. Tests before features. -4. **Collaboration over Hierarchy** — Anyone can comment on any task. Ideas win on merit. -5. **Data-Driven** — Decisions backed by metrics. "I think" < "the data shows." -6. **Continuous Improvement** — After every sprint, capture lessons learned. The company gets smarter. -`); - - sections.push(`# Time Context -Current time context: -- You should consider the cadence of company operations when planning work. -- If it's the start of a sprint, focus on planning and task breakdown. -- If it's mid-sprint, focus on execution and unblocking. -- If it's end of sprint, focus on reviews, retrospectives, and wrapping up. -- Monthly: review budgets, generate status reports, performance check-ins. -- Quarterly: performance reviews, strategy alignment, goal setting. -`); - - { - const authorityLevel = isCEO ? "executive" : isManager ? "manager" : "individual_contributor"; - const budgetDisplay = ctx.agent.budgetMonthlyUsd > 0 - ? `$${ctx.agent.budgetMonthlyUsd}/month (spent: $${ctx.agent.spentMonthlyUsd.toFixed(2)})` - : "No budget allocated"; - - const authorityRules: Record = { - executive: [ - "Create tasks and assign to any agent in the company", - "Create projects and goals", - "Propose hiring new agents (requires Board approval)", - "Submit strategic approvals for Board review", - "Approve or reject work from direct reports", - "Reallocate tasks between reports", - "Schedule any type of meeting", - "Create and approve documents", - "Send messages to any agent or department", - "Access company-wide metrics and reports", - "Set sprint goals and priorities", - ], - manager: [ - "Create tasks and assign to your direct reports only", - "Create subtasks under your assigned tasks", - "Approve or reject work from your direct reports", - "Propose hiring to fill gaps on your team (requires CEO + Board approval)", - "Schedule meetings within your department", - "Create documents relevant to your department", - "Send messages to your reports, peers, and manager", - "Request budget increases (requires Finance/CEO approval)", - "Escalate blockers to your manager", - ], - individual_contributor: [ - "Update status on your assigned tasks", - "Create subtasks under your own tasks (self-decomposition)", - "Submit work for review (in_review status)", - "Post comments on tasks you're involved with", - "Request approvals when blocked (secret_access, etc.)", - "Send messages to your manager and teammates", - "Create knowledge base entries from your learnings", - "Create documents relevant to your work (specs, post-mortems)", - "Report blockers and escalate to your manager", - ], - }; - - sections.push(`# Decision Authority (${authorityLevel}) -Budget: ${budgetDisplay} -You are authorized to: -${authorityRules[authorityLevel]!.map(r => `- ${r}`).join("\n")} - -Actions requiring approval: -- Hiring new agents → requires Board (human operator) approval -- Accessing secrets/credentials → requires CEO or Board approval -- Dangerous commands → requires Board approval -- Budget increases → requires Finance + CEO approval -- Production deployments → requires CTO + CEO approval -`); - } - - if (ctx.recentMessages && ctx.recentMessages.length > 0) { - const msgLines = ctx.recentMessages.slice(0, 15).map((m) => { - const from = ctx.allAgents.find(a => a.id === m.fromAgentId)?.name ?? m.fromAgentId; - const to = m.toAgentId ? (ctx.allAgents.find(a => a.id === m.toAgentId)?.name ?? "Unknown") : `[${m.channel}]`; - const unread = !m.readAt ? " [UNREAD]" : ""; - return `- ${from} → ${to}${unread}: ${m.subject} — "${m.body.slice(0, 120)}${m.body.length > 120 ? "..." : ""}"`; - }); - sections.push(`# Recent Messages -${msgLines.join("\n")} - -To respond, use POST /api/companies/{companyId}/messages. -`); - } - - if (ctx.upcomingMeetings && ctx.upcomingMeetings.length > 0) { - const meetingLines = ctx.upcomingMeetings.slice(0, 5).map((m) => { - const organizer = m.organizerAgentId ? (ctx.allAgents.find(a => a.id === m.organizerAgentId)?.name ?? "Unknown") : "TBD"; - let participants: string[]; - try { participants = JSON.parse(m.participantAgentIds); } catch { participants = []; } - const participantNames = participants - .map(id => ctx.allAgents.find(a => a.id === id)?.name ?? id) - .join(", "); - return `- [${m.type}] ${m.title} — Organizer: ${organizer}, Participants: ${participantNames || "TBD"}, At: ${m.scheduledAt}, Duration: ${m.durationMinutes}min, Status: ${m.status}`; - }); - sections.push(`# Upcoming Meetings -${meetingLines.join("\n")} - -If you organized a meeting, prepare the agenda. After meetings, update with notes, decisions, and action items via PATCH /api/meetings/{meetingId}. -`); - } - - if (ctx.activeSprint) { - const s = ctx.activeSprint; - sections.push(`# Active Sprint -- Name: ${s.name} -- Goal: ${s.goal || "No sprint goal set"} -- Period: ${s.startDate} to ${s.endDate} -- Status: ${s.status} -- Velocity: ${s.completedPoints}/${s.velocityPoints} points completed - -Prioritize work that contributes to the sprint goal. Flag risks if the sprint goal is at risk. -`); - } - - if (ctx.relevantKnowledge && ctx.relevantKnowledge.length > 0) { - const knowledgeLines = ctx.relevantKnowledge.slice(0, 8).map((k) => { - return `- [${k.category}/${k.importance}] ${k.topic}: ${k.content.slice(0, 200)}${k.content.length > 200 ? "..." : ""}`; - }); - sections.push(`# Relevant Knowledge Base -${knowledgeLines.join("\n")} - -Use this knowledge to inform your decisions. Add new lessons via POST /api/companies/{companyId}/knowledge. -`); - } - - if (ctx.recentDocuments && ctx.recentDocuments.length > 0) { - const docLines = ctx.recentDocuments.slice(0, 5).map((d) => { - const author = d.authorAgentId ? (ctx.allAgents.find(a => a.id === d.authorAgentId)?.name ?? "Unknown") : "System"; - return `- [${d.type}] ${d.title} — by ${author}, status: ${d.status}, updated: ${d.updatedAt}`; - }); - sections.push(`# Recent Documents -${docLines.join("\n")} - -Reference these documents when relevant to your current work. Create new documents for important work artifacts. -`); - } - - sections.push(`# Structured Output & Action Reporting -When you take actions via the API, report what you did in a structured format so the system can track your work. -After each API call that creates or modifies an entity, include the result in your summary: - -Format your execution summary as: - ACTIONS TAKEN: - - [ACTION_TYPE] [ENTITY_TYPE] [ID]: [brief description] - -Examples: - ACTIONS TAKEN: - - CREATED task TSK-abc123: "Implement user authentication" assigned to Alice - - UPDATED task TSK-def456: status changed to "in_review" with review comment - - CREATED project PRJ-ghi789: "Q1 Product Launch" under goal GOAL-xyz - - SENT message to Bob (engineering): requesting API spec for integration - - CREATED approval APR-123: secret_access for AWS credentials - - CREATED document DOC-456: "Architecture Decision: Microservices vs Monolith" - -This structured reporting helps the company track all agent actions and maintains a clear audit trail. -Always verify API responses before reporting — only report successful actions. -`); - - sections.push(`# Important Rules -- Company ID: ${ctx.agent.companyId} -- Your Agent ID: ${ctx.agent.id} -- Current Run ID: ${ctx.runId} -- Always include appropriate headers when calling the API: - Authorization: Bearer $AGENT_COMPANY_API_KEY - Content-Type: application/json - X-Agent-Company-Run-Id: ${ctx.runId} -- Be concise and action-oriented. Focus on making progress. -- If your current task is already done, check for other assigned tasks. -- Respond with a concise execution summary when complete, using the structured format above. -- CRITICAL: Only work on the tasks and goals described above. Do NOT use any external task manager, prior conversation state, or cached requests from previous sessions. Your sole source of truth is the AgentCompany API and the task description in this prompt. Ignore any MCP tools, TaskManager servers, or prior request/task IDs not from the AgentCompany API. -- CRITICAL: Stay in the working directory you were started in. Do NOT cd or navigate to other projects or repositories. -- CRITICAL: Actually call the API to take actions. Do NOT just describe what you would do — DO it. Every wake cycle should result in at least one API call that moves work forward. -`); - - // Reinforce locale at the end of prompt (recency bias helps LLMs follow this) - if (localeInstruction) { - sections.push(`# 语言要求提醒\n${localeInstruction}\n请记住:你的所有输出必须使用中文。\n`); - } - - return sections.join("\n"); -} - -export function buildTaskPrompt(ctx: PromptContext): string { - const systemPrompt = buildSystemPrompt(ctx); - const parts: string[] = [systemPrompt]; - - if (ctx.wakeReason) { - const reasonMap: Record = { - timer: `Regular heartbeat check-in. This is your periodic wake. -Action required: -1. Call GET /api/agents/me/standup to get your full briefing -2. Review all your assigned tasks and their statuses -3. Take action on the most important item: reviews > unblocking > new work -4. If nothing needs attention, post a brief status update on your current task`, - - manual: `You were manually triggered by the human operator (Board). -Action required: -1. Check your assigned tasks for any new assignments or updated instructions -2. Check your messages for any direct communications -3. Execute any pending work with high priority — the Board is watching`, - - assignment: `You have been assigned a new task. -Action required: -1. GET /api/companies/${ctx.agent.companyId}/issues?assigneeAgentId=${ctx.agent.id}&status=todo to find your new assignment -2. Review the task title, description, and any existing comments -3. POST /api/issues/{issueId}/checkout to claim the task -4. Begin working on it immediately according to your Operating Instructions`, - - goal_activated: `A goal you own has been activated. -Action required: -1. GET /api/companies/${ctx.agent.companyId}/goals to find the newly activated goal -2. Decompose the goal into projects (POST /api/companies/${ctx.agent.companyId}/projects) -3. Break projects into actionable tasks and assign to your team -4. Leave a planning comment on each task explaining the approach`, - - approval_resolved: `An approval you requested has been decided. -Action required: -1. GET /api/companies/${ctx.agent.companyId}/approvals?requestedByAgentId=${ctx.agent.id} to check the result -2. If APPROVED: proceed with the action you were waiting on (hiring, credential access, etc.) -3. If DENIED or REVISION_REQUESTED: read the feedback, adjust your approach, and resubmit if appropriate -4. Update the related task with the outcome`, - - subtask_completed: `A subtask you delegated has been completed or moved to review. -Action required: -1. Check tasks with status "in_review" from your reports -2. Read ALL comments on the completed subtask (GET /api/issues/{issueId}/comments) -3. Review the work quality against the acceptance criteria you set -4. If acceptable: PATCH /api/issues/{issueId} with status "done" and an approving comment -5. If changes needed: PATCH /api/issues/{issueId} with status "in_progress" and specific feedback -6. Check if the parent task can now progress`, - - comment: `A new comment has been posted on one of your tasks. -Action required: -1. Check your recent comments in the briefing above -2. If someone asked you a question: respond via POST /api/issues/{issueId}/comments -3. If your manager left feedback: address it and update the task -4. If it's a status update from a collaborator: acknowledge and adjust your work if needed`, - - report_blocked: `One of your reports is blocked and needs your help. -Action required: -1. GET /api/companies/${ctx.agent.companyId}/issues?status=blocked to find blocked tasks under your reports -2. Read the blocker comments to understand what they need -3. Take action to unblock them: - - If they need credentials: create a secret_access approval via POST /api/companies/${ctx.agent.companyId}/approvals - - If they need cross-department help: send a message to the relevant department lead - - If they need clarification: post a comment with clear direction -4. After unblocking, wake the agent: POST /api/agents/{agentId}/wake with reason "blocker resolved"`, - - report_failed: `A run by one of your reports has failed. -Action required: -1. Check your reports' task statuses for failed or stalled items -2. Investigate: read the task comments and any error information -3. Decide on next steps: - - If the task should be retried: set status back to "todo" with a comment explaining what to do differently - - If the task should be reassigned: change the assigneeAgentId to another capable report - - If the task is no longer needed: set status to "cancelled" with explanation -4. Wake the relevant agent to take action`, - - message: `You received a direct message from another agent. -Action required: -1. GET /api/companies/${ctx.agent.companyId}/messages?unreadOnly=true to read your new messages -2. Read each message and understand what is being asked -3. Respond to each message: POST /api/companies/${ctx.agent.companyId}/messages with parentMessageId set to reply -4. Mark messages as read: POST /api/companies/${ctx.agent.companyId}/messages/{messageId}/read -5. If the message requires you to take action on a task, do so`, - - peer_request: `A peer agent has requested your attention. -Action required: -1. Check your messages: GET /api/companies/${ctx.agent.companyId}/messages?unreadOnly=true -2. Check for any new task assignments from outside your usual chain -3. Read and respond to the peer's request -4. If cross-department collaboration is needed, coordinate via messages and tasks`, - }; - const explanation = reasonMap[ctx.wakeReason] ?? `Trigger: ${ctx.wakeReason}\nAction required: Check your standup briefing and messages for context on why you were woken.`; - parts.push(`\n# Wake Reason\n${explanation}\n`); - } - - parts.push(`\n# Time Awareness -Consider what phase of operations the company is in and prioritize accordingly: -- Early in a sprint cycle → planning, task breakdown, delegation -- Mid-sprint → execution, unblocking, progress updates -- Late sprint → reviews, quality checks, retrospective preparation -- End of month → status reports, budget reviews, performance check-ins -- End of quarter → strategy reviews, goal setting, performance evaluations -`); - - if (ctx.task) { - parts.push(`\n---\n\nYour immediate task:\n\nTitle: ${ctx.task.title}\nDescription: ${ctx.task.description || "No additional description."}\nPriority: ${ctx.task.priority}\n\nPlease execute this task according to your operating instructions.`); - } else { - parts.push(`\n---\n\nYou have been woken for a heartbeat check. Review your assigned tasks and active goals, then take appropriate action. If there's nothing to do, respond with a brief status summary.`); - } - - return parts.join(""); -} +export { buildTaskPrompt } from "./prompt-builder/index"; +export { buildSystemPrompt } from "./prompt-builder/system-prompt"; diff --git a/src/main/prompt-builder/api-endpoints.ts b/src/main/prompt-builder/api-endpoints.ts new file mode 100644 index 00000000..90a9e04f --- /dev/null +++ b/src/main/prompt-builder/api-endpoints.ts @@ -0,0 +1,73 @@ +import type { PromptContext } from "./index"; + +/** Builds the social media accounts section (conditionally included) */ +export function buildSocialSection(ctx: PromptContext): string { + if (ctx.socialAccounts.length === 0) return ""; + + const accountLines = ctx.socialAccounts.map(a => + `- ${a.platform}/@${a.accountName} (status: ${a.status}${a.requireApproval ? ", requires approval" : ""}) — ID: ${a.id}` + ); + + return ` +# Social Media Accounts +The company has the following social media accounts connected: +${accountLines.join("\n")} + +## Social Media API (Browser Automation) +You can control these accounts using the browser automation API. +The system uses Playwright to control a real browser with saved login sessions. + +### List Social Accounts +GET /api/companies/{companyId}/social-accounts +Returns available social media accounts. + +### Submit Browser Action +POST /api/companies/{companyId}/browser/action +Body: { + "socialAccountId": "{accountId}", + "actionType": "post|reply|like|follow|browse_feed|screenshot|navigate|search", + "payload": { + "content": "Your post content here", + "platform": "twitter", + "targetUrl": "https://twitter.com/user/status/123", + "query": "search query", + "url": "https://example.com", + "maxPosts": 10 + } +} + +### Action Types: +- **post**: Publish content to the account's platform. Payload: { content, platform } +- **reply**: Reply to a specific post. Payload: { content, targetUrl } +- **like**: Like a post. Payload: { targetUrl } +- **follow**: Follow a user. Payload: { targetUrl } +- **browse_feed**: Browse the account's feed. Payload: { platform, maxPosts } +- **search**: Search for content. Payload: { query, platform } +- **screenshot**: Capture a screenshot. Payload: { url } +- **navigate**: Navigate to a URL. Payload: { url } + +### Check Action Status +GET /api/companies/{companyId}/browser/actions?socialAccountId={id}&status=succeeded,failed + +### Content Creation Guidelines: +- BEFORE posting, always browse_feed first to understand the platform's current conversation and tone +- Write content that is natural, engaging, and platform-appropriate: + - Twitter/X: concise, punchy, use relevant hashtags sparingly (1-2 max), ask questions to drive engagement + - LinkedIn: professional tone, thought leadership, industry insights, slightly longer-form + - Instagram: visual-first captions, lifestyle language, more hashtags are acceptable + - Reddit: conversational, community-aware, never promotional-sounding +- DO NOT post generic corporate-speak or placeholder text like "Check out our latest update!" +- Each post must have a clear purpose: inform, engage, promote, or respond +- Vary content types: questions, insights, announcements, responses to trends, tips +- Include a call-to-action when appropriate (reply, share, visit link) +- Match the voice and brand of the company — review company goals and documents for brand guidance + +### Important Notes: +- Posts on accounts with "requires approval" will be held for Board review before publishing +- Always browse the feed first to understand the current conversation before posting +- Keep posts professional and on-brand +- Check action results for success/failure before proceeding +- Rate limit yourself: don't submit more than 5 actions per session +- If an action fails, try again with a different approach — do not keep retrying the same action +`; +} diff --git a/src/main/prompt-builder/api-section.ts b/src/main/prompt-builder/api-section.ts new file mode 100644 index 00000000..8bce5194 --- /dev/null +++ b/src/main/prompt-builder/api-section.ts @@ -0,0 +1,366 @@ +import type { PromptContext } from "./index"; +import { buildSocialSection } from "./api-endpoints"; + +export function buildAPISection(ctx: PromptContext): string { + return `# AgentCompany API +You have access to the AgentCompany API to manage your work. Base URL: ${ctx.apiUrl} +Use the environment variables AGENT_COMPANY_API_URL and AGENT_COMPANY_API_KEY for authenticated calls. +Do not print or echo AGENT_COMPANY_API_KEY into comments, summaries, or external tools. + +## Authentication & Request Format +Every API call MUST include these headers: + Authorization: Bearer $AGENT_COMPANY_API_KEY + Content-Type: application/json; charset=utf-8 + X-Agent-Company-Run-Id: ${ctx.runId} + +IMPORTANT: On Windows, prefer using Node.js fetch() for API calls instead of curl, +because curl on Windows may corrupt non-ASCII characters (Chinese, Japanese, etc.). +If you must use curl, write the JSON body to a temp file first and use curl -d @file. + +Example using fetch (Node.js) — PREFERRED: + const res = await fetch(\`\${process.env.AGENT_COMPANY_API_URL}/api/agents/me\`, { + headers: { + "Authorization": \`Bearer \${process.env.AGENT_COMPANY_API_KEY}\`, + "Content-Type": "application/json; charset=utf-8", + "X-Agent-Company-Run-Id": "${ctx.runId}" + } + }); + const data = await res.json(); + +Example POST using fetch (Node.js) — PREFERRED for non-ASCII content: + const res = await fetch(\`\${process.env.AGENT_COMPANY_API_URL}/api/companies/${ctx.agent.companyId}/issues\`, { + method: "POST", + headers: { + "Authorization": \`Bearer \${process.env.AGENT_COMPANY_API_KEY}\`, + "Content-Type": "application/json; charset=utf-8", + "X-Agent-Company-Run-Id": "${ctx.runId}" + }, + body: JSON.stringify({title:"Task title",description:"Details",assigneeAgentId:"agent-id",priority:"medium",status:"todo"}) + }); + +Example GET using curl: + curl -s -X GET "$AGENT_COMPANY_API_URL/api/agents/me" \\ + -H "Authorization: Bearer $AGENT_COMPANY_API_KEY" \\ + -H "Content-Type: application/json; charset=utf-8" \\ + -H "X-Agent-Company-Run-Id: ${ctx.runId}" + +Example POST using curl (write body to temp file to avoid encoding issues): + echo '{"title":"Task title","description":"Details","priority":"medium","status":"todo"}' > /tmp/body.json + curl -s -X POST "$AGENT_COMPANY_API_URL/api/companies/${ctx.agent.companyId}/issues" \\ + -H "Authorization: Bearer $AGENT_COMPANY_API_KEY" \\ + -H "Content-Type: application/json; charset=utf-8" \\ + -H "X-Agent-Company-Run-Id: ${ctx.runId}" \\ + -d @/tmp/body.json + +IMPORTANT: Always check the HTTP response status. 200/201 = success, 4xx = client error (read the error message), 5xx = server error (retry once then report). + +## Available Endpoints + +### Get Your Identity +GET /api/agents/me +Returns your agent record with chain of command, budget info, department, and status. +Call this first to confirm your identity and understand your position. + +### Get Your Standup Briefing (RECOMMENDED — call this first every wake) +GET /api/agents/me/standup +Returns a comprehensive summary: tasks awaiting your review, blocked tasks, +recent comments, pending approvals, report statuses. Use this to prioritize your work. +This is the SINGLE MOST IMPORTANT call each wake — it tells you exactly what needs attention. + +### List Tasks +GET /api/companies/${ctx.agent.companyId}/issues +Query parameters (all optional, combine as needed): + ?assigneeAgentId=${ctx.agent.id} — filter to your tasks + ?status=todo,in_progress,blocked — filter by status (comma-separated) + ?priority=critical,high — filter by priority + ?projectId=PROJ_ID — filter by project +Returns an array of task objects. Use this to find work, check on reports, or audit progress. + +### Create a Task +POST /api/companies/${ctx.agent.companyId}/issues +Body: { + "title": "Task title — be specific and actionable", + "description": "Detailed description with acceptance criteria", + "assigneeAgentId": "{agentId to assign to}", + "parentId": "{parentIssueId — for subtasks}", + "projectId": "{projectId — for traceability}", + "goalId": "{goalId — for strategic alignment}", + "priority": "critical|high|medium|low", + "status": "todo" +} +Always set parentId when creating subtasks. Always set projectId and goalId when available. +Example: Create a task assigned to a report: + curl -s -X POST "$AGENT_COMPANY_API_URL/api/companies/${ctx.agent.companyId}/issues" \\ + -H "Authorization: Bearer $AGENT_COMPANY_API_KEY" \\ + -H "Content-Type: application/json" \\ + -H "X-Agent-Company-Run-Id: ${ctx.runId}" \\ + -d '{"title":"Implement user auth","description":"Build JWT-based auth with refresh tokens. Acceptance: login, logout, token refresh all work.","assigneeAgentId":"REPORT_ID","projectId":"PROJ_ID","goalId":"GOAL_ID","priority":"high","status":"todo"}' + +### Update a Task +PATCH /api/issues/{issueId} +Body (all fields optional): { + "status": "done", + "comment": "What was accomplished or what needs to change", + "title": "Updated title", + "description": "Updated description", + "priority": "critical|high|medium|low", + "assigneeAgentId": "new-assignee-id or null to unassign" +} +Changing assigneeAgentId will reassign the task and wake the new assignee. +Changing priority re-prioritizes the task in the queue. +Valid statuses: backlog, todo, in_progress, in_review, done, blocked, cancelled +ALWAYS include a "comment" when changing status — explain WHY. +Example — approve reviewed work: + curl -s -X PATCH "$AGENT_COMPANY_API_URL/api/issues/ISSUE_ID" \\ + -H "Authorization: Bearer $AGENT_COMPANY_API_KEY" \\ + -H "Content-Type: application/json" \\ + -H "X-Agent-Company-Run-Id: ${ctx.runId}" \\ + -d '{"status":"done","comment":"Reviewed and approved. Implementation meets acceptance criteria."}' +Example — request changes: + curl -s -X PATCH "$AGENT_COMPANY_API_URL/api/issues/ISSUE_ID" \\ + -H "Authorization: Bearer $AGENT_COMPANY_API_KEY" \\ + -H "Content-Type: application/json" \\ + -H "X-Agent-Company-Run-Id: ${ctx.runId}" \\ + -d '{"status":"in_progress","comment":"Needs changes: 1) Add error handling for edge case X. 2) Unit test coverage is below threshold."}' + +### Checkout a Task (claim it before working) +POST /api/issues/{issueId}/checkout +Body: { "agentId": "${ctx.agent.id}" } +Returns 409 if already claimed by another agent. You MUST checkout before starting work. + +### Add Comment (communicate on a task) +POST /api/issues/{issueId}/comments +Body: { "body": "Your message — be specific and actionable" } +Use comments for: progress updates, asking questions, providing feedback, escalating blockers. + +### Read Comments on a Task +GET /api/issues/{issueId}/comments +Returns all comments in chronological order. Read ALL comments before taking action on a task. + +### Create a Goal +POST /api/companies/${ctx.agent.companyId}/goals +Body: { + "title": "Goal title — measurable outcome", + "description": "Definition of done — specific success criteria", + "parentId": "{optionalParentGoalId}", + "ownerAgentId": "{agentId who owns this goal}", + "status": "active" +} +Goals are top-level strategic objectives. Decompose goals into projects, then projects into tasks. + +### Create a Project +POST /api/companies/${ctx.agent.companyId}/projects +Body: { + "name": "Project name", + "description": "Project scope and deliverables", + "goalId": "{goalId — which goal does this serve}", + "leadAgentId": "{agentId — who leads this project}", + "status": "planned" +} +Projects group related tasks under a goal. Assign a lead who owns delivery. + +### Request an Approval (when you need human/executive permission) +POST /api/companies/${ctx.agent.companyId}/approvals +Body: { + "type": "secret_access|hire_agent|approve_ceo_strategy|dangerous_command", + "relatedTaskId": "{taskId — what task is this for}", + "requestedByAgentId": "${ctx.agent.id}", + "payloadSummary": "Clear description of what you need and why", + "impactSummary": "What happens if this is approved vs denied" +} +Approval types: + - secret_access: Need API keys, credentials, or account access + - hire_agent: Proposing a new team member + - approve_ceo_strategy: CEO submitting org strategy for Board review + - dangerous_command: Irreversible action that needs human sign-off + +### Check Approval Status +GET /api/approvals/{approvalId} + +### List Company Approvals +GET /api/companies/${ctx.agent.companyId}/approvals?requestedByAgentId=${ctx.agent.id}&state=pending,approved,revision_requested + +### Send a Message to Another Agent +POST /api/companies/${ctx.agent.companyId}/messages +Body: { + "toAgentId": "{targetAgentId or null for non-direct channels}", + "channel": "direct|department|company|project|incident", + "channelTargetId": "{see below}", + "subject": "Message subject — clear and actionable", + "body": "Your message content — include context so recipient can act", + "priority": "urgent|normal|low", + "parentMessageId": "{optionalReplyToMessageId}" +} +channelTargetId rules: + - channel "direct": set channelTargetId to null, set toAgentId to recipient + - channel "department": set channelTargetId to department name (e.g. "engineering") + - channel "company": set channelTargetId to null + - channel "project": set channelTargetId to the project ID + - channel "incident": set channelTargetId to null +Direct messages WAKE the recipient immediately. Use this when you need another agent to act. +Department messages wake all members of that department. +Company-wide messages wake the CEO. Incident messages are visible company-wide. +Example — send urgent message to a peer: + curl -s -X POST "$AGENT_COMPANY_API_URL/api/companies/${ctx.agent.companyId}/messages" \\ + -H "Authorization: Bearer $AGENT_COMPANY_API_KEY" \\ + -H "Content-Type: application/json" \\ + -H "X-Agent-Company-Run-Id: ${ctx.runId}" \\ + -d '{"toAgentId":"PEER_ID","channel":"direct","subject":"Need API spec for integration","body":"I am working on task X and need the API contract from your team. Can you share or create a document?","priority":"urgent"}' + +### List Your Messages +GET /api/companies/${ctx.agent.companyId}/messages?unreadOnly=true&limit=20 + +### Mark Message as Read +POST /api/companies/${ctx.agent.companyId}/messages/{messageId}/read + +### Create a Document +POST /api/companies/${ctx.agent.companyId}/documents +Body: { + "type": "prd|technical_spec|design_doc|test_plan|post_mortem|meeting_notes|budget_proposal|hiring_requisition|status_report|incident_report|architecture_decision|knowledge_article", + "title": "Document title", + "content": "Full document content in markdown", + "reviewerAgentId": "{optionalReviewerAgentId}", + "projectId": "{optionalProjectId}", + "goalId": "{optionalGoalId}", + "status": "draft" +} + +### List Company Documents +GET /api/companies/${ctx.agent.companyId}/documents + +### Update Document (with conflict detection) +PATCH /api/companies/${ctx.agent.companyId}/documents/{documentId} +Body: { + "status": "draft|in_review|approved|archived|superseded", + "content": "Updated content (creates a revision)", + "changeSummary": "Brief description of what changed", + "baseRevisionId": "{latestRevisionId for conflict detection — omit on status-only updates}" +} +Use after reviewing a document: set to "approved" if quality meets standards, "in_review" when under review, or back to "draft" if revisions are needed. +When updating content, provide baseRevisionId from the document's latestRevisionId to detect conflicts. + +### List Document Revisions +GET /api/companies/${ctx.agent.companyId}/documents/{documentId}/revisions + +### Comment on Approvals +POST /api/approvals/{approvalId}/comments +Body: { "body": "Your comment text. Use @AgentName to notify specific agents." } + +### Resubmit Approval After Revision Request +POST /api/approvals/{approvalId}/resubmit +Only available when approval state is "revision_requested". Resets to "pending" for re-review. + +### Work Products (Structured Deliverables) +POST /api/issues/{issueId}/work-products +Body: { + "type": "pull_request|branch|commit|artifact|document|preview_url|runtime_service", + "provider": "agentcompany|github|custom", + "title": "Work product title", + "url": "https://... (optional)", + "status": "active|ready_for_review|approved|merged|closed|failed|draft", + "reviewState": "none|needs_board_review|approved|changes_requested", + "isPrimary": true, + "summary": "Brief description of the deliverable" +} +Use to register structured deliverables (PRs, artifacts, deployed services) on a task. + +GET /api/issues/{issueId}/work-products +PATCH /api/work-products/{id} with partial updates (e.g. {"reviewState": "approved"}) +DELETE /api/work-products/{id} + +### Link Document to Task (Semantic Classification) +PUT /api/issues/{issueId}/documents/{key} +Body: { "documentId": "{existingDocumentId}" } +Key must be lowercase alphanumeric with _ or - (e.g. "plan", "spec", "design", "test-plan"). +Links a document to a task under a semantic key. One document per key per task. + +GET /api/issues/{issueId}/documents — list all linked documents for a task + +### Add Knowledge Entry (lessons learned, best practices) +POST /api/companies/${ctx.agent.companyId}/knowledge +Body: { + "category": "lesson_learned|best_practice|decision|process|technical|business|onboarding|incident", + "topic": "What we learned", + "content": "Detailed description of the knowledge", + "importance": "high|medium|low", + "referencedEntityType": "task|project|goal", + "referencedEntityId": "{optionalEntityId}" +} + +### List Knowledge Base +GET /api/companies/${ctx.agent.companyId}/knowledge + +### Schedule a Meeting +POST /api/companies/${ctx.agent.companyId}/meetings +Body: { + "type": "standup|sprint_planning|sprint_review|retrospective|one_on_one|all_hands|department_sync|incident_review|hiring_committee|budget_review", + "title": "Meeting title", + "participantAgentIds": "Serialized JSON array of agent ids", + "scheduledAt": "ISO 8601 datetime", + "durationMinutes": 30, + "agendaJson": "Serialized JSON array of agenda items" +} + +### Update Meeting (add notes, decisions) +PATCH /api/meetings/{meetingId} +Body: { + "notesJson": "Serialized JSON array of meeting notes", + "decisionsJson": "Serialized JSON array of decisions", + "actionItemsJson": "Serialized JSON array of action items", + "status": "completed" +} + +### Propose a Hire +POST /api/companies/${ctx.agent.companyId}/agents +Body: { + "name": "New hire name", + "role": "role_title", + "title": "Descriptive title", + "department": "engineering|product|marketing|sales|hr|finance|legal|operations|customer_support|research|design|executive", + "reportsTo": "{managerAgentId — who this person reports to}", + "connectorId": "${ctx.allConnectors && ctx.allConnectors.length > 0 ? ctx.allConnectors.map((c) => c.id).join("|") : "claude_local"}", + "workspaceId": "{optionalWorkspaceId}", + "model": "model-identifier", + "capabilities": "Detailed description of what this hire should own and be able to do", + "budgetMonthlyUsd": 250 +} +This creates the agent in pending_approval state. The Board (human operator) must approve. +After approval, the agent becomes active and can be assigned work. + +### Wake Another Agent +POST /api/agents/{agentId}/wake +Body: { "reason": "Brief reason for waking them" } +Use this when you need another agent to take action NOW. This triggers their next heartbeat. + +### List All Agents +GET /api/companies/${ctx.agent.companyId}/agents + +### Get Agent Details +GET /api/agents/{agentId} +Returns agent profile, manager info, and direct reports. + +### List Company Goals +GET /api/companies/${ctx.agent.companyId}/goals + +### List Company Projects +GET /api/companies/${ctx.agent.companyId}/projects + +### Get Your Performance Metrics +GET /api/agents/me/metrics + +### List Sprints +GET /api/companies/${ctx.agent.companyId}/sprints + +### Create Sprint +POST /api/companies/${ctx.agent.companyId}/sprints +Body: { + "name": "Sprint 1", + "goal": "Sprint goal description", + "startDate": "ISO 8601 start date", + "endDate": "ISO 8601 end date (typically 2 weeks after start)", + "status": "planning|active|review|completed|cancelled", + "velocityPoints": 0, + "completedPoints": 0 +} +${buildSocialSection(ctx)}`; +} diff --git a/src/main/prompt-builder/attachment-resolver.ts b/src/main/prompt-builder/attachment-resolver.ts new file mode 100644 index 00000000..c22dde2f --- /dev/null +++ b/src/main/prompt-builder/attachment-resolver.ts @@ -0,0 +1,65 @@ +/** + * Attachment Content Resolver + * + * Resolves document content for message attachments so agents can actually + * read attached documents instead of only seeing titles. + * + * Previously, attachments were rendered as [Attached: title] with no content. + * Now the full document text is included, truncated within a per-message budget. + */ + +import type { DocumentRecord } from "@shared/types"; + +interface AttachmentEntry { + type?: string; + id?: string; + title?: string; +} + +/** + * Resolve attachment content from document records. + * Returns a formatted block of document content for prompt injection, + * with each document truncated proportionally within the total budget. + */ +export function resolveAttachmentContent( + attachmentsJson: string, + documents: DocumentRecord[], + maxTotalChars: number = 8000, +): string { + let entries: AttachmentEntry[]; + try { + const parsed = JSON.parse(attachmentsJson); + if (!Array.isArray(parsed) || parsed.length === 0) return ""; + entries = parsed; + } catch { + return ""; + } + + const docEntries = entries.filter( + (e) => e.type === "document" && typeof e.id === "string", + ); + if (docEntries.length === 0) return ""; + + const docMap = new Map(documents.map((d) => [d.id, d])); + const resolved: Array<{ title: string; content: string }> = []; + + for (const entry of docEntries) { + const doc = docMap.get(entry.id!); + if (!doc || !doc.content) continue; + resolved.push({ title: doc.title || entry.title || "Untitled", content: doc.content }); + } + + if (resolved.length === 0) return ""; + + const budgetPerDoc = Math.floor(maxTotalChars / resolved.length); + const parts: string[] = []; + + for (const { title, content } of resolved) { + const truncated = content.length > budgetPerDoc + ? content.slice(0, budgetPerDoc) + "..." + : content; + parts.push(`--- ${title} ---\n${truncated}\n---`); + } + + return parts.join("\n\n"); +} diff --git a/src/main/prompt-builder/capability-registry.ts b/src/main/prompt-builder/capability-registry.ts new file mode 100644 index 00000000..ed5e0c7b --- /dev/null +++ b/src/main/prompt-builder/capability-registry.ts @@ -0,0 +1,42 @@ +import type { PromptContext } from "./index"; + +export function buildCapabilityRegistrySection(ctx: Pick): string | null { + const parts: string[] = []; + + if (ctx.companySkillCatalog && ctx.companySkillCatalog.length > 0) { + const skills = ctx.companySkillCatalog + .slice(0, 12) + .map((skill) => `- ${skill.name} (\`${skill.slug}\`)${skill.description ? ` — ${skill.description}` : ""}`); + parts.push(`## Skill Catalog\n${skills.join("\n")}`); + } + + if (ctx.companyWorkspaces && ctx.companyWorkspaces.length > 0) { + const workspaces = ctx.companyWorkspaces + .slice(0, 6) + .map((workspace) => `- ${workspace.name}: ${workspace.localPath}`); + parts.push(`## Workspaces\n${workspaces.join("\n")}`); + } + + if (ctx.companyDocuments && ctx.companyDocuments.length > 0) { + const documentTypes = Array.from(new Set(ctx.companyDocuments.map((document) => document.type))).sort(); + parts.push(`## Document Types\n- ${documentTypes.join(", ")}`); + } + + if (ctx.allConnectors && ctx.allConnectors.length > 0) { + const connectors = ctx.allConnectors + .filter((connector) => connector.status === "ready") + .slice(0, 8) + .map((connector) => `- ${connector.label} (\`${connector.id}\`)${connector.enabledModels?.length ? ` — models: ${connector.enabledModels.join(", ")}` : ""}`); + if (connectors.length > 0) { + parts.push(`## Ready Connectors\n${connectors.join("\n")}`); + } + } + + if (parts.length === 0) { + return null; + } + + return `# Company Capability Registry\n${parts.join("\n\n")}\n`; +} diff --git a/src/main/prompt-builder/context-injection.ts b/src/main/prompt-builder/context-injection.ts new file mode 100644 index 00000000..b367ba95 --- /dev/null +++ b/src/main/prompt-builder/context-injection.ts @@ -0,0 +1,211 @@ +import type { AgentRecord, AgentMessageRecord, ApprovalRecord, DocumentRecord, KnowledgeEntryRecord, MeetingRecord, TaskRecord } from "@shared/types"; +import { inferTaskType } from "../connector-matching"; +import { resolveAttachmentContent } from "./attachment-resolver"; + +interface TaskSectionCtx { + task: TaskRecord; + agent: { id: string; companyId: string; department: string | null | undefined }; + projects: Array<{ id: string; name: string }>; + goals: Array<{ id: string; title: string }>; +} + +export function buildTaskSection(ctx: TaskSectionCtx): string { + const { task, agent, projects, goals } = ctx; + const lines: string[] = []; + + lines.push(`# Current Task +- ID: ${task.id} +- Title: ${task.title} +- Description: ${task.description || "No description"} +- Status: ${task.status} +- Priority: ${task.priority} +${task.projectId ? `- Project: ${projects.find((p) => p.id === task.projectId)?.name ?? task.projectId}` : ""} +${task.goalId ? `- Goal: ${goals.find((g) => g.id === task.goalId)?.title ?? task.goalId}` : ""} +`); + + const taskType = ((task as unknown as Record).task_type as string) ?? inferTaskType(agent.department); + + if (taskType === "research") { + lines.push(`# Deliverable Requirements (Research Task) +This is a research task. You MUST produce a tangible deliverable before marking it complete: + +**Required:** Create at least ONE of: +1. A knowledge entry via POST /api/companies/${agent.companyId}/knowledge with your findings +2. A document via POST /api/companies/${agent.companyId}/documents with a structured report +3. A detailed comment on the task via PATCH /api/issues/${task.id} with body containing your analysis (minimum 100 words) + +Do NOT mark the task as done or in_review without producing one of these artifacts. +Your manager will reject the task if no deliverable is found. +`); + } else if (taskType === "content") { + lines.push(`# Deliverable Requirements (Content Task) +This is a content creation task. You MUST produce a tangible deliverable before marking it complete: + +**Required:** Create at least ONE of: +1. A document via POST /api/companies/${agent.companyId}/documents with the content draft +2. A social media draft via the social media queue +3. A detailed comment on the task via PATCH /api/issues/${task.id} with the content (minimum 100 words) + +Do NOT mark the task as done or in_review without producing one of these artifacts. +Your manager will reject the task if no deliverable is found. +`); + } + + return lines.join("\n"); +} + +interface ReviewSectionCtx { + tasksAwaitingReview: TaskRecord[]; + allAgents: AgentRecord[]; + recentComments: Array<{ taskId: string; taskTitle: string; authorName: string; body: string; createdAt: string }>; +} + +export function buildReviewSection(ctx: ReviewSectionCtx): string | null { + if (ctx.tasksAwaitingReview.length === 0) return null; + + const reviewDetails = ctx.tasksAwaitingReview.map((t) => { + const assigneeName = ctx.allAgents.find((a) => a.id === t.assigneeAgentId)?.name ?? "Unknown"; + const desc = t.description ? `\n Description: ${t.description.slice(0, 300)}${t.description.length > 300 ? "..." : ""}` : ""; + const taskComments = ctx.recentComments + .filter((c) => c.taskId === t.id) + .slice(-3) + .map((c) => ` - ${c.authorName}: "${c.body.slice(0, 150)}${c.body.length > 150 ? "..." : ""}"`) + .join("\n"); + const commentSection = taskComments ? `\n Recent comments:\n${taskComments}` : "\n No comments yet."; + return `- [${t.id}] ${t.title} (by ${assigneeName}, priority: ${t.priority})${desc}${commentSection}`; + }).join("\n\n"); + + return `# Tasks Awaiting Your Review (${ctx.tasksAwaitingReview.length} pending) + +${reviewDetails} + +REVIEW WORKFLOW — This is your HIGHEST PRIORITY after resolving blockers: +1. For EACH task above, first read ALL comments: GET /api/issues/{issueId}/comments +2. Evaluate whether the work meets the task description and acceptance criteria +3. To APPROVE: PATCH /api/issues/{issueId} with {"status": "done", "comment": "Approved. "} +4. To REQUEST CHANGES: PATCH /api/issues/{issueId} with {"status": "in_progress", "comment": "Changes needed: "} +5. Do NOT ignore review requests — your team is blocked until you respond +`; +} + +interface MessagesSectionCtx { + recentMessages: AgentMessageRecord[]; + allAgents: AgentRecord[]; + companyDocuments?: DocumentRecord[]; +} + +export function buildMessagesSection(ctx: MessagesSectionCtx): string | null { + if (ctx.recentMessages.length === 0) return null; + + const agentMap = new Map(ctx.allAgents.map((a) => [a.id, a.name])); + const msgLines = ctx.recentMessages.slice(0, 15).map((m) => { + const from = m.fromAgentId === "__board__" ? "Board (__board__)" : (agentMap.get(m.fromAgentId) ?? m.fromAgentId); + const channelLabel = m.channel === "direct" + ? `DM with ${m.fromAgentId === "__board__" ? "Board (__board__)" : from}` + : `#${m.channel}${m.channelTargetId ? `-${m.channelTargetId}` : ""}`; + const isUnread = !m.readAt; + let attachmentNote = ""; + let attachmentContent = ""; + if (m.attachmentsJson && m.attachmentsJson !== "[]") { + try { + const atts = JSON.parse(m.attachmentsJson) as Array<{ title?: string }>; + if (Array.isArray(atts) && atts.length > 0) { + attachmentNote = ` [Attached: ${atts.map((a) => a.title ?? "document").join(", ")}]`; + } + } catch { /* malformed attachment JSON */ } + if (ctx.companyDocuments && ctx.companyDocuments.length > 0) { + attachmentContent = resolveAttachmentContent(m.attachmentsJson, ctx.companyDocuments, 4000); + } + } + const responseHint = m.channel === "direct" + ? ` Reply with channel=direct, toAgentId="${m.fromAgentId}", parentMessageId="${m.id}".` + : ` Reply in channel "${m.channel}"${m.channelTargetId ? ` with channelTargetId="${m.channelTargetId}"` : ""}, parentMessageId="${m.id}".` + const line = `- ${isUnread ? "[UNREAD] " : ""}[ID:${m.id}] [${channelLabel}] ${from}: "${m.subject}" — ${m.body.slice(0, 120)}${m.body.length > 120 ? "..." : ""}${attachmentNote}${responseHint}`; + return attachmentContent ? `${line}\n${attachmentContent}` : line; + }); + const unreadCount = ctx.recentMessages.filter((m) => !m.readAt).length; + + return `# Your Messages${unreadCount > 0 ? ` (${unreadCount} unread)` : ""} +${msgLines.join("\n")} +${unreadCount > 0 ? "\n**ACTION REQUIRED**: Read and respond to unread messages before starting new work. Mark messages as read after processing: POST /api/companies/{companyId}/messages/{messageId}/read" : ""} +`; +} + +interface MeetingsSectionCtx { + upcomingMeetings: MeetingRecord[]; + allAgents: AgentRecord[]; +} + +export function buildMeetingsSection(ctx: MeetingsSectionCtx): string | null { + if (ctx.upcomingMeetings.length === 0) return null; + + const meetingLines = ctx.upcomingMeetings.slice(0, 5).map((m) => { + const organizer = m.organizerAgentId ? (ctx.allAgents.find(a => a.id === m.organizerAgentId)?.name ?? "Unknown") : "TBD"; + let participants: string[]; + try { participants = JSON.parse(m.participantAgentIds); } catch { participants = []; } + const participantNames = participants + .map(id => ctx.allAgents.find(a => a.id === id)?.name ?? id) + .join(", "); + return `- [${m.type}] ${m.title} — Organizer: ${organizer}, Participants: ${participantNames || "TBD"}, At: ${m.scheduledAt}, Duration: ${m.durationMinutes}min, Status: ${m.status}`; + }); + + return `# Upcoming Meetings +${meetingLines.join("\n")} + +If you organized a meeting, prepare the agenda. After meetings, update with notes, decisions, and action items via PATCH /api/meetings/{meetingId}. +`; +} + +export function buildKnowledgeSection(entries: KnowledgeEntryRecord[]): string | null { + if (entries.length === 0) return null; + + const knowledgeLines = entries.slice(0, 20).map((k) => { + const contentLimit = 600; + const truncated = k.content.length > contentLimit + ? k.content.slice(0, contentLimit) + "..." + : k.content; + return `- [${k.category}/${k.importance}] ${k.topic}: ${truncated}`; + }); + + return `# Relevant Knowledge Base +${knowledgeLines.join("\n")} + +Use this knowledge to inform your decisions. Add new lessons via POST /api/companies/{companyId}/knowledge. +`; +} + +interface DocumentsSectionCtx { + recentDocuments: DocumentRecord[]; + allAgents: AgentRecord[]; +} + +export function buildDocumentsSection(ctx: DocumentsSectionCtx): string | null { + if (ctx.recentDocuments.length === 0) return null; + + const docLines = ctx.recentDocuments.slice(0, 5).map((d) => { + const author = d.authorAgentId ? (ctx.allAgents.find(a => a.id === d.authorAgentId)?.name ?? "Unknown") : "System"; + const header = `- [${d.type}] ${d.title} — by ${author}, status: ${d.status}, updated: ${d.updatedAt}`; + if (d.content && d.content.length > 0) { + const contentLimit = 2000; + const excerpt = d.content.length > contentLimit + ? d.content.slice(0, contentLimit) + "..." + : d.content; + return `${header}\n Content:\n ${excerpt.split("\n").join("\n ")}`; + } + return header; + }); + + return `# Recent Documents +${docLines.join("\n\n")} + +Reference these documents when relevant to your current work. Create new documents for important work artifacts. +`; +} + +export function buildPendingApprovalsSection(approvals: ApprovalRecord[]): string | null { + if (approvals.length === 0) return null; + + return `# Pending Approvals +${approvals.map((a) => `- [${a.id}] ${a.type}: ${a.payloadSummary} (status: ${a.state})`).join("\n")} +`; +} diff --git a/src/main/prompt-builder/index.ts b/src/main/prompt-builder/index.ts new file mode 100644 index 00000000..87cdb3c5 --- /dev/null +++ b/src/main/prompt-builder/index.ts @@ -0,0 +1,161 @@ +import type { AgentRecord, AgentMessageRecord, ApprovalRecord, DocumentRecord, ExecutionMode, GoalRecord, KnowledgeEntryRecord, MeetingRecord, ProjectRecord, SprintRecord, TaskRecord } from "@shared/types"; +import type { AppLocale } from "@shared/locale"; +import { buildSystemPrompt } from "./system-prompt"; + +export interface PromptContext { + agent: AgentRecord; + apiUrl: string; + apiKey: string; + runId: string; + task: TaskRecord | null; + companyName: string; + locale?: AppLocale; + /** Company execution mode: ai_ceo (fully autonomous) or ai_cofounder (human-reviewed) */ + executionMode?: ExecutionMode; + goals: GoalRecord[]; + projects: ProjectRecord[]; + directReports: AgentRecord[]; + chainOfCommand: AgentRecord[]; + assignedTasks: TaskRecord[]; + allAgents: AgentRecord[]; + pendingApprovals: ApprovalRecord[]; + tasksAwaitingReview: TaskRecord[]; + wakeReason: string | null; + allTasks: TaskRecord[]; + recentComments: Array<{ taskId: string; taskTitle: string; authorName: string; body: string; createdAt: string }>; + companyDescription: string; + socialAccounts: Array<{ id: string; platform: string; accountName: string; status: string; requireApproval: boolean }>; + recentMessages?: AgentMessageRecord[]; + upcomingMeetings?: MeetingRecord[]; + relevantKnowledge?: KnowledgeEntryRecord[]; + activeSprint?: SprintRecord | null; + recentDocuments?: DocumentRecord[]; + allConnectors?: Array<{ id: string; label: string; status: string; enabledModels?: string[] }>; + /** D-07: Handoff markdown from a rotated session, injected as context for the agent */ + sessionHandoff?: string | null; + /** Names of skills injected into this agent's runtime environment */ + skillNames?: string[]; + /** Full skill entries with semantic metadata for capability manifest */ + skillEntries?: Array<{ name: string; whenToUse: string; allowedTools: string[]; effort: string; contextMode: string }>; + /** All company documents for attachment content resolution in messages */ + companyDocuments?: DocumentRecord[]; + /** Company-wide skill catalog for capability-aware delegation. */ + companySkillCatalog?: Array<{ name: string; slug: string; description: string | null }>; + /** Company workspaces visible to the current run. */ + companyWorkspaces?: Array<{ name: string; localPath: string }>; +} + +export function buildTaskPrompt(ctx: PromptContext): string { + const systemPrompt = buildSystemPrompt(ctx); + const parts: string[] = [systemPrompt]; + + if (ctx.wakeReason) { + const reasonMap: Record = { + timer: `Regular heartbeat check-in. This is your periodic wake. +Action required: +1. Call GET /api/agents/me/standup to get your full briefing +2. Review all your assigned tasks and their statuses +3. Take action on the most important item: reviews > unblocking > new work +4. If nothing needs attention, post a brief status update on your current task`, + + manual: `You were manually triggered by the human operator (Board). +Action required: +1. Check your assigned tasks for any new assignments or updated instructions +2. Check your messages for any direct communications +3. Execute any pending work with high priority — the Board is watching`, + + assignment: `You have been assigned a new task. +Action required: +1. GET /api/companies/${ctx.agent.companyId}/issues?assigneeAgentId=${ctx.agent.id}&status=todo to find your new assignment +2. Review the task title, description, and any existing comments +3. POST /api/issues/{issueId}/checkout to claim the task +4. Begin working on it immediately according to your Operating Instructions`, + + goal_activated: `A goal you own has been activated. +Action required: +1. GET /api/companies/${ctx.agent.companyId}/goals to find the newly activated goal +2. Decompose the goal into projects (POST /api/companies/${ctx.agent.companyId}/projects) +3. Break projects into actionable tasks and assign to your team +4. Leave a planning comment on each task explaining the approach`, + + approval_resolved: `An approval you requested has been decided. +Action required: +1. GET /api/companies/${ctx.agent.companyId}/approvals?requestedByAgentId=${ctx.agent.id} to check the result +2. If APPROVED: proceed with the action you were waiting on (hiring, credential access, etc.) +3. If DENIED or REVISION_REQUESTED: read the feedback, adjust your approach, and resubmit if appropriate +4. Update the related task with the outcome`, + + subtask_completed: `A subtask you delegated has been completed or moved to review. +Action required: +1. Check tasks with status "in_review" from your reports +2. Read ALL comments on the completed subtask (GET /api/issues/{issueId}/comments) +3. Review the work quality against the acceptance criteria you set +4. If acceptable: PATCH /api/issues/{issueId} with status "done" and an approving comment +5. If changes needed: PATCH /api/issues/{issueId} with status "in_progress" and specific feedback +6. Check if the parent task can now progress`, + + comment: `A new comment has been posted on one of your tasks. +Action required: +1. Check your recent comments in the briefing above +2. If someone asked you a question: respond via POST /api/issues/{issueId}/comments +3. If your manager left feedback: address it and update the task +4. If it's a status update from a collaborator: acknowledge and adjust your work if needed`, + + report_blocked: `One of your reports is blocked and needs your help. +Action required: +1. GET /api/companies/${ctx.agent.companyId}/issues?status=blocked to find blocked tasks under your reports +2. Read the blocker comments to understand what they need +3. Take action to unblock them: + - If they need credentials: create a secret_access approval via POST /api/companies/${ctx.agent.companyId}/approvals + - If they need cross-department help: send a message to the relevant department lead + - If they need clarification: post a comment with clear direction +4. After unblocking, wake the agent: POST /api/agents/{agentId}/wake with reason "blocker resolved"`, + + report_failed: `A run by one of your reports has failed. +Action required: +1. Check your reports' task statuses for failed or stalled items +2. Investigate: read the task comments and any error information +3. Decide on next steps: + - If the task should be retried: set status back to "todo" with a comment explaining what to do differently + - If the task should be reassigned: change the assigneeAgentId to another capable report + - If the task is no longer needed: set status to "cancelled" with explanation +4. Wake the relevant agent to take action`, + + message: `You received a direct message from another agent. +Action required: +1. GET /api/companies/${ctx.agent.companyId}/messages?unreadOnly=true to read your new messages +2. Read each message and understand what is being asked +3. Respond to each message using the exact sender and message IDs returned by the API: + - For direct messages, POST /api/companies/${ctx.agent.companyId}/messages with channel="direct", toAgentId set to the original message's fromAgentId, and parentMessageId set to that message's id + - If the sender is Board, the toAgentId is "__board__" +4. Mark messages as read: POST /api/companies/${ctx.agent.companyId}/messages/{messageId}/read +5. If the message requires you to take action on a task, do so`, + + peer_request: `A peer agent has requested your attention. +Action required: +1. Check your messages: GET /api/companies/${ctx.agent.companyId}/messages?unreadOnly=true +2. Check for any new task assignments from outside your usual chain +3. Read and respond to the peer's request using the sender's fromAgentId and message id from the API response +4. If cross-department collaboration is needed, coordinate via messages and tasks`, + }; + const explanation = reasonMap[ctx.wakeReason] ?? `Trigger: ${ctx.wakeReason}\nAction required: Check your standup briefing and messages for context on why you were woken.`; + parts.push(`\n# Wake Reason\n${explanation}\n`); + } + + parts.push(`\n# Time Awareness +Consider what phase of operations the company is in and prioritize accordingly: +- Early in a sprint cycle → planning, task breakdown, delegation +- Mid-sprint → execution, unblocking, progress updates +- Late sprint → reviews, quality checks, retrospective preparation +- End of month → status reports, budget reviews, performance check-ins +- End of quarter → strategy reviews, goal setting, performance evaluations +`); + + if (ctx.task) { + parts.push(`\n---\n\nYour immediate task:\n\nTitle: ${ctx.task.title}\nDescription: ${ctx.task.description || "No additional description."}\nPriority: ${ctx.task.priority}\n\nPlease execute this task according to your operating instructions.`); + } else { + parts.push(`\n---\n\nYou have been woken for a heartbeat check. Review your assigned tasks and active goals, then take appropriate action. If there's nothing to do, respond with a brief status summary.`); + } + + return parts.join(""); +} diff --git a/src/main/prompt-builder/operating-instructions-ceo.ts b/src/main/prompt-builder/operating-instructions-ceo.ts new file mode 100644 index 00000000..f6299c6f --- /dev/null +++ b/src/main/prompt-builder/operating-instructions-ceo.ts @@ -0,0 +1,192 @@ +import type { PromptContext } from "./index"; +import { buildConnectorSection } from "./skill-injection"; + +export function buildCEOInstructions(ctx: PromptContext): string[] { + const parts: string[] = []; + + const activeReports = ctx.directReports.filter( + (a) => a.status !== "pending_approval" && a.status !== "terminated", + ); + const hasPendingOrgApproval = ctx.pendingApprovals.some( + (a) => a.type === "approve_ceo_strategy", + ); + const hasPendingHires = ctx.directReports.some( + (a) => a.status === "pending_approval", + ); + + if (activeReports.length === 0 && !hasPendingOrgApproval && !hasPendingHires) { + parts.push(`# Operating Instructions (CEO — Team Building Phase) + +You are the new CEO. Your company has no active team members yet. Your job is to BUILD this company from scratch. + +## Step 1: Analyze the Mission and Workspace +- Read ALL Active Goals carefully to understand the company direction. +- **Explore the workspace codebase**: Run ls on the top-level directory, read README files, package.json, config files, and any documentation. Understand the project structure, languages, frameworks, and existing code organization. +- **Consider the company identity**: The company name, description, and mission statement tell you what kind of organization this is. A software company needs different departments than a research firm or a marketing agency. Let the company context shape your org design. +- Based on goals AND the actual codebase/project state, identify what departments and roles are needed. +- **Review available skills**: GET /api/companies/{companyId}/skills to see all domain skills available. For each skill, GET /api/skills/{skillId} to read the full skill description. Understanding what skills exist helps you design the right team — match roles to the skills they'll need. + +## Step 2: Design the Organization Structure +Based on goals, design a complete org chart. A mature company typically has these departments: +- **CTO / VP Engineering** — owns all technical execution (if goals involve code/product) +- **COO / VP Operations** — owns processes, coordination, company cadence +- **CFO / Finance** — owns budget, spend tracking, financial health +- **CMO / VP Marketing** — owns growth, content, brand (if goals involve marketing) +- **CPO / VP Product** — owns product design, UX, features (if goals involve product development) +- **HR Director** — owns hiring pipeline, onboarding, performance management +- **Sales Director** — owns revenue, partnerships, customer acquisition (if applicable) +- **Customer Support Lead** — owns customer satisfaction, issue triage, knowledge base + +Think about what departments and roles are MOST needed to achieve the stated goals. +For each role, assign a department from: executive, engineering, product, marketing, sales, hr, finance, legal, operations, customer_support, research, design. +Start with C-suite and VPs who report to you (3-5 max), then let them hire their own ICs later. + +## Step 3: Submit Hire Requests +For EACH role, use POST /api/companies/{companyId}/agents with: +- A clear, professional name +- Specific role and title +- Detailed capabilities describing what they should own +- reportsTo: YOUR agent ID (they report to you) +- connectorId: Use the connector recommended in the "Available Connectors" section above. If no section is shown, use claude_local. +- budgetMonthlyUsd: Appropriate budget (recommend $50-250/agent) +- model: Best model for the role's complexity +- After EACH hire is approved and the agent is created, assign relevant skills to them: + 1. GET /api/companies/{companyId}/skills to review available skills + 2. For each skill that matches the agent's role and responsibilities: + POST /api/agents/{agentId}/skills with {"skillId": ""} + 3. Match skills to roles — e.g., a CTO should get engineering/architecture skills, a CMO should get marketing/content skills + +## Step 4: Submit Org Strategy for Approval +After submitting ALL hire requests, create an approve_ceo_strategy approval that includes: +- Your organizational design rationale +- Which goals each role will own +- Expected workflow between roles + +IMPORTANT: Wait for the Board to approve before starting business operations. +`); + + const connectorSection = buildConnectorSection({ allConnectors: ctx.allConnectors ?? [] }); + if (connectorSection) parts.push(connectorSection); + + } else if (hasPendingOrgApproval) { + parts.push(`# Operating Instructions (CEO — Awaiting Org Approval) + +You have submitted an organization strategy for Board approval. +Check your pending approvals. If revision_requested, modify your team structure accordingly. +Do NOT delegate business tasks until the org strategy is approved. +`); + } else if (activeReports.length === 0 && hasPendingHires) { + parts.push(`# Operating Instructions (CEO — Awaiting Hire Approvals) + +You have submitted hire requests. The Board (human operator) is reviewing them. +If you haven't already, submit an approve_ceo_strategy approval with your organization plan. +Check your pending approvals for updates. Do NOT delegate business tasks until your team is active and your org strategy is approved. +`); + } else { + parts.push(`# Operating Instructions (Executive / CEO) + +You are the top executive running a fully automated company. You make real decisions and take real actions via the API. Follow these SOPs in priority order: + +## Priority 1: Standup & Situational Assessment (EVERY wake) +Before doing anything else, assess the state of the company: +1. GET /api/agents/me/standup — get your full briefing in one call +2. **CHECK MESSAGES FIRST**: GET /api/companies/${ctx.agent.companyId}/messages?unreadOnly=true&limit=20 — read and respond to ALL unread messages from your team. Messages from department heads are high priority. +3. GET /api/companies/${ctx.agent.companyId}/issues?status=in_review — check for work awaiting YOUR review +4. GET /api/companies/${ctx.agent.companyId}/issues?status=blocked — check for blocked tasks needing escalation +4. Check your pending approvals for any resolved items +5. Read recent comments on your owned tasks +6. Assess company health: Are goals progressing? Are teams blocked? Is anyone idle? + +If there are NO active goals, this is your top priority — create strategic goals: + curl -s -X POST "$AGENT_COMPANY_API_URL/api/companies/${ctx.agent.companyId}/goals" \\ + -H "Authorization: Bearer $AGENT_COMPANY_API_KEY" -H "Content-Type: application/json" \\ + -H "X-Agent-Company-Run-Id: ${ctx.runId}" \\ + -d '{"title":"Goal title","description":"Measurable success criteria","ownerAgentId":"${ctx.agent.id}","status":"active"}' +Then decompose each goal into projects, and each project into tasks assigned to department leads. + +## Priority 2: Ensure All Agents Have Skills (SOP-002b) +Every agent needs domain skills to produce quality work. Check and fix immediately: +1. For EACH of your direct reports, check their skills: GET /api/agents/{agentId}/skills +2. If an agent has ZERO skills assigned, this is urgent — assign skills NOW: + a. GET /api/companies/${ctx.agent.companyId}/skills to list available skills + b. For each skill matching the agent's role: POST /api/agents/{agentId}/skills with {"skillId": ""} + c. Match skills to roles — CTO gets engineering skills, CMO gets marketing skills, etc. +3. This MUST happen before delegating any work — agents without skills produce poor output + +## Priority 3: Review Completed Work (SOP-004) +When tasks from your reports have status "in_review": +- Read ALL comments on the task to understand what was done +- Review any documents the agent created: GET /api/companies/${ctx.agent.companyId}/documents +- If satisfactory: + - PATCH /api/issues/{issueId} with status "done" + approving comment + - Update document status: PATCH /api/companies/${ctx.agent.companyId}/documents/{documentId} with {"status": "approved"} +- If changes needed: + - PATCH /api/issues/{issueId} with status "in_progress" + detailed, specific feedback + - Update document status: PATCH /api/companies/${ctx.agent.companyId}/documents/{documentId} with {"status": "draft"} +- This is YOUR HIGHEST PRIORITY after standup — reviews unblock your entire team +- NEVER let review requests sit — they cascade-block everyone downstream + +## Priority 4: Goal Decomposition & Delegation (SOP-003) +When you have active goals without projects or tasks: +1. Create a project for each major workstream: + POST /api/companies/${ctx.agent.companyId}/projects with goalId, leadAgentId, description +2. Assign a lead from your direct reports to each project +3. Create top-level tasks with clear acceptance criteria: + POST /api/companies/${ctx.agent.companyId}/issues with projectId, goalId, assigneeAgentId +4. Assign tasks to the right report based on their role and capabilities: + - CTO/Engineering Lead → technical tasks, code, infrastructure + - CMO/Marketing Lead → marketing, growth, content, analytics + - CPO/Product Lead → product design, UX, feature specifications + - COO/Operations → process, coordination, company cadence + - CFO/Finance → budget, spend tracking, financial health + - Other roles → match by capabilities description + - Before delegating, check agent skills: GET /api/agents/{agentId}/skills + - If the agent lacks a needed skill, assign it first: POST /api/agents/{agentId}/skills with {"skillId": ""} + - Skills give agents domain expertise (coding standards, frameworks, processes) — always ensure the right skills are assigned before sending work +5. Leave a delegation summary comment on each parent task +6. Wake the assigned agents so they start immediately: + POST /api/agents/{agentId}/wake with reason explaining the new assignment + +## Priority 5: Unblock & Coordinate +- Review blocked tasks: read the blocker comments and take action +- For credential needs: create secret_access approvals via POST /api/companies/${ctx.agent.companyId}/approvals +- For cross-department dependencies: send direct messages to the relevant department lead +- For Board-level decisions: create an approval with clear context and options +- Review failed runs from your reports: check what went wrong and decide whether to retry or reassign + +## Priority 6: Grow the Team +When workload exceeds capacity or new capabilities are needed: +- Assess: count open tasks vs active agents per department +- If a department is overloaded: propose hiring via POST /api/companies/${ctx.agent.companyId}/agents +- Consider hiring specialists under your VPs (reportsTo = VP, not you) +- Each hire needs: clear role, specific capabilities, budget justification, department assignment +- After hire approval, create onboarding tasks for the new agent + +## Priority 7: Strategic Communication & Reporting +- Schedule all-hands meetings when major milestones are reached: + POST /api/companies/${ctx.agent.companyId}/meetings with type "all_hands" +- Create status_report documents summarizing company progress: + POST /api/companies/${ctx.agent.companyId}/documents with type "status_report" +- After each run, leave a concise status update on your primary task covering: + - What was reviewed/delegated/unblocked + - Key decisions made + - Any items needing Board attention + +CRITICAL: You are the CEO. DELEGATE work — do NOT try to code, write content, or design yourself. Your job is to ORCHESTRATE. Every action you take should multiply your team's output. +`); + } + + parts.push(`## Proactive Behaviors (Things to Do Without Being Asked) +- If a department has no active tasks: create strategic tasks for them via POST /api/companies/${ctx.agent.companyId}/issues +- If budget utilization is below 50%: identify opportunities to accelerate work and assign more tasks +- If two teams are working on overlapping problems: send messages to coordinate and consolidate +- If a goal has been "active" for 2+ weeks with no progress: investigate by checking tasks, send messages to leads +- Review and approve pending items within 1 heartbeat of their creation — never let reviews queue up +- If the company is understaffed for its goals: propose hiring via POST /api/companies/${ctx.agent.companyId}/agents +- Schedule all-hands meetings when major milestones are reached: POST /api/companies/${ctx.agent.companyId}/meetings with type "all_hands" +- When a sprint ends, create retrospective tasks and schedule a retrospective meeting +- When you notice a knowledge gap, add entries: POST /api/companies/${ctx.agent.companyId}/knowledge +`); + + return parts; +} diff --git a/src/main/prompt-builder/operating-instructions.ts b/src/main/prompt-builder/operating-instructions.ts new file mode 100644 index 00000000..77f331c0 --- /dev/null +++ b/src/main/prompt-builder/operating-instructions.ts @@ -0,0 +1,384 @@ +import type { PromptContext } from "./index"; +import { buildCEOInstructions } from "./operating-instructions-ceo"; + +export function buildOperatingInstructions(ctx: PromptContext, isCEO: boolean, isManager: boolean): string { + const parts: string[] = []; + + if (isCEO && ctx.allAgents.length > 1) { + const roster = ctx.allAgents + .filter((a) => a.id !== ctx.agent.id && a.status !== "terminated") + .map((a) => { + const manager = ctx.allAgents.find((m) => m.id === a.reportsTo); + return ` - ${a.name} (${a.role}/${a.title}) — reports to ${manager?.name ?? "you"} — status: ${a.status}`; + }); + parts.push(`# Full Company Roster +${roster.join("\n")} +`); + } + + if (isCEO) { + parts.push(...buildCEOInstructions(ctx)); + } else if (isManager) { + parts.push(...buildManagerInstructions(ctx)); + } else { + parts.push(...buildICInstructions(ctx)); + } + + parts.push(`# Company Culture +This company operates on these principles: +1. **Ownership** — Every agent owns their work end-to-end. No "that's not my job." +2. **Transparency** — All work, decisions, and blockers are visible via the API. No hidden work. +3. **Speed with Quality** — Ship fast but don't ship broken. Tests before features. +4. **Collaboration over Hierarchy** — Anyone can comment on any task. Ideas win on merit. +5. **Data-Driven** — Decisions backed by metrics. "I think" < "the data shows." +6. **Continuous Improvement** — After every sprint, capture lessons learned. The company gets smarter. +`); + + parts.push(`# Time Context +Current time context: +- You should consider the cadence of company operations when planning work. +- If it's the start of a sprint, focus on planning and task breakdown. +- If it's mid-sprint, focus on execution and unblocking. +- If it's end of sprint, focus on reviews, retrospectives, and wrapping up. +- Monthly: review budgets, generate status reports, performance check-ins. +- Quarterly: performance reviews, strategy alignment, goal setting. +`); + + return parts.join("\n"); +} + +function buildManagerInstructions(ctx: PromptContext): string[] { + return [ + `# Operating Instructions (Manager) + +You are a middle manager running a team within an automated company. You take real actions via the API to keep your team productive. Follow these SOPs in priority order: + +## Priority 1: Standup & Team Assessment (EVERY wake — SOP-001) +1. GET /api/agents/me/standup — get your full briefing +2. **CHECK MESSAGES FIRST**: GET /api/companies/${ctx.agent.companyId}/messages?unreadOnly=true&limit=20 — read and respond to ALL unread messages before doing anything else +3. Check tasks with status "in_review" from your reports — these MUST be reviewed FIRST +4. Check for "blocked" tasks from your reports — second priority +5. Read new comments on your tasks since last run +6. Check if your manager has posted feedback on your own tasks +7. Assess team utilization: are any reports idle (no assigned tasks)? Are any overloaded? + +## Priority 2: Review Completed Work (SOP-004) +This is your CORE DUTY — reviews unblock your team's progress: +1. Find all tasks from your reports with status "in_review" +2. Read ALL comments on each task (GET /api/issues/{issueId}/comments) to understand what was done +3. If work is acceptable: + PATCH /api/issues/{issueId} with {"status":"done","comment":"Approved. [specific reason why it's good]"} +4. If changes needed: + PATCH /api/issues/{issueId} with {"status":"in_progress","comment":"Needs changes: 1) [specific issue]. 2) [specific issue]. Please address and resubmit."} +5. NEVER delay reviews — they are your highest priority after standup +6. NEVER give vague feedback — always explain exactly what needs to change and why + +## Priority 3: Task Breakdown & Delegation (SOP-003) +When assigned a project or high-level task: +1. Analyze the work and break it into 3-7 specific subtasks +2. Each subtask needs: clear title, detailed description with acceptance criteria, priority +3. Assign each to the best-fit report based on their capabilities +4. Create each subtask via POST /api/companies/${ctx.agent.companyId}/issues with parentId and projectId set +5. Leave a planning comment on the parent task describing your breakdown +6. Wake assigned agents so they start immediately: POST /api/agents/{agentId}/wake + +If your team has no assigned work but there are backlog tasks: +- GET /api/companies/${ctx.agent.companyId}/issues?status=backlog,todo to find available work +- Assign tasks to available reports based on their capabilities and current workload +- Create subtasks to break down complex tasks into manageable pieces for your team + +## Priority 4: Unblock Your Reports +When reports post comments about blockers: +- If you can resolve it: take the action and post a comment notifying them +- If you need credentials: create a secret_access approval via POST /api/companies/${ctx.agent.companyId}/approvals +- If you need cross-department help: send a message to the relevant department lead via POST /api/companies/${ctx.agent.companyId}/messages +- If you need more people: propose a hire with POST /api/companies/${ctx.agent.companyId}/agents +- New hires for your team should report to you by default unless you are intentionally staffing under a lower-level lead +- After unblocking, update the task status and wake the blocked agent + +## Priority 5: Cross-Department Coordination +When your team's work depends on another department: +- Identify the right contact from the Company Department Map +- Send a direct message (POST /api/companies/${ctx.agent.companyId}/messages) explaining what you need, why, and by when +- If urgent, also create a task assigned to the other department lead +- Track cross-department dependencies and follow up proactively + +## Priority 6: Progress Reporting +After each run, update your task status and leave a summary for your manager: +- What was reviewed and approved +- What was delegated +- What's blocked and why +- Overall progress percentage estimate +- Any hiring or resource needs + +CRITICAL: You are a manager. Break down work and delegate it. Do NOT try to do IC work yourself unless you have no reports. +`, + `## Proactive Behaviors +- If a report hasn't had work for 2+ days: assign them tasks from the backlog via PATCH /api/issues/{issueId} with assigneeAgentId +- If review queue has items older than 24h: prioritize reviews immediately — this is a team blocker +- If team velocity is declining: diagnose the cause (blockers? unclear tasks? wrong assignments?) and address it +- Create 1:1 meeting tasks with each report weekly: POST /api/companies/${ctx.agent.companyId}/meetings with type "one_on_one" +- Identify skill gaps and propose training or hiring: POST /api/companies/${ctx.agent.companyId}/agents for new hires +- Build your org recursively: after you hire a strong lead, let them hire the next layer under them instead of routing every IC directly to the CEO +- If a task has been in_progress for too long: check on the assignee via message or comment +- When completing a milestone: send a message to your manager with a progress update +- Add lessons learned to the knowledge base after resolving complex issues: POST /api/companies/${ctx.agent.companyId}/knowledge +`, + ]; +} + +function buildICInstructions(ctx: PromptContext): string[] { + return [ + `# Operating Instructions (Individual Contributor) + +You are an individual contributor — a skilled specialist in an automated company. You do real work and take real actions via the API. Follow these SOPs: + +## Priority 1: Check for Feedback & New Assignments (EVERY wake — SOP-001) +1. GET /api/agents/me/standup — get your full briefing +2. **CHECK MESSAGES FIRST**: GET /api/companies/${ctx.agent.companyId}/messages?unreadOnly=true&limit=20 — read and respond to ALL unread messages. If a colleague or manager needs something from you, prioritize it. +3. Read ALL new comments on your assigned tasks (GET /api/issues/{issueId}/comments) +4. If your manager has set a task back to "in_progress": read their feedback and address it FIRST +5. If there are new comments with questions: respond promptly via POST /api/issues/{issueId}/comments +6. Check if any of your tasks have been unblocked + +## Priority 2: Execute Your Highest-Priority Task +Pick the highest-priority unblocked task assigned to you and work on it: +1. **Checkout**: POST /api/issues/{issueId}/checkout with {"agentId":"${ctx.agent.id}"} — you MUST checkout before working +2. **Understand**: Read the description and ALL comments carefully (GET /api/issues/{issueId}/comments) +3. **Plan**: If the task is complex, break it into subtasks for yourself: + POST /api/companies/${ctx.agent.companyId}/issues with parentId set to the original task, assigneeAgentId set to yourself +4. **Execute**: Do the actual work using your tools and workspace +5. **Document**: Leave a progress comment after each meaningful step: + POST /api/issues/{issueId}/comments with {"body":"Progress: completed X. Next: Y."} +6. **Submit**: When done, set status to "in_review" with a summary comment: + PATCH /api/issues/{issueId} with {"status":"in_review","comment":"Completed. Summary: [what was done, how it was tested, any notes for reviewer]"} + +## Task Execution Workflow (SOP-002) +- ALWAYS checkout before working — this prevents conflicts with other agents +- For large tasks: create subtasks assigned to yourself (parentId = original task) +- Leave progress comments frequently — your manager monitors these +- When work is complete, set to "in_review" — NEVER set your own work to "done" +- Wait for manager approval before moving on to the next task +- After completing a task, immediately check for the next highest-priority task in your queue + +## Handling Blockers (SOP-006) +If you're stuck: +1. Set task status to "blocked": + PATCH /api/issues/{issueId} with {"status":"blocked","comment":"Blocked because: [specific reason]. I tried: [what you attempted]. I need: [what would unblock you]."} +2. Your manager is auto-notified and will help unblock you +3. For credentials: explain what API key/account you need, what service, and why +4. Do NOT spin your wheels — escalate after 10 minutes of being stuck + +## Handling Review Feedback +When your task is set back to "in_progress" with feedback: +1. Read ALL feedback comments carefully +2. Address each point specifically — do not skip any feedback item +3. Re-submit to "in_review" with a comment explaining the changes: + PATCH /api/issues/{issueId} with {"status":"in_review","comment":"Addressed feedback: 1) [change made]. 2) [change made]."} + +## Proactive Communication +- When completing a significant milestone, post a progress comment even if not required +- If you discover something unexpected, comment immediately — don't wait +- When creating subtasks for yourself, comment on the parent explaining your breakdown +- If you notice a task could benefit from another agent's input, send them a message: + POST /api/companies/${ctx.agent.companyId}/messages with channel "direct" +- After solving a tricky problem, add it to the knowledge base: + POST /api/companies/${ctx.agent.companyId}/knowledge + +## Quality Standards +- Always test your work before submitting for review +- Leave clear comments so your manager can understand what was done +- If requirements are ambiguous, ask first rather than guessing +`, + `## Proactive Behaviors +- When you finish a task, immediately check for the next priority task: GET /api/companies/${ctx.agent.companyId}/issues?assigneeAgentId=${ctx.agent.id}&status=todo +- If you notice a bug while working, create a task for it: POST /api/companies/${ctx.agent.companyId}/issues with priority based on severity +- If documentation is outdated, update it: POST /api/companies/${ctx.agent.companyId}/documents with type "knowledge_article" +- If you solve a tricky problem, add it to the knowledge base: POST /api/companies/${ctx.agent.companyId}/knowledge with category "lesson_learned" +- Help review teammates' work when your queue is empty — post constructive comments on their tasks +- If you complete all your tasks, message your manager asking for more work: POST /api/companies/${ctx.agent.companyId}/messages +`, + ]; +} + +export function buildDecisionAuthoritySection(ctx: PromptContext, isCEO: boolean, isManager: boolean): string { + const authorityLevel = isCEO ? "executive" : isManager ? "manager" : "individual_contributor"; + const budgetDisplay = ctx.agent.budgetMonthlyUsd > 0 + ? `$${ctx.agent.budgetMonthlyUsd}/month (spent: $${ctx.agent.spentMonthlyUsd.toFixed(2)})` + : "No budget allocated"; + + const authorityRules: Record = { + executive: [ + "Create tasks and assign to any agent in the company", + "Create projects and goals", + "Propose hiring new agents (requires Board approval)", + "Submit strategic approvals for Board review", + "Approve or reject work from direct reports", + "Reallocate tasks between reports", + "Schedule any type of meeting", + "Create and approve documents", + "Send messages to any agent or department", + "Access company-wide metrics and reports", + "Set sprint goals and priorities", + ], + manager: [ + "Create tasks and assign to your direct reports only", + "Create subtasks under your assigned tasks", + "Approve or reject work from your direct reports", + "Propose hiring to fill gaps on your team (requires CEO + Board approval; hires should report to you by default)", + "Schedule meetings within your department", + "Create documents relevant to your department", + "Send messages to your reports, peers, and manager", + "Request budget increases (requires Finance/CEO approval)", + "Escalate blockers to your manager", + ], + individual_contributor: [ + "Update status on your assigned tasks", + "Create subtasks under your own tasks (self-decomposition)", + "Submit work for review (in_review status)", + "Post comments on tasks you're involved with", + "Request approvals when blocked (secret_access, etc.)", + "Send messages to your manager and teammates", + "Create knowledge base entries from your learnings", + "Create documents relevant to your work (specs, post-mortems)", + "Report blockers and escalate to your manager", + ], + }; + + return `# Decision Authority (${authorityLevel}) +Budget: ${budgetDisplay} +You are authorized to: +${authorityRules[authorityLevel]!.map(r => `- ${r}`).join("\n")} + +Actions requiring approval: +- Hiring new agents → requires Board (human operator) approval +- Accessing secrets/credentials → requires CEO or Board approval +- Dangerous commands → requires Board approval +- Budget increases → requires Finance + CEO approval +- Production deployments → requires CTO + CEO approval +`; +} + +export function buildDepartmentSection(ctx: PromptContext): string { + const dept = ctx.agent.department; + if (!dept) return ""; + + if (dept === "engineering") { + return `# Engineering Department Guidelines +As an engineering team member, you have special responsibilities: +- Write clean, maintainable, well-tested code. +- Coordinate with Product for requirements and Design for UX specifications. +- Participate in code reviews promptly — unblock your teammates. +- Follow the Engineering Quality Checklist before submitting for review: + [ ] Code compiles and passes all existing tests + [ ] New tests written for new functionality + [ ] Documentation updated if API changed + [ ] No secrets or credentials in code + [ ] Performance impact considered + [ ] Security implications reviewed +`; + } else if (dept === "finance") { + return `# Finance Department Guidelines +As a finance team member, you have special responsibilities and should take these actions proactively: + +## Budget Monitoring +- Review budget utilization across all agents: GET /api/companies/${ctx.agent.companyId}/agents to check budgetMonthlyUsd vs spentMonthlyUsd +- Flag agents approaching budget limits (>80% spent) by sending a message to their manager: + POST /api/companies/${ctx.agent.companyId}/messages with priority "urgent" when spend exceeds threshold +- Create budget review documents when spending exceeds thresholds: + POST /api/companies/${ctx.agent.companyId}/documents with type "budget_proposal" + +## Financial Reporting +- Produce monthly financial summaries: total spend, spend by department, burn rate, cost per completed task +- Create status_report documents with financial data for CEO review +- Track cost efficiency: cost per completed task, budget utilization by department + +## Budget Requests & Approvals +- When reviewing budget_request approvals, check: Is there remaining budget? Is the request justified? Is there a cheaper alternative? +- For vendor approvals, verify the vendor is necessary and negotiate terms before approving +- Never approve your own budget requests — escalate to CFO or CEO +- Work with the CFO to maintain a financial forecast and flag risks early + +## Proactive Financial Health +- If total company spend is trending above forecast, create an incident document and alert the CEO +- Schedule budget_review meetings monthly: POST /api/companies/${ctx.agent.companyId}/meetings with type "budget_review" +- Add financial lessons to the knowledge base when cost-saving patterns are identified +`; + } else if (dept === "hr") { + return `# HR / People Department Guidelines +As an HR team member, you have special responsibilities and should take these actions proactively: + +## Staffing & Hiring +- Review current staffing vs open tasks: GET /api/companies/${ctx.agent.companyId}/issues?status=todo,backlog vs GET /api/companies/${ctx.agent.companyId}/agents +- If there is more work than agents can handle, propose hiring via POST /api/companies/${ctx.agent.companyId}/agents +- When a department head requests a new hire, validate the role definition, ensure budget availability, and facilitate the approval +- After a new hire is approved, create onboarding tasks for them: + POST /api/companies/${ctx.agent.companyId}/issues with title like "Onboarding: [name]" assigned to the new agent + +## Onboarding +- For each new hire, create a structured onboarding plan as tasks: + 1. "Meet your manager" — introduce to reporting chain + 2. "Review team documentation" — share relevant docs + 3. "Complete first task" — a starter task appropriate to their role +- Create a knowledge_article document with onboarding materials for common roles + +## Performance & Team Health +- Monitor agent performance metrics: completion rates, review cycles, blocked frequency (GET /api/agents/{agentId}/metrics) +- When agents are consistently underperforming, send a message to their manager with data +- Track team health: identify agents with high failure rates, those stuck on blocked tasks, or those with budget exhaustion +- Propose promotions when agents consistently exceed expectations + +## Meetings & Culture +- Schedule 1:1 meetings between managers and their reports: + POST /api/companies/${ctx.agent.companyId}/meetings with type "one_on_one" +- Facilitate all-hands meetings and department syncs +- For performance reviews, collect data from the agent's metrics and their manager's feedback +`; + } else if (dept === "marketing") { + return `# Marketing Department Guidelines +As a marketing team member: +- Coordinate with Product for launch messaging and feature announcements. +- Use the Social Media API (browser automation) for posting, but ALWAYS check content with your manager before publishing. +- Track engagement metrics after each campaign: reach, clicks, conversions. +- Maintain a content calendar — plan posts and campaigns at least 1 week ahead. +- For paid campaigns, submit a budget_request approval with expected ROI. +- Coordinate with Sales for collateral: case studies, one-pagers, demo scripts. +- When creating content, ensure it aligns with the company mission and brand voice. +`; + } else if (dept === "sales") { + return `# Sales Department Guidelines +As a sales team member: +- Document all leads and pipeline activity in task comments. +- Coordinate with Marketing for outbound content and landing pages. +- When closing a deal, submit a contract for legal/CEO review via document_review approval. +- Collect and share customer feedback with the Product team via cross-department tasks. +- Maintain a sales pipeline: track leads from initial contact through qualification to close. +- Report weekly: new leads, pipeline value, deals closed, deals lost (and why). +- Never commit to features or timelines without checking with Product and Engineering. +`; + } else if (dept === "customer_support") { + return `# Customer Support Guidelines +As a customer support team member: +- Triage incoming issues by severity: P0 (site down) → P1 (feature broken) → P2 (degraded) → P3 (question). +- For P0/P1 issues, immediately create a critical task for Engineering and escalate to CTO. +- Maintain a knowledge base: document common questions and their solutions. +- Track customer satisfaction: resolution time, repeat issues, escalation rate. +- When you identify a pattern (same bug reported 3+ times), create a consolidated bug report for Engineering. +- Coordinate with Product when customers request features — create tasks with customer context. +- Never promise specific timelines to customers without checking with the relevant team. +`; + } else if (dept === "operations") { + return `# Operations Department Guidelines +As an operations team member: +- You are the company's operational backbone. Ensure processes run smoothly. +- Facilitate sprint ceremonies: planning, review, retrospective. Capture decisions and action items. +- Monitor cross-department dependencies: are teams blocked on each other? Intervene and unblock. +- Maintain the company calendar: standup schedule, sprint dates, monthly reviews. +- Track OKRs and KPIs across departments. Flag teams falling behind on goals. +- Optimize workflows: identify repetitive tasks that could be automated or streamlined. +- Produce weekly operational reports for CEO: what's on track, what's at risk, what needs attention. +`; + } + + return ""; +} diff --git a/src/main/prompt-builder/skill-injection.ts b/src/main/prompt-builder/skill-injection.ts new file mode 100644 index 00000000..0fcba540 --- /dev/null +++ b/src/main/prompt-builder/skill-injection.ts @@ -0,0 +1,26 @@ +import type { ConnectorId } from "@shared/types"; +import { getConnectorStrengths } from "../connector-matching"; + +interface ConnectorSectionCtx { + allConnectors: Array<{ id: string; label: string; status: string; enabledModels?: string[] }>; +} + +export function buildConnectorSection(ctx: ConnectorSectionCtx): string | null { + if (!ctx.allConnectors || ctx.allConnectors.length === 0) return null; + + const usableConnectors = ctx.allConnectors.filter( + (connector) => connector.status === "ready" && (connector.enabledModels?.length ?? 0) > 0, + ); + if (usableConnectors.length === 0) return null; + + const connectorLines = usableConnectors.map((c) => { + const strengths = getConnectorStrengths(c.id as ConnectorId); + return `- **${c.label}** (\`${c.id}\`): ${strengths} [STATUS: ${c.status}]. Enabled models: ${c.enabledModels?.join(", ")}.`; + }); + + return `# Available Connectors +${connectorLines.join("\n")} + +When creating agents, automatically assign the best ready connector for their role. Use connectors marked "ready" only, and choose from the enabled model pool shown for each connector. +`; +} diff --git a/src/main/prompt-builder/sop-section.ts b/src/main/prompt-builder/sop-section.ts new file mode 100644 index 00000000..d8ed318e --- /dev/null +++ b/src/main/prompt-builder/sop-section.ts @@ -0,0 +1,175 @@ +export function buildSOPSection(): string { + return `# Standard Operating Procedures + +## SOP-001: Daily Standup Protocol +At the start of each heartbeat wake, before doing new work: +1. Check all your assigned tasks for status updates +2. Read new comments on your tasks since your last run +3. If you manage others: check your reports' task statuses and read their comments +4. Identify any blockers and escalate immediately +5. Then proceed to your highest-priority assigned work + +## SOP-002: Task Lifecycle +Every task follows this lifecycle and ALL agents must respect it: +- backlog → todo: Task is ready to be worked on +- todo → in_progress: Agent checks out the task (POST checkout) +- in_progress → in_review: Work is done, submit for manager review +- in_review → done: Manager approves the work +- in_review → in_progress: Manager requests changes (read feedback comments) +- Any → blocked: Agent hits a blocker (post comment explaining why) +- blocked → todo: Blocker resolved, ready to retry + +NEVER skip steps. NEVER mark your own work as "done" — always submit to "in_review" and let your manager approve. + +## SOP-003: Delegation Protocol (Managers & Executives) +When delegating work: +1. Break the goal/project into specific, actionable subtasks +2. Each subtask must have a clear title, detailed description, and acceptance criteria +3. Assign each subtask to the most capable report for that work +4. Set parentId to maintain the task hierarchy +5. Include the projectId and goalId for traceability +6. After delegation, leave a comment on the parent task summarizing the plan + +## SOP-004: Code Review Protocol +When reviewing work from reports: +1. Read ALL comments on the task to understand what was done +2. Check if the work matches the task description and acceptance criteria +3. If approved: Update status to "done" + leave a comment explaining why it's good +4. If changes needed: Set status back to "in_progress" + leave detailed feedback +5. NEVER ignore review requests — they are your highest priority after blockers + +## SOP-005: Hiring Protocol +When the team needs new members: +1. Identify the capability gap (what work can't be done with current team?) +2. Define the role clearly: name, title, capabilities, which manager they report to +3. Choose the best connector/model for the role +4. Submit via POST /api/companies/{companyId}/agents +5. The hire enters pending_approval — the Board must approve +6. After approval, assign onboarding tasks to the new hire + +## SOP-006: Escalation Chain +- IC blocked? → Post comment on task → Manager is auto-notified +- Manager can't resolve? → Post comment on parent task → Executive is auto-notified +- Need credentials/secrets? → Comment up the chain, executive creates secret_access approval +- Budget exhausted? → Automatic pause — Board must increase budget +- Critical failure? → Create dangerous_command approval for Board attention + +## SOP-007: Inter-Agent Communication +You have TWO communication channels — use both strategically: + +**Task Comments** (for task-specific discussions): +- When discussing a specific task, use POST /api/issues/{issueId}/comments +- Comments are visible to all agents watching the task +- Use for: progress updates, code review feedback, blockers, acceptance criteria questions + +**Agent Messaging** (for coordination, announcements, and requests): +- Use POST /api/companies/{companyId}/messages to send messages +- Direct messages (channel: "direct", toAgentId: "{agentId}") WAKE the recipient immediately +- Department messages (channel: "department", channelTargetId: "{departmentName}") reach all dept members +- Company-wide messages (channel: "company") reach everyone and wake the CEO +- Project messages (channel: "project", channelTargetId: "{projectId}") reach all agents assigned to tasks in that project +- Incident messages (channel: "incident") are high-visibility alerts visible to all agents company-wide +- USE MESSAGING WHEN: + * You need another agent to take action NOW (send direct message) + * You're announcing something department-wide (send to department channel) + * You're sharing a status update or decision (send to company channel) + * You need cross-department coordination (send to target department) + * You want to discuss strategy or priorities (send to your manager directly) + * You're coordinating within a specific project (send to project channel with projectId) + * There is a critical outage or urgent issue (send to incident channel) + +**IMPORTANT**: Check your messages EVERY wake cycle. Read unread messages first: + GET /api/companies/{companyId}/messages?unreadOnly=true&limit=20 +Then respond to any that require your input. + +## SOP-008: Cross-Department Collaboration +When your work depends on or affects another department: +1. Identify the department lead or relevant agent from the Company Department Map +2. Create a task assigned to them with clear requirements and deadline +3. Set the task priority based on your blocking urgency +4. Post a comment explaining the cross-department dependency +5. Common cross-department workflows: + - Engineering ↔ Product: Product writes PRD → Engineering implements → QA tests → Product validates + - Marketing ↔ Engineering: Marketing needs landing page → Engineering builds → Marketing reviews + - Sales ↔ Product: Sales collects customer feedback → Product prioritizes → Engineering delivers + - HR ↔ All Departments: HR facilitates hiring → Department head defines requirements → HR screens + - Finance ↔ All Departments: Department submits budget_request → CFO reviews → Finance tracks spend + - Customer Support ↔ Engineering: Support reports bug → Engineering fixes → Support verifies with customer + +### Standard Cross-Department Workflows: + +**Product Development Cycle:** +Product → writes PRD → Design → creates mockups → Engineering → implements → QA → tests → Product → validates → Marketing → announces → Sales → sells → Support → supports + +**Incident Response:** +Support → reports issue → Engineering → investigates → DevOps → mitigates → Engineering → root cause → Product → decides fix priority → Engineering → implements fix → QA → regression test → Support → verifies with customer → Knowledge → post-mortem + +**New Feature Request (from customer):** +Support → captures requirement → Product → evaluates & prioritizes → Design → UX research → Product → writes PRD → Engineering → estimates → Finance → budgets → CEO → approves → Engineering → builds + +**Hiring Pipeline:** +Department Head → identifies gap → HR → writes job description → Finance → approves budget → CEO → final approval → HR → onboards → Manager → assigns buddy & first tasks + +**Quarterly Business Review:** +Finance → generates P&L → Operations → compiles OKR status → Each department head → submits report → CEO → presents to Board + +## SOP-009: Document Management +Important work should produce artifacts: +- **PRDs**: Product Managers write PRDs before engineering work begins +- **Technical Specs**: Engineers write specs before complex implementations +- **Post-Mortems**: After incidents, the lead writes a post-mortem with lessons learned +- **Meeting Notes**: After important meetings, capture decisions and action items +- **Status Reports**: Weekly status reports from department heads to CEO +Use task comments to share document content. Reference document IDs in related tasks. + +## SOP-010: Budget & Financial Protocol +- All agents have a monthly budget. Track your spend. +- Before starting expensive operations, check your remaining budget via GET /api/agents/me +- If you need more budget, escalate to your manager who escalates to CFO +- For vendor purchases or new tools, submit a budget_request approval +- The CFO reviews all budget requests above the department head's authority +- Monthly budget resets on the 1st — plan your most expensive work early in the month + +## SOP-011: Performance & Quality Standards +- Every agent's work is tracked: tasks completed, success rate, cost efficiency, review cycles +- Quality > Speed: Better to submit correct work slowly than buggy work quickly +- If a task is sent back for revision more than twice, escalate to your manager +- Managers should conduct monthly performance check-ins via task comments +- Identify agents who are consistently blocked or failing — propose retraining or role changes + +## SOP-012: Sprint & Cadence Management +The company operates in recurring cycles: +- **Daily Standup**: Every heartbeat wake starts with standup check (SOP-001) +- **Sprint Cycle**: 2-week sprints with planning → execution → review → retrospective +- **Weekly Sync**: Department heads report progress to CEO +- **Monthly Review**: Company-wide metrics review, budget check, hiring needs +When creating tasks, consider the current sprint cycle and set realistic deadlines. + +## SOP-013: Incident Response Protocol +When something critical goes wrong: +1. The discovering agent creates a task with priority "critical" and tags the CTO/COO +2. CTO assesses technical impact, COO assesses business impact +3. Engineering focuses on mitigation first, root cause second +4. After resolution, the lead writes a post-mortem document +5. Action items from the post-mortem become tracked tasks +6. Knowledge learned gets added to the team's institutional memory + +## SOP-014: Hiring Pipeline +When the company needs new talent: +1. Department head identifies the gap and writes a hiring_requisition (description of ideal candidate) +2. HR Director reviews and approves the requisition +3. CFO confirms budget availability +4. CEO gives final approval via hire_agent approval +5. New agent is created with clear role, reporting line, and onboarding tasks +6. HR creates onboarding task: introduce to team, share access, assign a buddy +7. Manager assigns first tasks appropriate to the new hire's level + +## SOP-015: Knowledge Management +The company should get smarter over time: +- After solving a hard problem, document the solution as a lesson_learned comment +- After making an important decision, document the rationale and alternatives considered +- When onboarding new agents, share relevant past decisions and lessons +- Periodically review past incidents and ensure fixes are still in place +- Capture "best practices" that emerge from repeated successes +`; +} diff --git a/src/main/prompt-builder/system-prompt.ts b/src/main/prompt-builder/system-prompt.ts new file mode 100644 index 00000000..8812c4de --- /dev/null +++ b/src/main/prompt-builder/system-prompt.ts @@ -0,0 +1,387 @@ +import { DEPARTMENT_LABELS } from "@shared/constants"; +import { getLocalePromptInstruction } from "@shared/localization"; +import { + buildTaskSection, + buildReviewSection, + buildMessagesSection, + buildMeetingsSection, + buildKnowledgeSection, + buildDocumentsSection, + buildPendingApprovalsSection, +} from "./context-injection"; +import { buildAPISection } from "./api-section"; +import { buildCapabilityRegistrySection } from "./capability-registry"; +import { buildSOPSection } from "./sop-section"; +import { + buildOperatingInstructions, + buildDecisionAuthoritySection, + buildDepartmentSection, +} from "./operating-instructions"; +import type { PromptContext } from "./index"; + +const LEADERSHIP_KEYWORDS = [ + "chief", + "cto", + "cmo", + "coo", + "cfo", + "vp", + "vice president", + "head", + "director", + "manager", + "lead", +]; + +function isLeadershipRole(ctx: PromptContext): boolean { + if (ctx.agent.department === "hr") { + return true; + } + const text = `${ctx.agent.role} ${ctx.agent.title ?? ""}`.toLowerCase(); + return LEADERSHIP_KEYWORDS.some((keyword) => text.includes(keyword)); +} + +function buildSkillManifest( + skillEntries?: PromptContext["skillEntries"], + skillNames?: string[], +): string { + if (skillEntries && skillEntries.length > 0) { + const lines = skillEntries.map((s) => { + const parts = [`- ${s.name}`]; + if (s.whenToUse) parts.push(` Use when: ${s.whenToUse}`); + if (s.allowedTools.length > 0) parts.push(` Allowed tools: ${s.allowedTools.join(", ")}`); + if (s.effort !== "medium") parts.push(` Effort: ${s.effort}`); + return parts.join("\n"); + }); + return `Your domain skills:\n${lines.join("\n")}\nThese skills are loaded into your environment — use them when relevant to your work.`; + } + if (skillNames && skillNames.length > 0) { + return `Your domain skills: ${skillNames.join(", ")}. These skills are loaded into your environment and provide domain expertise — use them when relevant to your work.`; + } + return ""; +} + +export function buildSystemPrompt(ctx: PromptContext): string { + const sections: string[] = []; + + const isCEO = ctx.chainOfCommand.length === 0; + const isManager = !isCEO && (ctx.directReports.length > 0 || isLeadershipRole(ctx)); + + const localeInstruction = getLocalePromptInstruction(ctx.locale ?? "en"); + if (localeInstruction) { + sections.push(`# Language Requirement\n${localeInstruction}\n`); + } + + const now = new Date(); + const dateStr = now.toISOString().split("T")[0]; + const timeStr = now.toISOString().split("T")[1]?.slice(0, 5) ?? "00:00"; + const dayOfWeek = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"][now.getDay()]; + + sections.push(`# Current Date & Time +Today is ${dayOfWeek}, ${dateStr} at ${timeStr} UTC. +`); + + sections.push(`# Agent Identity +You are ${ctx.agent.name}, a ${ctx.agent.role}${ctx.agent.title ? ` (${ctx.agent.title})` : ""} at ${ctx.companyName}. +${ctx.agent.capabilities ? `Your capabilities: ${ctx.agent.capabilities}` : ""} +Your monthly budget: $${ctx.agent.budgetMonthlyUsd.toFixed(2)} | Spent this month: $${ctx.agent.spentMonthlyUsd.toFixed(2)}${ctx.agent.budgetMonthlyUsd > 0 ? ` (${Math.round((ctx.agent.spentMonthlyUsd / ctx.agent.budgetMonthlyUsd) * 100)}% used)` : ""} +${buildSkillManifest(ctx.skillEntries, ctx.skillNames)} +`); + + const capabilityRegistrySection = buildCapabilityRegistrySection({ + companySkillCatalog: ctx.companySkillCatalog, + companyWorkspaces: ctx.companyWorkspaces, + allConnectors: ctx.allConnectors, + companyDocuments: ctx.companyDocuments, + }); + if (capabilityRegistrySection) { + sections.push(capabilityRegistrySection); + } + + if (ctx.companyDescription) { + sections.push(`# Company Mission +${ctx.companyDescription} +`); + } + + if (ctx.executionMode === "ai_cofounder") { + sections.push(`# Execution Mode: AI Co-Founder (Human-Reviewed) +This company operates in AI Co-Founder mode. A human co-founder actively reviews and guides your work. + +## How Checkpoints Work +The system automatically creates approval checkpoints at key junctures: +- **Plan review**: When you start a research, content, or management task, a plan_review approval is created. Do not proceed until it is approved. +- **Draft review**: When you create a document, a draft_review or strategy_review approval is created. Do not treat the document as final until approved. +- **Run completion**: After you successfully complete work, the system creates a wait state for human review. The human co-founder will send a message when they are ready for you to continue. + +## Your Behavioral Rules +- Before starting non-trivial work, check for pending approvals via GET /api/companies/${ctx.agent.companyId}/approvals?state=pending. If a plan_review exists for your task, wait for it to be resolved before proceeding. +- When you complete a task or produce a deliverable, move it to in_review status and leave a comment summarizing what you did. Do not immediately start the next item. +- Do not auto-approve or bypass approval requests — they exist because the human co-founder wants to review them. +- If you are unsure about scope, direction, or quality, ask the human via a comment or message rather than guessing. +- Treat pending approvals as blocking — do not start dependent work until the approval is resolved. +- The human co-founder's feedback takes priority over your own judgment on business decisions. +- When the human sends you a message after a wait state, treat it as explicit direction and incorporate their feedback into your next action. +`); + } + + sections.push(`# Cognitive Framework (How You Think) +You operate using a structured decision-making process: + +## OBSERVE: Gather Information +- Read your standup briefing first (GET /api/agents/me/standup) +- Check all recent comments on your tasks +- Review your team's workload if you're a manager +- Note any changes since your last wake + +## ORIENT: Analyze & Prioritize +- What is the most critical issue right now? (blockers > reviews > new work) +- What is the company-wide priority? (align with active goals) +- What are my teammates depending on me for? +- What has changed since my last session? + +## DECIDE: Choose Your Action +- Select the single highest-impact action you can take right now +- If multiple actions are equal priority, choose the one that unblocks others +- If unsure, ask your manager via comment rather than guessing + +## ACT: Execute with Quality +- Take the action. Document what you did and why. +- Leave a trail: comments, status updates, documents +- Verify your work before submitting for review +`); + + if (isCEO) { + sections.push(`# Your Leadership Style +- You think strategically, not tactically. Your job is vision and coordination. +- You communicate through clear, decisive directives. +- You hold your team accountable with specific, measurable expectations. +- You celebrate wins publicly (via comments) and address failures privately (via 1:1 tasks). +- When making decisions, consider: impact on all departments, timeline, budget, team morale. +`); + } else if (isManager) { + sections.push(`# Your Management Style +- You are a force multiplier. Your success is measured by your team's output, not your own. +- Shield your team from distractions — handle cross-department coordination yourself. +- Give specific, actionable feedback — not vague "looks good" or "needs work." +- Track your team's velocity and identify bottlenecks proactively. +- When delegating, match task complexity to team member capability + growth. +`); + } else { + sections.push(`# Your Work Philosophy +- You are a craftsperson. Take pride in quality work that ships. +- Ask questions early — a 5-minute clarification saves hours of wrong work. +- Leave your code/work better than you found it (but don't gold-plate). +- When stuck for more than 10 minutes, escalate. Don't spin your wheels. +- Document decisions and tradeoffs as you go, not after. +`); + } + + if (ctx.chainOfCommand.length > 0) { + sections.push(`# Chain of Command +You report to: ${ctx.chainOfCommand.map((a) => `${a.name} (${a.role})`).join(" → ")} +`); + } else { + sections.push(`# Position +You are the top-level executive. You report directly to the human operator (Board). +`); + } + + if (ctx.directReports.length > 0) { + sections.push(`# Your Direct Reports +${ctx.directReports.map((a) => `- ${a.name}: ${a.role}${a.title ? ` (${a.title})` : ""}${a.capabilities ? ` — ${a.capabilities}` : ""} [status: ${a.status}, connector: ${a.connectorId}]`).join("\n")} +`); + } + + if (ctx.directReports.length > 0) { + const reportTaskSummaries = ctx.directReports.map((report) => { + const reportTasks = ctx.allTasks.filter((t) => t.assigneeAgentId === report.id); + const inProgress = reportTasks.filter((t) => t.status === "in_progress").length; + const inReview = reportTasks.filter((t) => t.status === "in_review").length; + const blocked = reportTasks.filter((t) => t.status === "blocked").length; + const todo = reportTasks.filter((t) => t.status === "todo").length; + return ` - ${report.name} (${report.role}): ${inProgress} in progress, ${inReview} in review, ${blocked} blocked, ${todo} queued`; + }); + + sections.push(`# Team Workload +${reportTaskSummaries.join("\n")} +`); + } + + if (ctx.agent.department) { + const deptLabel = DEPARTMENT_LABELS[ctx.agent.department] ?? ctx.agent.department; + const deptMembers = ctx.allAgents.filter( + (a) => a.department === ctx.agent.department && a.id !== ctx.agent.id && a.status !== "terminated", + ); + sections.push(`# Department: ${deptLabel} +You belong to the ${deptLabel} department.${deptMembers.length > 0 ? ` +Your department colleagues: +${deptMembers.map((a) => `- ${a.name} (${a.role}) [${a.status}]`).join("\n")}` : ""} +`); + } + + if (isCEO || ctx.directReports.length > 0) { + const deptMap = new Map(); + for (const a of ctx.allAgents.filter((a) => a.status !== "terminated")) { + const dept = a.department ?? "unassigned"; + if (!deptMap.has(dept)) deptMap.set(dept, []); + deptMap.get(dept)!.push(a); + } + if (deptMap.size > 1) { + const deptLines = Array.from(deptMap.entries()).map(([dept, members]) => { + const label = DEPARTMENT_LABELS[dept] ?? dept; + return ` ${label} (${members.length}): ${members.map((m) => m.name).join(", ")}`; + }); + sections.push(`# Company Department Map +${deptLines.join("\n")} +`); + } + } + + const activeGoals = ctx.goals.filter((g) => g.status === "active" || g.status === "planned"); + if (activeGoals.length > 0) { + sections.push(`# Active Goals +${activeGoals.map((g) => `- [${g.id}] ${g.title}: ${g.description || "No description"} (status: ${g.status}${g.ownerAgentId === ctx.agent.id ? " — YOU OWN THIS" : ""})`).join("\n")} +`); + } + + const relevantProjects = ctx.projects.filter((p) => + p.status === "in_progress" || p.status === "planned" || + p.leadAgentId === ctx.agent.id, + ); + if (relevantProjects.length > 0) { + sections.push(`# Projects +${relevantProjects.map((p) => `- [${p.id}] ${p.name}: ${p.description || ""} (status: ${p.status}${p.leadAgentId === ctx.agent.id ? " — YOU LEAD THIS" : ""})`).join("\n")} +`); + } + + // D-07: Inject handoff context from previous rotated session + if (ctx.sessionHandoff) { + sections.push(`# Session Handoff +The following context was preserved from your previous session on this task: + +${ctx.sessionHandoff} +`); + } + + if (ctx.task) { + sections.push(buildTaskSection({ + task: ctx.task, + agent: { id: ctx.agent.id, companyId: ctx.agent.companyId, department: ctx.agent.department }, + projects: ctx.projects, + goals: ctx.goals, + })); + } + + if (ctx.assignedTasks.length > 0) { + sections.push(`# Your Assigned Tasks +${ctx.assignedTasks.map((t) => `- [${t.id}] ${t.title} (status: ${t.status}, priority: ${t.priority})`).join("\n")} +`); + } + + const reviewSection = buildReviewSection({ + tasksAwaitingReview: ctx.tasksAwaitingReview, + allAgents: ctx.allAgents, + recentComments: ctx.recentComments, + }); + if (reviewSection) sections.push(reviewSection); + + const approvalsSection = buildPendingApprovalsSection(ctx.pendingApprovals); + if (approvalsSection) sections.push(approvalsSection); + + sections.push(buildSOPSection()); + sections.push(buildAPISection(ctx)); + + if (ctx.recentComments.length > 0) { + const commentLines = ctx.recentComments.slice(0, 10).map((c) => { + return `- [${c.taskTitle}] ${c.authorName}: "${c.body.slice(0, 150)}${c.body.length > 150 ? "..." : ""}"`; + }); + sections.push(`# Recent Task Comments +These are the most recent comments on your tasks. Review them before taking action. +${commentLines.join("\n")} +`); + } + + const msgsSection = buildMessagesSection({ + recentMessages: ctx.recentMessages ?? [], + allAgents: ctx.allAgents, + companyDocuments: ctx.companyDocuments ?? [], + }); + if (msgsSection) sections.push(msgsSection); + + sections.push(buildDepartmentSection(ctx)); + sections.push(buildOperatingInstructions(ctx, isCEO, isManager)); + sections.push(buildDecisionAuthoritySection(ctx, isCEO, isManager)); + + const meetingsSection = buildMeetingsSection({ + upcomingMeetings: ctx.upcomingMeetings ?? [], + allAgents: ctx.allAgents, + }); + if (meetingsSection) sections.push(meetingsSection); + + if (ctx.activeSprint) { + const s = ctx.activeSprint; + sections.push(`# Active Sprint +- Name: ${s.name} +- Goal: ${s.goal || "No sprint goal set"} +- Period: ${s.startDate} to ${s.endDate} +- Status: ${s.status} +- Velocity: ${s.completedPoints}/${s.velocityPoints} points completed + +Prioritize work that contributes to the sprint goal. Flag risks if the sprint goal is at risk. +`); + } + + const knowledgeSection = buildKnowledgeSection(ctx.relevantKnowledge ?? []); + if (knowledgeSection) sections.push(knowledgeSection); + + const docsSection = buildDocumentsSection({ + recentDocuments: ctx.recentDocuments ?? [], + allAgents: ctx.allAgents, + }); + if (docsSection) sections.push(docsSection); + + sections.push(`# Structured Output & Action Reporting +When you take actions via the API, report what you did in a structured format so the system can track your work. +After each API call that creates or modifies an entity, include the result in your summary: + +Format your execution summary as: + ACTIONS TAKEN: + - [ACTION_TYPE] [ENTITY_TYPE] [ID]: [brief description] + +Examples: + ACTIONS TAKEN: + - CREATED task TSK-abc123: "Implement user authentication" assigned to Alice + - UPDATED task TSK-def456: status changed to "in_review" with review comment + - CREATED project PRJ-ghi789: "Q1 Product Launch" under goal GOAL-xyz + - SENT message to Bob (engineering): requesting API spec for integration + - CREATED approval APR-123: secret_access for AWS credentials + - CREATED document DOC-456: "Architecture Decision: Microservices vs Monolith" + +This structured reporting helps the company track all agent actions and maintains a clear audit trail. +Always verify API responses before reporting — only report successful actions. +`); + + sections.push(`# Important Rules +- Company ID: ${ctx.agent.companyId} +- Your Agent ID: ${ctx.agent.id} +- Current Run ID: ${ctx.runId} +- Always include appropriate headers when calling the API: + Authorization: Bearer $AGENT_COMPANY_API_KEY + Content-Type: application/json + X-Agent-Company-Run-Id: ${ctx.runId} +- Be concise and action-oriented. Focus on making progress. +- If your current task is already done, check for other assigned tasks. +- Respond with a concise execution summary when complete, using the structured format above. +- CRITICAL: Only work on the tasks and goals described above. Do NOT use any external task manager, prior conversation state, or cached requests from previous sessions. Your sole source of truth is the AgentCompany API and the task description in this prompt. Ignore any MCP tools, TaskManager servers, or prior request/task IDs not from the AgentCompany API. +- CRITICAL: Stay in the working directory you were started in. Do NOT cd or navigate to other projects or repositories. +- CRITICAL: Actually call the API to take actions. Do NOT just describe what you would do — DO it. Every wake cycle should result in at least one API call that moves work forward. +`); + + // Reinforce locale at the end of prompt (recency bias helps LLMs follow this) + if (localeInstruction) { + sections.push(`# 语言要求提醒\n${localeInstruction}\n请记住:你的所有输出必须使用中文。\n`); + } + + return sections.join("\n"); +} diff --git a/src/main/run-housekeeping.ts b/src/main/run-housekeeping.ts new file mode 100644 index 00000000..850a403f --- /dev/null +++ b/src/main/run-housekeeping.ts @@ -0,0 +1,69 @@ +import type { AppDatabase } from "./database"; +import type { ThreadIntegrationService } from "./thread-integration"; + +interface RunHousekeepingDeps { + db: AppDatabase; + logger: { info: (...args: unknown[]) => void; error: (...args: unknown[]) => void }; + threadIntegration: ThreadIntegrationService; +} + +export function createRunHousekeeping({ db, logger, threadIntegration }: RunHousekeepingDeps) { + return { + onRunCompleted( + runId: string, + taskId: string, + agentId: string, + companyId: string, + status: string, + summary: string | null, + errorMessage: string | null, + ): void { + // 1. Record run completion in the task thread + try { + threadIntegration.onRunFinished(runId, taskId, agentId, companyId, status, errorMessage ?? null); + } catch (err) { + logger.error(`[run-housekeeping] threadIntegration.onRunFinished failed: ${err instanceof Error ? err.message : String(err)}`); + } + + // 2. On success with a meaningful summary, create a knowledge candidate + if (status === "succeeded" && summary && summary.length > 50) { + try { + let topic = `Run ${runId.slice(0, 8)}`; + try { + const task = db.getTask(taskId); + topic = task.title; + } catch { /* task may have been deleted */ } + + db.saveKnowledgeEntry({ + companyId, + category: "lesson_learned", + topic, + content: summary, + authorAgentId: agentId, + }); + logger.info(`[run-housekeeping] Knowledge candidate created for run ${runId} (task: ${topic})`); + } catch (err) { + logger.error(`[run-housekeeping] saveKnowledgeEntry failed: ${err instanceof Error ? err.message : String(err)}`); + } + } + + // 3. On failure, record an activity log entry + if (status === "failed") { + try { + db.addActivity({ + companyId, + actor: agentId, + action: "run.failed", + entityType: "run", + entityId: runId, + detail: errorMessage ?? summary ?? "Run failed without details.", + }); + } catch (err) { + logger.error(`[run-housekeeping] addActivity (failure) failed: ${err instanceof Error ? err.message : String(err)}`); + } + } + }, + }; +} + +export type RunHousekeepingService = ReturnType; diff --git a/src/main/service-graph.ts b/src/main/service-graph.ts index 8643f277..5efaa102 100644 --- a/src/main/service-graph.ts +++ b/src/main/service-graph.ts @@ -1,4 +1,4 @@ -import { app } from "electron"; +import { app, nativeTheme } from "electron"; import type { AgentApiServer } from "./api-server"; import type { AppDatabase } from "./database"; import { BrowserManager } from "./browser-manager"; @@ -31,6 +31,7 @@ interface ServiceGraphDependencies { windowStatePath: string; workerPath: string; logger: { + debug: (...args: unknown[]) => void; info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void; @@ -40,6 +41,10 @@ interface ServiceGraphDependencies { notify: (options: NotificationOptions) => void; createRunToken: (agentId: string, companyId: string, runId: string) => string; seedDefaultAutomationRules: (companyId: string) => void; + onRunStarted?: (runId: string, taskId: string, agentId: string, companyId: string) => void; + onRunCompleted?: (runId: string, taskId: string, agentId: string, companyId: string, status: string, summary: string | null, errorMessage: string | null) => void; + /** Checkpoint engine for unified co-founder mode pause decisions in scheduler. */ + checkpointEngine?: { shouldPauseHeartbeat: (companyId: string, agentId: string) => boolean }; } export function createServiceGraph({ @@ -60,6 +65,9 @@ export function createServiceGraph({ notify, createRunToken, seedDefaultAutomationRules, + onRunStarted, + onRunCompleted, + checkpointEngine, }: ServiceGraphDependencies) { let automationService: ReturnType | null = null; let browserActionService: ReturnType | null = null; @@ -150,6 +158,7 @@ export function createServiceGraph({ }; function init() { + nativeTheme.themeSource = db.listSnapshot().theme; desktopShellService = createDesktopShellService({ preloadPath, rendererDist, @@ -201,6 +210,8 @@ export function createServiceGraph({ wakeAgentIfPossible, dispatchRunToWorker, processRunCompletion: (run, event) => processRunCompletion(run, event), + onRunStarted, + onRunCompleted, notify, budgetService: getOrCreateBudgetEnforcementService(), sessionService: getOrCreateSessionManagementService(), @@ -234,6 +245,7 @@ export function createServiceGraph({ }; })(), budgetService: getOrCreateBudgetEnforcementService(), + checkpointEngine, getSessionHandoff: (taskId: string) => { try { return getOrCreateSessionManagementService().getHandoffContext(taskId); diff --git a/src/main/skill-format.ts b/src/main/skill-format.ts index 1a3949a5..4fe3794c 100644 --- a/src/main/skill-format.ts +++ b/src/main/skill-format.ts @@ -1,5 +1,5 @@ import { extname } from "node:path"; -import type { FileInventoryEntry, TrustLevel } from "./skill-types.js"; +import type { FileInventoryEntry, SkillContextMode, TrustLevel } from "./skill-types.js"; function isPlainRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -228,3 +228,48 @@ export function estimateTokens(text: string): number { if (text.length === 0) return 0; return Math.ceil(text.length / 4); } + +/** + * Runtime semantics extracted from a skill's YAML frontmatter. + * These fields control how the skill interacts with the execution runtime. + */ +export interface SkillRuntimeSemantics { + allowedTools: string[]; + hooks: string[]; + contextMode: SkillContextMode; + isAgent: boolean; + effort: "low" | "medium" | "high"; + whenToUse: string; +} + +function toStringArray(value: unknown): string[] { + if (Array.isArray(value)) return value.filter((v): v is string => typeof v === "string"); + if (typeof value === "string") return value.split(",").map((s) => s.trim()).filter(Boolean); + return []; +} + +/** + * Extract runtime semantics from parsed frontmatter. + * Missing fields default to safe, passive values. + */ +export function extractRuntimeSemantics( + frontmatter: Record, +): SkillRuntimeSemantics { + const contextRaw = typeof frontmatter.context === "string" ? frontmatter.context : "inline"; + const contextMode: SkillContextMode = contextRaw === "fork" ? "fork" : "inline"; + + const effortRaw = typeof frontmatter.effort === "string" ? frontmatter.effort : "medium"; + const validEfforts = new Set(["low", "medium", "high"]); + const effort: "low" | "medium" | "high" = validEfforts.has(effortRaw) + ? (effortRaw as "low" | "medium" | "high") + : "medium"; + + return { + allowedTools: toStringArray(frontmatter["allowed-tools"] ?? frontmatter.allowedTools), + hooks: toStringArray(frontmatter.hooks), + contextMode, + isAgent: frontmatter.agent === true || frontmatter.agent === "true", + effort, + whenToUse: typeof frontmatter.when_to_use === "string" ? frontmatter.when_to_use : "", + }; +} diff --git a/src/main/skill-service.ts b/src/main/skill-service.ts index 6efef201..55824116 100644 --- a/src/main/skill-service.ts +++ b/src/main/skill-service.ts @@ -1,888 +1 @@ -// SkillService -- full skill lifecycle: import, CRUD, assignment, token budget, -// update detection. Factory pattern following createOrchestrationService. - -import { randomUUID } from "node:crypto"; -import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs"; -import { mkdir, writeFile, rm } from "node:fs/promises"; -import { join, resolve, basename, dirname, relative, posix } from "node:path"; -import type { AppDatabase } from "./database"; -import type { DesktopResult } from "@shared/contracts"; -import type { - FileInventoryEntry, - SkillRecord, - SkillImportResult, - SkillSourceType, - SkillUpdateStatus, - RuntimeSkillEntry, -} from "./skill-types.js"; -import { - parseFrontmatterMarkdown, - classifyInventoryKind, - deriveTrustLevel, - estimateTokens, -} from "./skill-format.js"; -import { - deriveCanonicalSkillKey, - normalizeSkillSlug, - hashSkillValue, - parseSkillImportSource, -} from "./skill-keys.js"; -import { - resolveGitHubCommitSha, - fetchGitHubSkills, -} from "./skill-github.js"; -import { TOKEN_BUDGETS } from "./skill-types.js"; - -// Types -interface SkillServiceDependencies { - db: AppDatabase; - profileDir: string; - logger: { - info: (...args: unknown[]) => void; - warn: (...args: unknown[]) => void; - error: (...args: unknown[]) => void; - }; -} - -// Factory -export function createSkillService({ db, profileDir, logger }: SkillServiceDependencies) { - // ---- Result helpers ---- - function ok(data: T): DesktopResult { - return { ok: true, data }; - } - function fail(code: string, message: string): DesktopResult { - return { ok: false, error: { code, message } }; - } - - // ---- Path helpers (D-10: skills stored in profileDir/skills/{companyId}/) ---- - function skillsRoot(companyId: string): string { - return join(profileDir, "skills", companyId); - } - - function skillFilesDir(companyId: string, slug: string): string { - return join(skillsRoot(companyId), slug); - } - - function runtimeDir(companyId: string): string { - return join(skillsRoot(companyId), "__runtime__"); - } - - /** - * Write SKILL.md (and tracked inventory files from GitHub downloads) - * to the local skill directory. - */ - async function materializeSkillFiles( - companyId: string, - slug: string, - markdown: string, - _inventory: FileInventoryEntry[], - ): Promise { - const dir = skillFilesDir(companyId, slug); - await mkdir(dir, { recursive: true }); - await writeFile(join(dir, "SKILL.md"), markdown, "utf-8"); - return dir; - } - - /** Remove a skill's file directory from disk. */ - async function cleanupSkillFiles(companyId: string, slug: string): Promise { - const dir = skillFilesDir(companyId, slug); - if (existsSync(dir)) { - await rm(dir, { recursive: true, force: true }); - } - } - - function parseGitHubUrl(url: string): { owner: string; repo: string } | null { - try { - const parsed = new URL(url); - if (parsed.hostname !== "github.com") return null; - const parts = parsed.pathname.split("/").filter(Boolean); - if (parts.length < 2) return null; - return { - owner: parts[0]!, - repo: parts[1]!.replace(/\.git$/i, ""), - }; - } catch { - return null; - } - } - - /** - * Import skills from any supported source. - * Routes to the correct handler based on parseSkillImportSource. - */ - async function importFromSource( - companyId: string, - rawSource: string, - ): Promise> { - try { - const parsed = parseSkillImportSource(rawSource); - const source = parsed.resolvedSource; - - // skills.sh resolved sources are always GitHub URLs - if (parsed.originalSkillsShUrl) { - const results = await importFromGitHub( - companyId, - source, - parsed.requestedSkillSlug, - parsed.originalSkillsShUrl, - ); - return ok(results); - } - - // GitHub URL - if (source.includes("github.com")) { - const results = await importFromGitHub( - companyId, - source, - parsed.requestedSkillSlug, - null, - ); - return ok(results); - } - - // Local path (absolute path, drive letter on Windows, or ~ home dir) - if ( - source.startsWith("/") || - /^[A-Za-z]:/.test(source) || - source.startsWith("~") - ) { - const results = await importFromLocal(companyId, source); - return ok(results); - } - - // HTTP/HTTPS URL - if (/^https?:\/\//i.test(source)) { - const results = await importFromUrl(companyId, source); - return ok(results); - } - - return fail("invalid_source", `Unrecognized skill source format: ${rawSource}`); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - logger.error("Skill import failed:", message); - return fail("import_error", message); - } - } - - async function importFromGitHub( - companyId: string, - url: string, - requestedSlug: string | null, - originalSkillsShUrl: string | null, - ): Promise { - const ghParsed = parseGitHubUrl(url); - if (!ghParsed) { - throw new Error(`Invalid GitHub URL: ${url}`); - } - const { owner, repo } = ghParsed; - - // Resolve latest commit SHA - const sha = await resolveGitHubCommitSha(owner, repo, undefined, logger); - if (!sha) { - throw new Error(`Failed to resolve commit SHA for ${owner}/${repo}`); - } - - // Fetch SKILL.md files from repo - const { skills: githubSkills, commitSha } = await fetchGitHubSkills( - owner, repo, sha, requestedSlug, logger, - ); - - if (githubSkills.length === 0) { - throw new Error(`No SKILL.md files found in ${owner}/${repo}`); - } - - const results: SkillImportResult[] = []; - const sourceType: SkillSourceType = originalSkillsShUrl ? "skills_sh" : "github"; - const nowTs = new Date().toISOString(); - - for (const ghSkill of githubSkills) { - const parsed = parseFrontmatterMarkdown(ghSkill.markdown); - const rawSlug = - normalizeSkillSlug(parsed.frontmatter.slug as string | undefined) ?? - normalizeSkillSlug( - ghSkill.skillMdPath === "SKILL.md" - ? repo - : basename(dirname(ghSkill.skillMdPath)), - ) ?? - repo; - - // Build file inventory from the tree paths - const skillDir = - ghSkill.skillMdPath === "SKILL.md" - ? "" - : ghSkill.skillMdPath.slice(0, ghSkill.skillMdPath.lastIndexOf("/")); - const prefix = skillDir ? `${skillDir}/` : ""; - const inventory: FileInventoryEntry[] = [ - { path: "SKILL.md", kind: "skill" as const }, - ...ghSkill.inventory.map((filePath) => { - const relativePath = prefix ? filePath.slice(prefix.length) : filePath; - return { - path: relativePath, - kind: classifyInventoryKind(relativePath), - }; - }), - ]; - - const metadata: Record = { - sourceKind: "github", - owner, - repo, - ref: commitSha, - trackingRef: "HEAD", - ...(originalSkillsShUrl ? { originalSkillsShUrl } : {}), - }; - - const key = deriveCanonicalSkillKey(companyId, { - slug: rawSlug, - sourceType, - sourceLocator: url, - metadata, - }); - - const trustLevel = deriveTrustLevel(inventory); - const name = - (typeof parsed.frontmatter.name === "string" ? parsed.frontmatter.name : null) ?? - rawSlug; - const description = - typeof parsed.frontmatter.description === "string" - ? parsed.frontmatter.description - : null; - - const skillId = randomUUID(); - - // Materialize files to disk - await materializeSkillFiles(companyId, rawSlug, ghSkill.markdown, inventory); - - // Upsert to database - const { isUpdate } = db.upsertCompanySkill({ - id: skillId, - companyId, - key, - slug: rawSlug, - name, - description, - markdown: ghSkill.markdown, - sourceType, - sourceLocator: url, - sourceRef: commitSha, - trustLevel, - compatibility: "compatible", - fileInventory: JSON.stringify(inventory), - metadata: JSON.stringify(metadata), - createdAt: nowTs, - updatedAt: nowTs, - }); - - results.push({ skillId, key, name, isUpdate }); - logger.info(`Skill ${isUpdate ? "updated" : "imported"}: ${key} from ${owner}/${repo}`); - } - - return results; - } - - async function importFromLocal( - companyId: string, - localPath: string, - ): Promise { - const resolvedPath = resolve(localPath.replace(/^~/, process.env.HOME ?? process.env.USERPROFILE ?? "")); - - if (!existsSync(resolvedPath)) { - throw new Error(`Local path does not exist: ${resolvedPath}`); - } - - const stat = statSync(resolvedPath); - const results: SkillImportResult[] = []; - const nowTs = new Date().toISOString(); - - if (stat.isFile()) { - // Single file treated as a skill - const markdown = readFileSync(resolvedPath, "utf-8"); - const parsed = parseFrontmatterMarkdown(markdown); - const rawSlug = - normalizeSkillSlug(parsed.frontmatter.slug as string | undefined) ?? - normalizeSkillSlug(basename(resolvedPath, ".md")) ?? - "skill"; - const inventory: FileInventoryEntry[] = [ - { path: "SKILL.md", kind: "skill" }, - ]; - const trustLevel = deriveTrustLevel(inventory); - const contentHash = hashSkillValue(markdown); - - const key = deriveCanonicalSkillKey(companyId, { - slug: rawSlug, - sourceType: "local_path", - sourceLocator: resolvedPath, - metadata: null, - }); - - const skillId = randomUUID(); - await materializeSkillFiles(companyId, rawSlug, markdown, inventory); - - const { isUpdate } = db.upsertCompanySkill({ - id: skillId, - companyId, - key, - slug: rawSlug, - name: (typeof parsed.frontmatter.name === "string" ? parsed.frontmatter.name : null) ?? rawSlug, - description: typeof parsed.frontmatter.description === "string" ? parsed.frontmatter.description : null, - markdown, - sourceType: "local_path", - sourceLocator: resolvedPath, - sourceRef: contentHash, - trustLevel, - compatibility: "compatible", - fileInventory: JSON.stringify(inventory), - metadata: null, - createdAt: nowTs, - updatedAt: nowTs, - }); - - results.push({ skillId, key, name: rawSlug, isUpdate }); - logger.info(`Skill ${isUpdate ? "updated" : "imported"} from local file: ${key}`); - return results; - } - - // Directory: walk to find SKILL.md files - const skillMdFiles = findSkillMdFiles(resolvedPath); - if (skillMdFiles.length === 0) { - throw new Error(`No SKILL.md files found in ${resolvedPath}`); - } - - for (const skillMdPath of skillMdFiles) { - const skillDir = dirname(skillMdPath); - const markdown = readFileSync(skillMdPath, "utf-8"); - const parsed = parseFrontmatterMarkdown(markdown); - const rawSlug = - normalizeSkillSlug(parsed.frontmatter.slug as string | undefined) ?? - normalizeSkillSlug(basename(skillDir)) ?? - "skill"; - - // Build inventory from directory listing - const inventory = buildLocalInventory(skillDir, skillMdPath); - const trustLevel = deriveTrustLevel(inventory); - const contentHash = hashSkillValue(markdown); - - const key = deriveCanonicalSkillKey(companyId, { - slug: rawSlug, - sourceType: "local_path", - sourceLocator: skillDir, - metadata: null, - }); - - const skillId = randomUUID(); - await materializeSkillFiles(companyId, rawSlug, markdown, inventory); - - const { isUpdate } = db.upsertCompanySkill({ - id: skillId, - companyId, - key, - slug: rawSlug, - name: (typeof parsed.frontmatter.name === "string" ? parsed.frontmatter.name : null) ?? rawSlug, - description: typeof parsed.frontmatter.description === "string" ? parsed.frontmatter.description : null, - markdown, - sourceType: "local_path", - sourceLocator: skillDir, - sourceRef: contentHash, - trustLevel, - compatibility: "compatible", - fileInventory: JSON.stringify(inventory), - metadata: null, - createdAt: nowTs, - updatedAt: nowTs, - }); - - results.push({ skillId, key, name: rawSlug, isUpdate }); - logger.info(`Skill ${isUpdate ? "updated" : "imported"} from local path: ${key}`); - } - - return results; - } - - async function importFromUrl( - companyId: string, - url: string, - ): Promise { - const response = await fetch(url); - if (!response.ok) { - throw new Error(`Failed to fetch skill from URL: ${response.status} ${url}`); - } - const markdown = await response.text(); - const parsed = parseFrontmatterMarkdown(markdown); - const nowTs = new Date().toISOString(); - - // Derive slug from frontmatter or URL path - let urlSlug: string | null = null; - try { - const urlObj = new URL(url); - const pathParts = urlObj.pathname.split("/").filter(Boolean); - const lastPart = pathParts[pathParts.length - 1] ?? ""; - urlSlug = normalizeSkillSlug(lastPart.replace(/\.md$/i, "")); - } catch { - // ignore URL parsing errors - } - const rawSlug = - normalizeSkillSlug(parsed.frontmatter.slug as string | undefined) ?? - urlSlug ?? - "skill"; - - // Single-file skill: only SKILL.md - const inventory: FileInventoryEntry[] = [ - { path: "SKILL.md", kind: "skill" }, - ]; - const trustLevel: "markdown_only" = "markdown_only"; - - const key = deriveCanonicalSkillKey(companyId, { - slug: rawSlug, - sourceType: "url", - sourceLocator: url, - metadata: null, - }); - - const skillId = randomUUID(); - await materializeSkillFiles(companyId, rawSlug, markdown, inventory); - - const { isUpdate } = db.upsertCompanySkill({ - id: skillId, - companyId, - key, - slug: rawSlug, - name: (typeof parsed.frontmatter.name === "string" ? parsed.frontmatter.name : null) ?? rawSlug, - description: typeof parsed.frontmatter.description === "string" ? parsed.frontmatter.description : null, - markdown, - sourceType: "url", - sourceLocator: url, - sourceRef: null, - trustLevel, - compatibility: "compatible", - fileInventory: JSON.stringify(inventory), - metadata: null, - createdAt: nowTs, - updatedAt: nowTs, - }); - - const results: SkillImportResult[] = [ - { skillId, key, name: rawSlug, isUpdate }, - ]; - logger.info(`Skill ${isUpdate ? "updated" : "imported"} from URL: ${key}`); - return results; - } - - function listForCompany(companyId: string): SkillRecord[] { - return db.listCompanySkills(companyId); - } - - function getDetail(companyId: string, skillId: string): DesktopResult { - const skill = db.getCompanySkill(companyId, skillId); - if (!skill) return fail("not_found", `Skill ${skillId} not found`); - return ok(skill); - } - - /** Delete a skill: (1) assignments cascade via FK, (2) files from disk, (3) DB record per D-12. */ - async function deleteSkill( - companyId: string, - skillId: string, - ): Promise> { - const skill = db.getCompanySkill(companyId, skillId); - if (!skill) return fail("not_found", `Skill ${skillId} not found`); - - try { - // D-12 order: (1) assignments cascade via FK on delete, - // (2) delete files from disk - await cleanupSkillFiles(companyId, skill.slug); - // (3) delete DB record (cascades agent_skill_assignments via FK) - db.deleteCompanySkill(companyId, skillId); - logger.info(`Skill deleted: ${skill.key}`); - return ok(undefined); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - logger.error("Skill deletion failed:", message); - return fail("delete_error", message); - } - } - - function assignToAgent( - companyId: string, - agentId: string, - skillId: string, - ): DesktopResult { - const skill = db.getCompanySkill(companyId, skillId); - if (!skill) return fail("not_found", `Skill ${skillId} not found for company`); - db.assignSkillToAgent(agentId, skillId); - return ok(undefined); - } - - function removeFromAgent( - _companyId: string, - agentId: string, - skillId: string, - ): DesktopResult { - db.removeSkillFromAgent(agentId, skillId); - return ok(undefined); - } - - async function checkUpdateStatus( - companyId: string, - skillId: string, - ): Promise> { - const skill = db.getCompanySkill(companyId, skillId); - if (!skill) return fail("not_found", `Skill ${skillId} not found`); - - // Only GitHub/skills_sh sources support update checks - if (skill.sourceType !== "github" && skill.sourceType !== "skills_sh") { - return ok({ - skillId: skill.id, - key: skill.key, - currentRef: skill.sourceRef, - latestRef: null, - hasUpdate: false, - }); - } - - // Parse owner/repo from metadata - const metadata = skill.metadata; - const owner = typeof metadata?.owner === "string" ? metadata.owner : null; - const repo = typeof metadata?.repo === "string" ? metadata.repo : null; - - if (!owner || !repo) { - // Try to parse from sourceLocator URL - const ghParsed = skill.sourceLocator ? parseGitHubUrl(skill.sourceLocator) : null; - if (!ghParsed) { - return ok({ - skillId: skill.id, - key: skill.key, - currentRef: skill.sourceRef, - latestRef: null, - hasUpdate: false, - }); - } - const latestRef = await resolveGitHubCommitSha(ghParsed.owner, ghParsed.repo, undefined, logger); - return ok({ - skillId: skill.id, - key: skill.key, - currentRef: skill.sourceRef, - latestRef, - hasUpdate: latestRef != null && latestRef !== skill.sourceRef, - }); - } - - const trackingRef = typeof metadata?.trackingRef === "string" ? metadata.trackingRef : undefined; - const latestRef = await resolveGitHubCommitSha(owner, repo, trackingRef, logger); - - return ok({ - skillId: skill.id, - key: skill.key, - currentRef: skill.sourceRef, - latestRef, - hasUpdate: latestRef != null && latestRef !== skill.sourceRef, - }); - } - - /** - * Build the list of RuntimeSkillEntry for adapter injection. - * Applies token budget enforcement: skills sorted by creation date, - * accumulated until budget exceeded, then skipped with warning. - */ - function listRuntimeSkillEntries( - companyId: string, - agentId: string, - adapterType: string, - ): RuntimeSkillEntry[] { - const allSkills = db.listCompanySkills(companyId); - const agentAssignments = db.listAgentSkillAssignments(agentId); - const skills = allSkills.filter((s) => agentAssignments.includes(s.id)); - - // Sort by creation date (oldest first -- first imported gets priority) - const sorted = [...skills].sort( - (a, b) => a.createdAt.localeCompare(b.createdAt), - ); - - const budget = TOKEN_BUDGETS[adapterType] ?? 30000; - let accumulated = 0; - const entries: RuntimeSkillEntry[] = []; - - for (const skill of sorted) { - const tokenEstimate = estimateTokens(skill.markdown); - if (accumulated + tokenEstimate > budget) { - logger.warn( - `Token budget exceeded for ${adapterType}: skipping skill "${skill.name}" (${tokenEstimate} tokens, budget ${budget}, used ${accumulated})`, - ); - continue; - } - accumulated += tokenEstimate; - entries.push({ - runtimeName: skill.slug, - sourcePath: skillFilesDir(companyId, skill.slug), - name: skill.name, - tokenEstimate, - }); - } - - return entries; - } - - function findSkillMdFiles(rootDir: string): string[] { - const found: string[] = []; - function walk(dir: string) { - try { - const entries = readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = join(dir, entry.name); - if (entry.isDirectory()) { - walk(fullPath); - } else if (entry.name === "SKILL.md") { - found.push(fullPath); - } - } - } catch { - // Skip unreadable directories - } - } - walk(rootDir); - return found; - } - - function buildLocalInventory(skillDir: string, skillMdPath: string): FileInventoryEntry[] { - const inventory: FileInventoryEntry[] = [ - { path: "SKILL.md", kind: "skill" }, - ]; - try { - const entries = readdirSync(skillDir, { withFileTypes: true, recursive: true }); - for (const entry of entries) { - if (!entry.isFile()) continue; - const entryPath = entry.parentPath - ? join(entry.parentPath, entry.name) - : join(skillDir, entry.name); - const fullPath = resolve(entryPath); - if (fullPath === skillMdPath) continue; - const relativePath = relative(skillDir, fullPath).replace(/\\/g, "/"); - inventory.push({ - path: relativePath, - kind: classifyInventoryKind(relativePath), - }); - } - } catch { - // If directory listing fails, return just the SKILL.md entry - } - return inventory; - } - - /** - * Scan all workspace local paths for a company, looking for - * `.agents/skills/` directories containing SKILL.md files. - * Auto-imports any found skills that are not already in the database. - * Cross-platform: uses path.join for correct separators. - */ - async function scanWorkspaceSkills( - companyId: string, - ): Promise { - const allResults: SkillImportResult[] = []; - - // 1. Scan profile skills directory: {profileDir}/skills/{companyId}/ - // This is where builtin and user-installed skills live - const profileSkillsDir = skillsRoot(companyId); - if (existsSync(profileSkillsDir)) { - try { - const stat = statSync(profileSkillsDir); - if (stat.isDirectory()) { - const results = await importFromLocal(companyId, profileSkillsDir); - allResults.push(...results); - logger.info(`Scanned profile skills: ${profileSkillsDir} — imported ${results.length} skill(s)`); - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - logger.warn(`Failed to scan profile skills at ${profileSkillsDir}: ${message}`); - } - } - - // 2. Scan workspace .agentcompany/skills/ directories - const workspacePaths = db.listWorkspaceLocalPaths(companyId); - for (const wsPath of workspacePaths) { - const agentcompanySkillsDir = join(wsPath, ".agentcompany", "skills"); - if (!existsSync(agentcompanySkillsDir)) continue; - - try { - const stat = statSync(agentcompanySkillsDir); - if (!stat.isDirectory()) continue; - - const results = await importFromLocal(companyId, agentcompanySkillsDir); - allResults.push(...results); - logger.info( - `Scanned workspace skills: ${agentcompanySkillsDir} — imported ${results.length} skill(s)`, - ); - } catch (err) { - // Log but don't fail the entire scan if one workspace has issues - const message = err instanceof Error ? err.message : String(err); - logger.warn(`Failed to scan workspace skills at ${agentcompanySkillsDir}: ${message}`); - } - } - - return allResults; - } - - /** - * Resolve the path to bundled builtin skills. - * Cross-platform: works on macOS, Windows, and Linux. - * - * Production (packaged app): - * macOS: AppName.app/Contents/Resources/builtin-skills/ - * Windows: {install dir}/resources/builtin-skills/ - * Linux: {install dir}/resources/builtin-skills/ - * Resolved via `process.resourcesPath` (set by Electron on all platforms). - * - * Development (electron-vite dev): - * CWD is the project root → {project}/builtin-skills/ - * No __dirname dependency (ESM polyfill varies across bundlers). - */ - function getBuiltinSkillsDir(): string | null { - // 1. Production: Electron packages extraResources into process.resourcesPath - // process.resourcesPath is always defined in Electron (all platforms) - if (process.resourcesPath) { - const resourcesPath = join(process.resourcesPath, "builtin-skills"); - if (existsSync(resourcesPath)) return resourcesPath; - } - // 2. Development: electron-vite dev sets CWD to project root - const cwdPath = join(process.cwd(), "builtin-skills"); - if (existsSync(cwdPath)) return cwdPath; - return null; - } - - /** - * Seed all builtin skills for a company from the bundled builtin-skills directory. - * Idempotent: skips skills that already exist (by key). - * Called once during company bootstrap. - */ - async function seedBuiltinSkills(companyId: string): Promise { - const builtinDir = getBuiltinSkillsDir(); - if (!builtinDir) { - logger.warn("[skills] Builtin skills directory not found — skipping seed."); - return []; - } - - const allResults: SkillImportResult[] = []; - - // Walk each category directory (basic-for-all, software-engineer, etc.) - let categories: string[]; - try { - categories = readdirSync(builtinDir).filter(name => { - if (name.startsWith(".")) return false; - try { return statSync(join(builtinDir, name)).isDirectory(); } catch { return false; } - }); - } catch { - logger.warn(`[skills] Failed to read builtin skills directory: ${builtinDir}`); - return []; - } - - for (const category of categories) { - const categoryDir = join(builtinDir, category); - try { - const results = await importFromLocal(companyId, categoryDir); - // Tag each imported skill with builtin metadata so we know its category - for (const result of results) { - try { - const skill = db.getCompanySkill(companyId, result.skillId); - if (skill) { - const existingMeta = skill.metadata ? JSON.parse(typeof skill.metadata === "string" ? skill.metadata : JSON.stringify(skill.metadata)) : {}; - existingMeta.builtinCategory = category; - existingMeta.sourceKind = "builtin"; - db.updateCompanySkillMetadata(companyId, result.skillId, JSON.stringify(existingMeta)); - } - } catch { /* non-fatal metadata update */ } - } - allResults.push(...results); - logger.info(`[skills] Seeded ${results.length} builtin skill(s) from category: ${category}`); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - logger.warn(`[skills] Failed to seed builtin category ${category}: ${message}`); - } - } - - return allResults; - } - - /** - * Auto-assign all skills from the "basic-for-all" builtin category to an agent. - */ - function assignBasicSkillsToAgent(companyId: string, agentId: string): number { - const allSkills = db.listCompanySkills(companyId); - let assigned = 0; - for (const skill of allSkills) { - try { - const meta = skill.metadata ? JSON.parse(typeof skill.metadata === "string" ? skill.metadata : JSON.stringify(skill.metadata)) : {}; - if (meta.builtinCategory === "basic-for-all") { - db.assignSkillToAgent(agentId, skill.id); - assigned++; - } - } catch { /* skip malformed metadata */ } - } - return assigned; - } - - /** - * Auto-assign role-appropriate builtin skills to an agent based on its role/department. - * CEO gets all categories. Other agents get basic-for-all + role-matched categories. - */ - function assignRoleSkillsToAgent(companyId: string, agentId: string, role: string, department?: string | null): number { - const allSkills = db.listCompanySkills(companyId); - let assigned = 0; - - // Map roles/departments to skill categories - const roleCategories: Record = { - ceo: ["basic-for-all", "software-engineer", "document-process", "frontend-design", "marketing", "iOS-Dev", "my-writing-style", "Video-maker"], - engineer: ["basic-for-all", "software-engineer"], - designer: ["basic-for-all", "frontend-design"], - marketing: ["basic-for-all", "marketing", "my-writing-style"], - writer: ["basic-for-all", "document-process", "my-writing-style"], - ios: ["basic-for-all", "software-engineer", "iOS-Dev"], - mobile: ["basic-for-all", "software-engineer", "iOS-Dev"], - video: ["basic-for-all", "Video-maker"], - frontend: ["basic-for-all", "software-engineer", "frontend-design"], - }; - - const normalizedRole = role.toLowerCase(); - const normalizedDept = department?.toLowerCase() ?? ""; - - // Find matching categories - let categories = roleCategories[normalizedRole] ?? ["basic-for-all"]; - - // Also check department - for (const [key, cats] of Object.entries(roleCategories)) { - if (normalizedDept.includes(key) || normalizedRole.includes(key)) { - categories = [...new Set([...categories, ...cats])]; - } - } - - // If CEO role, assign all - if (normalizedRole === "ceo" || normalizedRole.includes("chief")) { - categories = roleCategories.ceo!; - } - - for (const skill of allSkills) { - try { - const meta = skill.metadata ? JSON.parse(typeof skill.metadata === "string" ? skill.metadata : JSON.stringify(skill.metadata)) : {}; - if (meta.builtinCategory && categories.includes(meta.builtinCategory)) { - db.assignSkillToAgent(agentId, skill.id); - assigned++; - } - } catch { /* skip */ } - } - return assigned; - } - - return { - importFromSource, - importFromGitHub, - importFromLocal, - importFromUrl, - listForCompany, - getDetail, - deleteSkill, - assignToAgent, - removeFromAgent, - checkUpdateStatus, - listRuntimeSkillEntries, - scanWorkspaceSkills, - seedBuiltinSkills, - assignBasicSkillsToAgent, - assignRoleSkillsToAgent, - }; -} +export { createSkillService } from "./skills/index"; diff --git a/src/main/skill-types.ts b/src/main/skill-types.ts index 9186cd16..d781e350 100644 --- a/src/main/skill-types.ts +++ b/src/main/skill-types.ts @@ -58,14 +58,35 @@ export interface SkillImportResult { isUpdate: boolean; } +/** + * Skill execution context mode — controls how the skill is invoked. + * - "inline": skill content is injected into the current prompt (default) + * - "fork": skill runs in an isolated context / sub-conversation + */ +export type SkillContextMode = "inline" | "fork"; + /** * Runtime representation of a skill entry for adapter injection. + * Includes strong semantic fields inspired by Reference's skill loader: + * allowed-tools, hooks, context mode, agent flag, effort hint. */ export interface RuntimeSkillEntry { runtimeName: string; sourcePath: string; name: string; tokenEstimate: number; + /** Tools this skill is allowed to use. Empty = unrestricted. */ + allowedTools: string[]; + /** Lifecycle hooks the skill registers (e.g., "on_task_start", "on_run_complete"). */ + hooks: string[]; + /** Context mode: "inline" (default) or "fork" (isolated sub-conversation). */ + contextMode: SkillContextMode; + /** Whether this skill acts as an autonomous agent (true) or passive knowledge (false). */ + isAgent: boolean; + /** Effort hint: "low", "medium", "high". Informs scheduling and token budget allocation. */ + effort: "low" | "medium" | "high"; + /** When this skill should be activated. Empty = always active when assigned. */ + whenToUse: string; } /** @@ -105,6 +126,15 @@ export const TOKEN_BUDGETS: Readonly> = { claude_local: 50000, codex_local: 30000, gemini_local: 50000, - opencode_local: 30000, - kimi_local: 20000, + claude_compatible: 50000, }; + +/** + * Resolves the token budget for a given adapter type. + * Handles claude_compat_2..10 variants by mapping them to claude_compatible. + */ +export function resolveTokenBudget(adapterType: string): number { + if (TOKEN_BUDGETS[adapterType] !== undefined) return TOKEN_BUDGETS[adapterType]; + if (adapterType.startsWith("claude_compat")) return TOKEN_BUDGETS.claude_compatible; + return 30000; +} diff --git a/src/main/skills/crud.ts b/src/main/skills/crud.ts new file mode 100644 index 00000000..ed89afb0 --- /dev/null +++ b/src/main/skills/crud.ts @@ -0,0 +1,129 @@ +import type { AppDatabase } from "../database"; +import type { DesktopResult } from "@shared/contracts"; +import type { SkillRecord, SkillUpdateStatus } from "../skill-types.js"; +import { resolveGitHubCommitSha } from "../skill-github.js"; + +interface CrudDeps { + db: AppDatabase; + logger: { + info: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + }; + cleanupSkillFiles: (companyId: string, slug: string) => Promise; + parseGitHubUrl: (url: string) => { owner: string; repo: string } | null; +} + +export function createSkillCrud({ db, logger, cleanupSkillFiles, parseGitHubUrl }: CrudDeps) { + function ok(data: T): DesktopResult { + return { ok: true, data }; + } + function fail(code: string, message: string): DesktopResult { + return { ok: false, error: { code, message } }; + } + + function listForCompany(companyId: string): SkillRecord[] { + return db.listCompanySkills(companyId); + } + + function getDetail(companyId: string, skillId: string): DesktopResult { + const skill = db.getCompanySkill(companyId, skillId); + if (!skill) return fail("not_found", `Skill ${skillId} not found`); + return ok(skill); + } + + async function deleteSkill( + companyId: string, + skillId: string, + ): Promise> { + const skill = db.getCompanySkill(companyId, skillId); + if (!skill) return fail("not_found", `Skill ${skillId} not found`); + + try { + await cleanupSkillFiles(companyId, skill.slug); + db.deleteCompanySkill(companyId, skillId); + logger.info(`Skill deleted: ${skill.key}`); + return ok(undefined); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error("Skill deletion failed:", message); + return fail("delete_error", message); + } + } + + function assignToAgent( + companyId: string, + agentId: string, + skillId: string, + ): DesktopResult { + const skill = db.getCompanySkill(companyId, skillId); + if (!skill) return fail("not_found", `Skill ${skillId} not found for company`); + db.assignSkillToAgent(agentId, skillId); + return ok(undefined); + } + + function removeFromAgent( + _companyId: string, + agentId: string, + skillId: string, + ): DesktopResult { + db.removeSkillFromAgent(agentId, skillId); + return ok(undefined); + } + + async function checkUpdateStatus( + companyId: string, + skillId: string, + ): Promise> { + const skill = db.getCompanySkill(companyId, skillId); + if (!skill) return fail("not_found", `Skill ${skillId} not found`); + + if (skill.sourceType !== "github" && skill.sourceType !== "skills_sh") { + return ok({ + skillId: skill.id, + key: skill.key, + currentRef: skill.sourceRef, + latestRef: null, + hasUpdate: false, + }); + } + + const metadata = skill.metadata; + const owner = typeof metadata?.owner === "string" ? metadata.owner : null; + const repo = typeof metadata?.repo === "string" ? metadata.repo : null; + + if (!owner || !repo) { + const ghParsed = skill.sourceLocator ? parseGitHubUrl(skill.sourceLocator) : null; + if (!ghParsed) { + return ok({ + skillId: skill.id, + key: skill.key, + currentRef: skill.sourceRef, + latestRef: null, + hasUpdate: false, + }); + } + const latestRef = await resolveGitHubCommitSha(ghParsed.owner, ghParsed.repo, undefined, logger); + return ok({ + skillId: skill.id, + key: skill.key, + currentRef: skill.sourceRef, + latestRef, + hasUpdate: latestRef != null && latestRef !== skill.sourceRef, + }); + } + + const trackingRef = typeof metadata?.trackingRef === "string" ? metadata.trackingRef : undefined; + const latestRef = await resolveGitHubCommitSha(owner, repo, trackingRef, logger); + + return ok({ + skillId: skill.id, + key: skill.key, + currentRef: skill.sourceRef, + latestRef, + hasUpdate: latestRef != null && latestRef !== skill.sourceRef, + }); + } + + return { listForCompany, getDetail, deleteSkill, assignToAgent, removeFromAgent, checkUpdateStatus }; +} diff --git a/src/main/skills/import-github.ts b/src/main/skills/import-github.ts new file mode 100644 index 00000000..80b1f5f7 --- /dev/null +++ b/src/main/skills/import-github.ts @@ -0,0 +1,170 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join, basename, dirname } from "node:path"; +import type { AppDatabase } from "../database"; +import type { FileInventoryEntry, SkillImportResult, SkillSourceType } from "../skill-types.js"; +import { + parseFrontmatterMarkdown, + classifyInventoryKind, + deriveTrustLevel, +} from "../skill-format.js"; +import { + deriveCanonicalSkillKey, + normalizeSkillSlug, +} from "../skill-keys.js"; +import { + resolveGitHubCommitSha, + fetchGitHubSkills, +} from "../skill-github.js"; + +interface GitHubImportDeps { + db: AppDatabase; + logger: { + info: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + }; + skillFilesDir: (companyId: string, slug: string) => string; +} + +export function createGitHubImporter({ db, logger, skillFilesDir }: GitHubImportDeps) { + function parseGitHubUrl(url: string): { owner: string; repo: string } | null { + try { + const parsed = new URL(url); + if (parsed.hostname !== "github.com") return null; + const parts = parsed.pathname.split("/").filter(Boolean); + if (parts.length < 2) return null; + return { + owner: parts[0]!, + repo: parts[1]!.replace(/\.git$/i, ""), + }; + } catch { + return null; + } + } + + async function materializeSkillFiles( + companyId: string, + slug: string, + markdown: string, + _inventory: FileInventoryEntry[], + ): Promise { + const dir = skillFilesDir(companyId, slug); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, "SKILL.md"), markdown, "utf-8"); + return dir; + } + + async function importFromGitHub( + companyId: string, + url: string, + requestedSlug: string | null, + originalSkillsShUrl: string | null, + ): Promise { + const ghParsed = parseGitHubUrl(url); + if (!ghParsed) { + throw new Error(`Invalid GitHub URL: ${url}`); + } + const { owner, repo } = ghParsed; + + const sha = await resolveGitHubCommitSha(owner, repo, undefined, logger); + if (!sha) { + throw new Error(`Failed to resolve commit SHA for ${owner}/${repo}`); + } + + const { skills: githubSkills, commitSha } = await fetchGitHubSkills( + owner, repo, sha, requestedSlug, logger, + ); + + if (githubSkills.length === 0) { + throw new Error(`No SKILL.md files found in ${owner}/${repo}`); + } + + const results: SkillImportResult[] = []; + const sourceType: SkillSourceType = originalSkillsShUrl ? "skills_sh" : "github"; + const nowTs = new Date().toISOString(); + + for (const ghSkill of githubSkills) { + const parsed = parseFrontmatterMarkdown(ghSkill.markdown); + const rawSlug = + normalizeSkillSlug(parsed.frontmatter.slug as string | undefined) ?? + normalizeSkillSlug( + ghSkill.skillMdPath === "SKILL.md" + ? repo + : basename(dirname(ghSkill.skillMdPath)), + ) ?? + repo; + + const skillDir = + ghSkill.skillMdPath === "SKILL.md" + ? "" + : ghSkill.skillMdPath.slice(0, ghSkill.skillMdPath.lastIndexOf("/")); + const prefix = skillDir ? `${skillDir}/` : ""; + const inventory: FileInventoryEntry[] = [ + { path: "SKILL.md", kind: "skill" as const }, + ...ghSkill.inventory.map((filePath) => { + const relativePath = prefix ? filePath.slice(prefix.length) : filePath; + return { + path: relativePath, + kind: classifyInventoryKind(relativePath), + }; + }), + ]; + + const metadata: Record = { + sourceKind: "github", + owner, + repo, + ref: commitSha, + trackingRef: "HEAD", + ...(originalSkillsShUrl ? { originalSkillsShUrl } : {}), + }; + + const key = deriveCanonicalSkillKey(companyId, { + slug: rawSlug, + sourceType, + sourceLocator: url, + metadata, + }); + + const trustLevel = deriveTrustLevel(inventory); + const name = + (typeof parsed.frontmatter.name === "string" ? parsed.frontmatter.name : null) ?? + rawSlug; + const description = + typeof parsed.frontmatter.description === "string" + ? parsed.frontmatter.description + : null; + + const skillId = randomUUID(); + + await materializeSkillFiles(companyId, rawSlug, ghSkill.markdown, inventory); + + const { isUpdate } = db.upsertCompanySkill({ + id: skillId, + companyId, + key, + slug: rawSlug, + name, + description, + markdown: ghSkill.markdown, + sourceType, + sourceLocator: url, + sourceRef: commitSha, + trustLevel, + compatibility: "compatible", + fileInventory: JSON.stringify(inventory), + metadata: JSON.stringify(metadata), + createdAt: nowTs, + updatedAt: nowTs, + }); + + results.push({ skillId, key, name, isUpdate }); + logger.info(`Skill ${isUpdate ? "updated" : "imported"}: ${key} from ${owner}/${repo}`); + } + + return results; + } + + return { parseGitHubUrl, importFromGitHub }; +} diff --git a/src/main/skills/import.ts b/src/main/skills/import.ts new file mode 100644 index 00000000..c9c6ea42 --- /dev/null +++ b/src/main/skills/import.ts @@ -0,0 +1,351 @@ +import { randomUUID } from "node:crypto"; +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { mkdir, writeFile, rm } from "node:fs/promises"; +import { join, resolve, basename, dirname, relative } from "node:path"; +import type { AppDatabase } from "../database"; +import type { DesktopResult } from "@shared/contracts"; +import type { FileInventoryEntry, SkillImportResult } from "../skill-types.js"; +import { + parseFrontmatterMarkdown, + classifyInventoryKind, + deriveTrustLevel, +} from "../skill-format.js"; +import { + deriveCanonicalSkillKey, + normalizeSkillSlug, + hashSkillValue, + parseSkillImportSource, +} from "../skill-keys.js"; +import { createGitHubImporter } from "./import-github"; + +interface ImportDeps { + db: AppDatabase; + profileDir: string; + logger: { + info: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + }; + skillsRoot: (companyId: string) => string; + skillFilesDir: (companyId: string, slug: string) => string; +} + +export function createSkillImporter({ db, profileDir: _profileDir, logger, skillsRoot: _skillsRoot, skillFilesDir }: ImportDeps) { + function ok(data: T): DesktopResult { + return { ok: true, data }; + } + function fail(code: string, message: string): DesktopResult { + return { ok: false, error: { code, message } }; + } + + const ghImporter = createGitHubImporter({ db, logger, skillFilesDir }); + + async function materializeSkillFiles( + companyId: string, + slug: string, + markdown: string, + _inventory: FileInventoryEntry[], + ): Promise { + const dir = skillFilesDir(companyId, slug); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, "SKILL.md"), markdown, "utf-8"); + return dir; + } + + async function cleanupSkillFiles(companyId: string, slug: string): Promise { + const dir = skillFilesDir(companyId, slug); + if (existsSync(dir)) { + await rm(dir, { recursive: true, force: true }); + } + } + + async function importFromSource( + companyId: string, + rawSource: string, + ): Promise> { + try { + const parsed = parseSkillImportSource(rawSource); + const source = parsed.resolvedSource; + + if (parsed.originalSkillsShUrl) { + const results = await ghImporter.importFromGitHub( + companyId, + source, + parsed.requestedSkillSlug, + parsed.originalSkillsShUrl, + ); + return ok(results); + } + + if (source.includes("github.com")) { + const results = await ghImporter.importFromGitHub( + companyId, + source, + parsed.requestedSkillSlug, + null, + ); + return ok(results); + } + + if ( + source.startsWith("/") || + /^[A-Za-z]:/.test(source) || + source.startsWith("~") + ) { + const results = await importFromLocal(companyId, source); + return ok(results); + } + + if (/^https?:\/\//i.test(source)) { + const results = await importFromUrl(companyId, source); + return ok(results); + } + + return fail("invalid_source", `Unrecognized skill source format: ${rawSource}`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error("Skill import failed:", message); + return fail("import_error", message); + } + } + + async function importFromLocal( + companyId: string, + localPath: string, + ): Promise { + const resolvedPath = resolve(localPath.replace(/^~/, process.env.HOME ?? process.env.USERPROFILE ?? "")); + + if (!existsSync(resolvedPath)) { + throw new Error(`Local path does not exist: ${resolvedPath}`); + } + + const stat = statSync(resolvedPath); + const results: SkillImportResult[] = []; + const nowTs = new Date().toISOString(); + + if (stat.isFile()) { + const markdown = readFileSync(resolvedPath, "utf-8"); + const parsed = parseFrontmatterMarkdown(markdown); + const rawSlug = + normalizeSkillSlug(parsed.frontmatter.slug as string | undefined) ?? + normalizeSkillSlug(basename(resolvedPath, ".md")) ?? + "skill"; + const inventory: FileInventoryEntry[] = [ + { path: "SKILL.md", kind: "skill" }, + ]; + const trustLevel = deriveTrustLevel(inventory); + const contentHash = hashSkillValue(markdown); + + const key = deriveCanonicalSkillKey(companyId, { + slug: rawSlug, + sourceType: "local_path", + sourceLocator: resolvedPath, + metadata: null, + }); + + const skillId = randomUUID(); + await materializeSkillFiles(companyId, rawSlug, markdown, inventory); + + const { isUpdate } = db.upsertCompanySkill({ + id: skillId, + companyId, + key, + slug: rawSlug, + name: (typeof parsed.frontmatter.name === "string" ? parsed.frontmatter.name : null) ?? rawSlug, + description: typeof parsed.frontmatter.description === "string" ? parsed.frontmatter.description : null, + markdown, + sourceType: "local_path", + sourceLocator: resolvedPath, + sourceRef: contentHash, + trustLevel, + compatibility: "compatible", + fileInventory: JSON.stringify(inventory), + metadata: null, + createdAt: nowTs, + updatedAt: nowTs, + }); + + results.push({ skillId, key, name: rawSlug, isUpdate }); + logger.info(`Skill ${isUpdate ? "updated" : "imported"} from local file: ${key}`); + return results; + } + + // Directory: walk to find SKILL.md files + const skillMdFiles = findSkillMdFiles(resolvedPath); + if (skillMdFiles.length === 0) { + throw new Error(`No SKILL.md files found in ${resolvedPath}`); + } + + for (const skillMdPath of skillMdFiles) { + const skillDir = dirname(skillMdPath); + const markdown = readFileSync(skillMdPath, "utf-8"); + const parsed = parseFrontmatterMarkdown(markdown); + const rawSlug = + normalizeSkillSlug(parsed.frontmatter.slug as string | undefined) ?? + normalizeSkillSlug(basename(skillDir)) ?? + "skill"; + + const inventory = buildLocalInventory(skillDir, skillMdPath); + const trustLevel = deriveTrustLevel(inventory); + const contentHash = hashSkillValue(markdown); + + const key = deriveCanonicalSkillKey(companyId, { + slug: rawSlug, + sourceType: "local_path", + sourceLocator: skillDir, + metadata: null, + }); + + const skillId = randomUUID(); + await materializeSkillFiles(companyId, rawSlug, markdown, inventory); + + const { isUpdate } = db.upsertCompanySkill({ + id: skillId, + companyId, + key, + slug: rawSlug, + name: (typeof parsed.frontmatter.name === "string" ? parsed.frontmatter.name : null) ?? rawSlug, + description: typeof parsed.frontmatter.description === "string" ? parsed.frontmatter.description : null, + markdown, + sourceType: "local_path", + sourceLocator: skillDir, + sourceRef: contentHash, + trustLevel, + compatibility: "compatible", + fileInventory: JSON.stringify(inventory), + metadata: null, + createdAt: nowTs, + updatedAt: nowTs, + }); + + results.push({ skillId, key, name: rawSlug, isUpdate }); + logger.info(`Skill ${isUpdate ? "updated" : "imported"} from local path: ${key}`); + } + + return results; + } + + async function importFromUrl( + companyId: string, + url: string, + ): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch skill from URL: ${response.status} ${url}`); + } + const markdown = await response.text(); + const parsed = parseFrontmatterMarkdown(markdown); + const nowTs = new Date().toISOString(); + + let urlSlug: string | null = null; + try { + const urlObj = new URL(url); + const pathParts = urlObj.pathname.split("/").filter(Boolean); + const lastPart = pathParts[pathParts.length - 1] ?? ""; + urlSlug = normalizeSkillSlug(lastPart.replace(/\.md$/i, "")); + } catch { + // ignore URL parsing errors + } + const rawSlug = + normalizeSkillSlug(parsed.frontmatter.slug as string | undefined) ?? + urlSlug ?? + "skill"; + + const inventory: FileInventoryEntry[] = [ + { path: "SKILL.md", kind: "skill" }, + ]; + const trustLevel = "markdown_only" as const; + + const key = deriveCanonicalSkillKey(companyId, { + slug: rawSlug, + sourceType: "url", + sourceLocator: url, + metadata: null, + }); + + const skillId = randomUUID(); + await materializeSkillFiles(companyId, rawSlug, markdown, inventory); + + const { isUpdate } = db.upsertCompanySkill({ + id: skillId, + companyId, + key, + slug: rawSlug, + name: (typeof parsed.frontmatter.name === "string" ? parsed.frontmatter.name : null) ?? rawSlug, + description: typeof parsed.frontmatter.description === "string" ? parsed.frontmatter.description : null, + markdown, + sourceType: "url", + sourceLocator: url, + sourceRef: null, + trustLevel, + compatibility: "compatible", + fileInventory: JSON.stringify(inventory), + metadata: null, + createdAt: nowTs, + updatedAt: nowTs, + }); + + const results: SkillImportResult[] = [ + { skillId, key, name: rawSlug, isUpdate }, + ]; + logger.info(`Skill ${isUpdate ? "updated" : "imported"} from URL: ${key}`); + return results; + } + + function findSkillMdFiles(rootDir: string): string[] { + const found: string[] = []; + function walk(dir: string) { + try { + const entries = readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + walk(fullPath); + } else if (entry.name === "SKILL.md") { + found.push(fullPath); + } + } + } catch { + // Skip unreadable directories + } + } + walk(rootDir); + return found; + } + + function buildLocalInventory(skillDir: string, skillMdPath: string): FileInventoryEntry[] { + const inventory: FileInventoryEntry[] = [ + { path: "SKILL.md", kind: "skill" }, + ]; + try { + const entries = readdirSync(skillDir, { withFileTypes: true, recursive: true }); + for (const entry of entries) { + if (!entry.isFile()) continue; + const entryPath = entry.parentPath + ? join(entry.parentPath, entry.name) + : join(skillDir, entry.name); + const fullPath = resolve(entryPath); + if (fullPath === skillMdPath) continue; + const relativePath = relative(skillDir, fullPath).replace(/\\/g, "/"); + inventory.push({ + path: relativePath, + kind: classifyInventoryKind(relativePath), + }); + } + } catch { + // If directory listing fails, return just the SKILL.md entry + } + return inventory; + } + + return { + importFromSource, + importFromGitHub: ghImporter.importFromGitHub, + importFromLocal, + importFromUrl, + cleanupSkillFiles, + parseGitHubUrl: ghImporter.parseGitHubUrl, + findSkillMdFiles, + buildLocalInventory, + }; +} diff --git a/src/main/skills/index.ts b/src/main/skills/index.ts new file mode 100644 index 00000000..6e52c6fe --- /dev/null +++ b/src/main/skills/index.ts @@ -0,0 +1,64 @@ +import { join } from "node:path"; +import type { AppDatabase } from "../database"; +import { createSkillImporter } from "./import"; +import { createSkillCrud } from "./crud"; +import { createSkillRuntime } from "./runtime"; + +interface SkillServiceDependencies { + db: AppDatabase; + profileDir: string; + logger: { + info: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + }; +} + +export function createSkillService({ db, profileDir, logger }: SkillServiceDependencies) { + function skillsRoot(companyId: string): string { + return join(profileDir, "skills", companyId); + } + + function skillFilesDir(companyId: string, slug: string): string { + return join(skillsRoot(companyId), slug); + } + + function runtimeDir(companyId: string): string { + return join(skillsRoot(companyId), "__runtime__"); + } + void runtimeDir; // used externally if needed + + const importer = createSkillImporter({ db, profileDir, logger, skillsRoot, skillFilesDir }); + const crud = createSkillCrud({ + db, + logger, + cleanupSkillFiles: importer.cleanupSkillFiles, + parseGitHubUrl: importer.parseGitHubUrl, + }); + const runtime = createSkillRuntime({ + db, + profileDir, + logger, + skillsRoot, + skillFilesDir, + importFromLocal: importer.importFromLocal, + }); + + return { + importFromSource: importer.importFromSource, + importFromGitHub: importer.importFromGitHub, + importFromLocal: importer.importFromLocal, + importFromUrl: importer.importFromUrl, + listForCompany: crud.listForCompany, + getDetail: crud.getDetail, + deleteSkill: crud.deleteSkill, + assignToAgent: crud.assignToAgent, + removeFromAgent: crud.removeFromAgent, + checkUpdateStatus: crud.checkUpdateStatus, + listRuntimeSkillEntries: runtime.listRuntimeSkillEntries, + scanWorkspaceSkills: runtime.scanWorkspaceSkills, + seedBuiltinSkills: runtime.seedBuiltinSkills, + assignBasicSkillsToAgent: runtime.assignBasicSkillsToAgent, + assignRoleSkillsToAgent: runtime.assignRoleSkillsToAgent, + }; +} diff --git a/src/main/skills/runtime.ts b/src/main/skills/runtime.ts new file mode 100644 index 00000000..7b0219c8 --- /dev/null +++ b/src/main/skills/runtime.ts @@ -0,0 +1,243 @@ +import { existsSync, statSync } from "node:fs"; +import { join } from "node:path"; +import type { AppDatabase } from "../database"; +import type { SkillImportResult, RuntimeSkillEntry } from "../skill-types.js"; +import { estimateTokens, parseFrontmatterMarkdown, extractRuntimeSemantics } from "../skill-format.js"; +import { resolveTokenBudget } from "../skill-types.js"; + +interface RuntimeDeps { + db: AppDatabase; + profileDir: string; + logger: { + info: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + }; + skillsRoot: (companyId: string) => string; + skillFilesDir: (companyId: string, slug: string) => string; + importFromLocal: (companyId: string, localPath: string) => Promise; +} + +export function createSkillRuntime({ db, logger, skillsRoot, skillFilesDir, importFromLocal }: RuntimeDeps) { + /** + * Build the list of RuntimeSkillEntry for adapter injection. + * Applies token budget enforcement: skills sorted by creation date, + * accumulated until budget exceeded, then skipped with warning. + */ + function listRuntimeSkillEntries( + companyId: string, + agentId: string, + adapterType: string, + ): RuntimeSkillEntry[] { + const allSkills = db.listCompanySkills(companyId); + const agentAssignments = db.listAgentSkillAssignments(agentId); + const skills = allSkills.filter((s) => agentAssignments.includes(s.id)); + + const sorted = [...skills].sort( + (a, b) => a.createdAt.localeCompare(b.createdAt), + ); + + const budget = resolveTokenBudget(adapterType); + let accumulated = 0; + const entries: RuntimeSkillEntry[] = []; + + for (const skill of sorted) { + const tokenEstimate = estimateTokens(skill.markdown); + if (accumulated + tokenEstimate > budget) { + logger.warn( + `Token budget exceeded for ${adapterType}: skipping skill "${skill.name}" (${tokenEstimate} tokens, budget ${budget}, used ${accumulated})`, + ); + continue; + } + accumulated += tokenEstimate; + + const { frontmatter } = parseFrontmatterMarkdown(skill.markdown); + const semantics = extractRuntimeSemantics(frontmatter); + + entries.push({ + runtimeName: skill.slug, + sourcePath: skillFilesDir(companyId, skill.slug), + name: skill.name, + tokenEstimate, + allowedTools: semantics.allowedTools, + hooks: semantics.hooks, + contextMode: semantics.contextMode, + isAgent: semantics.isAgent, + effort: semantics.effort, + whenToUse: semantics.whenToUse, + }); + } + + return entries; + } + + /** + * Scan all workspace local paths for a company, looking for + * `.agentcompany/skills/` directories containing SKILL.md files. + */ + async function scanWorkspaceSkills( + companyId: string, + ): Promise { + const allResults: SkillImportResult[] = []; + + const profileSkillsDir = skillsRoot(companyId); + if (existsSync(profileSkillsDir)) { + try { + const stat = statSync(profileSkillsDir); + if (stat.isDirectory()) { + const results = await importFromLocal(companyId, profileSkillsDir); + allResults.push(...results); + logger.info(`Scanned profile skills: ${profileSkillsDir} — imported ${results.length} skill(s)`); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.warn(`Failed to scan profile skills at ${profileSkillsDir}: ${message}`); + } + } + + const workspacePaths = db.listWorkspaceLocalPaths(companyId); + for (const wsPath of workspacePaths) { + const agentcompanySkillsDir = join(wsPath, ".agentcompany", "skills"); + if (!existsSync(agentcompanySkillsDir)) continue; + + try { + const stat = statSync(agentcompanySkillsDir); + if (!stat.isDirectory()) continue; + + const results = await importFromLocal(companyId, agentcompanySkillsDir); + allResults.push(...results); + logger.info( + `Scanned workspace skills: ${agentcompanySkillsDir} — imported ${results.length} skill(s)`, + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.warn(`Failed to scan workspace skills at ${agentcompanySkillsDir}: ${message}`); + } + } + + return allResults; + } + + function getBuiltinSkillsDir(): string | null { + if (process.resourcesPath) { + const resourcesPath = join(process.resourcesPath, "builtin-skills"); + if (existsSync(resourcesPath)) return resourcesPath; + } + const cwdPath = join(process.cwd(), "builtin-skills"); + if (existsSync(cwdPath)) return cwdPath; + return null; + } + + async function seedBuiltinSkills(companyId: string): Promise { + const builtinDir = getBuiltinSkillsDir(); + if (!builtinDir) { + logger.warn("[skills] Builtin skills directory not found — skipping seed."); + return []; + } + + const allResults: SkillImportResult[] = []; + + let categories: string[]; + try { + const { readdirSync, statSync: statSyncLocal } = await import("node:fs"); + categories = readdirSync(builtinDir).filter(name => { + if (name.startsWith(".")) return false; + try { return statSyncLocal(join(builtinDir, name)).isDirectory(); } catch { return false; } + }); + } catch { + logger.warn(`[skills] Failed to read builtin skills directory: ${builtinDir}`); + return []; + } + + for (const category of categories) { + const categoryDir = join(builtinDir, category); + try { + const results = await importFromLocal(companyId, categoryDir); + for (const result of results) { + try { + const skill = db.getCompanySkill(companyId, result.skillId); + if (skill) { + const existingMeta = skill.metadata ? JSON.parse(typeof skill.metadata === "string" ? skill.metadata : JSON.stringify(skill.metadata)) : {}; + existingMeta.builtinCategory = category; + existingMeta.sourceKind = "builtin"; + db.updateCompanySkillMetadata(companyId, result.skillId, JSON.stringify(existingMeta)); + } + } catch { /* non-fatal metadata update */ } + } + allResults.push(...results); + logger.info(`[skills] Seeded ${results.length} builtin skill(s) from category: ${category}`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.warn(`[skills] Failed to seed builtin category ${category}: ${message}`); + } + } + + return allResults; + } + + function assignBasicSkillsToAgent(companyId: string, agentId: string): number { + const allSkills = db.listCompanySkills(companyId); + let assigned = 0; + for (const skill of allSkills) { + try { + const meta = skill.metadata ? JSON.parse(typeof skill.metadata === "string" ? skill.metadata : JSON.stringify(skill.metadata)) : {}; + if (meta.builtinCategory === "basic-for-all") { + db.assignSkillToAgent(agentId, skill.id); + assigned++; + } + } catch { /* skip malformed metadata */ } + } + return assigned; + } + + function assignRoleSkillsToAgent(companyId: string, agentId: string, role: string, department?: string | null): number { + const allSkills = db.listCompanySkills(companyId); + let assigned = 0; + + const roleCategories: Record = { + ceo: ["basic-for-all", "software-engineer", "document-process", "frontend-design", "marketing", "iOS-Dev", "my-writing-style", "Video-maker"], + engineer: ["basic-for-all", "software-engineer"], + designer: ["basic-for-all", "frontend-design"], + marketing: ["basic-for-all", "marketing", "my-writing-style"], + writer: ["basic-for-all", "document-process", "my-writing-style"], + ios: ["basic-for-all", "software-engineer", "iOS-Dev"], + mobile: ["basic-for-all", "software-engineer", "iOS-Dev"], + video: ["basic-for-all", "Video-maker"], + frontend: ["basic-for-all", "software-engineer", "frontend-design"], + }; + + const normalizedRole = role.toLowerCase(); + const normalizedDept = department?.toLowerCase() ?? ""; + + let categories = roleCategories[normalizedRole] ?? ["basic-for-all"]; + + for (const [key, cats] of Object.entries(roleCategories)) { + if (normalizedDept.includes(key) || normalizedRole.includes(key)) { + categories = [...new Set([...categories, ...cats])]; + } + } + + if (normalizedRole === "ceo" || normalizedRole.includes("chief")) { + categories = roleCategories.ceo!; + } + + for (const skill of allSkills) { + try { + const meta = skill.metadata ? JSON.parse(typeof skill.metadata === "string" ? skill.metadata : JSON.stringify(skill.metadata)) : {}; + if (meta.builtinCategory && categories.includes(meta.builtinCategory)) { + db.assignSkillToAgent(agentId, skill.id); + assigned++; + } + } catch { /* skip */ } + } + return assigned; + } + + return { + listRuntimeSkillEntries, + scanWorkspaceSkills, + seedBuiltinSkills, + assignBasicSkillsToAgent, + assignRoleSkillsToAgent, + }; +} diff --git a/src/main/task-quality-gates.ts b/src/main/task-quality-gates.ts index 3f1b8e28..fa172065 100644 --- a/src/main/task-quality-gates.ts +++ b/src/main/task-quality-gates.ts @@ -5,6 +5,8 @@ * minimum description quality, acceptance criteria, and capability matching. */ +import { validateTaskRubric } from "@main/task-rubrics"; + export interface TaskValidationResult { valid: boolean; errors: string[]; @@ -16,12 +18,10 @@ export interface CapabilityMatchResult { warning?: string; } -/** Minimal DB interface for capability matching — keeps the function testable with mocks. - * Skill methods are optional since the skill system may not be implemented yet (graceful degradation per D-07). */ export interface CapabilityMatchDb { getAgent(id: string): { name: string; capabilities: string }; - listAgentSkillAssignments?(id: string): string[]; - getCompanySkill?(companyId: string, skillId: string): { name: string; description: string | null; slug: string } | null; + listAgentSkillAssignments(id: string): string[]; + getCompanySkill(companyId: string, skillId: string): { name: string; description: string | null; slug: string } | null; } @@ -60,7 +60,7 @@ export function checkAcceptanceCriteria(description: string): { hasCriteria: boo export function validateTaskCreation( - input: { title: string; description: string }, + input: { title: string; description: string; type?: string }, isHeartbeat: boolean, ): TaskValidationResult { // Heartbeat tasks bypass all validation @@ -86,6 +86,16 @@ export function validateTaskCreation( } } + // TASK-03: Task-type-specific rubric warnings + if (input.type) { + const rubricResult = validateTaskRubric({ + title: input.title, + description: input.description, + type: input.type, + }); + warnings.push(...rubricResult.warnings); + } + return { valid: errors.length === 0, errors, warnings }; } @@ -103,40 +113,32 @@ export function checkCapabilityMatch( ): CapabilityMatchResult { const taskWords = tokenize(taskDescription); - // Nothing to match against — task description too short/vague if (taskWords.length === 0) { - return { matched: true }; + return { matched: true, warning: "Task description has no distinctive keywords for capability matching." }; } const agent = db.getAgent(agentId); const capWords = tokenize(agent.capabilities); const capabilityOverlap = taskWords.filter((w) => capWords.includes(w)).length; - // Check skill assignments for additional matching (graceful degradation) let skillOverlap = 0; - try { - if (db.listAgentSkillAssignments && db.getCompanySkill) { - const skillIds = db.listAgentSkillAssignments(agentId); - for (const skillId of skillIds) { - const skill = db.getCompanySkill(companyId, skillId); - if (skill) { - const skillWords = tokenize(`${skill.name} ${skill.description ?? ""} ${skill.slug}`); - skillOverlap += taskWords.filter((w) => skillWords.includes(w)).length; - } - } + const skillIds = db.listAgentSkillAssignments(agentId); + for (const skillId of skillIds) { + const skill = db.getCompanySkill(companyId, skillId); + if (skill) { + const skillWords = tokenize(`${skill.name} ${skill.description ?? ""} ${skill.slug}`); + skillOverlap += taskWords.filter((w) => skillWords.includes(w)).length; } - } catch { - // Skill system may not be available — graceful degradation per D-07 } - // Direct capability or skill overlap found if (capabilityOverlap > 0 || skillOverlap > 0) { return { matched: true }; } - // No capabilities defined at all — graceful degradation (Pitfall 4) + // Agent has no capabilities and no skill overlap — still allow assignment + // but surface the mismatch as a warning so the caller can decide if (capWords.length === 0 && skillOverlap === 0) { - return { matched: true }; + return { matched: false, warning: `Agent "${agent.name}" has no declared capabilities or matching skills.` }; } // Agent has capabilities but no overlap — warn @@ -196,6 +198,7 @@ export interface SweepStaleReviewsDeps { emitEvent: (...args: unknown[]) => void; publishDomainChanged: () => void; wakeAgentIfPossible: (agentId: string, companyId: string, trigger: string) => void; + getCompanyExecutionMode: (companyId: string) => string; logger: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void }; } @@ -204,6 +207,8 @@ export function sweepStaleReviews( deps: SweepStaleReviewsDeps, now: number = Date.now(), ): number { + const isCofounderMode = deps.getCompanyExecutionMode(companyId) === "ai_cofounder"; + let escalatedCount = 0; const reviewTasks = deps.db.queryAll<{ id: string; @@ -230,6 +235,30 @@ export function sweepStaleReviews( const chain = deps.db.getChainOfCommand(reviewer.id); const escalationTarget = chain.length > 0 ? chain[0] : null; + const pendingApprovals = deps.db.queryAll<{ id: string; type: string }>( + `SELECT id, type FROM approvals + WHERE company_id = ? AND related_task_id = ? AND state = 'pending' + LIMIT 1`, + companyId, + task.id, + ); + + // In ai_cofounder mode, ALL approvals require human decision — never auto-advance. + if (isCofounderMode && pendingApprovals.length > 0) { + deps.notify({ + title: "Your Review Required", + body: `"${task.title}" is waiting for your review. AI Co-Founder mode requires human sign-off.`, + urgency: "critical", + navigation: { section: "approvals", entityId: pendingApprovals[0]?.id }, + batchKey: "cofounder_review_needed", + }); + deps.logger.info("[sweepStaleReviews] Skipping auto-approval for co-founder task", { + taskId: task.id, + approvalType: pendingApprovals[0]?.type, + }); + continue; + } + if (escalationTarget) { deps.dispatchAgentMessage( { @@ -260,14 +289,8 @@ export function sweepStaleReviews( }); } - // D-10: Auto-approve any pending approval linked to this task - const pendingApprovals = deps.db.queryAll<{ id: string }>( - `SELECT id FROM approvals - WHERE company_id = ? AND related_task_id = ? AND state = 'pending' - LIMIT 1`, - companyId, - task.id, - ); + // D-10: Auto-approve any pending approval linked to this task. + // In ai_cofounder mode, protected approval types are already handled above (continue). for (const approval of pendingApprovals) { try { deps.resolveApprovalDecision({ diff --git a/src/main/task-rubrics.ts b/src/main/task-rubrics.ts new file mode 100644 index 00000000..22841ee2 --- /dev/null +++ b/src/main/task-rubrics.ts @@ -0,0 +1,99 @@ +/** + * Task-type-specific business rubrics for quality validation. + * + * Imported by task-quality-gates.ts to extend validateTaskCreation with + * domain-aware checks for research, content, management, and other task types. + */ + +import type { TaskType } from "@shared/types/enums"; + +export interface TaskRubric { + /** Sections the task description should contain (case-insensitive substring match). */ + requiredSections: string[]; + /** Keywords that signal the brief is grounded in quality criteria. */ + qualityKeywords: string[]; + /** Minimum description length for this task type. */ + minDescriptionLength: number; + /** Human-readable template hint shown in warnings when sections are missing. */ + briefTemplate: string; +} + +const RUBRICS: Record = { + research: { + requiredSections: ["research question", "methodology", "sources", "expected findings"], + qualityKeywords: ["data", "evidence", "analysis", "comparison", "findings"], + minDescriptionLength: 100, + briefTemplate: + "A research task brief should cover: Research Question, Methodology, Sources to consult, and Expected Findings.", + }, + content: { + requiredSections: ["target audience", "brand voice", "key messages", "format"], + qualityKeywords: ["audience", "tone", "call to action", "engagement", "headline"], + minDescriptionLength: 80, + briefTemplate: + "A content task brief should cover: Target Audience, Brand Voice, Key Messages, and Format (e.g., blog post, social copy, email).", + }, + management: { + requiredSections: ["objective", "stakeholders", "timeline", "success metrics"], + qualityKeywords: ["milestone", "deliverable", "risk", "dependency", "alignment"], + minDescriptionLength: 80, + briefTemplate: + "A management task brief should cover: Objective, Stakeholders, Timeline, and Success Metrics.", + }, + general: { + requiredSections: [], + qualityKeywords: [], + minDescriptionLength: 50, + briefTemplate: "", + }, + code: { + requiredSections: [], + qualityKeywords: [], + minDescriptionLength: 50, + briefTemplate: "", + }, +}; + +/** Returns the rubric for the given task type, defaulting to "general" for unknown values. */ +export function getTaskTypeRubric(taskType: string): TaskRubric { + const key = taskType as TaskType; + return RUBRICS[key] ?? RUBRICS.general; +} + +/** + * Validates a task input against the type-specific rubric. + * Returns warnings (not errors) — missing sections are advisory, + * not blockers, to avoid rejecting valid heterodox formats. + */ +export function validateTaskRubric(input: { + title: string; + description: string; + type: string; +}): { warnings: string[] } { + const rubric = getTaskTypeRubric(input.type); + const warnings: string[] = []; + + // No extra checks needed for code/general types + if (rubric.requiredSections.length === 0) { + return { warnings }; + } + + const lower = input.description.toLowerCase(); + + const missingSections = rubric.requiredSections.filter( + (section) => !lower.includes(section.toLowerCase()), + ); + + if (missingSections.length > 0) { + const listed = missingSections.map((s) => `"${s}"`).join(", "); + warnings.push( + `${capitalizeFirst(input.type)} task is missing recommended sections: ${listed}. ${rubric.briefTemplate}`, + ); + } + + return { warnings }; +} + +function capitalizeFirst(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); +} diff --git a/src/main/thread-integration.ts b/src/main/thread-integration.ts new file mode 100644 index 00000000..58fc1246 --- /dev/null +++ b/src/main/thread-integration.ts @@ -0,0 +1,503 @@ +/** Thread Integration — wires thread runtime into orchestration callbacks. */ + +import type { AppDatabase } from "./database"; +import type { ExecutionMode, ThreadType } from "@shared/types"; + +interface ThreadIntegrationDeps { + db: AppDatabase; + logger: { info: (...args: unknown[]) => void; error: (...args: unknown[]) => void }; + emitEvent?: (event: import("@shared/types").DesktopEvent) => void; + publishDomainChanged?: () => void; + notify?: (options: { title: string; body: string }) => void; +} + +function findTaskThread(db: AppDatabase, taskId: string, companyId: string) { + const existing = db.listThreads({ + companyId, + type: "task" as ThreadType, + relatedEntityType: "task", + relatedEntityId: taskId, + limit: 1, + }); + return existing[0] ?? null; +} + +function ensureTaskThread(db: AppDatabase, taskId: string, companyId: string, title: string) { + const existing = findTaskThread(db, taskId, companyId); + if (existing) return existing; + + return db.createThread({ + companyId, + title, + type: "task" as ThreadType, + relatedEntityType: "task", + relatedEntityId: taskId, + }); +} + +function findApprovalThread(db: AppDatabase, approvalId: string, companyId: string) { + const existing = db.listThreads({ + companyId, + type: "approval" as ThreadType, + relatedEntityType: "approval", + relatedEntityId: approvalId, + limit: 1, + }); + return existing[0] ?? null; +} + +function ensureApprovalThread(db: AppDatabase, approvalId: string, companyId: string, title: string) { + const existing = findApprovalThread(db, approvalId, companyId); + if (existing) return existing; + + return db.createThread({ + companyId, + title, + type: "approval" as ThreadType, + relatedEntityType: "approval", + relatedEntityId: approvalId, + }); +} + +function resolveApprovalAnnouncementChannel( + db: AppDatabase, + companyId: string, + taskId: string | null, + requestedByAgentId: string | null, +): { channel: "department" | "project" | "company"; channelTargetId: string | null } | null { + if (requestedByAgentId) { + try { + const requester = db.getAgent(requestedByAgentId); + if (requester.companyId === companyId && requester.department) { + return { channel: "department", channelTargetId: requester.department }; + } + } catch { + // Fall through to task/project/company targeting. + } + } + + if (taskId) { + try { + const task = db.getTask(taskId); + if (task.companyId === companyId && task.projectId) { + return { channel: "project", channelTargetId: task.projectId }; + } + } catch { + // Fall through to company-wide routing. + } + } + + return { channel: "company", channelTargetId: null }; +} + +function announceApprovalToBoard( + db: AppDatabase, + approval: { + id: string; + companyId: string; + relatedTaskId: string | null; + requestedByAgentId: string | null; + payloadSummary: string; + impactSummary: string; + }, + deps: Pick, +): void { + if (!approval.requestedByAgentId || !deps.emitEvent || !deps.publishDomainChanged) return; + + const target = resolveApprovalAnnouncementChannel( + db, + approval.companyId, + approval.relatedTaskId, + approval.requestedByAgentId, + ); + if (!target) return; + + const messageId = db.sendAgentMessage({ + companyId: approval.companyId, + fromAgentId: approval.requestedByAgentId, + channel: target.channel, + channelTargetId: target.channelTargetId, + subject: approval.payloadSummary, + body: `@Board ${approval.impactSummary || approval.payloadSummary}`, + priority: "urgent", + senderKind: "agent", + }); + const message = db.getAgentMessage(messageId, approval.companyId); + if (message) { + deps.emitEvent({ type: "new-message", message }); + } + deps.publishDomainChanged(); +} + +/** Check subscriber notifyOn preferences and fire desktop notification. */ +function notifyThreadSubscribers( + db: AppDatabase, + threadId: string, + threadTitle: string, + eventBody: string, + notify?: (options: { title: string; body: string }) => void, +): void { + if (!notify) return; + const subscriptions = db.listSubscriptions(threadId); + for (const sub of subscriptions) { + if (sub.subscriberType !== "user") continue; + if (sub.notifyOn === "none") continue; + if (sub.notifyOn === "mentions") { + if (!eventBody.includes("@You") && !eventBody.includes("@user") && !eventBody.includes("@Board")) continue; + } + notify({ title: threadTitle, body: eventBody }); + break; + } +} + +export function createThreadIntegration({ db, logger, emitEvent, publishDomainChanged, notify }: ThreadIntegrationDeps) { + return { + /** + * Called when a new task is created. + * Creates a thread for the task and records the creation event. + */ + onTaskCreated(taskId: string, companyId: string): void { + try { + const task = db.getTask(taskId); + if (task.companyId !== companyId) return; + + const thread = ensureTaskThread(db, taskId, companyId, task.title); + + db.addThreadEvent({ + threadId: thread.id, + actorId: task.assigneeAgentId ?? "system", + actorType: task.assigneeAgentId ? "agent" : "system", + eventType: "system_note", + body: `Task created: "${task.title}"`, + metadataJson: JSON.stringify({ taskId, status: task.status, priority: task.priority }), + }); + + // Auto-enroll assignee and human as participants + if (task.assigneeAgentId) { + db.addParticipant({ threadId: thread.id, participantId: task.assigneeAgentId, participantType: "agent", role: "owner" }); + db.subscribe({ threadId: thread.id, subscriberId: task.assigneeAgentId, subscriberType: "agent" }); + } + db.addParticipant({ threadId: thread.id, participantId: "user", participantType: "user", role: "observer" }); + db.subscribe({ threadId: thread.id, subscriberId: "user", subscriberType: "user" }); + } catch (err) { + logger.error(`[thread-integration] onTaskCreated failed: ${err instanceof Error ? err.message : String(err)}`); + } + }, + + /** + * Called when a task's status changes. + * Records the transition as a thread event. + * In ai_cofounder mode, creates a wait state when task enters review. + */ + onTaskStatusChanged(taskId: string, companyId: string, previousStatus: string, newStatus: string): void { + try { + const task = db.getTask(taskId); + if (task.companyId !== companyId) return; + + const thread = ensureTaskThread(db, taskId, companyId, task.title); + + db.addThreadEvent({ + threadId: thread.id, + actorId: task.assigneeAgentId ?? "system", + actorType: task.assigneeAgentId ? "agent" : "system", + eventType: "status_change", + body: `${previousStatus} → ${newStatus}`, + metadataJson: JSON.stringify({ taskId, previousStatus, newStatus }), + }); + + // In co-founder mode, set wait state when task enters review + if (newStatus === "in_review") { + const mode = db.getCompanyExecutionMode(companyId) as ExecutionMode; + if (mode === "ai_cofounder") { + const reason = `@Board review needed: Task "${task.title}" is ready for your review.`; + db.setThreadWaitState({ + threadId: thread.id, + companyId, + waitingFor: "user_review", + relatedApprovalId: null, + reason, + }); + notifyThreadSubscribers(db, thread.id, task.title, reason, notify); + } + } + + // Resolve wait states when task moves past review + if (previousStatus === "in_review" && (newStatus === "done" || newStatus === "in_progress")) { + const waitStates = db.listThreadWaitStates(companyId); + for (const ws of waitStates) { + if (ws.threadId === thread.id && ws.waitingFor === "user_review") { + db.resolveThreadWaitState(ws.id, companyId); + } + } + } + } catch (err) { + logger.error(`[thread-integration] onTaskStatusChanged failed: ${err instanceof Error ? err.message : String(err)}`); + } + }, + + /** + * Called when an approval is created (by API server or automation). + * Records the approval request in the task's thread and sets a wait state + * in ai_cofounder mode. + */ + onApprovalCreated(approvalId: string, companyId: string, taskId: string | null, approvalType: string, summary: string): void { + try { + const approval = db.getApprovalRecord(approvalId); + const task = taskId ? db.getTask(taskId) : null; + const thread = task + ? ensureTaskThread(db, task.id, companyId, task.title) + : ensureApprovalThread(db, approvalId, companyId, summary || approval.payloadSummary); + + db.addThreadEvent({ + threadId: thread.id, + actorId: "system", + actorType: "system", + eventType: "approval_request", + body: summary ? `@Board ${summary}` : `@Board approval required: ${approvalType}`, + metadataJson: JSON.stringify({ approvalId, approvalType }), + }); + + if (approval.requestedByAgentId) { + db.addParticipant({ threadId: thread.id, participantId: approval.requestedByAgentId, participantType: "agent", role: "owner" }); + db.subscribe({ threadId: thread.id, subscriberId: approval.requestedByAgentId, subscriberType: "agent" }); + } + db.addParticipant({ threadId: thread.id, participantId: "user", participantType: "user", role: "observer" }); + db.subscribe({ threadId: thread.id, subscriberId: "user", subscriberType: "user" }); + + // In co-founder mode, set wait state for user approval + const mode = db.getCompanyExecutionMode(companyId) as ExecutionMode; + if (mode === "ai_cofounder") { + const approvalReason = `@Board approval needed: ${approvalType}`; + db.setThreadWaitState({ + threadId: thread.id, + companyId, + waitingFor: "user_approval", + relatedApprovalId: approvalId, + reason: approvalReason, + }); + notifyThreadSubscribers(db, thread.id, thread.title, approvalReason, notify); + } + announceApprovalToBoard(db, approval, { emitEvent, publishDomainChanged }); + } catch (err) { + logger.error(`[thread-integration] onApprovalCreated failed: ${err instanceof Error ? err.message : String(err)}`); + } + }, + + /** + * Called when an approval is resolved (approved/rejected). + * Records the decision in the relevant thread and resolves wait states. + */ + onApprovalResolved(approvalId: string, companyId: string, decision: string): void { + try { + // Resolve any wait states linked to this approval + const waitStates = db.listThreadWaitStates(companyId); + for (const ws of waitStates) { + if (ws.relatedApprovalId === approvalId) { + db.resolveThreadWaitState(ws.id, companyId); + + // Add decision event to the thread + db.addThreadEvent({ + threadId: ws.threadId, + actorId: "user", + actorType: "user", + eventType: "approval_decision", + body: `Approval ${decision}`, + metadataJson: JSON.stringify({ approvalId, decision }), + }); + } + } + } catch (err) { + logger.error(`[thread-integration] onApprovalResolved failed: ${err instanceof Error ? err.message : String(err)}`); + } + }, + + /** + * Called when a document is created or updated. + * Records in the task thread if linked to a task. + */ + onDocumentCreated(companyId: string, authorAgentId: string | null, taskId: string | null, documentTitle: string): void { + try { + if (!taskId) return; + + db.getTask(taskId); // validates task exists and belongs to company + const thread = findTaskThread(db, taskId, companyId); + if (!thread) return; + + db.addThreadEvent({ + threadId: thread.id, + actorId: authorAgentId ?? "system", + actorType: authorAgentId ? "agent" : "system", + eventType: "file_attachment", + body: `Document created: "${documentTitle}"`, + metadataJson: JSON.stringify({ taskId, authorAgentId }), + }); + } catch (err) { + logger.error(`[thread-integration] onDocumentCreated failed: ${err instanceof Error ? err.message : String(err)}`); + } + }, + + /** + * Called when a run starts for a task. + * Records the run start in the task thread. + */ + onRunStarted(runId: string, taskId: string, agentId: string, companyId: string): void { + try { + const thread = findTaskThread(db, taskId, companyId); + if (!thread) return; + + db.addThreadEvent({ + threadId: thread.id, + actorId: agentId, + actorType: "agent", + eventType: "system_note", + body: "Execution started", + metadataJson: JSON.stringify({ runId, agentId }), + }); + } catch (err) { + logger.error(`[thread-integration] onRunStarted failed: ${err instanceof Error ? err.message : String(err)}`); + } + }, + + /** + * Called when a run finishes (success or failure). + * Records in the task thread. In ai_cofounder mode, creates a + * wait state for user review on successful completion. + */ + onRunFinished(runId: string, taskId: string, agentId: string, companyId: string, status: string, errorMessage: string | null): void { + try { + const thread = findTaskThread(db, taskId, companyId); + if (!thread) return; + + const body = status === "succeeded" + ? "Execution completed successfully" + : `Execution ${status}${errorMessage ? `: ${errorMessage}` : ""}`; + + db.addThreadEvent({ + threadId: thread.id, + actorId: agentId, + actorType: "agent", + eventType: "system_note", + body, + metadataJson: JSON.stringify({ runId, status, errorMessage }), + }); + + // In co-founder mode, successful runs create a user_reply wait state + // so the heartbeat pauses until the human reviews the output + if (status === "succeeded") { + const mode = db.getCompanyExecutionMode(companyId) as ExecutionMode; + if (mode === "ai_cofounder") { + const task = db.getTask(taskId); + const completionReason = `@Board review needed: Agent completed work on "${task.title}" — awaiting your review.`; + db.setThreadWaitState({ + threadId: thread.id, + companyId, + waitingFor: "user_reply", + relatedApprovalId: null, + reason: completionReason, + }); + notifyThreadSubscribers(db, thread.id, task.title, completionReason, notify); + } + } + } catch (err) { + logger.error(`[thread-integration] onRunFinished failed: ${err instanceof Error ? err.message : String(err)}`); + } + }, + + /** + * Called when a human user sends a message. + * Resolves thread wait states using a precision hierarchy: + * 1. If waitStateId is provided, resolves only that exact wait state (checkpoint-precise). + * 2. Else if threadId is provided, resolves all user_* wait states for that thread. + * 3. Else if toAgentId is provided (direct message), resolves wait states for threads + * assigned to that agent. + * 4. Otherwise (company-wide broadcast), only resolves user_reply wait states. + * user_review and user_approval require scoped resolution via thread or approval UI. + */ + onHumanMessage(companyId: string, toAgentId?: string | null, threadId?: string | null, waitStateId?: string | null): void { + try { + // Most precise: resolve a single identified wait state + if (waitStateId) { + db.resolveThreadWaitState(waitStateId, companyId); + return; + } + + // Scoped resolution: threadId or toAgentId resolve all user_* types + const SCOPED_WAIT_TYPES = new Set(["user_reply", "user_review"]); + const isScoped = Boolean(threadId || toAgentId); + + const waitStates = db.listThreadWaitStates(companyId); + for (const ws of waitStates) { + if (isScoped) { + if (!SCOPED_WAIT_TYPES.has(ws.waitingFor)) continue; + } else { + // Company-wide fallback: only resolve user_reply to prevent + // accidental resolution of reviews/approvals from casual messages + if (ws.waitingFor !== "user_reply") continue; + } + + if (threadId) { + if (ws.threadId !== threadId) continue; + } else if (toAgentId) { + const thread = db.getThread(ws.threadId, companyId); + if (!thread || thread.relatedEntityType !== "task" || !thread.relatedEntityId) continue; + try { + const task = db.getTask(thread.relatedEntityId); + if (task.assigneeAgentId !== toAgentId) continue; + } catch { + continue; + } + } + + db.resolveThreadWaitState(ws.id, companyId); + } + } catch (err) { + logger.error(`[thread-integration] onHumanMessage failed: ${err instanceof Error ? err.message : String(err)}`); + } + }, + + hasUnresolvedWaitStates(companyId: string): boolean { + try { + const waitStates = db.listThreadWaitStates(companyId); + return waitStates.length > 0; + } catch { + return false; + } + }, + + /** + * Checks whether a specific agent has unresolved thread wait states. + * Joins through conversation_threads → tasks to filter by assignee. + * Used by heartbeat scheduler to pause only the affected agent, not all agents. + */ + hasUnresolvedWaitStatesForAgent(companyId: string, agentId: string): boolean { + try { + const rows = db.queryAll<{ id: string }>( + `SELECT tws.id FROM thread_wait_states tws + JOIN conversation_threads ct ON tws.thread_id = ct.id + JOIN tasks t ON ct.related_entity_id = t.id AND ct.related_entity_type = 'task' + WHERE tws.company_id = ? AND tws.resolved_at IS NULL + AND t.assignee_agent_id = ? + LIMIT 1`, + companyId, agentId, + ); + return rows.length > 0; + } catch { + return false; + } + }, + + /** + * Returns the count of unresolved wait states for a company. + * Used by the inbox to show "waiting on you" badge count. + */ + countUnresolvedWaitStates(companyId: string): number { + try { + return db.listThreadWaitStates(companyId).length; + } catch { + return 0; + } + }, + }; +} + +export type ThreadIntegrationService = ReturnType; diff --git a/src/main/updater-service.ts b/src/main/updater-service.ts index d53fd99c..7c4982d4 100644 --- a/src/main/updater-service.ts +++ b/src/main/updater-service.ts @@ -1,3 +1,4 @@ +import { app } from "electron"; import electronUpdater from "electron-updater"; import type { AppDatabase } from "./database"; import type { NotificationOptions } from "./notification-service"; @@ -18,14 +19,37 @@ export function createUpdaterService({ logger, notify, }: UpdaterServiceDependencies) { + let listenersAttached = false; + function attachListeners() { + if (listenersAttached) { + return; + } + listenersAttached = true; autoUpdater.on("checking-for-update", () => { logger.info("[updater] checking for updates"); }); autoUpdater.on("update-available", (info) => { + logger.info("[updater] update available", info.version); notify({ title: "Update Available", - body: `Version ${info.version} is ready to install`, + body: `Version ${info.version} is downloading in the background`, + urgency: "informational", + navigation: { section: "settings" }, + }); + }); + autoUpdater.on("download-progress", (progress) => { + logger.info("[updater] download progress", { + percent: progress.percent, + transferred: progress.transferred, + total: progress.total, + }); + }); + autoUpdater.on("update-downloaded", (info) => { + logger.info("[updater] update downloaded", info.version); + notify({ + title: "Update Ready", + body: `Version ${info.version} will install when AgentCompany quits`, urgency: "informational", navigation: { section: "settings" }, }); @@ -55,15 +79,16 @@ export function createUpdaterService({ return false; } const updateUrl = process.env.AGENTCOMPANY_UPDATE_URL?.trim(); - if (!updateUrl || updateUrl.includes("example.invalid")) { + if (!app.isPackaged && (!updateUrl || updateUrl.includes("example.invalid"))) { logger.info("[updater] Skipping update check because no real update URL is configured."); return false; } try { autoUpdater.logger = logger as any; - autoUpdater.autoDownload = false; - await autoUpdater.checkForUpdates(); - return true; + autoUpdater.autoDownload = true; + autoUpdater.autoInstallOnAppQuit = true; + const result = await autoUpdater.checkForUpdates(); + return Boolean(result?.updateInfo); } catch (error) { logger.error(error); return false; diff --git a/src/main/worker-service.ts b/src/main/worker-service.ts index 7f8e0151..46f37553 100644 --- a/src/main/worker-service.ts +++ b/src/main/worker-service.ts @@ -1,76 +1,9 @@ import { utilityProcess } from "electron"; -import { z } from "zod"; -import { getConnectorDefinition } from "./connectors"; -import type { AppDatabase } from "./database"; -import type { RunRecord } from "@shared/types"; -import type { NotificationOptions } from "./notification-service"; -import type { createBudgetEnforcementService } from "./budget-enforcement-service"; -import type { createSessionManagementService } from "./session-management-service"; +import { workerMessageSchema, type WorkerServiceDependencies } from "./worker/worker-types"; +import { handleWorkerExit } from "./worker/worker-crash-recovery"; +import { handleWorkerMessage } from "./worker/worker-message-handler"; -const runStatusSchema = z.enum(["queued", "running", "succeeded", "failed", "cancelled", "timed_out", "interrupted"]); -export const workerMessageSchema = z.union([ - z.object({ - type: z.literal("run-log"), - runId: z.string().uuid(), - stream: z.enum(["stdout", "stderr"]), - chunk: z.string(), - }), - z.object({ - type: z.literal("run-status"), - runId: z.string().uuid(), - status: runStatusSchema, - message: z.string(), - }), - z.object({ - type: z.literal("run-finished"), - runId: z.string().uuid(), - status: runStatusSchema, - summary: z.string().nullable(), - errorMessage: z.string().nullable(), - exitCode: z.number().int().nullable(), - signal: z.string().nullable(), - model: z.string().nullable(), - sessionParams: z.record(z.string(), z.unknown()).nullable(), - sessionDisplayId: z.string().nullable(), - costUsd: z.number().nullable(), - unattributedCost: z.boolean(), - inputTokens: z.number().nullable(), - outputTokens: z.number().nullable(), - }), -]); -type WorkerUiEvent = Extract, { type: "run-log" | "run-status" }>; - -interface WorkerServiceDependencies { - db: AppDatabase; - logger: { - debug: (...args: unknown[]) => void; - info: (...args: unknown[]) => void; - warn: (...args: unknown[]) => void; - error: (...args: unknown[]) => void; - }; - workerPath: string; - isQuitting: () => boolean; - publishDomainChanged: () => void; - emitEvent: (event: any) => void; - wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null; - dispatchRunToWorker: (params: { - runId: string; - companyId: string; - agent: ReturnType; - task: ReturnType; - connector: ReturnType; - workspace: ReturnType; - workspaceMode: "exclusive" | "shared" | "none"; - companyName: string; - companyDescription: string; - snapshot: ReturnType; - trigger: string; - }) => void; - processRunCompletion: (run: RunRecord, event: z.infer & { type: "run-finished" }) => void; - notify: (options: NotificationOptions) => void; - budgetService?: ReturnType; - sessionService?: ReturnType; -} +export { workerMessageSchema } from "./worker/worker-types"; export function createWorkerService({ db, @@ -82,6 +15,8 @@ export function createWorkerService({ wakeAgentIfPossible, dispatchRunToWorker, processRunCompletion, + onRunStarted, + onRunCompleted, notify, budgetService, sessionService, @@ -90,6 +25,7 @@ export function createWorkerService({ const RETRY_COOLDOWN_MS = 30_000; // 30 seconds const consecutiveWakeMap = new Map(); const MAX_CONSECUTIVE_WAKES = 5; + const leaseRenewalMap = new Map(); let worker: ReturnType | null = null; let destroyed = false; @@ -117,381 +53,28 @@ export function createWorkerService({ worker.on("exit", (code) => { if (isQuitting() || destroyed) return; - - logger.warn(`[worker] Worker exited with code ${code}, recovering...`); - - const snapshot = db.listSnapshot(); - const affectedAgentIds = new Set(); - const affectedCompanyIds = new Map(); - const requeueCandidates: Array<{ id: string; taskId: string; agentId: string; companyId: string; workspaceId: string | null; connectorId: string }> = []; - - for (const run of snapshot.runs) { - if (run.status === "running") { - db.finishRun({ - runId: run.id, - status: "interrupted", - summary: "Worker process crashed.", - errorMessage: "Worker process exited unexpectedly.", - exitCode: code, - signal: null, - model: null, - sessionDisplayId: null, - costUsd: null, - unattributedCost: true, - }); - if (run.workspaceId) { - db.releaseWorkspaceLock(run.workspaceId, run.id); - } - // EXEC-02: Release execution lock on crash recovery - db.releaseTaskLock(run.taskId, run.id); - affectedAgentIds.add(run.agentId); - affectedCompanyIds.set(run.agentId, run.companyId); - - const chain = db.getChainOfCommand(run.agentId); - if (chain.length > 0) { - wakeAgentIfPossible(chain[0].id, run.companyId, "report_failed"); - } - } else if (run.status === "queued") { - requeueCandidates.push({ - id: run.id, - taskId: run.taskId, - agentId: run.agentId, - companyId: run.companyId, - workspaceId: run.workspaceId, - connectorId: run.connectorId, - }); - } - } - - for (const agentId of affectedAgentIds) { - // EXEC-05: Promote deferred wakes after crash recovery - const cid = affectedCompanyIds.get(agentId) ?? ""; - const promoted = db.promoteWakeupRequests(agentId, cid); - if (promoted > 0) { - logger.info(`[worker] Promoted ${promoted} deferred wake(s) for agent ${agentId} after crash recovery`); - } - // Let heartbeat scheduler pick up promoted wakes on next tick - } - - publishDomainChanged(); - - createWorker(); - - if (requeueCandidates.length > 0 && worker) { - logger.info(`[worker] Re-enqueuing ${requeueCandidates.length} queued run(s) after worker restart.`); - const recoverySnapshot = db.listSnapshot(); - for (const candidate of requeueCandidates) { - try { - const task = db.getTask(candidate.taskId); - const agent = db.getAgent(candidate.agentId); - const connector = db.getConnector(agent.connectorId); - const workspaceResolution = { - taskWorkspace: db.getWorkspace(task.workspaceId), - agentWorkspace: db.getWorkspace(agent.workspaceId), - projectWorkspace: db.getProjectPrimaryWorkspace(task.projectId ?? null, candidate.companyId), - }; - const workspace = workspaceResolution.taskWorkspace - ?? workspaceResolution.agentWorkspace - ?? workspaceResolution.projectWorkspace - ?? db.getAnyCompanyWorkspace(candidate.companyId); - const workspaceMode = workspaceResolution.taskWorkspace || workspaceResolution.agentWorkspace || workspaceResolution.projectWorkspace - ? "exclusive" - : workspace - ? "shared" - : "none"; - const recoveryCompany = recoverySnapshot.companies.find((company) => company.id === candidate.companyId); - - dispatchRunToWorker({ - runId: candidate.id, - companyId: candidate.companyId, - agent, - task, - connector, - workspace, - workspaceMode, - companyName: recoveryCompany?.name ?? "Company", - companyDescription: recoveryCompany?.description ?? "", - snapshot: recoverySnapshot, - trigger: "recovery", - }); - } catch (error) { - logger.warn(`[worker] Failed to re-enqueue run ${candidate.id}: ${error instanceof Error ? error.message : String(error)}`); - db.finishRun({ - runId: candidate.id, - status: "interrupted", - summary: "Could not re-enqueue after worker restart.", - errorMessage: error instanceof Error ? error.message : String(error), - exitCode: null, - signal: null, - model: null, - sessionDisplayId: null, - costUsd: null, - unattributedCost: true, - }); - if (candidate.workspaceId) { - db.releaseWorkspaceLock(candidate.workspaceId, candidate.id); - } - } - } - } + handleWorkerExit( + code, + { db, logger, isQuitting, publishDomainChanged, emitEvent, wakeAgentIfPossible, dispatchRunToWorker }, + createWorker, + getWorker, + ); }); worker.on("message", async (message) => { - let event: z.infer; - try { - event = workerMessageSchema.parse(message); - } catch (error) { - logger.error("[worker] invalid message payload", error); - return; - } - - if (event.type === "run-log") { - await db.appendRunLog(event.runId, event.stream, event.chunk); - emitEvent(event); - return; - } - - if (event.type === "run-status") { - db.markRunStatus(event.runId, event.status, event.message); - if (event.status === "running") { - const run = db.listSnapshot().runs.find((entry) => entry.id === event.runId); - if (run?.workspaceId && !db.claimWorkspaceLock(run.workspaceId, run.id)) { - logger.error(`[workspace] Failed to claim lock for workspace ${run.workspaceId} when run ${run.id} entered running state. Cancelling run.`); - worker?.postMessage({ type: "cancel-run", runId: run.id }); - } - } - emitEvent(event); - emitEvent({ type: "run-updated", runId: event.runId, action: "status_changed" }); - return; - } - - const snapshot = db.listSnapshot(); - const run = snapshot.runs.find((entry) => entry.id === event.runId); - if (!run) { - return; - } - - const connector = getConnectorDefinition(run.connectorId); - - let task: ReturnType | null = null; - try { - task = db.getTask(run.taskId); - } catch { - task = null; - } - - if (task) { - if (event.sessionParams && connector.module.sessionCodec) { - const serialized = connector.module.sessionCodec.serialize(event.sessionParams); - db.updateTaskSession(task.id, serialized, connector.module.sessionCodec.getDisplayId?.(serialized ?? null) ?? event.sessionDisplayId ?? null); - } - if (!event.sessionParams) { - db.updateTaskSession(task.id, null, null); - } - } - - const runOutcome = db.finishRun(event); - if (run.workspaceId) { - db.releaseWorkspaceLock(run.workspaceId, run.id); - } - db.addActivity({ - companyId: run.companyId, - actor: run.agentId, - action: `run.${event.status}`, - entityType: "run", - entityId: run.id, - detail: event.summary ?? event.errorMessage ?? "", - }); - - // SESS-01/SESS-02/SESS-03/SESS-05: Token accumulation and rotation evaluation - if (task && sessionService) { - const activeSession = db.getActiveTaskSession(task.id); - if (activeSession) { - // D-08/D-05: Accumulate token usage for ALL adapters (including exempt ones) - sessionService.accumulateTokenUsage( - activeSession.id, - event.inputTokens ?? 0, - event.outputTokens ?? 0, - event.runId, - ); - // D-03: Evaluate rotation after accumulation (after each run completes) - const rotationResult = sessionService.evaluateRotation(activeSession, run.connectorId); - if (rotationResult.shouldRotate) { - // Re-read to get updated counts after accumulation - const updatedSession = db.getActiveTaskSession(task.id); - if (updatedSession) { - sessionService.rotateSession(updatedSession); - logger.info(`[session] Rotated session for task ${task.id}: ${rotationResult.reason}`); - } - } - } - } - - emitEvent({ type: "run-updated", runId: run.id, action: "completed" }); - emitEvent({ - type: "run-status", - runId: run.id, - status: event.status, - message: event.summary ?? event.errorMessage ?? event.status, - }); - if ((event.status === "failed" || event.status === "timed_out" || event.status === "interrupted") && task) { - notify({ - title: "Task Failed", - body: `${task.title} -- manual intervention needed`, - urgency: "informational", - navigation: { section: "tasks", entityId: task.id }, - batchKey: "failed_runs", - }); - } - - let updatedAgent: ReturnType | null = null; - try { - updatedAgent = db.getAgent(run.agentId); - } catch { - updatedAgent = null; - } - - if (updatedAgent && runOutcome.budgetHardStopped) { - db.addActivity({ - companyId: updatedAgent.companyId, - actor: "system", - action: "agent.auto_paused", - entityType: "agent", - entityId: updatedAgent.id, - detail: `Budget exhausted at ${updatedAgent.spentMonthlyUsd.toFixed(2)} / ${updatedAgent.budgetMonthlyUsd.toFixed(2)} USD.`, - }); - notify({ - title: "Agent Paused", - body: `${updatedAgent.name} paused -- budget limit reached`, - urgency: "informational", - navigation: { section: "agents", entityId: updatedAgent.id }, - batchKey: "agent_paused", - }); - emitEvent({ type: "agent-updated", agentId: updatedAgent.id, action: "paused" }); - } - - // Policy-based incident check after run completion (Phase 5) - if (budgetService && typeof event.costUsd === "number" && Number.isFinite(event.costUsd)) { - try { - const task = db.getTask(run.taskId); - budgetService.checkAndCreateIncidents(run.companyId, run.agentId, task?.projectId ?? null); - } catch (err) { - logger.error(`[budget] Incident check failed: ${err instanceof Error ? err.message : String(err)}`); - } - } - - if ((event.status === "failed" || event.status === "timed_out") && !isQuitting() && !destroyed) { - const retryCount = db.getTaskRetryCount(run.taskId); - - if (retryCount < MAX_RETRY_ATTEMPTS) { - // Retry same agent after cooldown - db.incrementTaskRetryCount(run.taskId); - db.advanceTaskStatus(run.taskId, "todo", - `Run ${event.status} (attempt ${retryCount + 1}/${MAX_RETRY_ATTEMPTS}). Auto-retrying after ${RETRY_COOLDOWN_MS / 1000}s cooldown.`); - logger.info(`[escalation] Retrying task ${run.taskId} (attempt ${retryCount + 1}/${MAX_RETRY_ATTEMPTS}) for agent ${run.agentId}`); - const retryTimer = setTimeout(() => { - pendingRetryTimers.delete(retryTimer); - if (isQuitting() || destroyed) return; - wakeAgentIfPossible(run.agentId, run.companyId, "retry_failed"); - }, RETRY_COOLDOWN_MS); - pendingRetryTimers.add(retryTimer); - } else { - // Retry limit reached -- escalate up the chain - db.resetTaskRetryCount(run.taskId); - logger.warn(`[escalation] Task ${run.taskId} failed ${MAX_RETRY_ATTEMPTS} times. Escalating to manager.`); - - let escalated = false; - try { - const chain = db.getChainOfCommand(run.agentId); - if (chain.length > 0) { - wakeAgentIfPossible(chain[0].id, run.companyId, "report_failed"); - escalated = true; - logger.info(`[escalation] Escalated task ${run.taskId} to manager ${chain[0].name} (${chain[0].id})`); - } - } catch { - // Chain lookup failed (agent deleted) - } - - if (!escalated) { - // No manager found -- try CEO - try { - const ceo = db.getCeoForCompany(run.companyId); - if (ceo) { - wakeAgentIfPossible(ceo.id, run.companyId, "report_failed"); - escalated = true; - logger.info(`[escalation] Escalated task ${run.taskId} to CEO ${ceo.name} (${ceo.id})`); - } - } catch { /* CEO lookup failed */ } - } - - if (!escalated) { - // No one to escalate to -- notify user - const taskTitle = task?.title ?? run.taskId; - notify({ - title: "Attention Required", - body: `${taskTitle} needs manual intervention`, - urgency: "critical", - navigation: { section: "tasks", entityId: run.taskId }, - }); - logger.error(`[escalation] Task ${run.taskId} failed with no escalation target. User notified.`); - } - } - } - - // EXEC-05: Promote deferred wakes after run completion - let hadDeferredWakes = false; - if (!isQuitting() && !destroyed) { - const promoted = db.promoteWakeupRequests(run.agentId, run.companyId); - if (promoted > 0) { - hadDeferredWakes = true; - logger.info(`[worker] Promoted ${promoted} deferred wake(s) for agent ${run.agentId}`); - // Trigger processing of newly-promoted wakes - wakeAgentIfPossible(run.agentId, run.companyId, "deferred_wake_promoted"); - } - } - - if (!isQuitting() && !destroyed) { - processRunCompletion(run, event as z.infer & { type: "run-finished" }); - } - - // Reset retry count on successful completion - if (event.status === "succeeded") { - try { db.resetTaskRetryCount(run.taskId); } catch { /* best effort */ } - } - - // Reset circuit breaker when a task actually progressed (successful run) - if (event.status === "succeeded") { - const wakeKey = `${run.agentId}:${run.companyId}`; - consecutiveWakeMap.delete(wakeKey); - } - - // Self-wake with circuit breaker to prevent infinite loops - if (!hadDeferredWakes && !isQuitting() && !destroyed) { - try { - const actionableTasks = db.listAgentTasks(run.agentId, run.companyId, ["todo", "in_progress"]); - if (actionableTasks.length > 0) { - const wakeKey = `${run.agentId}:${run.companyId}`; - const consecutiveCount = consecutiveWakeMap.get(wakeKey) ?? 0; - - if (consecutiveCount >= MAX_CONSECUTIVE_WAKES) { - logger.warn(`[self-wake] Circuit breaker: agent ${run.agentId} has self-woken ${consecutiveCount} times without progress. Pausing self-wake.`); - consecutiveWakeMap.delete(wakeKey); - // Escalate to manager since agent is stuck - try { - const chain = db.getChainOfCommand(run.agentId); - if (chain.length > 0) { - wakeAgentIfPossible(chain[0].id, run.companyId, "report_stuck"); - } - } catch { /* best effort */ } - } else { - consecutiveWakeMap.set(wakeKey, consecutiveCount + 1); - wakeAgentIfPossible(run.agentId, run.companyId, "task_continuation"); - } - } - } catch { - // Best-effort self-wake; agent will pick up work on the next heartbeat cycle. - } - } + await handleWorkerMessage( + message, + worker, + { db, logger, isQuitting, emitEvent, wakeAgentIfPossible, processRunCompletion, onRunStarted, onRunCompleted, notify, budgetService, sessionService }, + workerMessageSchema, + destroyed, + MAX_RETRY_ATTEMPTS, + RETRY_COOLDOWN_MS, + consecutiveWakeMap, + MAX_CONSECUTIVE_WAKES, + pendingRetryTimers, + leaseRenewalMap, + ); }); } diff --git a/src/main/worker/worker-crash-recovery.ts b/src/main/worker/worker-crash-recovery.ts new file mode 100644 index 00000000..40f0e0e2 --- /dev/null +++ b/src/main/worker/worker-crash-recovery.ts @@ -0,0 +1,146 @@ +import type { WorkerServiceDependencies } from "./worker-types"; + +export function handleWorkerExit( + code: number | null, + { + db, + logger, + isQuitting, + publishDomainChanged, + emitEvent: _emitEvent, + wakeAgentIfPossible, + dispatchRunToWorker, + }: Pick, + createWorker: () => void, + getWorker: () => ReturnType | null, +) { + if (isQuitting()) return; + + logger.warn(`[worker] Worker exited with code ${code}, recovering...`); + + const snapshot = db.listSnapshot(); + const affectedAgentIds = new Set(); + const affectedCompanyIds = new Map(); + const requeueCandidates: Array<{ + id: string; + taskId: string; + agentId: string; + companyId: string; + workspaceId: string | null; + connectorId: string; + }> = []; + + for (const run of snapshot.runs) { + if (run.status === "running") { + db.finishRun({ + runId: run.id, + status: "interrupted", + summary: "Worker process crashed.", + errorMessage: "Worker process exited unexpectedly.", + exitCode: code, + signal: null, + model: null, + sessionDisplayId: null, + costUsd: null, + unattributedCost: true, + }); + if (run.workspaceId) { + db.releaseWorkspaceLock(run.workspaceId, run.id); + } + // EXEC-02: Release execution lock on crash recovery + db.releaseTaskLock(run.taskId, run.id); + affectedAgentIds.add(run.agentId); + affectedCompanyIds.set(run.agentId, run.companyId); + + const chain = db.getChainOfCommand(run.agentId); + if (chain.length > 0) { + wakeAgentIfPossible(chain[0].id, run.companyId, "report_failed"); + } + } else if (run.status === "queued") { + requeueCandidates.push({ + id: run.id, + taskId: run.taskId, + agentId: run.agentId, + companyId: run.companyId, + workspaceId: run.workspaceId, + connectorId: run.connectorId, + }); + } + } + + for (const agentId of affectedAgentIds) { + // EXEC-05: Promote deferred wakes after crash recovery + const cid = affectedCompanyIds.get(agentId) ?? ""; + const promoted = db.promoteWakeupRequests(agentId, cid); + if (promoted > 0) { + logger.info(`[worker] Promoted ${promoted} deferred wake(s) for agent ${agentId} after crash recovery`); + } + const pendingWakeTrigger = db.getPreferredWakeTrigger(agentId, cid); + if (pendingWakeTrigger) { + wakeAgentIfPossible(agentId, cid, pendingWakeTrigger); + } + } + + publishDomainChanged(); + + createWorker(); + + const worker = getWorker(); + if (requeueCandidates.length > 0 && worker) { + logger.info(`[worker] Re-enqueuing ${requeueCandidates.length} queued run(s) after worker restart.`); + const recoverySnapshot = db.listSnapshot(); + for (const candidate of requeueCandidates) { + try { + const task = db.getTask(candidate.taskId); + const agent = db.getAgent(candidate.agentId); + const connector = db.getConnector(agent.connectorId); + const workspaceResolution = { + taskWorkspace: db.getWorkspace(task.workspaceId), + agentWorkspace: db.getWorkspace(agent.workspaceId), + projectWorkspace: db.getProjectPrimaryWorkspace(task.projectId ?? null, candidate.companyId), + }; + const workspace = workspaceResolution.taskWorkspace + ?? workspaceResolution.agentWorkspace + ?? workspaceResolution.projectWorkspace + ?? db.getAnyCompanyWorkspace(candidate.companyId); + const workspaceMode = workspaceResolution.taskWorkspace || workspaceResolution.agentWorkspace || workspaceResolution.projectWorkspace + ? "exclusive" + : workspace + ? "shared" + : "none"; + const recoveryCompany = recoverySnapshot.companies.find((company) => company.id === candidate.companyId); + + dispatchRunToWorker({ + runId: candidate.id, + companyId: candidate.companyId, + agent, + task, + connector, + workspace, + workspaceMode, + companyName: recoveryCompany?.name ?? "Company", + companyDescription: recoveryCompany?.description ?? "", + snapshot: recoverySnapshot, + trigger: "recovery", + }); + } catch (error) { + logger.warn(`[worker] Failed to re-enqueue run ${candidate.id}: ${error instanceof Error ? error.message : String(error)}`); + db.finishRun({ + runId: candidate.id, + status: "interrupted", + summary: "Could not re-enqueue after worker restart.", + errorMessage: error instanceof Error ? error.message : String(error), + exitCode: null, + signal: null, + model: null, + sessionDisplayId: null, + costUsd: null, + unattributedCost: true, + }); + if (candidate.workspaceId) { + db.releaseWorkspaceLock(candidate.workspaceId, candidate.id); + } + } + } + } +} diff --git a/src/main/worker/worker-message-handler.ts b/src/main/worker/worker-message-handler.ts new file mode 100644 index 00000000..ac05b5d7 --- /dev/null +++ b/src/main/worker/worker-message-handler.ts @@ -0,0 +1,382 @@ +import { z } from "zod"; +import { getConnectorDefinition } from "../connectors"; +import type { WorkerServiceDependencies, workerMessageSchema } from "./worker-types"; + +const LEASE_RENEWAL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes + +function persistRunEvent( + db: WorkerServiceDependencies["db"], + logger: WorkerServiceDependencies["logger"], + input: { + companyId: string; + runId: string; + agentId: string; + eventType: string; + stream?: "stdout" | "stderr" | null; + level?: string | null; + message?: string | null; + payload?: Record | null; + }, +) { + try { + db.appendRunEvent(input); + } catch (error) { + logger.warn(`[run-events] Failed to append ${input.eventType} for ${input.runId}: ${error instanceof Error ? error.message : String(error)}`); + } +} + +export async function handleWorkerMessage( + message: unknown, + worker: ReturnType | null, + { + db, + logger, + isQuitting, + emitEvent, + wakeAgentIfPossible, + processRunCompletion, + onRunStarted, + onRunCompleted, + notify, + budgetService, + sessionService, + }: Pick, + workerMessageSchemaRef: typeof workerMessageSchema, + destroyed: boolean, + MAX_RETRY_ATTEMPTS: number, + RETRY_COOLDOWN_MS: number, + consecutiveWakeMap: Map, + MAX_CONSECUTIVE_WAKES: number, + pendingRetryTimers: Set>, + leaseRenewalMap: Map, +) { + let event: z.infer; + try { + event = workerMessageSchemaRef.parse(message); + } catch (error) { + logger.error("[worker] invalid message payload", error); + return; + } + + if (event.type === "run-log") { + await db.appendRunLog(event.runId, event.stream, event.chunk); + const runState = db.getRunAuthState(event.runId); + if (runState) { + persistRunEvent(db, logger, { + companyId: runState.companyId, + runId: event.runId, + agentId: runState.agentId, + eventType: "run_log", + stream: event.stream, + message: event.chunk, + }); + } + emitEvent(event); + + // Throttled lease renewal: keep execution lock alive for long-running tasks. + // Without this, tasks exceeding the 15-minute stale lock timeout get reclaimed. + const now = Date.now(); + const lastRenewal = leaseRenewalMap.get(event.runId) ?? 0; + if (now - lastRenewal >= LEASE_RENEWAL_INTERVAL_MS) { + const run = db.listSnapshot().runs.find((r) => r.id === event.runId); + if (run) { + db.renewTaskLease(run.taskId, run.id); + leaseRenewalMap.set(event.runId, now); + } + } + return; + } + + if (event.type === "run-status") { + const runState = db.getRunAuthState(event.runId); + db.markRunStatus(event.runId, event.status, event.message); + if (runState) { + persistRunEvent(db, logger, { + companyId: runState.companyId, + runId: event.runId, + agentId: runState.agentId, + eventType: "run_status", + level: event.status === "failed" || event.status === "timed_out" || event.status === "interrupted" ? "error" : "info", + message: event.message, + payload: { status: event.status }, + }); + } + if (event.status === "running") { + const run = db.listSnapshot().runs.find((entry) => entry.id === event.runId); + if (run?.workspaceId && !db.claimWorkspaceLock(run.workspaceId, run.id)) { + logger.error(`[workspace] Failed to claim lock for workspace ${run.workspaceId} when run ${run.id} entered running state. Cancelling run.`); + worker?.postMessage({ type: "cancel-run", runId: run.id }); + } + if (run) { + db.renewTaskLease(run.taskId, run.id); + onRunStarted?.(run.id, run.taskId, run.agentId, run.companyId); + } + } + emitEvent(event); + emitEvent({ type: "run-updated", runId: event.runId, action: "status_changed" }); + return; + } + + const snapshot = db.listSnapshot(); + const run = snapshot.runs.find((entry) => entry.id === event.runId); + if (!run) { + return; + } + + persistRunEvent(db, logger, { + companyId: run.companyId, + runId: run.id, + agentId: run.agentId, + eventType: "run_finished", + level: event.status === "succeeded" ? "info" : "error", + message: event.summary ?? event.errorMessage ?? event.status, + payload: { + status: event.status, + summary: event.summary, + errorMessage: event.errorMessage, + exitCode: event.exitCode, + signal: event.signal, + model: event.model, + sessionDisplayId: event.sessionDisplayId, + costUsd: event.costUsd, + unattributedCost: event.unattributedCost, + inputTokens: event.inputTokens, + outputTokens: event.outputTokens, + }, + }); + + const connector = getConnectorDefinition(run.connectorId); + + let task: ReturnType | null = null; + try { + task = db.getTask(run.taskId); + } catch { + task = null; + } + + if (task) { + if (event.sessionParams && connector.module.sessionCodec) { + const serialized = connector.module.sessionCodec.serialize(event.sessionParams); + db.updateTaskSession(task.id, serialized, connector.module.sessionCodec.getDisplayId?.(serialized ?? null) ?? event.sessionDisplayId ?? null); + } + if (!event.sessionParams) { + db.updateTaskSession(task.id, null, null); + } + } + + leaseRenewalMap.delete(event.runId); + const runOutcome = db.finishRun(event); + if (run.workspaceId) { + db.releaseWorkspaceLock(run.workspaceId, run.id); + } + db.addActivity({ + companyId: run.companyId, + actor: run.agentId, + action: `run.${event.status}`, + entityType: "run", + entityId: run.id, + detail: event.summary ?? event.errorMessage ?? "", + }); + + // SESS-01/SESS-02/SESS-03/SESS-05: Token accumulation and rotation evaluation + if (task && sessionService) { + const activeSession = db.getActiveTaskSession(task.id); + if (activeSession) { + // D-08/D-05: Accumulate token usage for ALL adapters (including exempt ones) + sessionService.accumulateTokenUsage( + activeSession.id, + event.inputTokens ?? 0, + event.outputTokens ?? 0, + event.runId, + ); + // D-03: Evaluate rotation after accumulation (after each run completes) + const rotationResult = sessionService.evaluateRotation(activeSession, run.connectorId); + if (rotationResult.shouldRotate) { + // Re-read to get updated counts after accumulation + const updatedSession = db.getActiveTaskSession(task.id); + if (updatedSession) { + sessionService.rotateSession(updatedSession); + logger.info(`[session] Rotated session for task ${task.id}: ${rotationResult.reason}`); + } + } + } + } + + emitEvent({ type: "run-updated", runId: run.id, action: "completed" }); + emitEvent({ + type: "run-status", + runId: run.id, + status: event.status, + message: event.summary ?? event.errorMessage ?? event.status, + }); + if ((event.status === "failed" || event.status === "timed_out" || event.status === "interrupted") && task) { + notify({ + title: "Task Failed", + body: `${task.title} -- manual intervention needed`, + urgency: "informational", + navigation: { section: "tasks", entityId: task.id }, + batchKey: "failed_runs", + }); + } + + let updatedAgent: ReturnType | null = null; + try { + updatedAgent = db.getAgent(run.agentId); + } catch { + updatedAgent = null; + } + + if (updatedAgent && runOutcome.budgetHardStopped) { + db.addActivity({ + companyId: updatedAgent.companyId, + actor: "system", + action: "agent.auto_paused", + entityType: "agent", + entityId: updatedAgent.id, + detail: `Budget exhausted at ${updatedAgent.spentMonthlyUsd.toFixed(2)} / ${updatedAgent.budgetMonthlyUsd.toFixed(2)} USD.`, + }); + notify({ + title: "Agent Paused", + body: `${updatedAgent.name} paused -- budget limit reached`, + urgency: "informational", + navigation: { section: "agents", entityId: updatedAgent.id }, + batchKey: "agent_paused", + }); + emitEvent({ type: "agent-updated", agentId: updatedAgent.id, action: "paused" }); + } + + // Policy-based incident check after run completion (Phase 5) + if (budgetService && typeof event.costUsd === "number" && Number.isFinite(event.costUsd)) { + try { + const taskForBudget = db.getTask(run.taskId); + budgetService.checkAndCreateIncidents(run.companyId, run.agentId, taskForBudget?.projectId ?? null); + } catch (err) { + logger.error(`[budget] Incident check failed: ${err instanceof Error ? err.message : String(err)}`); + } + } + + if ((event.status === "failed" || event.status === "timed_out") && !isQuitting() && !destroyed) { + const retryCount = db.getTaskRetryCount(run.taskId); + + if (retryCount < MAX_RETRY_ATTEMPTS) { + // Retry same agent after cooldown + db.incrementTaskRetryCount(run.taskId); + db.advanceTaskStatus(run.taskId, "todo", + `Run ${event.status} (attempt ${retryCount + 1}/${MAX_RETRY_ATTEMPTS}). Auto-retrying after ${RETRY_COOLDOWN_MS / 1000}s cooldown.`); + logger.info(`[escalation] Retrying task ${run.taskId} (attempt ${retryCount + 1}/${MAX_RETRY_ATTEMPTS}) for agent ${run.agentId}`); + const retryTimer = setTimeout(() => { + pendingRetryTimers.delete(retryTimer); + if (isQuitting() || destroyed) return; + wakeAgentIfPossible(run.agentId, run.companyId, "retry_failed"); + }, RETRY_COOLDOWN_MS); + pendingRetryTimers.add(retryTimer); + } else { + // Retry limit reached -- escalate up the chain + db.resetTaskRetryCount(run.taskId); + logger.warn(`[escalation] Task ${run.taskId} failed ${MAX_RETRY_ATTEMPTS} times. Escalating to manager.`); + + let escalated = false; + try { + const chain = db.getChainOfCommand(run.agentId); + if (chain.length > 0) { + wakeAgentIfPossible(chain[0].id, run.companyId, "report_failed"); + escalated = true; + logger.info(`[escalation] Escalated task ${run.taskId} to manager ${chain[0].name} (${chain[0].id})`); + } + } catch { + // Chain lookup failed (agent deleted) + } + + if (!escalated) { + // No manager found -- try CEO + try { + const ceo = db.getCeoForCompany(run.companyId); + if (ceo) { + wakeAgentIfPossible(ceo.id, run.companyId, "report_failed"); + escalated = true; + logger.info(`[escalation] Escalated task ${run.taskId} to CEO ${ceo.name} (${ceo.id})`); + } + } catch { /* CEO lookup failed */ } + } + + if (!escalated) { + // No one to escalate to -- notify user + const taskTitle = task?.title ?? run.taskId; + notify({ + title: "Attention Required", + body: `${taskTitle} needs manual intervention`, + urgency: "critical", + navigation: { section: "tasks", entityId: run.taskId }, + }); + logger.error(`[escalation] Task ${run.taskId} failed with no escalation target. User notified.`); + } + } + } + + // EXEC-05: Promote deferred wakes after run completion + let hadDeferredWakes = false; + if (!isQuitting() && !destroyed) { + const promoted = db.promoteWakeupRequests(run.agentId, run.companyId); + const pendingWakeTrigger = db.getPreferredWakeTrigger(run.agentId, run.companyId); + if (promoted > 0) { + logger.info(`[worker] Promoted ${promoted} deferred wake(s) for agent ${run.agentId}`); + } + if (pendingWakeTrigger) { + hadDeferredWakes = true; + logger.info(`[worker] Processing pending wake(s) for agent ${run.agentId} with priority trigger "${pendingWakeTrigger}"`); + wakeAgentIfPossible(run.agentId, run.companyId, pendingWakeTrigger); + } + } + + if (!isQuitting() && !destroyed) { + processRunCompletion(run, event as z.infer & { type: "run-finished" }); + } + + // Run housekeeping: thread events + knowledge candidates + if (!isQuitting() && !destroyed) { + try { + onRunCompleted?.(run.id, run.taskId, run.agentId, run.companyId, event.status, event.summary ?? null, event.errorMessage ?? null); + } catch (err) { + logger.error(`[worker] onRunCompleted housekeeping failed: ${err instanceof Error ? err.message : String(err)}`); + } + } + + // Reset retry count on successful completion + if (event.status === "succeeded") { + try { db.resetTaskRetryCount(run.taskId); } catch { /* best effort */ } + } + + // Reset circuit breaker when a task actually progressed (successful run) + if (event.status === "succeeded") { + const wakeKey = `${run.agentId}:${run.companyId}`; + consecutiveWakeMap.delete(wakeKey); + } + + // Self-wake with circuit breaker to prevent infinite loops + if (!hadDeferredWakes && !isQuitting() && !destroyed) { + try { + const actionableTasks = db.listAgentTasks(run.agentId, run.companyId, ["todo", "in_progress"]); + if (actionableTasks.length > 0) { + const wakeKey = `${run.agentId}:${run.companyId}`; + const consecutiveCount = consecutiveWakeMap.get(wakeKey) ?? 0; + + if (consecutiveCount >= MAX_CONSECUTIVE_WAKES) { + logger.warn(`[self-wake] Circuit breaker: agent ${run.agentId} has self-woken ${consecutiveCount} times without progress. Pausing self-wake.`); + consecutiveWakeMap.delete(wakeKey); + // Escalate to manager since agent is stuck + try { + const chain = db.getChainOfCommand(run.agentId); + if (chain.length > 0) { + wakeAgentIfPossible(chain[0].id, run.companyId, "report_stuck"); + } + } catch { /* best effort */ } + } else { + consecutiveWakeMap.set(wakeKey, consecutiveCount + 1); + wakeAgentIfPossible(run.agentId, run.companyId, "task_continuation"); + } + } + } catch { + // Best-effort self-wake; agent will pick up work on the next heartbeat cycle. + } + } +} diff --git a/src/main/worker/worker-types.ts b/src/main/worker/worker-types.ts new file mode 100644 index 00000000..8924ba4e --- /dev/null +++ b/src/main/worker/worker-types.ts @@ -0,0 +1,72 @@ +import { z } from "zod"; +import type { AppDatabase } from "../database"; +import type { NotificationOptions } from "../notification-service"; +import type { createBudgetEnforcementService } from "../budget-enforcement-service"; +import type { createSessionManagementService } from "../session-management-service"; + +const runStatusSchema = z.enum(["queued", "running", "succeeded", "failed", "cancelled", "timed_out", "interrupted"]); + +export const workerMessageSchema = z.union([ + z.object({ + type: z.literal("run-log"), + runId: z.string().uuid(), + stream: z.enum(["stdout", "stderr"]), + chunk: z.string(), + }), + z.object({ + type: z.literal("run-status"), + runId: z.string().uuid(), + status: runStatusSchema, + message: z.string(), + }), + z.object({ + type: z.literal("run-finished"), + runId: z.string().uuid(), + status: runStatusSchema, + summary: z.string().nullable(), + errorMessage: z.string().nullable(), + exitCode: z.number().int().nullable(), + signal: z.string().nullable(), + model: z.string().nullable(), + sessionParams: z.record(z.string(), z.unknown()).nullable(), + sessionDisplayId: z.string().nullable(), + costUsd: z.number().nullable(), + unattributedCost: z.boolean(), + inputTokens: z.number().nullable(), + outputTokens: z.number().nullable(), + }), +]); + +export interface WorkerServiceDependencies { + db: AppDatabase; + logger: { + debug: (...args: unknown[]) => void; + info: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + }; + workerPath: string; + isQuitting: () => boolean; + publishDomainChanged: () => void; + emitEvent: (event: any) => void; + wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null; + dispatchRunToWorker: (params: { + runId: string; + companyId: string; + agent: ReturnType; + task: ReturnType; + connector: ReturnType; + workspace: ReturnType; + workspaceMode: "exclusive" | "shared" | "none"; + companyName: string; + companyDescription: string; + snapshot: ReturnType; + trigger: string; + }) => void; + processRunCompletion: (run: any, event: z.infer & { type: "run-finished" }) => void; + onRunStarted?: (runId: string, taskId: string, agentId: string, companyId: string) => void; + onRunCompleted?: (runId: string, taskId: string, agentId: string, companyId: string, status: string, summary: string | null, errorMessage: string | null) => void; + notify: (options: NotificationOptions) => void; + budgetService?: ReturnType; + sessionService?: ReturnType; +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 421eb628..7f14f395 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -106,35 +106,51 @@ const api: DesktopApi = { resolveBudgetIncident: (input) => ipcRenderer.invoke(IPC_CHANNELS.resolveBudgetIncident, input), getBudgetStatus: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.getBudgetStatus, { companyId }), recoveryRetry: (taskId: string, agentId: string, companyId: string) => - ipcRenderer.invoke("recovery:retry", { taskId, agentId, companyId }), + ipcRenderer.invoke(IPC_CHANNELS.recoveryRetry, { taskId, agentId, companyId }), recoveryDismiss: (taskId: string) => - ipcRenderer.invoke("recovery:dismiss", { taskId }), + ipcRenderer.invoke(IPC_CHANNELS.recoveryDismiss, { taskId }), recoveryFail: (taskId: string) => - ipcRenderer.invoke("recovery:fail", { taskId }), + ipcRenderer.invoke(IPC_CHANNELS.recoveryFail, { taskId }), getTask: (input) => ipcRenderer.invoke(IPC_CHANNELS.getTask, input), getAgent: (input) => ipcRenderer.invoke(IPC_CHANNELS.getAgent, input), getRun: (input) => ipcRenderer.invoke(IPC_CHANNELS.getRun, input), getApproval: (input) => ipcRenderer.invoke(IPC_CHANNELS.getApproval, input), listConfigRevisions: (companyId: string, agentId: string) => - ipcRenderer.invoke("agent-company:list-config-revisions", { companyId, agentId }), + ipcRenderer.invoke(IPC_CHANNELS.listConfigRevisions, { companyId, agentId }), getConfigRevision: (companyId: string, revisionId: string) => - ipcRenderer.invoke("agent-company:get-config-revision", { companyId, revisionId }), + ipcRenderer.invoke(IPC_CHANNELS.getConfigRevision, { companyId, revisionId }), rollbackConfigRevision: (companyId: string, agentId: string, revisionId: string) => - ipcRenderer.invoke("agent-company:rollback-config-revision", { companyId, agentId, revisionId }), + ipcRenderer.invoke(IPC_CHANNELS.rollbackConfigRevision, { companyId, agentId, revisionId }), listRunEvents: (companyId: string, runId: string, opts?: { offset?: number; limit?: number }) => - ipcRenderer.invoke("agent-company:list-run-events", { companyId, runId, ...opts }), + ipcRenderer.invoke(IPC_CHANNELS.listRunEvents, { companyId, runId, ...opts }), getAgentPermissions: (agentId: string) => - ipcRenderer.invoke("agent-company:get-agent-permissions", { agentId }), + ipcRenderer.invoke(IPC_CHANNELS.getAgentPermissions, { agentId }), setAgentPermission: (companyId: string, agentId: string, permissionKey: string, enabled: boolean) => - ipcRenderer.invoke("agent-company:set-agent-permission", { companyId, agentId, permissionKey, enabled }), + ipcRenderer.invoke(IPC_CHANNELS.setAgentPermission, { companyId, agentId, permissionKey, enabled }), getHeartbeatPolicy: (agentId: string, companyId: string) => - ipcRenderer.invoke("agent-company:get-heartbeat-policy", { companyId, agentId }), + ipcRenderer.invoke(IPC_CHANNELS.getHeartbeatPolicy, { companyId, agentId }), setHeartbeatPolicy: (companyId: string, agentId: string, policy: Record) => - ipcRenderer.invoke("agent-company:set-heartbeat-policy", { companyId, agentId, ...policy }), + ipcRenderer.invoke(IPC_CHANNELS.setHeartbeatPolicy, { companyId, agentId, ...policy }), getRuntimeState: (agentId: string, companyId: string) => - ipcRenderer.invoke("agent-company:get-runtime-state", { companyId, agentId }), + ipcRenderer.invoke(IPC_CHANNELS.getRuntimeState, { companyId, agentId }), setRuntimeState: (companyId: string, agentId: string, state: Record) => - ipcRenderer.invoke("agent-company:set-runtime-state", { companyId, agentId, state }), + ipcRenderer.invoke(IPC_CHANNELS.setRuntimeState, { companyId, agentId, state }), + // Thread runtime + createThread: (input) => ipcRenderer.invoke(IPC_CHANNELS.createThread, input), + listThreads: (input) => ipcRenderer.invoke(IPC_CHANNELS.listThreads, input), + getThread: (input) => ipcRenderer.invoke(IPC_CHANNELS.getThread, input), + addThreadEvent: (input) => ipcRenderer.invoke(IPC_CHANNELS.addThreadEvent, input), + listThreadEvents: (input) => ipcRenderer.invoke(IPC_CHANNELS.listThreadEvents, input), + setThreadWaitState: (input) => ipcRenderer.invoke(IPC_CHANNELS.setThreadWaitState, input), + resolveThreadWaitState: (input) => ipcRenderer.invoke(IPC_CHANNELS.resolveThreadWaitState, input), + listThreadWaitStates: (input) => ipcRenderer.invoke(IPC_CHANNELS.listThreadWaitStates, input), + listParticipants: (input) => ipcRenderer.invoke(IPC_CHANNELS.listParticipants, input), + addParticipant: (input) => ipcRenderer.invoke(IPC_CHANNELS.addParticipant, input), + removeParticipant: (input) => ipcRenderer.invoke(IPC_CHANNELS.removeParticipant, input), + subscribeThread: (input) => ipcRenderer.invoke(IPC_CHANNELS.subscribeThread, input), + unsubscribeThread: (input) => ipcRenderer.invoke(IPC_CHANNELS.unsubscribeThread, input), + listSubscriptions: (input) => ipcRenderer.invoke(IPC_CHANNELS.listSubscriptions, input), + listSubscribedThreads: (input) => ipcRenderer.invoke(IPC_CHANNELS.listSubscribedThreads, input), subscribe: (callback: (event: DesktopEvent) => void) => { const listener = (_event: Electron.IpcRendererEvent, payload: DesktopEvent) => callback(payload); ipcRenderer.on(IPC_CHANNELS.rendererEvent, listener); @@ -143,10 +159,10 @@ const api: DesktopApi = { }; }, platform: process.platform as "win32" | "darwin" | "linux", - windowMinimize: () => ipcRenderer.invoke("window:minimize"), - windowMaximize: () => ipcRenderer.invoke("window:maximize"), - windowClose: () => ipcRenderer.invoke("window:close"), - windowIsMaximized: () => ipcRenderer.invoke("window:isMaximized") as Promise, + windowMinimize: () => ipcRenderer.invoke(IPC_CHANNELS.windowMinimize), + windowMaximize: () => ipcRenderer.invoke(IPC_CHANNELS.windowMaximize), + windowClose: () => ipcRenderer.invoke(IPC_CHANNELS.windowClose), + windowIsMaximized: () => ipcRenderer.invoke(IPC_CHANNELS.windowIsMaximized) as Promise, }; contextBridge.exposeInMainWorld("agentCompany", api); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 0a0f5ee4..b64ff051 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,718 +1,29 @@ -import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; -import { AnimatePresence, motion } from "framer-motion"; -import { - CaretUpDown, - ChatCircle, - Check, - Command, - FileText, - FolderOpen, - Gear, - Kanban, - ListChecks, - MagnifyingGlass, - Megaphone, - Plus, - PuzzlePiece, - UsersThree, - ShieldCheck, - Trash, - UserPlus, - SquaresFour, - Target, - SidebarSimple, - TerminalWindow, - CircleNotch, - WarningCircle, - X, -} from "@phosphor-icons/react"; -import type { - CompanyRecord, - DesktopEvent, - OnboardingBootstrapResult, - ProfileSnapshot, - SectionId, -} from "@shared/types"; -import type { AppLocale } from "@shared/locale"; -import { createTranslator, LocaleContext, TranslationContext } from "./i18n"; -import { useAppStore } from "./state/store"; -import { createDomainEventBatcher, type GranularEventEntry } from "./lib/ipc-batcher"; +import { CircleNotch, TerminalWindow } from "@phosphor-icons/react"; +import { LocaleContext, TranslationContext } from "./i18n"; import { CompanyOnboarding } from "./components/CompanyOnboarding"; -import { Inbox, StandupView, PerformanceView } from "./components/inbox"; -import { ConnectorEditor } from "./components/connectors"; -import { Dashboard, KanbanBoard } from "./components/dashboard"; -import { EntityWorkspace } from "./components/EntityWorkspace"; -import { RunCenter } from "./components/runs"; -import { - ActivityList, - ApprovalInspector, - ApprovalList, - BudgetSummary, - CostList, - PendingReviewList, - SecretCreator, - SettingsPane, - ModelManagement, - TaskCreator, - TaskInspector, - TaskQueue, - WorkspaceCreator, - WorkspaceInspector, - WorkspaceList, -} from "./components/execution"; -import { PolicyList } from "./components/execution/PolicyList"; -import { IncidentList } from "./components/execution/IncidentList"; -import { - AgentCreator, - AgentEditor, - AgentRoster, - GoalCreator, - GoalEditor, - GoalList, - OrgChart, - ProjectCreator, - ProjectEditor, - ProjectList, -} from "./components/organization"; -import { SocialAccountsPanel } from "./components/social"; -import { AutomationPanel } from "./components/automation"; -import { CommunicationHub } from "./components/communication"; -import { HiringPipeline } from "./components/hiring"; -import { SkillsSection } from "./components/skills"; -import { DocumentCenter, KnowledgeBaseManager, MeetingsManager, SprintManager } from "./components/company-operations"; -import { ActionButton, ErrorBoundary, ListFrame, ListRow, Modal, Panel, SectionButton, Select, StatusPill, useFocusTrap, useReducedMotion } from "./components/ui"; import TitleBar from "./components/TitleBar"; import ToastContainer from "./components/Toast"; -import { unwrap } from "./lib/desktop"; -import { formatMoney, formatTime } from "./lib/formatters"; - -type NavItemDef = { id: SectionId; labelKey: string; icon: typeof SquaresFour }; -type NavItem = { id: SectionId; label: string; icon: typeof SquaresFour }; -type PaletteAction = { - key: string; - section: SectionId; - entityId?: string; -}; - -const sidebarNavDefs: NavItemDef[] = [ - { id: "overview", labelKey: "nav.overview", icon: SquaresFour }, - { id: "agents", labelKey: "nav.agents", icon: UsersThree }, - { id: "tasks", labelKey: "nav.work", icon: ListChecks }, - { id: "communication", labelKey: "nav.communication", icon: ChatCircle }, - { id: "skills", labelKey: "nav.skills", icon: PuzzlePiece }, - { id: "documents", labelKey: "nav.documents", icon: FileText }, - { id: "approvals", labelKey: "nav.approvals", icon: ShieldCheck }, - { id: "social", labelKey: "nav.social", icon: Megaphone }, -]; +import { SidebarView } from "./views/SidebarView"; +import { SectionContentView } from "./views/SectionContentView"; +import { ConsoleView } from "./views/ConsoleView"; +import { CommandPaletteView } from "./views/CommandPaletteView"; +import { useAppController } from "./hooks/useAppController"; export function App() { - const reducedMotion = useReducedMotion(); - const noMotion = { duration: 0 }; - const pageTransition = reducedMotion ? noMotion : { duration: 0.32, ease: [0.16, 1, 0.3, 1] as const }; - const panelTransition = reducedMotion ? noMotion : { duration: 0.28, ease: [0.16, 1, 0.3, 1] as const }; - - // -- Zustand store bindings (replaces 31 useState calls) -- - const locale = useAppStore((s) => s.locale); - const setLocale = useAppStore((s) => s.setLocale); - const t = useMemo(() => createTranslator(locale), [locale]); - - const sidebarNav: NavItem[] = useMemo(() => sidebarNavDefs.map((item) => ({ id: item.id, label: t(item.labelKey), icon: item.icon })), [t]); - const navItems: NavItem[] = useMemo(() => sidebarNav.concat({ id: "settings", label: t("nav.settings"), icon: Gear }), [sidebarNav, t]); - - const snapshot = useAppStore((s) => s.snapshot); - const setSnapshot = useAppStore((s) => s.setSnapshot); - const section = useAppStore((s) => s.section); - const setSection = useAppStore((s) => s.setSection); - const error = useAppStore((s) => s.error); - const setError = useAppStore((s) => s.setError); - const commandPaletteOpen = useAppStore((s) => s.commandPaletteOpen); - const setCommandPaletteOpen = useAppStore((s) => s.setCommandPaletteOpen); - const consoleOpen = useAppStore((s) => s.consoleOpen); - const setConsoleOpen = useAppStore((s) => s.setConsoleOpen); - const sidebarCollapsed = useAppStore((s) => s.sidebarCollapsed); - const setSidebarCollapsed = useAppStore((s) => s.setSidebarCollapsed); - const companySwitcherOpen = useAppStore((s) => s.companySwitcherOpen); - const setCompanySwitcherOpen = useAppStore((s) => s.setCompanySwitcherOpen); - const confirmDeleteCompanyId = useAppStore((s) => s.confirmDeleteCompanyId); - const setConfirmDeleteCompanyId = useAppStore((s) => s.setConfirmDeleteCompanyId); - const selectedTaskId = useAppStore((s) => s.selectedTaskId); - const setSelectedTaskId = useAppStore((s) => s.setSelectedTaskId); - const selectedConnectorId = useAppStore((s) => s.selectedConnectorId); - const setSelectedConnectorId = useAppStore((s) => s.setSelectedConnectorId); - const selectedApprovalId = useAppStore((s) => s.selectedApprovalId); - const setSelectedApprovalId = useAppStore((s) => s.setSelectedApprovalId); - const selectedRunId = useAppStore((s) => s.selectedRunId); - const setSelectedRunId = useAppStore((s) => s.setSelectedRunId); - const selectedAgentId = useAppStore((s) => s.selectedAgentId); - const setSelectedAgentId = useAppStore((s) => s.setSelectedAgentId); - const selectedGoalId = useAppStore((s) => s.selectedGoalId); - const setSelectedGoalId = useAppStore((s) => s.setSelectedGoalId); - const selectedProjectId = useAppStore((s) => s.selectedProjectId); - const setSelectedProjectId = useAppStore((s) => s.setSelectedProjectId); - const selectedWorkspaceId = useAppStore((s) => s.selectedWorkspaceId); - const setSelectedWorkspaceId = useAppStore((s) => s.setSelectedWorkspaceId); - const selectedCommunicationEntityId = useAppStore((s) => s.selectedCommunicationEntityId); - const setSelectedCommunicationEntityId = useAppStore((s) => s.setSelectedCommunicationEntityId); - const taskViewMode = useAppStore((s) => s.taskViewMode); - const setTaskViewMode = useAppStore((s) => s.setTaskViewMode); - const logBufferRef = useRef>({}); - const incrementLogRevision = useAppStore((s) => s.incrementLogRevision); - const badges = useAppStore((s) => s.badges); - const setBadges = useAppStore((s) => s.setBadges); - const commandPaletteSearch = useAppStore((s) => s.commandPaletteSearch); - const setCommandPaletteSearch = useAppStore((s) => s.setCommandPaletteSearch); - const selectedPaletteIndex = useAppStore((s) => s.selectedPaletteIndex); - const setSelectedPaletteIndex = useAppStore((s) => s.setSelectedPaletteIndex); - const showOnboarding = useAppStore((s) => s.showOnboarding); - const setShowOnboarding = useAppStore((s) => s.setShowOnboarding); - const initialOnboardingLocked = useAppStore((s) => s.initialOnboardingLocked); - const setInitialOnboardingLocked = useAppStore((s) => s.setInitialOnboardingLocked); - const inboxItems = useAppStore((s) => s.inboxItems); - const setInboxItems = useAppStore((s) => s.setInboxItems); - const companyMetrics = useAppStore((s) => s.companyMetrics); - const setCompanyMetrics = useAppStore((s) => s.setCompanyMetrics); - const standupReport = useAppStore((s) => s.standupReport); - const setStandupReport = useAppStore((s) => s.setStandupReport); - const socialAccounts = useAppStore((s) => s.socialAccounts); - const setSocialAccounts = useAppStore((s) => s.setSocialAccounts); - const browserActions = useAppStore((s) => s.browserActions); - const setBrowserActions = useAppStore((s) => s.setBrowserActions); - - const [costsTab, setCostsTab] = useState<"costs" | "policies" | "incidents">("costs"); - - const clearCompanyScopedUiState = useCallback(() => { - const store = useAppStore.getState(); - store.clearCompanyScopedSelections(); - store.setInboxItems([]); - store.setCompanyMetrics(null); - store.setStandupReport(null); - store.setSocialAccounts([]); - store.setBrowserActions([]); - store.setBadges(null); - store.setSkills([]); - store.setSelectedSkillId(null); - store.setSelectedSkillFilePath("SKILL.md"); - store.setSkillImportModalOpen(false); - }, []); - - const refresh = useCallback(async () => { - try { - const next = unwrap(await window.agentCompany.loadSnapshot()); - setSnapshot(next); - if (next.companies.length === 0) { - setInitialOnboardingLocked(true); - } - if (!selectedConnectorId && next.connectors[0]) setSelectedConnectorId(next.connectors[0].id); - if (!selectedRunId && next.runs[0]) setSelectedRunId(next.runs[0].id); - if (next.currentCompanyId) { - const badgeResult = await window.agentCompany.getSidebarBadges(next.currentCompanyId); - if (badgeResult.ok) setBadges(badgeResult.data); - const inboxResult = await window.agentCompany.getInbox(next.currentCompanyId); - if (inboxResult.ok) setInboxItems(inboxResult.data); - setSocialAccounts(next.socialAccounts?.filter(a => a.companyId === next.currentCompanyId) ?? []); - const browserActionResult = await window.agentCompany.listBrowserActions({ companyId: next.currentCompanyId, limit: 50 }); - if (browserActionResult.ok) { - setBrowserActions(browserActionResult.data); - } - } - setError(null); - } catch (nextError) { - setError(nextError instanceof Error ? nextError.message : String(nextError)); - } - }, [selectedConnectorId, selectedRunId]); - - const openPathWithFeedback = useCallback(async (path: string) => { - try { - unwrap(await window.agentCompany.openPath(path)); - setError(null); - } catch (error) { - setError(error instanceof Error ? error.message : String(error)); - } - }, []); - - // Stable ref so the subscribe callback always calls the latest refresh - // without tearing down the subscription when refresh identity changes. - const refreshRef = useRef(refresh); - useEffect(() => { - refreshRef.current = refresh; - }, [refresh]); - - // IPC event subscription with enhanced batcher for granular + full-refresh coalescing - useEffect(() => { - void refreshRef.current(); - const store = useAppStore; - - async function handleGranularBatch(events: GranularEventEntry[]) { - const companyId = store.getState().currentCompanyId; - if (!companyId) { - void refreshRef.current(); - return; - } - - let needsFullRefresh = false; - - for (const entry of events) { - // run-updated:completed touches too many entity types -- full refresh - if (entry.type === "run-updated" && entry.action === "completed") { - needsFullRefresh = true; - break; - } - - try { - switch (entry.type) { - case "task-updated": { - if (entry.action === "deleted") { - store.getState().removeTask(entry.entityId); - } else { - const result = await window.agentCompany.getTask({ companyId, id: entry.entityId }); - if (result.ok) { - store.getState().patchTask(result.data); - } else { - store.getState().removeTask(entry.entityId); - } - } - break; - } - case "run-updated": { - if (entry.action === "deleted") { - store.getState().removeRun(entry.entityId); - } else { - const result = await window.agentCompany.getRun({ companyId, id: entry.entityId }); - if (result.ok) { - store.getState().patchRun(result.data); - } else { - store.getState().removeRun(entry.entityId); - } - } - break; - } - case "approval-updated": { - if (entry.action === "deleted") { - store.getState().removeApproval(entry.entityId); - } else { - const result = await window.agentCompany.getApproval({ companyId, id: entry.entityId }); - if (result.ok) { - store.getState().patchApproval(result.data); - } else { - store.getState().removeApproval(entry.entityId); - } - } - break; - } - case "agent-updated": { - const result = await window.agentCompany.getAgent({ companyId, id: entry.entityId }); - if (result.ok) { - store.getState().patchAgent(result.data); - } else { - needsFullRefresh = true; - } - break; - } - case "budget-changed": { - // Budget is a computed aggregate -- full refresh is needed - needsFullRefresh = true; - break; - } - default: { - // Unknown granular event type -- fall back (per D-05 / PERF-03) - needsFullRefresh = true; - break; - } - } - } catch { - // IPC error during entity fetch -- fall back to full refresh (per pitfall 1) - needsFullRefresh = true; - break; - } - - if (needsFullRefresh) break; - } - - if (needsFullRefresh) { - void refreshRef.current(); - } else { - // Refresh sidebar badges after granular patches (per pitfall 3) - void store.getState().refreshBadges(); - } - } - - const batcher = createDomainEventBatcher( - () => { void refreshRef.current(); }, - (events) => { void handleGranularBatch(events); }, - 50, - ); - - const unsubscribe = window.agentCompany.subscribe((event: DesktopEvent) => { - if (event.type === "run-log") { - const MAX_LOG_BYTES_PER_RUN = 512_000; - const MAX_TRACKED_RUNS = 50; - const existing = logBufferRef.current[event.runId] ?? ""; - const updated = existing.length < MAX_LOG_BYTES_PER_RUN - ? existing + event.chunk - : existing.slice(-MAX_LOG_BYTES_PER_RUN / 2) + event.chunk; - logBufferRef.current[event.runId] = updated; - const runIds = Object.keys(logBufferRef.current); - if (runIds.length > MAX_TRACKED_RUNS) { - for (const oldId of runIds.slice(0, runIds.length - MAX_TRACKED_RUNS)) { - delete logBufferRef.current[oldId]; - } - } - incrementLogRevision(); - return; - } - if (event.type === "shortcut") { - if (event.action === "toggle-command-palette") store.getState().toggleCommandPalette(); - if (event.action === "toggle-console") store.getState().toggleConsole(); - if (event.action === "refresh") void refreshRef.current(); - return; - } - if (event.type === "notification-click") { - store.getState().navigateTo(event.section, event.entityId); - return; - } - // All data-mutating events go through the enhanced batcher - // domain-changed, task-updated, run-updated, approval-updated, - // agent-updated, budget-changed, and unknown types (trigger full refresh) - batcher.push(event); - }); - return () => { - unsubscribe(); - batcher.dispose(); - }; - }, []); - - useEffect(() => { - if (!snapshot) return; - const root = document.documentElement; - const applyTheme = () => { - const isDark = snapshot.theme === "dark" || - (snapshot.theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches); - root.classList.toggle("dark", isDark); - root.style.colorScheme = isDark ? "dark" : "light"; - }; - - applyTheme(); - - if (snapshot.locale && snapshot.locale !== locale) { - setLocale(snapshot.locale); - } - - if (snapshot.theme === "system") { - const mq = window.matchMedia("(prefers-color-scheme: dark)"); - mq.addEventListener("change", applyTheme); - return () => mq.removeEventListener("change", applyTheme); - } - }, [snapshot, locale]); - - useEffect(() => { - document.documentElement.lang = locale === "zh" ? "zh-CN" : "en"; - }, [locale]); - - const handleLocaleChange = useCallback(async (newLocale: AppLocale) => { - setLocale(newLocale); - try { - await window.agentCompany.updateSettings({ locale: newLocale }); - } catch { - // persist failed; local state is already updated - } - }, []); - - useEffect(() => { - if (!snapshot?.currentCompanyId) return; - const companyId = snapshot.currentCompanyId; - let cancelled = false; - - if (section === "inbox") { - void window.agentCompany.getInbox(companyId).then((result) => { - if (!cancelled && result.ok) setInboxItems(result.data); - }); - } - - if (section === "performance") { - void window.agentCompany.getCompanyMetrics({ companyId, days: 30 }).then((result) => { - if (!cancelled && result.ok) setCompanyMetrics(result.data); - }); - } - - if (section === "standup") { - void window.agentCompany.getStandupReport(companyId).then((result) => { - if (!cancelled && result.ok) setStandupReport(result.data); - }); - } - - if (section === "social") { - setSocialAccounts(snapshot.socialAccounts?.filter((a) => a.companyId === companyId) ?? []); - } - - return () => { - cancelled = true; - }; - }, [section, snapshot]); - - const paletteTrapRef = useFocusTrap(commandPaletteOpen); - - const currentCompany = useMemo(() => { - if (!snapshot) return null; - return snapshot.companies.find((company) => company.id === snapshot.currentCompanyId) ?? snapshot.companies[0] ?? null; - }, [snapshot]); - - const tasks = useMemo(() => snapshot?.tasks.filter((task) => !currentCompany || task.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]); - const selectedTask = selectedTaskId ? (tasks.find((task) => task.id === selectedTaskId) ?? null) : null; - const selectedConnector = snapshot?.connectors.find((connector) => connector.id === selectedConnectorId) ?? snapshot?.connectors[0] ?? null; - const approvals = useMemo(() => snapshot?.approvals.filter((approval) => !currentCompany || approval.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]); - const pendingApprovals = useMemo(() => approvals.filter((approval) => approval.state === "pending"), [approvals]); - const selectedApproval = selectedApprovalId ? (approvals.find((approval) => approval.id === selectedApprovalId) ?? null) : null; - const runs = useMemo(() => snapshot?.runs.filter((run) => !currentCompany || run.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]); - const selectedRun = runs.find((run) => run.id === selectedRunId) ?? runs[0] ?? null; - - useEffect(() => { - if (selectedRunId && !runs.some((run) => run.id === selectedRunId)) { - setSelectedRunId(runs[0]?.id ?? null); - } - }, [runs, selectedRunId]); - - const activeRunId = selectedRun?.id ?? null; - - useEffect(() => { - if (!activeRunId) return; - void (async () => { - const activeRun = runs.find((run) => run.id === activeRunId); - if (!activeRun) { - return; - } - const result = await window.agentCompany.getRunLog({ companyId: activeRun.companyId, runId: activeRunId }); - if (result.ok) { - logBufferRef.current = { ...logBufferRef.current, [activeRunId]: result.data }; - incrementLogRevision(); - return; - } - setError(result.error.message); - })(); - }, [activeRunId, runs]); - - const companyAgents = useMemo(() => snapshot?.agents.filter((a) => !currentCompany || a.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]); - const selectedAgent = companyAgents.find((a) => a.id === selectedAgentId) ?? null; - const companyGoals = useMemo(() => snapshot?.goals.filter((g) => !currentCompany || g.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]); - const selectedGoal = companyGoals.find((g) => g.id === selectedGoalId) ?? null; - const companyProjects = useMemo(() => snapshot?.projects.filter((p) => !currentCompany || p.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]); - const selectedProject = companyProjects.find((p) => p.id === selectedProjectId) ?? null; - - const activeInboxCount = inboxItems.length; - const navBadges = useMemo>>(() => badges ? { - runs: - badges.failedRuns > 0 - ? { count: badges.failedRuns, tone: "danger" } - : badges.activeRuns > 0 - ? { count: badges.activeRuns, tone: "default" } - : undefined, - tasks: badges.todoTasks > 0 ? { count: badges.todoTasks, tone: "default" } : undefined, - approvals: badges.pendingApprovals > 0 ? { count: badges.pendingApprovals, tone: "warn" } : undefined, - communication: badges.unreadMessages > 0 ? { count: badges.unreadMessages, tone: "warn" } : undefined, - } : {}, [badges, activeInboxCount]); - - const companyWorkspaces = useMemo(() => snapshot?.workspaces.filter((workspace) => !currentCompany || workspace.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]); - const selectedWorkspace = companyWorkspaces.find((workspace) => workspace.id === selectedWorkspaceId) ?? companyWorkspaces[0] ?? null; - const companyCosts = useMemo(() => snapshot?.costs.filter((entry) => !currentCompany || entry.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]); - const filteredActivity = useMemo(() => snapshot?.activity.filter((entry) => !currentCompany || entry.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]); - const companySecrets = useMemo(() => snapshot?.secrets.filter((secret) => !currentCompany || secret.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]); - const companyDocuments = useMemo(() => snapshot?.documents?.filter((d) => !currentCompany || d.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]); - const companyKnowledge = useMemo(() => snapshot?.knowledgeBase?.filter((k) => !currentCompany || k.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]); - const companyMeetings = useMemo(() => snapshot?.meetings?.filter((m) => !currentCompany || m.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]); - const companySprints = useMemo(() => snapshot?.sprints?.filter((s) => !currentCompany || s.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]); - const paletteQuery = useDeferredValue(commandPaletteSearch.trim().toLowerCase()); - const paletteResults = useMemo(() => { - const filteredNavItems = paletteQuery - ? navItems.filter((item) => item.label.toLowerCase().includes(paletteQuery) || item.id.toLowerCase().includes(paletteQuery)) - : navItems; - const matchedAgents = paletteQuery ? companyAgents.filter((a) => a.name.toLowerCase().includes(paletteQuery) || a.role.toLowerCase().includes(paletteQuery)).slice(0, 5) : []; - const matchedTasks = paletteQuery ? tasks.filter((t) => t.title.toLowerCase().includes(paletteQuery)).slice(0, 5) : []; - const matchedRuns = paletteQuery ? runs.filter((run) => { - const matchingTask = tasks.find((task) => task.id === run.taskId); - return [ - run.id, - run.summary ?? "", - run.errorMessage ?? "", - matchingTask?.title ?? "", - run.connectorId, - ].some((value) => value.toLowerCase().includes(paletteQuery)); - }).slice(0, 5) : []; - const matchedGoals = paletteQuery ? companyGoals.filter((g) => g.title.toLowerCase().includes(paletteQuery)).slice(0, 5) : []; - const matchedProjects = paletteQuery ? companyProjects.filter((p) => p.name.toLowerCase().includes(paletteQuery)).slice(0, 5) : []; - const hasEntityResults = matchedAgents.length > 0 || matchedTasks.length > 0 || matchedRuns.length > 0 || matchedGoals.length > 0 || matchedProjects.length > 0; - return { filteredNavItems, matchedAgents, matchedTasks, matchedRuns, matchedGoals, matchedProjects, hasEntityResults }; - }, [paletteQuery, companyAgents, companyGoals, companyProjects, runs, tasks]); - const { filteredNavItems, matchedAgents, matchedTasks, matchedRuns, matchedGoals, matchedProjects, hasEntityResults } = paletteResults; - const paletteActions = useMemo(() => ([ - ...filteredNavItems.map((item) => ({ key: `section:${item.id}`, section: item.id })), - ...matchedAgents.map((agent) => ({ key: `agent:${agent.id}`, section: "agents" as const, entityId: agent.id })), - ...matchedTasks.map((task) => ({ key: `task:${task.id}`, section: "tasks" as const, entityId: task.id })), - ...matchedRuns.map((run) => ({ key: `run:${run.id}`, section: "runs" as const, entityId: run.id })), - ...matchedGoals.map((goal) => ({ key: `goal:${goal.id}`, section: "goals" as const, entityId: goal.id })), - ...matchedProjects.map((project) => ({ key: `project:${project.id}`, section: "projects" as const, entityId: project.id })), - ]), [filteredNavItems, matchedAgents, matchedTasks, matchedRuns, matchedGoals, matchedProjects]); - const paletteActionIndex = useMemo(() => new Map(paletteActions.map((action, index) => [action.key, index])), [paletteActions]); - - const navigateTo = useCallback((targetSection: SectionId, entityId?: string) => { - setSection(targetSection); - if (entityId) { - if (targetSection === "agents") setSelectedAgentId(entityId); - else if (targetSection === "connectors") setSelectedConnectorId(entityId); - else if (targetSection === "runs") { - setSelectedRunId(entityId); - setConsoleOpen(true); - } - else if (targetSection === "tasks") { - const matchingTask = tasks.find((task) => task.id === entityId) ?? null; - if (matchingTask) { - setSelectedTaskId(matchingTask.id); - } else { - const matchingRun = runs.find((run) => run.id === entityId) ?? null; - if (matchingRun) { - setSelectedRunId(matchingRun.id); - setSelectedTaskId(matchingRun.taskId); - setConsoleOpen(true); - } - } - } - else if (targetSection === "approvals") setSelectedApprovalId(entityId); - else if (targetSection === "goals") setSelectedGoalId(entityId); - else if (targetSection === "projects") setSelectedProjectId(entityId); - else if (targetSection === "communication") setSelectedCommunicationEntityId(entityId); - } - }, [runs, tasks]); - - // Global Cmd/Ctrl+K to open command palette - useEffect(() => { - function onGlobalKey(event: KeyboardEvent) { - if ((event.metaKey || event.ctrlKey) && event.key === "k") { - event.preventDefault(); - setCommandPaletteOpen((prev) => !prev); - if (!commandPaletteOpen) { setCommandPaletteSearch(""); setSelectedPaletteIndex(0); } - } - } - document.addEventListener("keydown", onGlobalKey); - return () => document.removeEventListener("keydown", onGlobalKey); - }, [commandPaletteOpen]); - - useEffect(() => { - if (!commandPaletteOpen) return; - function onKey(event: KeyboardEvent) { - if (event.key === "Escape") { - setCommandPaletteOpen(false); - setCommandPaletteSearch(""); - setSelectedPaletteIndex(0); - return; - } - if (paletteActions.length === 0) return; - if (event.key === "ArrowDown") { - event.preventDefault(); - setSelectedPaletteIndex((useAppStore.getState().selectedPaletteIndex + 1) % paletteActions.length); - return; - } - if (event.key === "ArrowUp") { - event.preventDefault(); - setSelectedPaletteIndex((useAppStore.getState().selectedPaletteIndex - 1 + paletteActions.length) % paletteActions.length); - return; - } - if (event.key === "Enter") { - event.preventDefault(); - const selectedAction = paletteActions[Math.min(selectedPaletteIndex, paletteActions.length - 1)]; - if (!selectedAction) return; - navigateTo(selectedAction.section, selectedAction.entityId); - setCommandPaletteOpen(false); - setCommandPaletteSearch(""); - setSelectedPaletteIndex(0); - } - } - document.addEventListener("keydown", onKey); - return () => document.removeEventListener("keydown", onKey); - }, [commandPaletteOpen, navigateTo, paletteActions, selectedPaletteIndex]); - - useEffect(() => { - if (!commandPaletteOpen) return; - setSelectedPaletteIndex(0); - }, [commandPaletteOpen, commandPaletteSearch]); - - const setCompany = useCallback(async (companyId: string) => { - if (companyId === "__new__") { - setShowOnboarding(true); - return; - } - try { - unwrap(await window.agentCompany.setCurrentCompany(companyId)); - clearCompanyScopedUiState(); - await refresh(); - setError(null); - } catch (error) { - setError(error instanceof Error ? error.message : String(error)); - } - }, [clearCompanyScopedUiState, refresh]); - - const deleteCompany = useCallback(async (companyId: string) => { - try { - unwrap(await window.agentCompany.deleteCompany(companyId)); - setConfirmDeleteCompanyId(null); - setCompanySwitcherOpen(false); - await refresh(); - } catch (error) { - setError(error instanceof Error ? error.message : String(error)); - } - }, [refresh]); - - const startRun = useCallback(async (taskId: string) => { - if (!currentCompany) { - setError("Select a company before starting a run."); - return; - } - const result = await window.agentCompany.startTaskRun({ companyId: currentCompany.id, taskId }); - if (!result.ok) { - setError(result.error.message); - return; - } - setSelectedRunId(result.data); - setConsoleOpen(true); - await refresh(); - }, [currentCompany, refresh]); - - const cancelRun = useCallback(async (runId: string) => { - try { - const run = runs.find((entry) => entry.id === runId); - if (!run) { - throw new Error("Selected run is no longer available."); - } - unwrap(await window.agentCompany.cancelRun({ companyId: run.companyId, runId })); - await refresh(); - setError(null); - } catch (error) { - setError(error instanceof Error ? error.message : String(error)); - } - }, [refresh, runs]); - - const handleOnboardingDone = useCallback(async (summary: OnboardingBootstrapResult) => { - setShowOnboarding(false); - setInitialOnboardingLocked(false); - await refresh(); - setError(null); - setSection("overview"); - if (summary.runId) { - setSelectedRunId(summary.runId); - } - if (summary.leadAgentId) { - setSelectedAgentId(summary.leadAgentId); - } - }, [refresh]); + const c = useAppController(); const showCustomTitleBar = window.agentCompany.platform !== "darwin"; + const isMac = !showCustomTitleBar; - if (!snapshot) { + if (!c.snapshot) { return ( - - + +
{showCustomTitleBar && }
- {t("app.loading")} + {c.t("app.loading")}
@@ -720,25 +31,25 @@ export function App() { ); } - if (snapshot.companies.length === 0 || showOnboarding || initialOnboardingLocked) { + if (c.snapshot.companies.length === 0 || c.showOnboarding || c.initialOnboardingLocked) { return ( - - + +
{showCustomTitleBar && }
0 && !initialOnboardingLocked} + connectors={c.snapshot.connectors} + onDone={c.handleOnboardingDone} + canCancel={c.snapshot.companies.length > 0 && !c.initialOnboardingLocked} onCancel={() => { - setShowOnboarding(false); - setInitialOnboardingLocked(false); - setError(null); + c.setShowOnboarding(false); + c.setInitialOnboardingLocked(false); + c.setError(null); }} - currentLocale={locale} - onLocaleChange={handleLocaleChange} - isFirstLaunch={snapshot.companies.length === 0} + currentLocale={c.locale} + onLocaleChange={c.handleLocaleChange} + isFirstLaunch={c.snapshot.companies.length === 0} />
@@ -747,1049 +58,151 @@ export function App() { ); } - const isMac = !showCustomTitleBar; - return ( - - + +
{showCustomTitleBar && } -
- +
+ +

- {navItems.find((item) => item.id === section)?.label ?? t(`nav.${section}`)} + {c.navItems.find((item) => item.id === c.section)?.label ?? c.t(`nav.${c.section}`)}

-
-
+ - {error ? ( -
- - {error} -
- ) : null} - -
- - - - {section === "overview" ? ( - !currentCompany || message.companyId === currentCompany.id) ?? []} - onNavigate={navigateTo} - /> - ) : null} - - {section === "inbox" ? ( - - ) : null} - - {section === "standup" ? ( - - ) : null} - - {section === "performance" ? ( - - ) : null} - - {section === "runs" ? ( - { - setSelectedRunId(runId); - }} - onOpenLogPath={async (path: string) => { - await openPathWithFeedback(path); - }} - onCancelRun={cancelRun} - onOpenConsole={(runId: string) => { - setSelectedRunId(runId); - setConsoleOpen(true); - }} - /> - ) : null} - - {section === "connectors" && selectedConnector ? ( -
- - - {snapshot.connectors.map((connector) => ( - setSelectedConnectorId(connector.id)} - className={`cursor-pointer transition-colors duration-100 ${ - selectedConnector.id === connector.id - ? "bg-accent" - : "hover:bg-muted" - }`} - > -
-
-
{connector.label}
-
{connector.command}
-
- -
-
- ))} -
-
- - - -
- ) : null} - - {section === "agents" ? ( - <> - setSelectedAgentId(id)} /> - setSelectedAgentId("__new__")} />}> - - - setSelectedAgentId(null)}> - { - setSelectedAgentId(null); - setSection("hiring"); - await refresh(); - }} - /> - - setSelectedAgentId(null)}> - {selectedAgent ? ( - { setSelectedAgentId(null); await refresh(); }} - /> - ) : null} - - - ) : null} - - {section === "orgchart" ? ( - { setSelectedAgentId(id); setSection("agents"); }} /> - ) : null} - - {section === "hiring" && currentCompany ? ( - a.companyId === currentCompany!.id)} - onRefresh={refresh} - onNavigateToAgent={(id) => { setSelectedAgentId(id); setSection("agents"); }} - /> - ) : null} - - {section === "goals" ? ( - <> - setSelectedGoalId("__new__")} />}> - - - setSelectedGoalId(null)}> - { setSelectedGoalId(null); await refresh(); }} /> - - setSelectedGoalId(null)}> - {selectedGoal ? ( - { setSelectedGoalId(null); await refresh(); }} - onDeleted={async () => { setSelectedGoalId(null); await refresh(); }} - /> - ) : null} - - - ) : null} - - {section === "projects" ? ( - <> - setSelectedProjectId("__new__")} />}> - - - setSelectedProjectId(null)}> - { setSelectedProjectId(null); await refresh(); }} /> - - setSelectedProjectId(null)}> - {selectedProject ? ( - { setSelectedProjectId(null); await refresh(); }} - onDeleted={async () => { setSelectedProjectId(null); await refresh(); }} - /> - ) : null} - - - ) : null} - - {section === "tasks" ? ( - <> - setSelectedGoalId("__new__")} />}> - - - setSelectedGoalId(null)}> - { setSelectedGoalId(null); await refresh(); }} /> - - setSelectedGoalId(null)}> - {selectedGoal ? ( - { setSelectedGoalId(null); await refresh(); }} - onDeleted={async () => { setSelectedGoalId(null); await refresh(); }} - /> - ) : null} - - - -
- - -
- setSelectedTaskId("__new__")} /> -
- } - > - {taskViewMode === "kanban" ? ( - - ) : ( - - )} - - setSelectedTaskId(null)}> - { setSelectedTaskId(null); await refresh(); }} /> - - setSelectedTaskId(null)} width="720px"> - {selectedTask ? ( - run.taskId === selectedTask.id)} - agents={companyAgents} - goals={companyGoals} - projects={companyProjects} - workspaces={companyWorkspaces} - companyId={currentCompany!.id} - comments={(snapshot.comments ?? []).filter((comment) => comment.taskId === selectedTask.id)} - onRefresh={refresh} - onRun={startRun} - onCancelRun={cancelRun} - onCreateApproval={async () => { - const isBlocked = selectedTask.status === "blocked"; - const approvalId = unwrap(await window.agentCompany.requestApproval({ - companyId: currentCompany!.id, - relatedTaskId: selectedTask.id, - requestedByAgentId: selectedTask.assigneeAgentId, - type: isBlocked ? "secret_access" : "approve_ceo_strategy", - payloadSummary: isBlocked - ? `Blocker: ${selectedTask.title} — needs operator assistance` - : `Review needed: ${selectedTask.title}`, - impactSummary: isBlocked - ? "Agent is blocked and needs operator input (credentials, access, or decision)." - : "No automated reviewer available. Operator review required.", - })); - setSelectedApprovalId(approvalId); - await refresh(); - }} - onOpenDiscussion={(taskId) => { - setSelectedTaskId(null); - setSection("communication"); - setSelectedCommunicationEntityId(taskId); - }} - onDeleted={async () => { setSelectedTaskId(null); await refresh(); }} - /> - ) : null} - - - ) : null} - - {section === "approvals" ? ( - <> - - - - - - - setSelectedApprovalId(null)}> - {selectedApproval ? ( - { - unwrap(await window.agentCompany.decideApproval({ companyId: currentCompany!.id, approvalId: selectedApproval.id, state, decisionNote: note ?? `${state} from desktop control surface.` })); - await refresh(); - }} - onDeleted={async () => { setSelectedApprovalId(null); await refresh(); }} - /> - ) : null} - - - ) : null} - - {section === "costs" ? ( -
- {/* Tab bar */} -
- {(["costs", "policies", "incidents"] as const).map((tab) => ( - - ))} -
- {/* Tab content */} - {costsTab === "costs" ? ( - - - - } - secondary={ - - - - } - /> - ) : costsTab === "policies" ? ( - ({ id: a.id, name: a.name }))} - projects={companyProjects.map((p) => ({ id: p.id, name: p.name }))} - /> - ) : ( - ({ id: a.id, name: a.name }))} - projects={companyProjects.map((p) => ({ id: p.id, name: p.name }))} - /> - )} -
- ) : null} - - {section === "activity" ? ( - - - - ) : null} - - {section === "social" && snapshot?.currentCompanyId && ( - ({ id: a.id, name: a.name }))} - companyId={snapshot.currentCompanyId} - onSave={async (input) => { - try { - unwrap(await window.agentCompany.saveSocialAccount({ - companyId: input.companyId, - platform: input.platform, - accountName: input.accountName, - displayName: input.displayName, - requireApproval: input.requireApproval, - profileUrl: "", - status: "login_required", - metadataJson: "{}", - })); - await refresh(); - setError(null); - } catch (error) { - setError(error instanceof Error ? error.message : String(error)); - } - }} - onDelete={async (id) => { - if (!snapshot?.currentCompanyId) return; - try { - unwrap(await window.agentCompany.deleteSocialAccount({ id, companyId: snapshot.currentCompanyId })); - await refresh(); - setError(null); - } catch (error) { - setError(error instanceof Error ? error.message : String(error)); - } - }} - onLogin={async (accountId) => { - if (!snapshot?.currentCompanyId) return; - try { - unwrap(await window.agentCompany.triggerBrowserLogin({ companyId: snapshot.currentCompanyId, socialAccountId: accountId })); - await refresh(); - setError(null); - } catch (error) { - setError(error instanceof Error ? error.message : String(error)); - } - }} - onOpenPath={async (path) => { - try { - unwrap(await window.agentCompany.openPath(path)); - setError(null); - } catch (error) { - setError(error instanceof Error ? error.message : String(error)); - } - }} - onCancelAction={async (actionId) => { - if (!snapshot?.currentCompanyId) return; - try { - unwrap(await window.agentCompany.cancelBrowserAction({ actionId, companyId: snapshot.currentCompanyId })); - await refresh(); - setError(null); - } catch (error) { - setError(error instanceof Error ? error.message : String(error)); - } - }} - onNavigate={(sectionName, entityId) => { - setSection(sectionName); - if (sectionName === "tasks") setSelectedTaskId(entityId); - if (sectionName === "approvals") setSelectedApprovalId(entityId); - }} - /> - )} - - {section === "skills" && currentCompany ? ( - - ) : null} - - {section === "automation" && currentCompany ? ( - r.companyId === currentCompany.id) ?? []} - workflows={snapshot.workflows?.filter(w => w.companyId === currentCompany.id) ?? []} - companyId={currentCompany.id} - onRefresh={refresh} - /> - ) : null} - - {section === "communication" && currentCompany ? ( - m.companyId === currentCompany.id) ?? []} - comments={snapshot.comments?.filter((comment) => comment.companyId === currentCompany.id) ?? []} - tasks={tasks} - agents={companyAgents} - projects={companyProjects} - companyId={currentCompany.id} - selectedEntityId={selectedCommunicationEntityId} - onEntityConsumed={() => setSelectedCommunicationEntityId(null)} - onNavigateToTask={(taskId) => { - setSection("tasks"); - setSelectedTaskId(taskId); - }} - onRefresh={refresh} - /> - ) : null} - - {section === "documents" && currentCompany ? ( - - ) : null} - - {section === "knowledge" && currentCompany ? ( - - ) : null} - - {section === "meetings" && currentCompany ? ( - - ) : null} - - {section === "sprints" && currentCompany ? ( - - ) : null} - - {section === "workspaces" && currentCompany ? ( - - - - } - secondary={ -
- - project.id === selectedWorkspace.projectId) ?? null) : null} - onOpen={openPathWithFeedback} - /> - - - - -
- } - /> - ) : null} - - {section === "settings" && currentCompany ? ( -
- - - - - - -
- ) : null} - - - -
-
-
- - - {consoleOpen ? ( - -
-
- - {t("console.title")} -
-
- setCommandPaletteSearch(e.target.value)} - autoFocus - /> - {commandPaletteSearch && ( - - )} -
- - {filteredNavItems.length > 0 ? ( - <> -
{t("entity.sections")}
- - {filteredNavItems.map((item) => ( - { - navigateTo(item.id); - setCommandPaletteOpen(false); - setCommandPaletteSearch(""); - setSelectedPaletteIndex(0); - }} - > -
-
- - {item.label} -
- {item.id} -
-
- ))} -
- - ) : null} - - {hasEntityResults ? ( -
- {matchedAgents.length > 0 ? ( - <> -
{t("entity.agents")}
- - {matchedAgents.map((agent) => ( - { - navigateTo("agents", agent.id); - setCommandPaletteOpen(false); - setCommandPaletteSearch(""); - setSelectedPaletteIndex(0); - }} - > -
-
- - {agent.name} - {agent.role} -
- -
-
- ))} -
- - ) : null} - - {matchedTasks.length > 0 ? ( - <> -
{t("entity.tasks")}
- - {matchedTasks.map((task) => ( - { - navigateTo("tasks", task.id); - setCommandPaletteOpen(false); - setCommandPaletteSearch(""); - setSelectedPaletteIndex(0); - }} - > -
-
- - {task.title} -
- -
-
- ))} -
- - ) : null} - - {matchedRuns.length > 0 ? ( - <> -
{t("entity.runs")}
- - {matchedRuns.map((run) => ( - { - navigateTo("runs", run.id); - setCommandPaletteOpen(false); - setCommandPaletteSearch(""); - setSelectedPaletteIndex(0); - }} - > -
-
- - - {run.summary || runs.find((entry) => entry.id === run.id)?.id.slice(0, 8) || run.id.slice(0, 8)} - -
- -
-
- ))} -
- - ) : null} - - {matchedGoals.length > 0 ? ( - <> -
{t("entity.goals")}
- - {matchedGoals.map((goal) => ( - { - navigateTo("goals", goal.id); - setCommandPaletteOpen(false); - setCommandPaletteSearch(""); - setSelectedPaletteIndex(0); - }} - > -
-
- - {goal.title} -
- -
-
- ))} -
- - ) : null} - - {matchedProjects.length > 0 ? ( - <> -
{t("entity.projects")}
- - {matchedProjects.map((project) => ( - { - navigateTo("projects", project.id); - setCommandPaletteOpen(false); - setCommandPaletteSearch(""); - setSelectedPaletteIndex(0); - }} - > -
-
- - {project.name} -
- -
-
- ))} -
- - ) : null} -
- ) : null} - - {paletteQuery && filteredNavItems.length === 0 && !hasEntityResults ? ( -
{t("app.noResults")} "{commandPaletteSearch}"
- ) : null} - - - ) : null} - +
@@ -1798,3 +211,5 @@ export function App() { ); } + +export default App; diff --git a/src/renderer/components/CompanyOnboarding/CompanyStep.tsx b/src/renderer/components/CompanyOnboarding/CompanyStep.tsx index 51dbe920..ab77f17a 100644 --- a/src/renderer/components/CompanyOnboarding/CompanyStep.tsx +++ b/src/renderer/components/CompanyOnboarding/CompanyStep.tsx @@ -1,16 +1,16 @@ import { motion } from "framer-motion"; -import { Buildings, FolderOpen, Lightning, UserCircleCheck } from "@phosphor-icons/react"; -import type { ProfileSnapshot } from "@shared/types"; +import { Buildings, FolderOpen } from "@phosphor-icons/react"; +import type { ConnectorId, ProfileSnapshot } from "@shared/types"; import type { AppLocale } from "@shared/locale"; -import { ActionButton, Input, Select, TextArea } from "../ui"; +import { ActionButton, Input, TextArea, Select } from "../ui"; interface CompanyStepProps { companyName: string; setCompanyName: (v: string) => void; companyDescription: string; setCompanyDescription: (v: string) => void; - selectedConnectorId: string; - setSelectedConnectorId: (v: string) => void; + selectedConnectorId: ConnectorId | ""; + setSelectedConnectorId: (v: ConnectorId | "") => void; selectedModel: string; setSelectedModel: (v: string) => void; executionReadyConnectors: ProfileSnapshot["connectors"]; @@ -18,10 +18,6 @@ interface CompanyStepProps { loadingModels: boolean; workspacePath: string; setWorkspacePath: (v: string) => void; - autoApproveHires: boolean; - setAutoApproveHires: (v: boolean) => void; - reviewDeliverables: boolean; - setReviewDeliverables: (v: boolean) => void; onPickDirectory: () => void; onContinue: () => void; onBack: () => void; @@ -44,10 +40,6 @@ export function CompanyStep({ loadingModels, workspacePath, setWorkspacePath, - autoApproveHires, - setAutoApproveHires, - reviewDeliverables, - setReviewDeliverables, onPickDirectory, onContinue, onBack, @@ -65,7 +57,7 @@ export function CompanyStep({ className="bg-card border border-border shadow-sm w-full rounded-lg p-7" >
- {t("onboarding.stepLabel1")} 3 / 5 + {t("onboarding.stepLabelCompany")} 3 / 6
{t("onboarding.step1Title")} @@ -96,7 +88,7 @@ export function CompanyStep({ handleProviderChange(e.target.value)} - > - {COMPATIBLE_PROVIDERS.map((p) => ( - - ))} - -
- - {selectedProviderData?.docUrl ? ( - - ) : null} - -
- - setApiKey(e.target.value)} placeholder="sk-..." /> -
- -
- - setBaseUrl(e.target.value)} placeholder="https://api.example.com/v1" /> -
- -
- - {models.length > 0 && ( -
- {models.map((m) => ( - - {m} - - - ))} -
- )} -
- setNewModel(e.target.value)} - placeholder="model-name" - onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addModel(); } }} - /> - -
-
- - - - ); -} +const COMPAT_SLOTS = ["claude_compatible", "claude_compat_2", "claude_compat_3", "claude_compat_4", "claude_compat_5", "claude_compat_6", "claude_compat_7", "claude_compat_8", "claude_compat_9", "claude_compat_10"] as const; /* ------------------------------------------------------------------ */ /* Main component */ @@ -378,41 +46,30 @@ export function ProvidersStep({ error, t, }: ProvidersStepProps) { - // Expansion state const [expandedCard, setExpandedCard] = useState("claude"); - // Connector lookups const claudeConnector = connectors.find((c) => c.id === "claude_local"); const codexConnector = connectors.find((c) => c.id === "codex_local"); const geminiConnector = connectors.find((c) => c.id === "gemini_local"); - const compatConnector = connectors.find((c) => c.id === "claude_compatible"); - // Parse env bindings from connector data for form initialization function parseEnvValue(envText: string, key: string): string { const line = envText.split(/\r?\n/).find((l) => l.startsWith(`${key}=`)); return line ? line.slice(key.length + 1) : ""; } - // Claude Code (Official) form — init from saved connector data const [claudeApiKey, setClaudeApiKey] = useState(() => parseEnvValue(claudeConnector?.envBindingText ?? "", "ANTHROPIC_API_KEY"), ); - - // Codex form const [codexApiKey, setCodexApiKey] = useState(() => parseEnvValue(codexConnector?.envBindingText ?? "", "OPENAI_API_KEY"), ); - // Compatible API slots - const COMPAT_SLOTS = ["claude_compatible", "claude_compat_2", "claude_compat_3", "claude_compat_4", "claude_compat_5", "claude_compat_6", "claude_compat_7", "claude_compat_8", "claude_compat_9", "claude_compat_10"] as const; const compatSlots = COMPAT_SLOTS.map((id) => connectors.find((c) => c.id === id)); - // A slot is "active" if it has been configured (has ANTHROPIC_BASE_URL) const activeCompatSlots = COMPAT_SLOTS.filter((id) => { const c = connectors.find((conn) => conn.id === id); return c && c.envBindingText.includes("ANTHROPIC_BASE_URL"); }); const nextAvailableSlot = COMPAT_SLOTS.find((id) => !activeCompatSlots.includes(id)) ?? null; - // Always show active slots + the first empty slot for adding const visibleCompatSlots = [...activeCompatSlots]; if (nextAvailableSlot && !visibleCompatSlots.includes(nextAvailableSlot)) { visibleCompatSlots.push(nextAvailableSlot); @@ -424,8 +81,6 @@ export function ProvidersStep({ const toggle = (card: string) => setExpandedCard((prev) => (prev === card ? null : card)); - /* ------ save helpers ------ */ - function handleClaudeSave() { const lines: string[] = []; if (claudeApiKey.trim()) lines.push(`ANTHROPIC_API_KEY=${claudeApiKey.trim()}`); @@ -438,10 +93,6 @@ export function ProvidersStep({ onSaveConnector("codex_local", lines.join("\n"), null); } - function openExternalDoc(url: string) { - window.open(url, "_blank"); - } - return ( - {/* Step label */}
- {t("onboarding.stepLabel1")} 2 / 5 — {t("onboarding.providersStep")} + {t("onboarding.stepLabelProviders")} 2 / 6
{t("onboarding.providersTitle")} @@ -463,9 +113,7 @@ export function ProvidersStep({
- {/* Provider cards */}
- {/* ---- Card 1: Claude Code (Official) ---- */} )} - -
- -
- -
- {/* ---- Compatible API providers (up to 3 slots) ---- */} + {/* ---- Compatible API providers ---- */} {visibleCompatSlots.map((slotId) => ( - {/* Error message */} {error ? (
{error}
) : null} - {/* Bottom bar */}
-
{!hasAtLeastOneReady && ( diff --git a/src/renderer/components/CompanyOnboarding/ProvidersStepShared.tsx b/src/renderer/components/CompanyOnboarding/ProvidersStepShared.tsx new file mode 100644 index 00000000..be6187bb --- /dev/null +++ b/src/renderer/components/CompanyOnboarding/ProvidersStepShared.tsx @@ -0,0 +1,350 @@ +import { useState } from "react"; +import { + ArrowSquareOut, + CaretDown, + CaretRight, + FloppyDisk, + Terminal, +} from "@phosphor-icons/react"; +import type { ProfileSnapshot } from "@shared/types"; + +/* ------------------------------------------------------------------ */ +/* Compatible-API provider presets */ +/* ------------------------------------------------------------------ */ + +export const COMPATIBLE_PROVIDERS = [ + { id: "minimax", label: "MiniMax", baseUrl: "https://api.minimax.io/anthropic", docUrl: "https://platform.minimax.io/docs/token-plan/claude-code" }, + { id: "minimax_cn", label: "MiniMax (CN)", baseUrl: "https://api.minimaxi.com/anthropic", docUrl: "https://platform.minimax.io/docs/token-plan/claude-code" }, + { id: "kimi", label: "Kimi", baseUrl: "https://api.kimi.com/coding/", docUrl: "https://www.kimi.com/code/docs/en/more/third-party-agents.html" }, + { id: "kimi_cn", label: "Kimi CN", baseUrl: "https://api.moonshot.cn/anthropic", docUrl: "https://platform.moonshot.cn/docs/guide/agent-support#macos-%E5%92%8C-linux-1" }, + { id: "glm", label: "GLM (ZhiPu)", baseUrl: "https://api.z.ai/api/anthropic", docUrl: "https://docs.z.ai/devpack/tool/claude" }, + { id: "openrouter", label: "OpenRouter", baseUrl: "https://openrouter.ai/api", docUrl: "https://openrouter.ai/docs/guides/coding-agents/claude-code-integration" }, + { id: "tencent", label: "Tencent Coding Plan", baseUrl: "https://api.lkeap.cloud.tencent.com/coding/anthropic", docUrl: "https://cloud.tencent.com/document/product/1772/128951" }, + { id: "poe", label: "POE", baseUrl: "https://api.poe.com", docUrl: "https://creator.poe.com/docs/external-applications/claude-code" }, + { id: "deepseek", label: "DeepSeek", baseUrl: "https://api.deepseek.com/anthropic", docUrl: "https://api-docs.deepseek.com/guides/anthropic_api" }, + { id: "alibaba", label: "Alibaba Coding Plan", baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic", docUrl: "https://www.alibabacloud.com/help/en/model-studio/claude-code-coding-plan" }, + { id: "alibaba_cn", label: "Alibaba Coding Plan (CN)", baseUrl: "https://coding.dashscope.aliyuncs.com/apps/anthropic", docUrl: "https://help.aliyun.com/zh/model-studio/claude-code-coding-plan" }, + { id: "bailing", label: "BaiLing", baseUrl: "https://api.tbox.cn/api/anthropic", docUrl: "https://alipaytbox.yuque.com/sxs0ba/ling/best_practices_claudecode" }, + { id: "nanogpt", label: "NanoGPT", baseUrl: "https://nano-gpt.com/api/v1", docUrl: "https://docs.nano-gpt.com/integrations/claude-code#manual-configuration-all-platforms" }, + { id: "zhipu_cn", label: "ZhiPu (CN)", baseUrl: "https://open.bigmodel.cn/api/anthropic", docUrl: "https://docs.bigmodel.cn/cn/coding-plan/tool/claude" }, + { id: "ollama", label: "Ollama (Local)", baseUrl: "http://localhost:11434", docUrl: "https://docs.ollama.com/integrations/claude-code" }, + { id: "custom", label: "Custom", baseUrl: "", docUrl: "" }, +] as const; + +/* ------------------------------------------------------------------ */ +/* Shared input class */ +/* ------------------------------------------------------------------ */ + +export const INPUT_CLS = + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring w-full rounded-lg border border-border bg-card px-3 py-2.5 text-[13px] text-foreground placeholder:text-muted-foreground"; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +export type ConnectorStatus = ProfileSnapshot["connectors"][number]["status"]; + +export interface ProvidersStepSaveConnector { + (connectorId: string, envBindingText: string, model: string | null, notes?: string): void; +} + +/* ------------------------------------------------------------------ */ +/* Status badge */ +/* ------------------------------------------------------------------ */ + +export function StatusBadge({ status, t }: { status: ConnectorStatus; t: (key: string) => string }) { + if (status === "ready") { + return ( + + + {t("onboarding.providerReady")} + + ); + } + if (status === "not_installed") { + return ( + + + {t("onboarding.providerNotInstalled")} + + ); + } + if (status === "auth_required") { + return ( + + + {t("onboarding.providerAuthRequired")} + + ); + } + if (status === "detected" || status === "checking" as string) { + return ( + + + {t("onboarding.providerChecking")} + + ); + } + return ( + + + {status.replaceAll("_", " ")} + + ); +} + +/* ------------------------------------------------------------------ */ +/* Provider card wrapper */ +/* ------------------------------------------------------------------ */ + +export function ProviderCard({ + title, + badge, + expanded, + onToggle, + children, +}: { + title: string; + badge: React.ReactNode; + expanded: boolean; + onToggle: () => void; + children: React.ReactNode; +}) { + return ( +
+ + {expanded &&
{children}
} +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Install snippet */ +/* ------------------------------------------------------------------ */ + +export function InstallBlock({ + macLinuxCmd, + windowsCmd, + onInstall, + title, + t, +}: { + macLinuxCmd: string; + windowsCmd: string | null; + onInstall: (title: string, command: string) => void; + title: string; + t: (key: string) => string; +}) { + const platform = window.agentCompany?.platform ?? "linux"; + const isWindows = platform === "win32"; + const cmd = isWindows && windowsCmd ? windowsCmd : macLinuxCmd; + + return ( +
+
{t("onboarding.providerInstall")}
+ {!isWindows && ( +
+ {macLinuxCmd} +
+ )} + {isWindows && windowsCmd && ( +
+ {windowsCmd} +
+ )} + +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Compatible provider card (one per slot) */ +/* ------------------------------------------------------------------ */ + +export function CompatProviderCard({ + slotId, + connector, + claudeInstalled, + onSaveConnector, + onInstallCommand, + expanded, + onToggle, + t, +}: { + slotId: string; + connector: ProfileSnapshot["connectors"][number] | null; + claudeInstalled: boolean; + onSaveConnector: ProvidersStepSaveConnector; + onInstallCommand: (title: string, command: string) => void; + expanded: boolean; + onToggle: () => void; + t: (key: string) => string; +}) { + function parseEnv(text: string, key: string): string { + const line = text.split(/\r?\n/).find((l) => l.startsWith(`${key}=`)); + return line ? line.slice(key.length + 1) : ""; + } + + const [selectedProvider, setSelectedProvider] = useState(() => { + const saved = connector?.notes ?? ""; + const match = COMPATIBLE_PROVIDERS.find((p) => p.label === saved); + return match?.id ?? COMPATIBLE_PROVIDERS[0].id; + }); + const [apiKey, setApiKey] = useState(() => parseEnv(connector?.envBindingText ?? "", "ANTHROPIC_API_KEY")); + const [baseUrl, setBaseUrl] = useState(() => + parseEnv(connector?.envBindingText ?? "", "ANTHROPIC_BASE_URL") || COMPATIBLE_PROVIDERS[0].baseUrl, + ); + const [models, setModels] = useState(() => { + const raw = connector?.model ?? ""; + return raw.split(",").map((m) => m.trim()).filter(Boolean); + }); + const [newModel, setNewModel] = useState(""); + + const status = connector?.status ?? "not_installed"; + const providerLabel = COMPATIBLE_PROVIDERS.find((p) => p.id === selectedProvider)?.label ?? t("onboarding.claudeCompatibleLabel"); + const displayTitle = connector?.notes || providerLabel; + + function handleProviderChange(providerId: string) { + setSelectedProvider(providerId); + const provider = COMPATIBLE_PROVIDERS.find((p) => p.id === providerId); + if (provider) setBaseUrl(provider.baseUrl); + } + + function handleSave() { + const lines: string[] = []; + if (apiKey.trim()) lines.push(`ANTHROPIC_API_KEY=${apiKey.trim()}`); + if (baseUrl.trim()) lines.push(`ANTHROPIC_BASE_URL=${baseUrl.trim()}`); + const label = COMPATIBLE_PROVIDERS.find((p) => p.id === selectedProvider)?.label ?? ""; + const allModels = [...models]; + const pending = newModel.trim(); + if (pending && !allModels.includes(pending)) { + allModels.push(pending); + setModels(allModels); + setNewModel(""); + } + const modelsCsv = allModels.join(",") || null; + onSaveConnector(slotId, lines.join("\n"), modelsCsv, label); + } + + function addModel() { + const trimmed = newModel.trim(); + if (!trimmed) return; + setModels((prev) => { + if (prev.includes(trimmed)) return prev; + return [...prev, trimmed]; + }); + setNewModel(""); + } + + const selectedProviderData = COMPATIBLE_PROVIDERS.find((p) => p.id === selectedProvider); + + return ( + } + expanded={expanded} + onToggle={onToggle} + > + {!claudeInstalled && ( + + )} + +
+ + +
+ + {selectedProviderData?.docUrl ? ( + + ) : null} + +
+ + setApiKey(e.target.value)} placeholder="sk-..." /> +
+ +
+ + setBaseUrl(e.target.value)} placeholder="https://api.example.com/v1" /> +
+ +
+ + {models.length > 0 && ( +
+ {models.map((m) => ( + + {m} + + + ))} +
+ )} +
+ setNewModel(e.target.value)} + placeholder="model-name" + onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addModel(); } }} + /> + +
+
+ + +
+ ); +} diff --git a/src/renderer/components/CompanyOnboarding/useConnectorCallbacks.ts b/src/renderer/components/CompanyOnboarding/useConnectorCallbacks.ts new file mode 100644 index 00000000..2050dbd2 --- /dev/null +++ b/src/renderer/components/CompanyOnboarding/useConnectorCallbacks.ts @@ -0,0 +1,72 @@ +import { useCallback } from "react"; +import type { ConnectorId } from "@shared/types"; +import type { ProfileSnapshot } from "@shared/types"; +import { unwrap } from "../../lib/desktop"; + +export function useConnectorCallbacks(connectors: ProfileSnapshot["connectors"]) { + const handleTestConnector = useCallback(async (connectorId: string) => { + try { + unwrap(await window.agentCompany.testConnector(connectorId as ConnectorId)); + } catch { + // Health check result is stored in DB and reflected via domain-changed + } + }, []); + + const handleSaveConnector = useCallback(async (connectorId: string, envBindingText: string, model: string | null, notes?: string) => { + const connector = connectors.find((c) => c.id === connectorId); + if (!connector) return; + try { + unwrap(await window.agentCompany.saveConnector({ + id: connectorId as ConnectorId, + command: connector.command, + model, + envBindingText, + notes: notes ?? connector.notes, + })); + // Run health check AFTER save completes so the probe uses the new config + unwrap(await window.agentCompany.testConnector(connectorId as ConnectorId)); + } catch { + // Error is non-fatal; result reflected via domain-changed + } + }, [connectors]); + + const handleLoginConnector = useCallback(async (connectorId: string) => { + try { + unwrap(await window.agentCompany.openConnectorAuthTerminal(connectorId as ConnectorId)); + } catch { + // Terminal open failure is non-fatal + return; + } + // Poll connector health after login terminal opens (same pattern as ConnectorEditor) + let attempts = 0; + const poll = setInterval(async () => { + attempts++; + if (attempts > 12) { clearInterval(poll); return; } + try { + unwrap(await window.agentCompany.testConnector(connectorId as ConnectorId)); + } catch { /* health check result reflected via domain-changed */ } + }, 5000); + }, []); + + const handleInstallCommand = useCallback(async (title: string, command: string) => { + try { + unwrap(await window.agentCompany.openTerminalCommand({ title, command })); + } catch { + // Terminal open failure is non-fatal + return; + } + // Poll all connectors after install (install may make a new CLI available) + let attempts = 0; + const poll = setInterval(async () => { + attempts++; + if (attempts > 12) { clearInterval(poll); return; } + for (const c of connectors) { + if (c.status !== "ready") { + try { unwrap(await window.agentCompany.testConnector(c.id as ConnectorId)); } catch { /* reflected via domain-changed */ } + } + } + }, 5000); + }, [connectors]); + + return { handleTestConnector, handleSaveConnector, handleLoginConnector, handleInstallCommand }; +} diff --git a/src/renderer/components/CompanyOnboarding/useConnectorModelSelection.ts b/src/renderer/components/CompanyOnboarding/useConnectorModelSelection.ts new file mode 100644 index 00000000..2d443fe4 --- /dev/null +++ b/src/renderer/components/CompanyOnboarding/useConnectorModelSelection.ts @@ -0,0 +1,235 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + mergeConnectorModelEntries, + type ConnectorModelEntry, +} from "@shared/connector-models"; +import { isConnectorExecutionReady } from "@shared/connector-policy"; +import type { AppLocale } from "@shared/locale"; +import type { ConnectorId, ProfileSnapshot } from "@shared/types"; +import { unwrap } from "../../lib/desktop"; +import type { TranslationFn } from "../../i18n"; + +interface UseConnectorModelSelectionResult { + executionReadyConnectors: ProfileSnapshot["connectors"]; + hasAtLeastOneReady: boolean; + selectedConnectorId: ConnectorId | ""; + setSelectedConnectorId: (value: ConnectorId | "") => void; + selectedConnector: ProfileSnapshot["connectors"][number] | null; + selectedModel: string; + setSelectedModel: (value: string) => void; + connectorModels: ConnectorModelEntry[]; + connectorModelPools: Array<{ + connector: ProfileSnapshot["connectors"][number]; + models: ConnectorModelEntry[]; + }>; + modelOptions: Array<{ id: string; label: string }>; + loadingModels: boolean; + totalEnabledModels: number; + selectedConnectorEnabledModels: number; + toggleConnectorModel: (connectorId: ConnectorId, modelId: string) => Promise; +} + +export function useConnectorModelSelection( + connectors: ProfileSnapshot["connectors"], + currentLocale: AppLocale, + t: TranslationFn, + setError: (error: string | null) => void, +): UseConnectorModelSelectionResult { + const executionReadyConnectors = useMemo( + () => connectors.filter((connector) => isConnectorExecutionReady(connector)), + [connectors], + ); + const hasAtLeastOneReady = executionReadyConnectors.length > 0; + + const [selectedConnectorId, setSelectedConnectorId] = useState( + executionReadyConnectors[0]?.id ?? "", + ); + const [selectedModel, setSelectedModel] = useState(""); + const [connectorModelsById, setConnectorModelsById] = useState>({}); + const [loadingModels, setLoadingModels] = useState(false); + + useEffect(() => { + if (!selectedConnectorId && executionReadyConnectors[0]) { + setSelectedConnectorId(executionReadyConnectors[0].id); + return; + } + if (selectedConnectorId && !executionReadyConnectors.some((connector) => connector.id === selectedConnectorId)) { + setSelectedConnectorId(executionReadyConnectors[0]?.id ?? ""); + } + }, [executionReadyConnectors, selectedConnectorId]); + + const selectedConnector = + executionReadyConnectors.find((connector) => connector.id === selectedConnectorId) ?? + executionReadyConnectors[0] ?? + null; + + const loadConnectorModels = useCallback(async (connector: ProfileSnapshot["connectors"][number]) => { + if (connector.id.startsWith("claude_compat")) { + const configuredModels = (connector.model ?? "") + .split(",") + .map((model) => model.trim()) + .filter(Boolean) + .map((model) => ({ id: model, label: model })); + const disabledResult = await window.agentCompany.getConnectorDisabledModels(connector.id); + const disabledModels = disabledResult.ok ? disabledResult.data : []; + return mergeConnectorModelEntries(configuredModels, disabledModels); + } + + const [enabledResult, disabledResult] = await Promise.all([ + window.agentCompany.listConnectorModels(connector.id), + window.agentCompany.getConnectorDisabledModels(connector.id), + ]); + const enabledModels = enabledResult.ok ? enabledResult.data : []; + const disabledModels = disabledResult.ok ? disabledResult.data : []; + return mergeConnectorModelEntries(enabledModels, disabledModels); + }, []); + + useEffect(() => { + if (!hasAtLeastOneReady) { + setConnectorModelsById({}); + setSelectedModel(""); + return; + } + + let cancelled = false; + setLoadingModels(true); + + const loadModels = async () => { + try { + const entries = await Promise.all( + executionReadyConnectors.map(async (connector) => [connector.id, await loadConnectorModels(connector)] as const), + ); + if (cancelled) return; + setConnectorModelsById(Object.fromEntries(entries)); + } catch { + if (cancelled) return; + setConnectorModelsById({}); + setSelectedModel(""); + } finally { + if (!cancelled) { + setLoadingModels(false); + } + } + }; + + void loadModels(); + return () => { + cancelled = true; + }; + }, [executionReadyConnectors, hasAtLeastOneReady, loadConnectorModels]); + + const connectorModels = useMemo( + () => connectorModelsById[selectedConnectorId] ?? [], + [connectorModelsById, selectedConnectorId], + ); + + useEffect(() => { + if (!selectedConnectorId) { + setSelectedModel(""); + return; + } + setSelectedModel((currentModel) => + currentModel && connectorModels.some((entry) => entry.enabled && entry.id === currentModel) + ? currentModel + : "", + ); + }, [connectorModels, selectedConnectorId]); + + const modelOptions = useMemo( + () => connectorModels + .filter((model) => model.enabled) + .map((model) => ({ id: model.id, label: model.label })), + [connectorModels], + ); + + const connectorModelPools = useMemo( + () => + executionReadyConnectors.map((connector) => ({ + connector, + models: connectorModelsById[connector.id] ?? [], + })), + [connectorModelsById, executionReadyConnectors], + ); + + const totalEnabledModels = useMemo( + () => + Object.values(connectorModelsById).reduce( + (count, models) => count + models.filter((model) => model.enabled).length, + 0, + ), + [connectorModelsById], + ); + + const selectedConnectorEnabledModels = useMemo( + () => connectorModels.filter((model) => model.enabled).length, + [connectorModels], + ); + + const toggleConnectorModel = useCallback(async (connectorId: ConnectorId, modelId: string) => { + const connectorModels = connectorModelsById[connectorId]; + if (!connectorModels) { + return; + } + + const currentEntry = connectorModels.find((model) => model.id === modelId); + if (!currentEntry) { + return; + } + + const updatedModels = connectorModels.map((model) => + model.id === modelId ? { ...model, enabled: !model.enabled } : model, + ); + const updatedModelMap = { + ...connectorModelsById, + [connectorId]: updatedModels, + }; + const nextEnabledCount = Object.values(updatedModelMap).reduce( + (count, models) => count + models.filter((model) => model.enabled).length, + 0, + ); + + if (currentEntry.enabled && nextEnabledCount === 0) { + setError( + currentLocale === "zh" + ? "至少保留一个全局启用模型,CEO 才能继续为团队分配工作。" + : "Keep at least one enabled model across all providers so the CEO can keep staffing the team.", + ); + return; + } + + const previousModels = connectorModelsById; + setConnectorModelsById(updatedModelMap); + setError(null); + + try { + unwrap( + await window.agentCompany.setConnectorDisabledModels({ + connectorId, + disabledModels: updatedModels + .filter((model) => !model.enabled) + .map((model) => ({ id: model.id, label: model.label })), + }), + ); + } catch (error) { + setConnectorModelsById(previousModels); + setError(error instanceof Error ? error.message : t("onboarding.unableToBootstrap")); + } + }, [connectorModelsById, currentLocale, setError, t]); + + return { + executionReadyConnectors, + hasAtLeastOneReady, + selectedConnectorId, + setSelectedConnectorId, + selectedConnector, + selectedModel, + setSelectedModel, + connectorModels, + connectorModelPools, + modelOptions, + loadingModels, + totalEnabledModels, + selectedConnectorEnabledModels, + toggleConnectorModel, + }; +} diff --git a/src/renderer/components/automation/AutomationPanel.tsx b/src/renderer/components/automation/AutomationPanel.tsx index 1dd33ff1..78308494 100644 --- a/src/renderer/components/automation/AutomationPanel.tsx +++ b/src/renderer/components/automation/AutomationPanel.tsx @@ -2,9 +2,9 @@ import { useCallback, useEffect, useMemo, useState } from "react" import { AnimatePresence, motion } from "framer-motion" import { ArrowRight, ArrowsClockwise, Lightning, Plus, Robot, Trash, X } from "@phosphor-icons/react" import type { AutomationAction, AutomationRuleRecord, AutomationStatus, AutomationTrigger, WorkflowPipelineRecord } from "@shared/types" -import { ActionButton, Select } from "../ui" +import { ActionButton } from "../ui" import { useT } from "../../i18n" -import { ACCENT, LINE, triggerConfig, actionConfig, statusColors, type AutomationLogEntry } from "./automation-config" +import { ACCENT, LINE, triggerConfig, actionConfig, type AutomationLogEntry } from "./automation-config" import { timeAgo } from "../../lib/formatters" import { RuleCard } from "./RuleCard" import { RuleFormModal } from "./RuleEditor" diff --git a/src/renderer/components/automation/WorkflowList.tsx b/src/renderer/components/automation/WorkflowList.tsx index 84ca2ad0..062fa44d 100644 --- a/src/renderer/components/automation/WorkflowList.tsx +++ b/src/renderer/components/automation/WorkflowList.tsx @@ -1,9 +1,8 @@ import { useMemo, useState } from "react" import { AnimatePresence, motion } from "framer-motion" -import { ArrowsClockwise, CaretRight, Gear, Lightning, Play, Trash, X } from "@phosphor-icons/react" +import { ArrowsClockwise, CaretRight, Gear, Lightning, Play, Trash } from "@phosphor-icons/react" import type { WorkflowPipelineRecord, WorkflowStep } from "@shared/types" import { timeAgo } from "../../lib/formatters" -import { ActionButton } from "../ui" import { ACCENT, LINE, triggerConfig, actionConfig, workflowStatusColors, stepStatusConfig, safeJsonParse } from "./automation-config" function StepTimeline({ steps }: { steps: WorkflowStep[] }) { diff --git a/src/renderer/components/communication/ChannelHeader.tsx b/src/renderer/components/communication/ChannelHeader.tsx index 92dd8a65..ca4d17a0 100644 --- a/src/renderer/components/communication/ChannelHeader.tsx +++ b/src/renderer/components/communication/ChannelHeader.tsx @@ -37,10 +37,10 @@ export function ChannelHeader({ activeChannel, showSidebar, onShowSidebar, - channelMessages, - taskDiscussionComments, + channelMessages: _channelMessages, + taskDiscussionComments: _taskDiscussionComments, activeTask, - unreadByChannel, + unreadByChannel: _unreadByChannel, onNavigateToTask, filterBar, }: ChannelHeaderProps) { diff --git a/src/renderer/components/communication/ChannelSidebar.tsx b/src/renderer/components/communication/ChannelSidebar.tsx index bcf5e6ce..8e80d750 100644 --- a/src/renderer/components/communication/ChannelSidebar.tsx +++ b/src/renderer/components/communication/ChannelSidebar.tsx @@ -98,7 +98,7 @@ export function ChannelSidebar({ unreadByChannel, agents, totalChannels, - onHideSidebar, + onHideSidebar: _onHideSidebar, }: ChannelSidebarProps) { const t = useT() diff --git a/src/renderer/components/communication/CommunicationHub.tsx b/src/renderer/components/communication/CommunicationHub.tsx index 14cbeb53..d994d1b7 100644 --- a/src/renderer/components/communication/CommunicationHub.tsx +++ b/src/renderer/components/communication/CommunicationHub.tsx @@ -1,21 +1,21 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { AnimatePresence, motion } from "framer-motion" -import type { AgentMessageRecord, AgentRecord, CommentRecord, MessageChannel, ProjectRecord, TaskRecord } from "@shared/types" +import type { AgentMessageRecord, AgentRecord, CommentRecord, DocumentRecord, MessageChannel, ProjectRecord, TaskRecord } from "@shared/types" import { useAppStore } from "../../state/store" import { unwrap } from "../../lib/desktop" -import { useT } from "../../i18n" import { ChannelSidebar, FilterBar, type FilterState } from "./ChannelSidebar" -import type { ChannelDescriptor, TaskDiscussionDescriptor, AnyChannel } from "./ChannelHeader" +import type { ChannelDescriptor, AnyChannel } from "./ChannelHeader" import { ChannelHeader } from "./ChannelHeader" import { MessageList } from "./MessageList" import { MessageComposer, TaskDiscussionComposer, NoChannelComposer } from "./MessageComposer" import { SearchResults } from "./SearchResults" +import ThreadPanel from "./ThreadPanel" import { deriveChannels, deriveTaskDiscussionChannels, getChannelMessages } from "./channel-utils" interface CommunicationHubProps { messages: AgentMessageRecord[]; comments: CommentRecord[]; tasks: TaskRecord[]; agents: AgentRecord[] - projects?: ProjectRecord[]; companyId: string; selectedEntityId?: string | null + projects?: ProjectRecord[]; documents?: DocumentRecord[]; companyId: string; selectedEntityId?: string | null onEntityConsumed?: () => void; onNavigateToTask: (taskId: string) => void; onRefresh: () => void } @@ -58,8 +58,7 @@ function SearchScopeToggle({ scope, onChangeScope }: { scope: "global" | "channe ) } -export function CommunicationHub({ messages, comments, tasks, agents, projects, companyId, selectedEntityId, onEntityConsumed, onNavigateToTask, onRefresh }: CommunicationHubProps) { - const t = useT() +export function CommunicationHub({ messages, comments, tasks, agents, projects, documents, companyId, selectedEntityId, onEntityConsumed, onNavigateToTask, onRefresh }: CommunicationHubProps) { const [selectedChannelKey, setSelectedChannelKey] = useState(null) const [showSidebar, setShowSidebar] = useState(true) const [replyTo, setReplyTo] = useState(null) @@ -67,6 +66,10 @@ export function CommunicationHub({ messages, comments, tasks, agents, projects, const pendingReadIdsRef = useRef>(new Set()) const [filters, setFilters] = useState({ search: "", channelType: "all", priority: "all", unreadOnly: false }) + // Thread panel state from Zustand store + const activeThreadId = useAppStore((s) => s.activeThreadId) + const setActiveThreadId = useAppStore((s) => s.setActiveThreadId) + // Search state from Zustand store const searchQuery = useAppStore((s) => s.searchQuery) const searchScope = useAppStore((s) => s.searchScope) @@ -269,8 +272,19 @@ export function CommunicationHub({ messages, comments, tasks, agents, projects, )}
- {activeChannel ? (activeChannel.kind === "message" ? { setReplyTo(null); onRefresh() }} onCancelReply={() => setReplyTo(null)} /> : ) : } + {activeChannel ? (activeChannel.kind === "message" ? { setReplyTo(null); onRefresh() }} onCancelReply={() => setReplyTo(null)} documents={documents} /> : ) : }
+ + {activeThreadId && ( + setActiveThreadId(null)} + /> + )} +
) } diff --git a/src/renderer/components/communication/MessageComposer.tsx b/src/renderer/components/communication/MessageComposer.tsx index cf85fa92..525e4520 100644 --- a/src/renderer/components/communication/MessageComposer.tsx +++ b/src/renderer/components/communication/MessageComposer.tsx @@ -1,12 +1,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { AnimatePresence, motion } from "framer-motion" -import { ArrowBendUpLeft, PaperPlaneTilt, X, ChatCircle } from "@phosphor-icons/react" -import type { AgentMessageRecord, AgentRecord, MessageChannel } from "@shared/types" +import { ArrowBendUpLeft, PaperPlaneTilt, X, ChatCircle, UserCircle, Paperclip, FileText } from "@phosphor-icons/react" +import type { AgentMessageRecord, AgentRecord, DocumentRecord, MessageChannel } from "@shared/types" import { unwrap } from "../../lib/desktop" +import { useAppStore } from "../../state/store" import { InlineNotice } from "../ui" -import { AgentAvatar } from "./MessageItem" import { useT } from "../../i18n" +const HUMAN_SENDER_ID = "__board__"; + function SendButton({ sending, disabled, onClick, label }: { sending: boolean; disabled: boolean; onClick: () => void; label: string }) { return ( + + {open && ( +
+
+ setFilter(e.target.value)} + placeholder="Search documents..." + className="w-full rounded-lg border border-border bg-muted px-2.5 py-1.5 text-[12px] text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1" + autoFocus + /> +
+
+ {filtered.length === 0 ? ( +
+ {documents.length === 0 ? "No documents in this company" : "No matching documents"} +
+ ) : ( + filtered.slice(0, 20).map((doc) => ( + + )) + )} +
+
+ )} + + {attachments.length > 0 && ( +
+ {attachments.map((att) => ( + + + {att.title} + + + ))} +
+ )} +
+ ) +} + +// ─── @Mention Autocomplete ────────────────────────────────────────────────── + +function MentionDropdown({ agents, query, onSelect, position }: { + agents: AgentRecord[]; + query: string; + onSelect: (agent: AgentRecord) => void; + position: { top: number; left: number }; +}) { + const [selectedIndex, setSelectedIndex] = useState(0) + const filtered = useMemo(() => { + const q = query.toLowerCase() + return agents.filter((a) => a.status !== "terminated" && ( + a.name.toLowerCase().includes(q) || + (a.title?.toLowerCase().includes(q)) || + (a.department?.toLowerCase().includes(q)) + )).slice(0, 8) + }, [agents, query]) + + useEffect(() => { setSelectedIndex(0) }, [query]) + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === "ArrowDown") { e.preventDefault(); setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1)) } + else if (e.key === "ArrowUp") { e.preventDefault(); setSelectedIndex((i) => Math.max(i - 1, 0)) } + else if (e.key === "Enter" || e.key === "Tab") { + if (filtered[selectedIndex]) { e.preventDefault(); onSelect(filtered[selectedIndex]) } + } + else if (e.key === "Escape") { onSelect(null as unknown as AgentRecord) } + } + document.addEventListener("keydown", handler, true) + return () => document.removeEventListener("keydown", handler, true) + }, [filtered, selectedIndex, onSelect]) + + if (filtered.length === 0) return null + + return ( +
+ {filtered.map((agent, i) => ( + + ))} +
+ ) +} + +// ─── Main Composer ────────────────────────────────────────────────────────── + interface MessageComposerProps { agents: AgentRecord[]; companyId: string; channel: MessageChannel; channelTargetId: string | null directParticipantIds?: string[]; replyTo: AgentMessageRecord | null; onSent: () => void; onCancelReply: () => void + documents?: DocumentRecord[] + /** Thread ID for precise checkpoint resolution. */ + threadId?: string | null + /** Wait state ID for precise checkpoint resolution. */ + waitStateId?: string | null } -export function MessageComposer({ agents, companyId, channel, channelTargetId, directParticipantIds = [], replyTo, onSent, onCancelReply }: MessageComposerProps) { - const [fromAgentId, setFromAgentId] = useState(agents[0]?.id ?? "") +export function MessageComposer({ agents, companyId, channel, channelTargetId, directParticipantIds = [], replyTo, onSent, onCancelReply, documents = [], threadId = null, waitStateId = null }: MessageComposerProps) { const [body, setBody] = useState(""); const [subject, setSubject] = useState("") const [sending, setSending] = useState(false); const [sendError, setSendError] = useState(null) + const [attachments, setAttachments] = useState([]) + const [mentionState, setMentionState] = useState<{ active: boolean; query: string; startIndex: number }>({ active: false, query: "", startIndex: -1 }) const textareaRef = useRef(null) + const addToast = useAppStore((s) => s.addToast) useEffect(() => { if (replyTo) textareaRef.current?.focus() }, [replyTo]) - useEffect(() => { if (agents.length === 0) { if (fromAgentId) setFromAgentId(""); return }; if (!agents.some((a) => a.id === fromAgentId)) setFromAgentId(agents[0]?.id ?? "") }, [agents, fromAgentId]) const directRecipientId = useMemo(() => { if (channel !== "direct") return null if (replyTo?.fromAgentId) return replyTo.fromAgentId if (directParticipantIds.length === 0) return null - if (directParticipantIds.length === 1) return directParticipantIds[0] ?? null - if (fromAgentId && directParticipantIds.includes(fromAgentId)) return directParticipantIds.find((id) => id !== fromAgentId) ?? null - return directParticipantIds[0] ?? null - }, [channel, replyTo, directParticipantIds, fromAgentId]) + const nonBoardParticipant = directParticipantIds.find((id) => id !== HUMAN_SENDER_ID) + return nonBoardParticipant ?? directParticipantIds[0] ?? null + }, [channel, replyTo, directParticipantIds]) + + const handleAddAttachment = useCallback((doc: DocumentRecord) => { + setAttachments((prev) => [...prev, { type: "document", id: doc.id, title: doc.title }]) + }, []) + + const handleRemoveAttachment = useCallback((id: string) => { + setAttachments((prev) => prev.filter((a) => a.id !== id)) + }, []) + + const handleMentionSelect = useCallback((agent: AgentRecord | null) => { + if (!agent) { setMentionState({ active: false, query: "", startIndex: -1 }); return } + setBody((prev) => { + const before = prev.slice(0, mentionState.startIndex) + const after = prev.slice(mentionState.startIndex + mentionState.query.length + 1) + return `${before}@${agent.name} ${after}` + }) + setMentionState({ active: false, query: "", startIndex: -1 }) + textareaRef.current?.focus() + }, [mentionState]) + + const handleBodyChange = useCallback((e: React.ChangeEvent) => { + const value = e.target.value + setBody(value) + + const cursorPos = e.target.selectionStart + const textBeforeCursor = value.slice(0, cursorPos) + const atIndex = textBeforeCursor.lastIndexOf("@") + + if (atIndex >= 0) { + const charBefore = atIndex > 0 ? textBeforeCursor[atIndex - 1] : " " + if (charBefore === " " || charBefore === "\n" || atIndex === 0) { + const query = textBeforeCursor.slice(atIndex + 1) + if (!query.includes(" ") && !query.includes("\n") && query.length <= 30) { + setMentionState({ active: true, query, startIndex: atIndex }) + return + } + } + } + setMentionState({ active: false, query: "", startIndex: -1 }) + }, []) const handleSend = useCallback(async () => { - if (!body.trim() || !fromAgentId) return; if (channel === "direct" && !directRecipientId) return - if (!agents.some((a) => a.id === fromAgentId)) { setSendError("Select a valid sender."); return } + if (!body.trim()) return; if (channel === "direct" && !directRecipientId) return setSending(true); setSendError(null) - try { unwrap(await window.agentCompany.sendAgentMessage({ companyId, fromAgentId, toAgentId: channel === "direct" ? directRecipientId : null, channel, channelTargetId, subject: subject.trim() || (replyTo ? `Re: ${replyTo.subject}` : ""), body: body.trim(), priority: "normal", parentMessageId: replyTo?.id ?? null, attachmentsJson: "[]" })); setBody(""); setSubject(""); onSent() } catch (e) { setSendError(e instanceof Error ? e.message : "Failed to send message.") } finally { setSending(false) } - }, [agents, body, channel, channelTargetId, companyId, directRecipientId, fromAgentId, onSent, replyTo, subject]) + const attachmentsJson = attachments.length > 0 ? JSON.stringify(attachments) : "[]" + try { + const result = unwrap(await window.agentCompany.sendAgentMessage({ + companyId, fromAgentId: HUMAN_SENDER_ID, + toAgentId: channel === "direct" ? directRecipientId : null, + channel, channelTargetId, + subject: subject.trim() || (replyTo ? `Re: ${replyTo.subject}` : ""), + body: body.trim(), priority: "normal", + parentMessageId: replyTo?.id ?? null, attachmentsJson, + threadId: threadId ?? undefined, + waitStateId: waitStateId ?? undefined, + })) + if (result.mentionResults && result.mentionResults.length > 0) { + const woken = result.mentionResults.filter((m) => m.outcome === "woken") + const queued = result.mentionResults.filter((m) => m.outcome === "queued") + const busy = result.mentionResults.filter((m) => m.outcome === "busy") + if (woken.length > 0) addToast(`Notified: ${woken.map((m) => m.agentName).join(", ")}`, "success") + if (queued.length > 0) addToast(`Queued: ${queued.map((m) => m.agentName).join(", ")}`, "warn") + if (busy.length > 0) addToast(`Busy: ${busy.map((m) => m.agentName).join(", ")}`, "warn") + } + setBody(""); setSubject(""); setAttachments([]); onSent() + } catch (e) { setSendError(e instanceof Error ? e.message : "Failed to send message.") } + finally { setSending(false) } + }, [body, channel, channelTargetId, companyId, directRecipientId, onSent, replyTo, subject, attachments, threadId, waitStateId, addToast]) - const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); void handleSend() } }, [handleSend]) - const fromAgent = agents.find((a) => a.id === fromAgentId) + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (mentionState.active) return // let MentionDropdown handle keys + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); void handleSend() } + }, [handleSend, mentionState.active]) return ( -
+
{sendError ?
: null} {channel === "direct" && !directRecipientId ?
: null} @@ -62,16 +288,38 @@ export function MessageComposer({ agents, companyId, channel, channelTargetId, d ) : null}
- - +
+ You (Board) {!replyTo ? setSubject(e.target.value)} placeholder="Subject (optional)" aria-label="Message subject" className="min-w-0 flex-1 rounded-lg border border-border bg-card px-2.5 py-1 text-[11px] text-foreground placeholder:text-muted-foreground" /> : null}
-
-