Build the current workspace snapshot on native CI runners
This branch snapshots the in-progress desktop workspace so GitHub-hosted Windows and Linux runners can package the same source tree that already passes local verification on macOS. Constraint: Local Apple Silicon Docker/Wine packaging crashes before Windows artifacts are produced Rejected: Publish from the incomplete base repo state | remote typecheck failed because current source additions were missing Confidence: medium Scope-risk: broad Directive: Treat this branch as a build snapshot only; do not merge it without reviewing the unrelated workspace changes it contains Tested: local typecheck, lint, workspace typecheck, unit tests, integration tests, e2e smoke, macOS signed release upload Not-tested: Native Windows/Linux artifacts before this CI run
This commit is contained in:
1 parent
0e497cd892
commit
b772e1c758
271 files changed
+35047
-22879
No files matched your search
+12
-4
@@ -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
|
||||
build/release/AgentCompany-0.4.90-arm64.dmg
|
||||
local-release/
|
||||
@@ -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`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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: <pkg>/dist/server/ -> <pkg>/skills/
|
||||
path.resolve(__moduleDir, "../../../../../skills"), // dev: src/server/ -> repo root/skills/
|
||||
];
|
||||
|
||||
export async function resolveAgentCompanySkillsDir(): Promise<string | null> {
|
||||
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<void>,
|
||||
): Promise<string> {
|
||||
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<string, unknown>;
|
||||
context: Record<string, unknown>;
|
||||
authToken?: string;
|
||||
}
|
||||
|
||||
export interface ClaudeRuntimeConfig {
|
||||
command: string;
|
||||
cwd: string;
|
||||
workspaceId: string | null;
|
||||
workspaceRepoUrl: string | null;
|
||||
workspaceRepoRef: string | null;
|
||||
env: Record<string, string>;
|
||||
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<string, string>, key: string): boolean {
|
||||
const raw = env[key];
|
||||
return typeof raw === "string" && raw.trim().length > 0;
|
||||
}
|
||||
|
||||
export function resolveClaudeBillingType(env: Record<string, string>): "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<ClaudeRuntimeConfig> {
|
||||
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<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const runtimeServiceIntents = Array.isArray(context.agentCompanyRuntimeServiceIntents)
|
||||
? context.agentCompanyRuntimeServiceIntents.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const runtimeServices = Array.isArray(context.agentCompanyRuntimeServices)
|
||||
? context.agentCompanyRuntimeServices.filter(
|
||||
(value): value is Record<string, unknown> => 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<string, string> = { ...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,
|
||||
};
|
||||
}
|
||||
@@ -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: <pkg>/dist/server/ -> <pkg>/skills/
|
||||
path.resolve(__moduleDir, "../../../../../skills"), // dev: src/server/ -> repo root/skills/
|
||||
];
|
||||
|
||||
async function resolveAgentCompanySkillsDir(): Promise<string | null> {
|
||||
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<void>,
|
||||
): Promise<string> {
|
||||
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<string, unknown>;
|
||||
context: Record<string, unknown>;
|
||||
authToken?: string;
|
||||
}
|
||||
|
||||
interface ClaudeRuntimeConfig {
|
||||
command: string;
|
||||
cwd: string;
|
||||
workspaceId: string | null;
|
||||
workspaceRepoUrl: string | null;
|
||||
workspaceRepoRef: string | null;
|
||||
env: Record<string, string>;
|
||||
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<string, string>, key: string): boolean {
|
||||
const raw = env[key];
|
||||
return typeof raw === "string" && raw.trim().length > 0;
|
||||
}
|
||||
|
||||
function resolveClaudeBillingType(env: Record<string, string>): "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<ClaudeRuntimeConfig> {
|
||||
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<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const runtimeServiceIntents = Array.isArray(context.agentCompanyRuntimeServiceIntents)
|
||||
? context.agentCompanyRuntimeServiceIntents.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const runtimeServices = Array.isArray(context.agentCompanyRuntimeServices)
|
||||
? context.agentCompanyRuntimeServices.filter(
|
||||
(value): value is Record<string, unknown> => 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<string, string> = { ...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;
|
||||
|
||||
@@ -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: <pkg>/dist/server/ -> <pkg>/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<string, string>, key: string): boolean {
|
||||
const raw = env[key];
|
||||
return typeof raw === "string" && raw.trim().length > 0;
|
||||
}
|
||||
|
||||
export function resolveCodexBillingType(env: Record<string, string>): "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<string | null> {
|
||||
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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: <pkg>/dist/server/ -> <pkg>/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<string, string>, key: string): boolean {
|
||||
const raw = env[key];
|
||||
return typeof raw === "string" && raw.trim().length > 0;
|
||||
}
|
||||
|
||||
function resolveCodexBillingType(env: Record<string, string>): "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<string | null> {
|
||||
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<AdapterExecutionResult> {
|
||||
const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx;
|
||||
|
||||
@@ -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<string, string>, key: string): boolean {
|
||||
const raw = env[key];
|
||||
return typeof raw === "string" && raw.trim().length > 0;
|
||||
}
|
||||
|
||||
export function resolveGeminiBillingType(env: Record<string, string>): "api" | "subscription" {
|
||||
return hasNonEmptyEnvValue(env, "GEMINI_API_KEY") || hasNonEmptyEnvValue(env, "GOOGLE_API_KEY")
|
||||
? "api"
|
||||
: "subscription";
|
||||
}
|
||||
|
||||
export function renderEnvNote(env: Record<string, string>): 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, string>): 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<string | null> {
|
||||
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<void> {
|
||||
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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, string>, key: string): boolean {
|
||||
const raw = env[key];
|
||||
return typeof raw === "string" && raw.trim().length > 0;
|
||||
}
|
||||
|
||||
function resolveGeminiBillingType(env: Record<string, string>): "api" | "subscription" {
|
||||
return hasNonEmptyEnvValue(env, "GEMINI_API_KEY") || hasNonEmptyEnvValue(env, "GOOGLE_API_KEY")
|
||||
? "api"
|
||||
: "subscription";
|
||||
}
|
||||
|
||||
function renderEnvNote(env: Record<string, string>): 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, string>): 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<string | null> {
|
||||
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<void> {
|
||||
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<AdapterExecutionResult> {
|
||||
const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx;
|
||||
@@ -275,25 +162,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
);
|
||||
}
|
||||
}
|
||||
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;
|
||||
})();
|
||||
|
||||
const renderedPrompt = renderTemplate(promptTemplate, {
|
||||
agentId: agent.id,
|
||||
companyId: agent.companyId,
|
||||
@@ -313,6 +181,24 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
// and pass the content via stdin to avoid ENAMETOOLONG.
|
||||
const SAFE_ARG_LIMIT = 30_000;
|
||||
const useStdin = prompt.length > 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`);
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown> | null {
|
||||
try {
|
||||
return JSON.parse(value) as Record<string, unknown>;
|
||||
} 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<string, unknown>, 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<string, unknown>)[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<string, unknown>) {
|
||||
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<string, string>): Record<string, string> {
|
||||
const redacted: Record<string, string> = {};
|
||||
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<string, string> {
|
||||
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<string, string> = {
|
||||
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";
|
||||
}
|
||||
@@ -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<string, RunningProcess>();
|
||||
|
||||
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<string | null> {
|
||||
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<SpawnTarget> {
|
||||
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<string, string>;
|
||||
timeoutSec: number;
|
||||
graceSec: number;
|
||||
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
onLogError?: (err: unknown, runId: string, message: string) => void;
|
||||
stdin?: string;
|
||||
},
|
||||
): Promise<RunProcessResult> {
|
||||
const onLogError = opts.onLogError ?? ((err, id, msg) => console.warn({ err, runId: id }, msg));
|
||||
|
||||
return new Promise<RunProcessResult>((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<void> = 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);
|
||||
}
|
||||
}
|
||||
@@ -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<string, InstalledSkillTarget>;
|
||||
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<string, unknown>): {
|
||||
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<string, unknown>;
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
desiredSkills: string[],
|
||||
): Record<string, unknown> {
|
||||
const next = { ...config };
|
||||
const raw = next.agentCompanySkillSync;
|
||||
const current =
|
||||
typeof raw === "object" && raw !== null && !Array.isArray(raw)
|
||||
? { ...(raw as Record<string, unknown>) }
|
||||
: {};
|
||||
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<void> = (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<string>,
|
||||
): Promise<string[]> {
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
@@ -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<void>,
|
||||
): Promise<void> {
|
||||
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<string | null> {
|
||||
const candidates = [
|
||||
...AGENTCOMPANY_SKILL_ROOT_RELATIVE_CANDIDATES.map((relativePath) => path.resolve(moduleDir, relativePath)),
|
||||
...additionalCandidates.map((candidate) => path.resolve(candidate)),
|
||||
];
|
||||
const seenRoots = new Set<string>();
|
||||
|
||||
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<AgentCompanySkillEntry[]> {
|
||||
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<Map<string, InstalledSkillTarget>> {
|
||||
const entries = await fs.readdir(skillsHome, { withFileTypes: true }).catch(() => []);
|
||||
const out = new Map<string, InstalledSkillTarget>();
|
||||
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<string, unknown>,
|
||||
moduleDir: string,
|
||||
additionalCandidates: string[] = [],
|
||||
): Promise<AgentCompanySkillEntry[]> {
|
||||
const configuredEntries = normalizeConfiguredAgentCompanyRuntimeSkills(config.agentCompanyRuntimeSkills);
|
||||
if (configuredEntries.length > 0) return configuredEntries;
|
||||
return listAgentCompanySkillEntries(moduleDir, additionalCandidates);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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`;
|
||||
}
|
||||
|
||||
|
||||
+273
-47
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
+4
-2324
File diff suppressed because it is too large.
Load diff
@@ -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<ApiServerEventCallbacks>,
|
||||
allowedOrigins?: Iterable<string>,
|
||||
): 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;
|
||||
}
|
||||
@@ -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}` });
|
||||
});
|
||||
}
|
||||
@@ -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<string, unknown> : {};
|
||||
if (typeof payload.name === "string" && payload.name.toLowerCase() === input.name.toLowerCase()) {
|
||||
server.json(res, 409, { error: `A hire request for "${input.name}" is already pending approval.` });
|
||||
return;
|
||||
}
|
||||
} catch { /* 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 });
|
||||
});
|
||||
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
const commentBody = typeof data.body === "string" ? data.body.trim() : "";
|
||||
if (!commentBody) { server.json(res, 400, { error: "Comment body is required" }); return; }
|
||||
const commentId = db.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<string, unknown>;
|
||||
const taskIds = Array.isArray(data.taskIds) ? data.taskIds.filter((id): id is string => typeof id === "string") : [];
|
||||
if (taskIds.length === 0) { server.json(res, 400, { error: "taskIds array is required" }); return; }
|
||||
for (const taskId of taskIds) {
|
||||
if (!db.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 });
|
||||
});
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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" }); }
|
||||
});
|
||||
}
|
||||
@@ -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<string>();
|
||||
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<string, unknown>),
|
||||
companyId,
|
||||
fromAgentId: token.agentId,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
server.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() });
|
||||
return;
|
||||
}
|
||||
|
||||
const dispatchResult = dispatchAgentMessage(
|
||||
{
|
||||
db,
|
||||
emitEvent,
|
||||
notify: server.callbacks_.notify,
|
||||
publishDomainChanged: onDomainChanged,
|
||||
wakeAgentIfPossible: (agentId, cid, trigger) => {
|
||||
if (!agentId) return null;
|
||||
return onWakeAgent(agentId, cid, trigger);
|
||||
},
|
||||
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 });
|
||||
});
|
||||
}
|
||||
@@ -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<string, unknown>);
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -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<AgentApiServer["json"]>[0], event: string, data: unknown) {
|
||||
res.write(`event: ${event}\n`);
|
||||
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||
}
|
||||
|
||||
export function registerRunRoutes(server: AgentApiServer) {
|
||||
server.addRoute("GET", "/api/runs/:runId/events", (req, res, params, token) => {
|
||||
const runId = 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);
|
||||
});
|
||||
}
|
||||
@@ -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<string, unknown> = {
|
||||
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<typeof db.saveTask>[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<string>();
|
||||
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 });
|
||||
});
|
||||
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
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<string, number> = {};
|
||||
for (const t of tasks) {
|
||||
tasksByStatus[t.status] = (tasksByStatus[t.status] ?? 0) + 1;
|
||||
}
|
||||
|
||||
server.json(res, 200, {
|
||||
totalAgents: agents.length,
|
||||
activeAgents: agents.filter((a) => a.status === "active" || a.status === "running").length,
|
||||
totalTasks: tasks.length,
|
||||
tasksByStatus,
|
||||
recentActivity: recentActivity.map((a) => ({
|
||||
id: a.id,
|
||||
actor: a.actor,
|
||||
action: a.action,
|
||||
entityType: a.entityType,
|
||||
entityId: a.entityId,
|
||||
detail: a.detail,
|
||||
createdAt: a.createdAt,
|
||||
})),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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<string, string>,
|
||||
token: TokenPayload,
|
||||
body: unknown,
|
||||
) => void | Promise<void>;
|
||||
|
||||
export interface Route {
|
||||
method: string;
|
||||
pattern: RegExp;
|
||||
paramNames: string[];
|
||||
handler: RequestHandler;
|
||||
}
|
||||
|
||||
export interface ApiServerEventCallbacks {
|
||||
onDomainChanged: () => void;
|
||||
onWakeAgent: (agentId: string, companyId: string, trigger: string) => string | null;
|
||||
onTaskCreated: (taskId: string, companyId: string) => void;
|
||||
onTaskStatusChanged: (taskId: string, companyId: string, previousStatus: string, newStatus: string) => void;
|
||||
onCommentPosted: (taskId: string, companyId: string, authorAgentId: string | null) => void;
|
||||
onApprovalCreated: (approvalId: string, companyId: string, taskId: string | null, approvalType: string, summary: string) => void;
|
||||
onApprovalResolved: (approvalId: string, companyId: string, decision: string) => void;
|
||||
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<typeof createServer> | null = null;
|
||||
private port = 0;
|
||||
private db: AppDatabase;
|
||||
private onDomainChangedCb: () => void;
|
||||
private onWakeAgentCb: (agentId: string, companyId: string, trigger: string) => string | null;
|
||||
private emitEventCb: (event: DesktopEvent) => void;
|
||||
private callbacks: ApiServerEventCallbacks;
|
||||
private allowedOrigins: Set<string>;
|
||||
private onBrowserActionCb: ((actionId: string, companyId: string, socialAccountId: string, actionType: string, payload: Record<string, unknown>) => void) | null = null;
|
||||
|
||||
constructor(
|
||||
db: AppDatabase,
|
||||
onDomainChanged: () => void,
|
||||
onWakeAgent: (agentId: string, companyId: string, trigger: string) => string | null,
|
||||
callbacks?: Partial<ApiServerEventCallbacks>,
|
||||
allowedOrigins: Iterable<string> = ["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<string, unknown>) => void) {
|
||||
this.onBrowserActionCb = handler;
|
||||
}
|
||||
|
||||
getPort(): number {
|
||||
return this.port;
|
||||
}
|
||||
|
||||
async start(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.server = createServer((req, res) => void this.handleRequest(req, res));
|
||||
this.server.listen(0, "127.0.0.1", () => {
|
||||
const 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<string, string> = {};
|
||||
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<unknown> {
|
||||
const MAX_BODY_BYTES = 1_048_576; // 1 MB
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let bytes = 0;
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
bytes += chunk.length;
|
||||
if (bytes > MAX_BODY_BYTES) {
|
||||
req.destroy();
|
||||
reject(new Error("Request body too large"));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on("end", () => {
|
||||
try {
|
||||
const raw = Buffer.concat(chunks);
|
||||
const data = decodeBodyToUtf8(raw);
|
||||
resolve(data ? JSON.parse(data) : null);
|
||||
}
|
||||
catch { reject(new Error("Invalid JSON")); }
|
||||
});
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
json(res: ServerResponse, status: number, data: unknown) {
|
||||
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
companyRefError(
|
||||
res: ServerResponse,
|
||||
companyId: string,
|
||||
refs: Array<{ id: string | null | undefined; table: "agents" | "goals" | "projects" | "tasks" | "workspaces" | "approvals"; label: string }>,
|
||||
) {
|
||||
const invalid = refs.find((ref) => ref.id && !this.db.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<string, unknown>) => void) | null {
|
||||
return this.onBrowserActionCb;
|
||||
}
|
||||
get port_(): number { return this.port; }
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
+2
-1574
File diff suppressed because it is too large.
Load diff
@@ -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<string, unknown>;
|
||||
} 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<string, number>;
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
@@ -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<void>;
|
||||
isQuitting: () => boolean;
|
||||
}
|
||||
|
||||
export interface ApprovalHandlerCallbacks {
|
||||
dispatchAutomation: (trigger: string, ctx: AutomationEventContext) => void;
|
||||
handleTaskStatusChange: (taskId: string, companyId: string, previousStatus: string, newStatus: string) => void;
|
||||
runWorkflowPipeline: (workflowId: string, companyId: string) => Promise<void>;
|
||||
trackTimer: (timer: ReturnType<typeof setTimeout>) => void;
|
||||
}
|
||||
|
||||
export function handleApprovalResolved(
|
||||
deps: ApprovalHandlerDeps,
|
||||
callbacks: ApprovalHandlerCallbacks,
|
||||
approvalId: string,
|
||||
companyId: string,
|
||||
decision: string,
|
||||
): void {
|
||||
if (deps.isQuitting()) return;
|
||||
const { db, logger, publishDomainChanged, notify, wakeAgentIfPossible, 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");
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
if (Object.keys(conditions).length === 0) return true;
|
||||
if (conditions.status && ctx.newStatus && conditions.status !== ctx.newStatus) return false;
|
||||
if (conditions.previousStatus && ctx.previousStatus && conditions.previousStatus !== ctx.previousStatus) return false;
|
||||
if (conditions.priority) {
|
||||
try {
|
||||
const task = ctx.taskId ? db.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);
|
||||
}
|
||||
@@ -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<void>;
|
||||
isQuitting: () => boolean;
|
||||
}
|
||||
|
||||
export interface HandlerCallbacks {
|
||||
dispatchAutomation: (trigger: string, ctx: AutomationEventContext) => void;
|
||||
handleTaskStatusChange: (taskId: string, companyId: string, previousStatus: string, newStatus: string) => void;
|
||||
runWorkflowPipeline: (workflowId: string, companyId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function handleTaskStatusChange(
|
||||
deps: HandlerDeps,
|
||||
callbacks: Pick<HandlerCallbacks, "dispatchAutomation">,
|
||||
taskId: string,
|
||||
companyId: string,
|
||||
previousStatus: string,
|
||||
newStatus: string,
|
||||
): void {
|
||||
if (deps.isQuitting()) return;
|
||||
if (previousStatus === newStatus) return;
|
||||
const { db, wakeAgentIfPossible } = deps;
|
||||
const { dispatchAutomation } = callbacks;
|
||||
|
||||
let task: ReturnType<typeof db.getTask> | 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<HandlerCallbacks, "dispatchAutomation">,
|
||||
taskId: string,
|
||||
companyId: string,
|
||||
): void {
|
||||
if (deps.isQuitting()) return;
|
||||
const { db, wakeAgentIfPossible } = deps;
|
||||
const { dispatchAutomation } = callbacks;
|
||||
|
||||
let task: ReturnType<typeof db.getTask> | 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");
|
||||
}
|
||||
}
|
||||
@@ -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<typeof db.getTask> | 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<string, unknown>).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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { AppDatabase } from "../database";
|
||||
|
||||
const DEPARTMENT_SKILL_KEYWORDS: Record<string, string[]> = {
|
||||
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;
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
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<string, unknown>; ctx: AutomationEventContext } {
|
||||
let config: Record<string, unknown> = {};
|
||||
try {
|
||||
config = JSON.parse(step.configJson) as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new Error(`Workflow step "${step.name}" has invalid config JSON.`);
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
ctx: {
|
||||
companyId,
|
||||
agentId: actor?.id,
|
||||
department: actor?.department ?? step.assigneeDepartment ?? undefined,
|
||||
taskId: typeof config.taskId === "string" ? config.taskId : undefined,
|
||||
runId: typeof config.runId === "string" ? config.runId : undefined,
|
||||
projectId: typeof config.projectId === "string" ? config.projectId : undefined,
|
||||
newStatus: typeof config.status === "string" ? config.status : undefined,
|
||||
} satisfies AutomationEventContext,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runWorkflowPipeline(
|
||||
workflowId: string,
|
||||
companyId: string,
|
||||
deps: WorkflowDeps,
|
||||
actionDeps: ActionDeps,
|
||||
actionCallbacks: ActionCallbacks,
|
||||
): Promise<void> {
|
||||
const { db, publishDomainChanged } = deps;
|
||||
|
||||
const workflow = db.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string>();
|
||||
const activeWorkflowTriggerExecutions = new Set<string>();
|
||||
const pendingScheduledTimers = new Set<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const actionDeps: ActionDeps = {
|
||||
db,
|
||||
logger,
|
||||
emitEvent,
|
||||
publishDomainChanged,
|
||||
wakeAgentIfPossible,
|
||||
};
|
||||
|
||||
const workflowDeps: WorkflowDeps = {
|
||||
db,
|
||||
logger,
|
||||
publishDomainChanged,
|
||||
};
|
||||
|
||||
const handlerDeps: HandlerDeps = {
|
||||
db,
|
||||
logger,
|
||||
publishDomainChanged,
|
||||
notify,
|
||||
wakeAgentIfPossible,
|
||||
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<void> {
|
||||
return runWorkflowPipeline(workflowId, companyId, workflowDeps, actionDeps, actionCallbacks);
|
||||
}
|
||||
|
||||
function _handleTaskCreated(taskId: string, companyId: string): void {
|
||||
handleTaskCreated(handlerDeps, { dispatchAutomation }, taskId, companyId);
|
||||
}
|
||||
|
||||
function _handleApprovalResolved(approvalId: string, companyId: string, decision: string): void {
|
||||
handleApprovalResolved(approvalDeps, approvalCallbacks, approvalId, companyId, decision);
|
||||
}
|
||||
|
||||
function _handleTaskStatusChange(taskId: string, companyId: string, previousStatus: string, newStatus: string): void {
|
||||
handleTaskStatusChange(handlerDeps, { dispatchAutomation }, taskId, companyId, previousStatus, newStatus);
|
||||
}
|
||||
|
||||
const actionCallbacks: ActionCallbacks = {
|
||||
handleTaskCreated: _handleTaskCreated,
|
||||
handleApprovalResolved: _handleApprovalResolved,
|
||||
handleTaskStatusChange: _handleTaskStatusChange,
|
||||
};
|
||||
|
||||
const approvalCallbacks: ApprovalHandlerCallbacks = {
|
||||
dispatchAutomation,
|
||||
handleTaskStatusChange: _handleTaskStatusChange,
|
||||
runWorkflowPipeline: _runWorkflowPipeline,
|
||||
trackTimer: (timer) => { pendingScheduledTimers.add(timer); },
|
||||
};
|
||||
|
||||
const runCompletionCallbacks: RunCompletionCallbacks = {
|
||||
dispatchAutomation,
|
||||
handleTaskStatusChange: _handleTaskStatusChange,
|
||||
};
|
||||
|
||||
function evaluateAutomationRules(trigger: string, ctx: AutomationEventContext) {
|
||||
try {
|
||||
const rules = db.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,
|
||||
};
|
||||
}
|
||||
@@ -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<AutomationAction> = 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<void>;
|
||||
isQuitting: () => boolean;
|
||||
}
|
||||
|
||||
export interface RunFinishedEvent {
|
||||
status: RunStatus;
|
||||
summary: string | null;
|
||||
errorMessage: string | null;
|
||||
costUsd: number | null;
|
||||
}
|
||||
@@ -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<typeof createServiceGraph>;
|
||||
ipcSupport: ReturnType<typeof createIpcSupport>;
|
||||
threadIntegration: ReturnType<typeof createThreadIntegration>;
|
||||
}) => 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<string, unknown>) => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
+18
-524
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, PlatformRule> = {
|
||||
twitter: {
|
||||
loginUrl: "https://x.com/i/flow/login",
|
||||
authCheckUrl: "https://x.com/home",
|
||||
allowedHosts: ["x.com", "twitter.com"],
|
||||
cookieOrigins: ["https://x.com", "https://twitter.com"],
|
||||
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<BrowserActionResult> {
|
||||
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<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
private waitForLoad(win: BrowserWindow, timeoutMs = 15000): Promise<void> {
|
||||
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<boolean> {
|
||||
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<string, string> = {
|
||||
twitter: "https://x.com/compose/tweet",
|
||||
linkedin: "https://www.linkedin.com/feed/",
|
||||
instagram: "https://www.instagram.com/",
|
||||
facebook: "https://www.facebook.com/",
|
||||
reddit: "https://www.reddit.com/submit",
|
||||
bluesky: "https://bsky.app/",
|
||||
youtube: "https://www.youtube.com/",
|
||||
tiktok: "https://www.tiktok.com/",
|
||||
};
|
||||
return urls[platform] || this.getPlatformRules(platform).loginUrl;
|
||||
}
|
||||
|
||||
private getFeedUrl(platform: string): string {
|
||||
const urls: Record<string, string> = {
|
||||
twitter: "https://x.com/home",
|
||||
linkedin: "https://www.linkedin.com/feed/",
|
||||
instagram: "https://www.instagram.com/",
|
||||
facebook: "https://www.facebook.com/",
|
||||
reddit: "https://www.reddit.com/",
|
||||
bluesky: "https://bsky.app/",
|
||||
youtube: "https://www.youtube.com/",
|
||||
tiktok: "https://www.tiktok.com/",
|
||||
};
|
||||
return urls[platform] || this.getPlatformRules(platform).loginUrl;
|
||||
}
|
||||
|
||||
private getSearchUrl(platform: string, query: string): string {
|
||||
const q = encodeURIComponent(query);
|
||||
const urls: Record<string, string> = {
|
||||
twitter: `https://x.com/search?q=${q}`,
|
||||
linkedin: `https://www.linkedin.com/search/results/all/?keywords=${q}`,
|
||||
reddit: `https://www.reddit.com/search/?q=${q}`,
|
||||
};
|
||||
return urls[platform] || `https://google.com/search?q=${q}`;
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
for (const [, win] of this.loginWindows) {
|
||||
if (!win.isDestroyed()) win.close();
|
||||
|
||||
@@ -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<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function waitForLoad(win: BrowserWindow, timeoutMs = 15000): Promise<void> {
|
||||
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<string>,
|
||||
activeActionWindows: Map<string, BrowserWindow>,
|
||||
): Promise<BrowserActionResult> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
export interface BrowserAction {
|
||||
id: string;
|
||||
socialAccountId: string;
|
||||
platform: string;
|
||||
actionType: string;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface BrowserActionResult {
|
||||
success: boolean;
|
||||
cancelled: boolean;
|
||||
summary: string;
|
||||
screenshotPath: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface QueuedAction {
|
||||
action: BrowserAction;
|
||||
resolve: (result: BrowserActionResult) => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
|
||||
export interface PlatformRule {
|
||||
loginUrl: string;
|
||||
authCheckUrl: string;
|
||||
allowedHosts: string[];
|
||||
cookieOrigins: string[];
|
||||
unauthenticatedUrlMarkers: string[];
|
||||
}
|
||||
|
||||
export const PLATFORM_RULES: Record<string, PlatformRule> = {
|
||||
twitter: {
|
||||
loginUrl: "https://x.com/i/flow/login",
|
||||
authCheckUrl: "https://x.com/home",
|
||||
allowedHosts: ["x.com", "twitter.com"],
|
||||
cookieOrigins: ["https://x.com", "https://twitter.com"],
|
||||
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"],
|
||||
},
|
||||
};
|
||||
@@ -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<string, string> = {
|
||||
twitter: "https://x.com/compose/tweet",
|
||||
linkedin: "https://www.linkedin.com/feed/",
|
||||
instagram: "https://www.instagram.com/",
|
||||
facebook: "https://www.facebook.com/",
|
||||
reddit: "https://www.reddit.com/submit",
|
||||
bluesky: "https://bsky.app/",
|
||||
youtube: "https://www.youtube.com/",
|
||||
tiktok: "https://www.tiktok.com/",
|
||||
};
|
||||
return urls[platform] || getPlatformRules(platform).loginUrl;
|
||||
}
|
||||
|
||||
export function getFeedUrl(platform: string): string {
|
||||
const urls: Record<string, string> = {
|
||||
twitter: "https://x.com/home",
|
||||
linkedin: "https://www.linkedin.com/feed/",
|
||||
instagram: "https://www.instagram.com/",
|
||||
facebook: "https://www.facebook.com/",
|
||||
reddit: "https://www.reddit.com/",
|
||||
bluesky: "https://bsky.app/",
|
||||
youtube: "https://www.youtube.com/",
|
||||
tiktok: "https://www.tiktok.com/",
|
||||
};
|
||||
return urls[platform] || getPlatformRules(platform).loginUrl;
|
||||
}
|
||||
|
||||
export function getSearchUrl(platform: string, query: string): string {
|
||||
const q = encodeURIComponent(query);
|
||||
const urls: Record<string, string> = {
|
||||
twitter: `https://x.com/search?q=${q}`,
|
||||
linkedin: `https://www.linkedin.com/search/results/all/?keywords=${q}`,
|
||||
reddit: `https://www.reddit.com/search/?q=${q}`,
|
||||
};
|
||||
return urls[platform] || `https://google.com/search?q=${q}`;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<typeof createCheckpointEngine>;
|
||||
@@ -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`,
|
||||
|
||||
@@ -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<Array<{ id: string; label: string }>>;
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
];
|
||||
@@ -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<string | null> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, { id: string; label: string }>();
|
||||
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 };
|
||||
}
|
||||
+4
-354
@@ -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<Array<{ id: string; label: string }>>;
|
||||
}
|
||||
|
||||
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}`);
|
||||
|
||||
@@ -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<typeof buildThreadMethods>;
|
||||
protected readonly company: ReturnType<typeof buildCompanyMethods>;
|
||||
protected readonly agents: ReturnType<typeof buildAgentMethods>;
|
||||
protected readonly tasks: ReturnType<typeof buildTaskMethods>;
|
||||
protected readonly messages: ReturnType<typeof buildMessageMethods>;
|
||||
protected readonly runs: ReturnType<typeof buildRunMethods>;
|
||||
protected readonly snap: ReturnType<typeof buildSnapshotMethods>;
|
||||
protected readonly settings: ReturnType<typeof buildSettingsMethods>;
|
||||
protected readonly connectorsCrud: ReturnType<typeof buildConnectorMethods>;
|
||||
protected readonly workspacesMod: ReturnType<typeof buildWorkspaceMethods>;
|
||||
protected readonly approvalsMod: ReturnType<typeof buildApprovalMethods>;
|
||||
protected readonly social: ReturnType<typeof buildSocialMethods>;
|
||||
protected readonly activityMod: ReturnType<typeof buildActivityMethods>;
|
||||
protected readonly skills: ReturnType<typeof buildSkillMethods>;
|
||||
protected readonly budget: ReturnType<typeof buildBudgetMethods>;
|
||||
protected readonly entityCrud: ReturnType<typeof buildEntityCrudMethods>;
|
||||
protected readonly documentsMod: ReturnType<typeof buildDocumentMethods>;
|
||||
protected readonly automationMod: ReturnType<typeof buildAutomationMethods>;
|
||||
protected readonly knowledgeMod: ReturnType<typeof buildKnowledgeMethods>;
|
||||
protected readonly wakeup: ReturnType<typeof buildWakeupMethods>;
|
||||
protected readonly backupMod: ReturnType<typeof buildBackupMethods>;
|
||||
|
||||
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 = <T>(sql: string, ...params: SQLInputValue[]) => this.readAll<T>(sql, ...params);
|
||||
const tx = <T>(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 = <T>(sql: string, ...params: SQLInputValue[]) => this.readAll<T>(sql, ...params);
|
||||
const tx = <T>(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<string, unknown>) { 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<string, unknown> | 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<string, unknown> | 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<T>(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<string>([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<T>(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<string, unknown>; configAfter: Record<string, unknown>; source?: string; rolledBackFromRevisionId?: string | null; createdByAgentId?: string | null; createdByUserId?: string | null }) { return this.documentsMod.recordConfigRevision(input); }
|
||||
}
|
||||
+163
-5450
File diff suppressed because it is too large.
Load diff
@@ -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<CommentRecord>(
|
||||
"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,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
configAfter: Record<string, unknown>;
|
||||
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<string, any> | 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<string, string[]>();
|
||||
|
||||
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<string>();
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<string>();
|
||||
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<AgentRecord>(
|
||||
"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<AgentRecord>(
|
||||
"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<string, boolean> {
|
||||
const row = getDb()
|
||||
.prepare("SELECT permissions_json FROM agents WHERE id = ?")
|
||||
.get(agentId) as { permissions_json: string } | undefined;
|
||||
return parseJson<Record<string, boolean>>(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<Record<string, boolean>>(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<Record<string, boolean>>(row?.permissions_json, {});
|
||||
return perms[permissionKey] === true;
|
||||
},
|
||||
|
||||
getRuntimeState(agentId: string): Record<string, unknown> {
|
||||
const row = getDb()
|
||||
.prepare("SELECT runtime_state_json FROM agents WHERE id = ?")
|
||||
.get(agentId) as { runtime_state_json: string | null } | undefined;
|
||||
return parseJson<Record<string, unknown>>(row?.runtime_state_json, {});
|
||||
},
|
||||
|
||||
setRuntimeState(
|
||||
agentId: string,
|
||||
companyId: string,
|
||||
state: Record<string, unknown>,
|
||||
): 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<Record<string, unknown>>(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<Record<string, unknown>>(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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
configAfter: Record<string, unknown>;
|
||||
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 }),
|
||||
};
|
||||
}
|
||||
@@ -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<ApprovalRecord>(
|
||||
`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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<string, any>;
|
||||
|
||||
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<string> {
|
||||
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<SqliteRow>(
|
||||
"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[];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<string, any>;
|
||||
|
||||
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<SqliteRow>(
|
||||
"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<SqliteRow>(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<SqliteRow>(sql, companyId)
|
||||
: readAll<SqliteRow>(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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
@@ -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<Partial<ConnectorRecord>>(
|
||||
"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,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<string, any>;
|
||||
|
||||
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<string, unknown>): 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<string, unknown>; configAfter: Record<string, unknown>;
|
||||
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<any>(
|
||||
"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<string[]>(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<string, unknown>; configAfter: Record<string, unknown>;
|
||||
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<string[]>(row.changed_keys, []),
|
||||
configBefore: parseJson<Record<string, unknown>>(row.config_before, {}),
|
||||
configAfter: parseJson<Record<string, unknown>>(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<string, any>;
|
||||
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,
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<string, string>): Record<string, string> {
|
||||
const invalidKeys = new Set(listInvalidEnvBindingKeys(bindingText));
|
||||
const bindings = parseEnvBindingText(bindingText);
|
||||
const result: Record<string, string> = {};
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { SQLInputValue } from "node:sqlite";
|
||||
|
||||
export type SqliteRow = Record<string, any>;
|
||||
export type ReadAll = <T>(sql: string, ...params: SQLInputValue[]) => T[];
|
||||
export type Transaction = <T>(operation: () => T) => T;
|
||||
|
||||
export function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
export function parseJson<T>(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`;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { AppDatabase, STALE_LOCK_TIMEOUT_MS } from "../database";
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<AgentMessageRecord>(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<string>();
|
||||
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<AgentMessageRecord>(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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<string, any>;
|
||||
|
||||
/**
|
||||
* 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<RunRecord>(
|
||||
`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<string, unknown> | 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<string, unknown> | null; createdAt: string;
|
||||
}> {
|
||||
const offset = opts?.offset ?? 0;
|
||||
const limit = opts?.limit ?? 500;
|
||||
return readAll<any>(
|
||||
"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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 = <T>(sql: string, ...params: SQLInputValue[]) => T[];
|
||||
type Transaction = <T>(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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<string, any>;
|
||||
|
||||
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<FileInventoryEntry[]>(row.file_inventory, []),
|
||||
metadata: parseJson<Record<string, unknown> | 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<SqliteRow>(
|
||||
"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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<string, any>;
|
||||
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<CompanyRecord>(
|
||||
"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<WorkspaceRecord>(
|
||||
`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<any>(
|
||||
`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<GoalRecord>(
|
||||
`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<ProjectRecord>(
|
||||
`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<TaskRecord>(
|
||||
`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<ApprovalRecord>(
|
||||
`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<RunRecord>(
|
||||
`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<CostRecord>(
|
||||
`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<ActivityRecord>(
|
||||
`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<SecretRecord>(
|
||||
`select id, company_id as companyId, name, description, backend, updated_at as updatedAt from secrets${companyWhere} order by updated_at desc`,
|
||||
...companyArgs,
|
||||
),
|
||||
comments: readAll<CommentRecord>(
|
||||
`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<SqliteRow>(
|
||||
`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<SqliteRow>(
|
||||
`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<SqliteRow>(
|
||||
`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<SqliteRow>(
|
||||
`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<SqliteRow>(
|
||||
`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<SqliteRow>(
|
||||
`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<SqliteRow>(
|
||||
`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<SqliteRow>(
|
||||
`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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<string, any>;
|
||||
|
||||
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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<TaskRecord>(
|
||||
`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<string, unknown> | 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<string, unknown> | 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<string, unknown> | null = null;
|
||||
if (row?.sessionJson) {
|
||||
try {
|
||||
sessionParams = JSON.parse(row.sessionJson) as Record<string, unknown>;
|
||||
} 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);
|
||||
},
|
||||
|
||||
};
|
||||
}
|
||||
@@ -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<TaskRecord>(
|
||||
`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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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<string, any>;
|
||||
type ReadAll = <T>(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<SqliteRow>(
|
||||
`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<SqliteRow>(
|
||||
`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<SqliteRow>(
|
||||
`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<SqliteRow>(
|
||||
`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<SqliteRow>(
|
||||
`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<SqliteRow>(
|
||||
`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,
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<string, unknown> = JSON.parse(existingJson);
|
||||
const incoming: Record<string, unknown> = JSON.parse(incomingJson);
|
||||
return JSON.stringify({ ...existing, ...incoming });
|
||||
}
|
||||
|
||||
return {
|
||||
enqueueWakeupRequest(companyId: string, agentId: string, trigger: string, detail: string, context?: Record<string, unknown>): 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<string, unknown> = 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<void>;
|
||||
onBackupProfile: () => Promise<void>;
|
||||
onShortcut: (action: "toggle-command-palette" | "toggle-console" | "refresh") => void;
|
||||
getMainWindow: () => BrowserWindow | null;
|
||||
ensureWindow: () => Promise<BrowserWindow | null>;
|
||||
requestQuitWithConfirmation: () => Promise<void>;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<void>;
|
||||
onBackupProfile: () => Promise<void>;
|
||||
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<BrowserWindow | null> | 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<string, string> = {
|
||||
".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,
|
||||
};
|
||||
}
|
||||
@@ -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<void>;
|
||||
onBackupProfile: () => Promise<void>;
|
||||
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<BrowserWindow | null> | 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<string, string> = {
|
||||
".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,
|
||||
};
|
||||
}
|
||||
@@ -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<ApprovalType>([
|
||||
"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<ApprovalType>([
|
||||
"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;
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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<string>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Loaded 100 of 271 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user