This commit is contained in:
ZacharyZhang-NY committed 2026-04-05 20:14:48 -04:00
1 parent 09971cabc2
commit 9145572422
163 files changed
+3348 -2874

No files matched your search

+33 -7
View File
@@ -4,6 +4,8 @@ on:
push:
tags:
- 'v*'
pull_request:
branches: [main]
workflow_dispatch:
inputs:
publish:
@@ -108,7 +110,7 @@ jobs:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Upload release files
- name: Upload to CI release (Cloudflare transport)
env:
GH_TOKEN: ${{ github.token }}
run: |
@@ -125,6 +127,15 @@ jobs:
build/release/*.blockmap \
build/release/latest-mac.yml \
--clobber
- name: Upload artifacts for release job
uses: actions/upload-artifact@v4
with:
name: macos-${{ matrix.arch }}
path: |
build/release/*.dmg
build/release/*.zip
build/release/*.blockmap
build/release/latest-mac.yml
build-windows:
name: Windows (${{ matrix.arch }})
@@ -136,8 +147,8 @@ jobs:
fail-fast: false
matrix:
include:
- arch: x64-ia32
builder_args: --win --x64 --ia32
- arch: x64
builder_args: --win --x64
- arch: arm64
builder_args: --win --arm64
steps:
@@ -157,7 +168,7 @@ jobs:
env:
CSC_LINK: ${{ secrets.WIN_CERTIFICATE_P12_BASE64 }}
CSC_KEY_PASSWORD: ${{ secrets.WIN_CERTIFICATE_PASSWORD }}
- name: Upload release files
- name: Upload to CI release (Cloudflare transport)
shell: bash
env:
GH_TOKEN: ${{ github.token }}
@@ -174,6 +185,14 @@ jobs:
build/release/*.blockmap \
build/release/latest.yml \
--clobber
- name: Upload artifacts for release job
uses: actions/upload-artifact@v4
with:
name: windows-${{ matrix.arch }}
path: |
build/release/*.exe
build/release/*.blockmap
build/release/latest.yml
build-linux:
name: Linux (${{ matrix.arch }})
@@ -199,9 +218,6 @@ jobs:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Install snapcraft (x64 only)
if: matrix.arch == 'x64'
run: sudo snap install snapcraft --classic
- name: Build electron-vite
run: pnpm exec electron-vite build
- name: Package
@@ -224,6 +240,16 @@ jobs:
build/release/*.blockmap \
build/release/latest-linux.yml \
--clobber
- name: Upload artifacts for release job
uses: actions/upload-artifact@v4
with:
name: linux-${{ matrix.arch }}
path: |
build/release/*.AppImage
build/release/*.deb
build/release/*.rpm
build/release/*.blockmap
build/release/latest-linux.yml
# ─────────────────────────────────────────────
# Stage 4: Publish to GitHub Releases
+10 -8
View File
@@ -6,14 +6,16 @@
"extraMetadata": {
"version": "0.4.151"
},
"publish": [
{
"provider": "generic",
"url": "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC"
}
],
"mac": {
"hardenedRuntime": true,
"sign": "/Users/zacharyzhang/Documents/Github/AgentCompany/scripts/electron-builder-mac-sign.mjs"
"hardenedRuntime": false,
"identity": null,
"target": [
{
"target": "zip",
"arch": [
"arm64"
]
}
]
}
}
-1
View File
@@ -39,7 +39,6 @@ win:
- target: nsis
arch:
- x64
- ia32
- arm64
linux:
+1
View File
@@ -14,6 +14,7 @@ export default tseslint.config(
"oldreference/**",
"Reference/**",
"boss-build/**",
"local-release/**",
"builtin-skills/**",
"node_modules/**",
"**/node_modules/**",
+2 -3
View File
@@ -25,10 +25,9 @@
"typecheck:all": "pnpm -r typecheck",
"test:unit": "node scripts/run-vitest.mjs run --config vitest.config.ts tests/unit",
"test:integration": "node scripts/run-vitest.mjs run --config vitest.config.ts tests/integration",
"test:packages": "echo 'no standalone package tests'",
"test:e2e": "node --no-warnings ./node_modules/@playwright/test/cli.js test -c tests/e2e/playwright.config.ts",
"test:run": "pnpm test:unit && pnpm test:integration && pnpm test:packages && pnpm test:e2e",
"verify": "pnpm typecheck && pnpm lint && pnpm typecheck:all && pnpm test:unit && pnpm test:integration && pnpm test:packages && pnpm test:e2e",
"test:run": "pnpm test:unit && pnpm test:integration && pnpm test:e2e",
"verify": "pnpm typecheck && pnpm lint && pnpm typecheck:all && pnpm test:unit && pnpm test:integration && pnpm test:e2e",
"verify:release": "pnpm verify && pnpm build:dir",
"postinstall": "node scripts/postinstall.mjs"
},
@@ -1,34 +1,37 @@
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";
import {
hasNonEmptyEnvValue,
buildSkillsCandidates,
resolveSkillsDir,
parseManagedSkills,
parseExtraArgs,
buildCommonAgentCompanyEnv,
resolveSessionResumeState,
} from "@agentcompany/adapter-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 {
hasNonEmptyEnvValue,
buildSkillsCandidates,
resolveSkillsDir as resolveAgentCompanySkillsDir,
parseManagedSkills,
parseExtraArgs,
buildCommonAgentCompanyEnv,
resolveSessionResumeState,
};
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 const SKILLS_CANDIDATES = buildSkillsCandidates(import.meta.url);
/**
* Create a tmpdir with `.claude/skills/` containing symlinks to skills from
@@ -44,13 +47,12 @@ export async function buildSkillsDir(
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();
const skillsDir = await resolveSkillsDir(SKILLS_CANDIDATES);
if (!skillsDir) return tmp;
const entries = await fs.readdir(skillsDir, { withFileTypes: true });
for (const entry of entries) {
@@ -98,13 +100,7 @@ export function buildLoginResult(input: {
};
}
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";
}
@@ -112,140 +108,17 @@ export async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Pro
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 { env, cwd, workspaceId, workspaceRepoUrl, workspaceRepoRef } = await buildCommonAgentCompanyEnv(
{ runId, agent, config, context, authToken },
{ ensureAbsoluteDirectory },
);
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);
})();
const extraArgs = parseExtraArgs(config);
return {
command,
@@ -2,7 +2,6 @@ import fs from "node:fs/promises";
import path from "node:path";
import type { AdapterExecutionContext, AdapterExecutionResult } from "@agentcompany/adapter-utils";
import type { RunProcessResult } from "@agentcompany/adapter-utils/server-utils";
import type { ManagedSkillRef } from "@agentcompany/adapter-utils";
import {
asString,
asNumber,
@@ -26,6 +25,8 @@ import {
buildLoginResult,
resolveClaudeBillingType,
buildClaudeRuntimeConfig,
parseManagedSkills,
resolveSessionResumeState,
} from "./execute-helpers.js";
export async function runClaudeLogin(input: {
@@ -104,11 +105,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
extraArgs,
} = runtimeConfig;
const billingType = resolveClaudeBillingType(env);
const managedSkills: ManagedSkillRef[] = Array.isArray(config.managedSkills)
? (config.managedSkills as ManagedSkillRef[]).filter(
(s) => typeof s.runtimeName === "string" && typeof s.sourcePath === "string",
)
: [];
const managedSkills = parseManagedSkills(config);
const skillsDir = await buildSkillsDir(managedSkills, onLog);
// When instructionsFilePath is configured, create a combined temp file that
@@ -123,13 +120,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
effectiveInstructionsFilePath = combinedPath;
}
const runtimeSessionParams = parseObject(runtime.sessionParams);
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
const canResumeSession =
runtimeSessionId.length > 0 &&
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
const sessionId = canResumeSession ? runtimeSessionId : null;
const sessionResume = resolveSessionResumeState(runtime, cwd);
const { sessionId, runtimeSessionId, runtimeSessionCwd, canResumeSession } = sessionResume;
if (runtimeSessionId && !canResumeSession) {
await onLog(
"stderr",
@@ -1,17 +1,29 @@
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 {
hasNonEmptyEnvValue,
buildSkillsCandidates,
resolveSkillsDir,
parseManagedSkills,
parseExtraArgs,
buildCommonAgentCompanyEnv,
resolveSessionResumeState,
} from "@agentcompany/adapter-utils";
import {
createManagedSkillSymlinks,
} from "@agentcompany/adapter-utils/server-utils";
import fs from "node:fs/promises";
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 {
hasNonEmptyEnvValue,
parseManagedSkills,
parseExtraArgs,
buildCommonAgentCompanyEnv,
resolveSessionResumeState,
};
export const SKILLS_CANDIDATES = buildSkillsCandidates(import.meta.url);
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;
@@ -31,13 +43,7 @@ export function stripCodexRolloutNoise(text: string): string {
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";
}
@@ -48,11 +54,7 @@ export function codexHomeDir(): string {
}
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;
return resolveSkillsDir(SKILLS_CANDIDATES);
}
export async function ensureCodexSkillsInjected(
@@ -61,13 +63,12 @@ export async function ensureCodexSkillsInjected(
) {
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();
const skillsDir = await resolveSkillsDir(SKILLS_CANDIDATES);
if (!skillsDir) return;
await fs.mkdir(skillsHome, { recursive: true });
@@ -1,13 +1,10 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { AdapterExecutionContext, AdapterExecutionResult, ManagedSkillRef } from "@agentcompany/adapter-utils";
import fs from "node:fs/promises";
import type { AdapterExecutionContext, AdapterExecutionResult } from "@agentcompany/adapter-utils";
import {
asString,
asNumber,
asBoolean,
asStringArray,
parseObject,
buildAgentCompanyEnv,
redactEnvForLogs,
ensureAbsoluteDirectory,
ensureCommandResolvable,
@@ -22,6 +19,10 @@ import {
stripCodexRolloutNoise,
resolveCodexBillingType,
ensureCodexSkillsInjected,
parseManagedSkills,
parseExtraArgs,
buildCommonAgentCompanyEnv,
resolveSessionResumeState,
} from "./execute-helpers.js";
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
@@ -43,155 +44,32 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
asBoolean(config.dangerouslyBypassSandbox, false),
);
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, "");
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
const workspaceBranch = asString(workspaceContext.branchName, "");
const workspaceWorktreePath = asString(workspaceContext.worktreePath, "");
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 managedSkills: ManagedSkillRef[] = Array.isArray(config.managedSkills)
? (config.managedSkills as ManagedSkillRef[]).filter(
(s) => typeof s.runtimeName === "string" && typeof s.sourcePath === "string",
)
: [];
const { env, cwd, workspaceId, workspaceRepoUrl, workspaceRepoRef } =
await buildCommonAgentCompanyEnv(
{ runId, agent, config, context, authToken },
{ ensureAbsoluteDirectory },
);
const managedSkills = parseManagedSkills(config);
await ensureCodexSkillsInjected(managedSkills, onLog);
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 [k, v] of Object.entries(envConfig)) {
if (typeof v === "string") env[k] = v;
}
if (!hasExplicitApiKey && authToken) {
env.AGENT_COMPANY_API_KEY = authToken;
}
const billingType = resolveCodexBillingType(env);
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);
})();
const extraArgs = parseExtraArgs(config);
const runtimeSessionParams = parseObject(runtime.sessionParams);
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
const canResumeSession =
runtimeSessionId.length > 0 &&
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
const sessionId = canResumeSession ? runtimeSessionId : null;
const { sessionId, runtimeSessionId, runtimeSessionCwd, canResumeSession } =
resolveSessionResumeState(runtime, cwd);
if (runtimeSessionId && !canResumeSession) {
await onLog(
"stderr",
`[agent-company] Codex session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
);
}
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
let instructionsPrefix = "";
@@ -244,8 +122,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (model) args.push("--model", model);
if (modelReasoningEffort) args.push("-c", `model_reasoning_effort=${JSON.stringify(modelReasoningEffort)}`);
if (extraArgs.length > 0) {
// Filter out flags the adapter already manages to prevent duplicates
// (e.g. user shell aliases that inject --dangerously-bypass-approvals-and-sandbox)
const managedFlags = [
"--dangerously-bypass-approvals-and-sandbox",
"--json",
@@ -353,7 +229,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
provider: "openai",
model,
billingType,
costUsd: null,
costUsd: attempt.parsed.costUsd,
resultJson: {
stdout: attempt.proc.stdout,
stderr: attempt.proc.stderr,
@@ -4,6 +4,7 @@ export function parseCodexJsonl(stdout: string) {
let sessionId: string | null = null;
const messages: string[] = [];
let errorMessage: string | null = null;
let costUsd: number | null = null;
const usage = {
inputTokens: 0,
cachedInputTokens: 0,
@@ -43,6 +44,8 @@ export function parseCodexJsonl(stdout: string) {
usage.inputTokens = asNumber(usageObj.input_tokens, usage.inputTokens);
usage.cachedInputTokens = asNumber(usageObj.cached_input_tokens, usage.cachedInputTokens);
usage.outputTokens = asNumber(usageObj.output_tokens, usage.outputTokens);
const rawCost = event.total_cost_usd;
if (typeof rawCost === "number" && Number.isFinite(rawCost)) costUsd = rawCost;
continue;
}
@@ -50,6 +53,8 @@ export function parseCodexJsonl(stdout: string) {
const err = parseObject(event.error);
const msg = asString(err.message, "").trim();
if (msg) errorMessage = msg;
const rawCost = event.total_cost_usd;
if (typeof rawCost === "number" && Number.isFinite(rawCost)) costUsd = rawCost;
}
}
@@ -57,6 +62,7 @@ export function parseCodexJsonl(stdout: string) {
sessionId,
summary: messages.join("\n\n").trim(),
usage,
costUsd,
errorMessage,
};
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@agentcompany/adapter-gemini-local",
"version": "0.2.7",
"version": "0.3.0",
"type": "module",
"exports": {
".": "./src/index.ts",
@@ -2,22 +2,29 @@ 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 {
hasNonEmptyEnvValue,
buildSkillsCandidates,
resolveSkillsDir,
parseManagedSkills,
parseExtraArgs,
buildCommonAgentCompanyEnv,
resolveSessionResumeState,
} 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 {
hasNonEmptyEnvValue,
parseManagedSkills,
parseExtraArgs,
buildCommonAgentCompanyEnv,
resolveSessionResumeState,
};
export function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
const raw = env[key];
return typeof raw === "string" && raw.trim().length > 0;
}
export const SKILLS_CANDIDATES = buildSkillsCandidates(import.meta.url);
export function resolveGeminiBillingType(env: Record<string, string>): "api" | "subscription" {
return hasNonEmptyEnvValue(env, "GEMINI_API_KEY") || hasNonEmptyEnvValue(env, "GOOGLE_API_KEY")
@@ -54,11 +61,7 @@ export function renderApiAccessNote(env: Record<string, string>): string {
}
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;
return resolveSkillsDir(SKILLS_CANDIDATES);
}
export function geminiSkillsHome(): string {
@@ -75,13 +78,12 @@ export async function ensureGeminiSkillsInjected(
): 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();
const skillsDir = await resolveSkillsDir(SKILLS_CANDIDATES);
if (!skillsDir) return;
try {
@@ -1,17 +1,14 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { AdapterExecutionContext, AdapterExecutionResult, ManagedSkillRef } from "@agentcompany/adapter-utils";
import type { AdapterExecutionContext, AdapterExecutionResult } from "@agentcompany/adapter-utils";
import {
asBoolean,
asNumber,
asString,
asStringArray,
buildAgentCompanyEnv,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePathInEnv,
parseObject,
filterConflictingArgs,
redactEnvForLogs,
renderTemplate,
@@ -31,6 +28,10 @@ import {
renderEnvNote,
renderApiAccessNote,
ensureGeminiSkillsInjected,
parseManagedSkills,
parseExtraArgs,
buildCommonAgentCompanyEnv,
resolveSessionResumeState,
} from "./execute-helpers.js";
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
@@ -44,95 +45,25 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const model = asString(config.model, DEFAULT_GEMINI_LOCAL_MODEL).trim();
const sandbox = asBoolean(config.sandbox, false);
const workspaceContext = parseObject(context.agentCompanyWorkspace);
const workspaceCwd = asString(workspaceContext.cwd, "");
const workspaceSource = asString(workspaceContext.source, "");
const workspaceId = asString(workspaceContext.workspaceId, "");
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
const workspaceHints = Array.isArray(context.agentCompanyWorkspaces)
? context.agentCompanyWorkspaces.filter(
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
)
: [];
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 managedSkills: ManagedSkillRef[] = Array.isArray(config.managedSkills)
? (config.managedSkills as ManagedSkillRef[]).filter(
(s) => typeof s.runtimeName === "string" && typeof s.sourcePath === "string",
)
: [];
const { env, cwd, workspaceId, workspaceRepoUrl, workspaceRepoRef } =
await buildCommonAgentCompanyEnv(
{ runId, agent, config, context, authToken },
{ ensureAbsoluteDirectory },
);
const managedSkills = parseManagedSkills(config);
await ensureGeminiSkillsInjected(managedSkills, onLog);
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 (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 (workspaceHints.length > 0) env.AGENT_COMPANY_WORKSPACES_JSON = JSON.stringify(workspaceHints);
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 billingType = resolveGeminiBillingType(env);
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);
})();
const extraArgs = parseExtraArgs(config);
const runtimeSessionParams = parseObject(runtime.sessionParams);
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
const canResumeSession =
runtimeSessionId.length > 0 &&
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
const sessionId = canResumeSession ? runtimeSessionId : null;
const { sessionId, runtimeSessionId, runtimeSessionCwd, canResumeSession } =
resolveSessionResumeState(runtime, cwd);
if (runtimeSessionId && !canResumeSession) {
await onLog(
"stderr",
@@ -175,10 +106,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const apiAccessNote = renderApiAccessNote(env);
const prompt = `${instructionsPrefix}${envNote}${apiAccessNote}${renderedPrompt}`;
// When the prompt is short enough for a command-line argument, pass it
// directly as a positional arg (Gemini CLI's native interface). When it
// exceeds the OS limit (~32k chars on Windows), write it to a temp file
// and pass the content via stdin to avoid ENAMETOOLONG.
const SAFE_ARG_LIMIT = 30_000;
const useStdin = prompt.length > SAFE_ARG_LIMIT;
const commandNotes = (() => {
@@ -295,7 +222,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const clearSessionForTurnLimit = isGeminiTurnLimitResult(attempt.parsed.resultEvent, attempt.proc.exitCode);
// On retry, don't fall back to old session ID — the old session was stale
const canFallbackToRuntimeSession = !isRetry;
const resolvedSessionId = attempt.parsed.sessionId
?? (canFallbackToRuntimeSession ? (runtimeSessionId ?? runtime.sessionId ?? null) : null);
@@ -361,7 +287,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
return toResult(initial);
} finally {
// Clean up temp prompt file if used
if (promptFilePath) {
await fs.unlink(promptFilePath).catch(() => {});
}
@@ -0,0 +1,232 @@
import { fileURLToPath } from "node:url";
import path from "node:path";
import fs from "node:fs/promises";
import type { ManagedSkillRef } from "./types.js";
import { asString, asStringArray, parseObject } from "./server-utils-parsers.js";
import { buildAgentCompanyEnv } from "./server-utils-parsers.js";
// ---------------------------------------------------------------------------
// hasNonEmptyEnvValue
// ---------------------------------------------------------------------------
export function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
const raw = env[key];
return typeof raw === "string" && raw.trim().length > 0;
}
// ---------------------------------------------------------------------------
// Skills directory resolution
// ---------------------------------------------------------------------------
/**
* Build the standard two-candidate list for a given adapter package.
* `importMetaUrl` should be `import.meta.url` from the adapter's execute-helpers.ts.
* - published: <pkg>/dist/server/ -> <pkg>/skills/
* - dev: src/server/ -> repo root/skills/
*/
export function buildSkillsCandidates(importMetaUrl: string): string[] {
const moduleDir = path.dirname(fileURLToPath(importMetaUrl));
return [
path.resolve(moduleDir, "../../skills"),
path.resolve(moduleDir, "../../../../../skills"),
];
}
/**
* Resolve the first existing skills directory from the given candidates.
*/
export async function resolveSkillsDir(candidates: string[]): Promise<string | null> {
for (const candidate of candidates) {
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
if (isDir) return candidate;
}
return null;
}
// ---------------------------------------------------------------------------
// ManagedSkills type guard + parser
// ---------------------------------------------------------------------------
export function parseManagedSkills(config: Record<string, unknown>): ManagedSkillRef[] {
if (!Array.isArray(config.managedSkills)) return [];
return (config.managedSkills as ManagedSkillRef[]).filter(
(s) => typeof s.runtimeName === "string" && typeof s.sourcePath === "string",
);
}
// ---------------------------------------------------------------------------
// extraArgs parser
// ---------------------------------------------------------------------------
export function parseExtraArgs(config: Record<string, unknown>): string[] {
const fromExtraArgs = asStringArray(config.extraArgs);
if (fromExtraArgs.length > 0) return fromExtraArgs;
return asStringArray(config.args);
}
// ---------------------------------------------------------------------------
// Common env building
// ---------------------------------------------------------------------------
export interface CommonAgentCompanyEnvInput {
runId: string;
agent: { id: string; companyId: string };
config: Record<string, unknown>;
context: Record<string, unknown>;
authToken?: string;
}
export interface CommonAgentCompanyEnvResult {
env: Record<string, string>;
cwd: string;
effectiveWorkspaceCwd: string;
workspaceId: string | null;
workspaceRepoUrl: string | null;
workspaceRepoRef: string | null;
hasExplicitApiKey: boolean;
}
/**
* Build the AGENT_COMPANY_* environment block common to all local adapters.
* Returns the env map plus resolved workspace fields needed for session params.
*/
export async function buildCommonAgentCompanyEnv(
input: CommonAgentCompanyEnvInput,
opts: {
ensureAbsoluteDirectory: (dir: string, options: { createIfMissing: boolean }) => Promise<void>;
},
): Promise<CommonAgentCompanyEnvResult> {
const { runId, agent, config, context, authToken } = input;
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 as unknown, "");
const configuredCwd = asString(config.cwd, "");
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
await opts.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;
// Merge caller-configured env overrides
for (const [key, value] of Object.entries(envConfig)) {
if (typeof value === "string") env[key] = value;
}
if (!hasExplicitApiKey && authToken) {
env.AGENT_COMPANY_API_KEY = authToken;
}
return {
env,
cwd,
effectiveWorkspaceCwd,
workspaceId,
workspaceRepoUrl,
workspaceRepoRef,
hasExplicitApiKey,
};
}
// ---------------------------------------------------------------------------
// Session resume validation
// ---------------------------------------------------------------------------
export interface SessionResumeState {
sessionId: string | null;
runtimeSessionId: string;
runtimeSessionCwd: string;
canResumeSession: boolean;
}
export function resolveSessionResumeState(
runtime: { sessionId?: string | null; sessionParams?: Record<string, unknown> | null },
cwd: string,
): SessionResumeState {
const runtimeSessionParams = parseObject(runtime.sessionParams);
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
const canResumeSession =
runtimeSessionId.length > 0 &&
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
return {
sessionId: canResumeSession ? runtimeSessionId : null,
runtimeSessionId,
runtimeSessionCwd,
canResumeSession,
};
}
+14 -1
View File
@@ -33,5 +33,18 @@ export {
REDACTED_HOME_PATH_USER,
redactHomePathUserSegments,
redactHomePathUserSegmentsInValue,
redactTranscriptEntryPaths,
} from "./log-redaction.js";
export type {
CommonAgentCompanyEnvInput,
CommonAgentCompanyEnvResult,
SessionResumeState,
} from "./execute-helpers.js";
export {
hasNonEmptyEnvValue,
buildSkillsCandidates,
resolveSkillsDir,
parseManagedSkills,
parseExtraArgs,
buildCommonAgentCompanyEnv,
resolveSessionResumeState,
} from "./execute-helpers.js";
@@ -1,5 +1,3 @@
import type { TranscriptEntry } from "./types.js";
export const REDACTED_HOME_PATH_USER = "[]";
const HOME_PATH_PATTERNS = [
@@ -49,33 +47,3 @@ export function redactHomePathUserSegmentsInValue<T>(value: T): T {
return redacted as T;
}
export function redactTranscriptEntryPaths(entry: TranscriptEntry): TranscriptEntry {
switch (entry.kind) {
case "assistant":
case "thinking":
case "user":
case "stderr":
case "system":
case "stdout":
return { ...entry, text: redactHomePathUserSegments(entry.text) };
case "tool_call":
return { ...entry, name: redactHomePathUserSegments(entry.name), input: redactHomePathUserSegmentsInValue(entry.input) };
case "tool_result":
return { ...entry, content: redactHomePathUserSegments(entry.content) };
case "init":
return {
...entry,
model: redactHomePathUserSegments(entry.model),
sessionId: redactHomePathUserSegments(entry.sessionId),
};
case "result":
return {
...entry,
text: redactHomePathUserSegments(entry.text),
subtype: redactHomePathUserSegments(entry.subtype),
errors: entry.errors.map((error) => redactHomePathUserSegments(error)),
};
default:
return entry;
}
}
@@ -1,4 +1,4 @@
import { spawn, execSync, type ChildProcess } from "node:child_process";
import { spawn, spawnSync, 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";
@@ -356,7 +356,7 @@ export async function runChildProcess(
function killProcessTree(pid: number): void {
if (process.platform === "win32") {
try {
execSync(`taskkill /T /F /PID ${pid}`, { stdio: "ignore", timeout: 5000 });
spawnSync("taskkill", ["/T", "/F", "/PID", String(pid)], { stdio: "ignore", timeout: 5000 });
} catch { /* process already exited */ }
} else {
try {
@@ -224,30 +224,6 @@ export function resolveAgentCompanyDesiredSkillNames(
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".
-8
View File
@@ -35,11 +35,3 @@ export function summarizeProbeDetail(stdout: string, stderr: string, parsedError
return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean;
}
export function normalizeEnv(input: unknown): Record<string, string> {
if (typeof input !== "object" || input === null || Array.isArray(input)) return {};
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
if (typeof value === "string") env[key] = value;
}
return env;
}
+2
View File
@@ -7,6 +7,7 @@ import { registerMessageRoutes } from "./routes-messages";
import { registerApprovalRoutes } from "./routes-approvals";
import { registerFileRoutes } from "./routes-files";
import { registerMiscRoutes } from "./routes-misc";
import { registerMeetingRoutes } from "./routes-meetings";
import { registerRunRoutes } from "./routes-runs";
import type { AppDatabase } from "../database";
@@ -33,6 +34,7 @@ export function createApiServer(
registerApprovalRoutes(server);
registerFileRoutes(server);
registerMiscRoutes(server);
registerMeetingRoutes(server);
registerRunRoutes(server);
return server;
}
+35 -31
View File
@@ -1,14 +1,14 @@
import { z } from "zod";
import { canControlAgent } from "../agent-permissions";
import type { AgentApiServer } from "./server-core";
import { requireParam, type AgentApiServer } from "./server-core";
export function registerAgentControlRoutes(server: AgentApiServer) {
const db = server.db_;
const onDomainChanged = server.onDomainChanged_;
const onWakeAgent = server.onWakeAgent_;
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 targetAgentId = requireParam(params, "agentId");
const targetAgent = (() => {
try {
return db.getAgent(targetAgentId);
@@ -47,12 +47,14 @@ export function registerAgentControlRoutes(server: AgentApiServer) {
});
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) {
const targetAgentId = requireParam(params, "agentId");
let targetAgent;
try {
targetAgent = db.getAgent(targetAgentId);
} catch {
targetAgent = null;
}
if (!targetAgent || targetAgent.companyId !== token.companyId) {
server.json(res, 404, { error: "Agent not found or not in the same company" });
return;
}
@@ -78,35 +80,37 @@ export function registerAgentControlRoutes(server: AgentApiServer) {
});
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) {
const agentId = requireParam(params, "agentId");
let agent;
try { agent = db.getAgent(agentId); } catch { agent = null; }
if (!agent || agent.companyId !== token.companyId) {
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);
const skills = skillIds
.map((sid) => db.getCompanySkill(token.companyId, sid))
.filter((s): s is NonNullable<typeof s> => s != null);
server.json(res, 200, skills.map((s) => ({
id: s!.id,
name: s!.name,
slug: s!.slug,
description: s!.description,
sourceType: s!.sourceType,
trustLevel: s!.trustLevel,
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 agentId = requireParam(params, "agentId");
const parsed = z.object({ skillId: z.string().uuid() }).safeParse(body);
if (!parsed.success) {
server.json(res, 400, { error: "Invalid body: skillId (uuid) required" });
return;
}
const snapshot = db.listSnapshot();
const agent = snapshot.agents.find((a) => a.id === agentId && a.companyId === token.companyId);
if (!agent) {
let agent;
try { agent = db.getAgent(agentId); } catch { agent = null; }
if (!agent || agent.companyId !== token.companyId) {
server.json(res, 404, { error: "Agent not found in your company" });
return;
}
@@ -121,11 +125,11 @@ export function registerAgentControlRoutes(server: AgentApiServer) {
});
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) {
const agentId = requireParam(params, "agentId");
const skillId = requireParam(params, "skillId");
let agent;
try { agent = db.getAgent(agentId); } catch { agent = null; }
if (!agent || agent.companyId !== token.companyId) {
server.json(res, 404, { error: "Agent not found in your company" });
return;
}
+108 -53
View File
@@ -3,12 +3,44 @@ 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";
import { requireParam, type AgentApiServer } from "./server-core";
type AgentStandupRow = {
id: string; companyId: string; name: string; role: string;
status: string; reportsTo: string | null; department: string | null;
};
type TaskStandupRow = {
id: string; companyId: string; title: string;
assigneeAgentId: string | null; status: string; priority: string;
};
type ApprovalStandupRow = {
id: string; companyId: string; type: string;
requestedByAgentId: string; state: string;
payloadSummary: string | null; decisionNote: string | null;
};
type TaskCommentRow = {
id: string; taskId: string; authorName: string;
body: string; createdAt: string;
};
type AgentListRow = {
id: string; companyId: string; name: string; role: string;
title: string | null; department: string | null; status: string;
reportsTo: string | null; connectorId: string; workspaceId: string | null;
model: string | null; capabilities: string | null;
budgetMonthlyUsd: number | null; spentMonthlyUsd: number | null;
metadataJson: string; heartbeatEnabled: number;
heartbeatIntervalSec: number | null; lastHeartbeatAt: string | null;
createdAt: string; updatedAt: string;
};
export function registerAgentRoutes(server: AgentApiServer) {
const db = server.db_;
const callbacks = server.callbacks_;
const onDomainChanged = server.onDomainChanged_;
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);
@@ -17,17 +49,29 @@ export function registerAgentRoutes(server: AgentApiServer) {
});
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) {
const agent = db.getAgent(token.agentId);
if (!agent || agent.companyId !== token.companyId) {
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 companyAgents = db.queryAll<AgentStandupRow>(
"select id, company_id as companyId, name, role, status, reports_to as reportsTo, department from agents where company_id = ? order by created_at asc",
token.companyId,
);
const companyTasks = db.queryAll<TaskStandupRow>(
"select id, company_id as companyId, title, assignee_agent_id as assigneeAgentId, status, priority from tasks where company_id = ? order by created_at desc",
token.companyId,
);
const companyApprovals = db.queryAll<ApprovalStandupRow>(
"select id, company_id as companyId, type, requested_by_agent_id as requestedByAgentId, state, payload_summary as payloadSummary, decision_note as decisionNote from approvals where company_id = ? order by created_at desc",
token.companyId,
);
const myTasks = companyTasks.filter((t) => t.assigneeAgentId === token.agentId);
const directReports = companyAgents.filter((a) => a.reportsTo === token.agentId);
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 reportTasks = companyTasks.filter((t) => t.assigneeAgentId && reportIds.has(t.assigneeAgentId));
const tasksAwaitingMyReview = reportTasks.filter((t) => t.status === "in_review");
const blockedReportTasks = reportTasks.filter((t) => t.status === "blocked");
@@ -35,23 +79,26 @@ export function registerAgentRoutes(server: AgentApiServer) {
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 allRelevantTaskIds = new Set([...myTasks.map((t) => t.id), ...reportTasks.map((t) => t.id)]);
const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
const recentComments = allRelevantTaskIds.size > 0
? db.queryAll<TaskCommentRow>(
`select id, task_id as taskId, author_name as authorName, body, created_at as createdAt from task_comments where task_id in (${[...allRelevantTaskIds].map(() => "?").join(",")}) and created_at > ? order by created_at desc limit 20`,
...[...allRelevantTaskIds], twoHoursAgo,
)
: [];
const myPendingApprovals = snapshot.approvals.filter(
(a) => a.requestedByAgentId === token.agentId && a.state === "pending" && a.companyId === token.companyId,
const myPendingApprovals = companyApprovals.filter(
(a) => a.requestedByAgentId === token.agentId && a.state === "pending",
);
const myResolvedApprovals = snapshot.approvals.filter(
(a) => a.requestedByAgentId === token.agentId && a.state !== "pending" && a.companyId === token.companyId,
const myResolvedApprovals = companyApprovals.filter(
(a) => a.requestedByAgentId === token.agentId && a.state !== "pending",
).slice(0, 5);
const agentNameMap = new Map(companyAgents.map((a) => [a.id, a.name]));
const taskTitleMap = new Map(companyTasks.map((t) => [t.id, t.title]));
const socialAccounts = db.listSocialAccounts(token.companyId);
server.json(res, 200, {
agent: { id: agent.id, name: agent.name, role: agent.role, status: agent.status },
summary: {
@@ -66,18 +113,18 @@ export function registerAgentRoutes(server: AgentApiServer) {
tasksAwaitingReview: tasksAwaitingMyReview.map((t) => ({
id: t.id,
title: t.title,
assignee: snapshot.agents.find((a) => a.id === t.assigneeAgentId)?.name ?? "Unknown",
assignee: agentNameMap.get(t.assigneeAgentId ?? "") ?? "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",
assignee: agentNameMap.get(t.assigneeAgentId ?? "") ?? "Unknown",
priority: t.priority,
})),
recentComments: recentComments.map((c) => ({
taskId: c.taskId,
taskTitle: snapshot.tasks.find((t) => t.id === c.taskId)?.title ?? "",
taskTitle: taskTitleMap.get(c.taskId) ?? "",
author: c.authorName,
body: c.body.slice(0, 200),
createdAt: c.createdAt,
@@ -96,7 +143,7 @@ export function registerAgentRoutes(server: AgentApiServer) {
decisionNote: a.decisionNote,
})),
reportStatuses: directReports.map((r) => {
const rTasks = snapshot.tasks.filter((t) => t.assigneeAgentId === r.id && t.companyId === token.companyId);
const rTasks = companyTasks.filter((t) => t.assigneeAgentId === r.id);
return {
id: r.id,
name: r.name,
@@ -108,16 +155,14 @@ export function registerAgentRoutes(server: AgentApiServer) {
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,
})),
socialAccounts: socialAccounts.map((a) => ({
id: a.id,
platform: a.platform,
accountName: a.accountName,
displayName: a.displayName,
status: a.status,
requireApproval: a.requireApproval,
})),
});
});
@@ -132,14 +177,14 @@ export function registerAgentRoutes(server: AgentApiServer) {
});
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) {
const agentId = requireParam(params, "agentId");
const agent = db.getAgent(agentId);
if (!agent || agent.companyId !== token.companyId) {
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);
const agentMetric = companyMetrics.agentMetrics.find((metric) => metric.agentId === agentId);
if (!agentMetric) {
server.json(res, 404, { error: "Metrics not found" });
return;
@@ -148,14 +193,20 @@ export function registerAgentRoutes(server: AgentApiServer) {
});
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) {
let agent;
try {
agent = db.getAgent(requireParam(params, "agentId"));
} catch {
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;
if (agent.companyId !== token.companyId) {
server.json(res, 404, { error: "Agent not found" });
return;
}
const directReports = db.getDirectReports(agent.id, token.companyId);
const reportsTo = agent.reportsTo;
const manager = reportsTo ? (() => { try { return db.getAgent(reportsTo); } catch { return null; } })() : null;
server.json(res, 200, {
...agent,
manager: manager ? { id: manager.id, name: manager.name, role: manager.role } : null,
@@ -168,13 +219,15 @@ export function registerAgentRoutes(server: AgentApiServer) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const snapshot = db.listSnapshot();
const agents = snapshot.agents.filter((a) => a.companyId === params.companyId);
const agents = db.queryAll<AgentListRow>(
"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 = ? order by created_at asc",
params.companyId,
).map((a) => ({ ...a, heartbeatEnabled: Boolean(a.heartbeatEnabled) }));
server.json(res, 200, agents);
});
server.addRoute("POST", "/api/companies/:companyId/agents", (_req, res, params, token, body) => {
const companyId = params.companyId!;
const companyId = requireParam(params, "companyId");
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
@@ -231,8 +284,9 @@ export function registerAgentRoutes(server: AgentApiServer) {
}
// 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 existingAgents = db.queryAll<{ id: string; name: string; role: string }>(
"select id, name, role from agents where company_id = ? and status != 'terminated'",
companyId,
);
const duplicateByName = existingAgents.find(
(a) => a.name.toLowerCase() === input.name.toLowerCase(),
@@ -242,8 +296,9 @@ export function registerAgentRoutes(server: AgentApiServer) {
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",
const pendingHires = db.queryAll<{ payloadJson: string | null }>(
"select payload_json as payloadJson from approvals where company_id = ? and type = 'hire_agent' and state = 'pending'",
companyId,
);
for (const pending of pendingHires) {
try {
+45 -27
View File
@@ -1,8 +1,10 @@
import { z } from "zod";
import log from "electron-log/main.js";
import type { SQLInputValue } from "node:sqlite";
import { approvalInputSchema } from "@shared/contracts";
import { resolveApprovalDecision, shouldAutoApproveApproval } from "../approval-policy";
import type { AgentApiServer } from "./server-core";
import { requireParam, type AgentApiServer } from "./server-core";
import type { AppDatabase } from "../database";
const approvalListQuerySchema = z.object({
requestedByAgentId: z.string().uuid().optional(),
@@ -10,14 +12,29 @@ const approvalListQuerySchema = z.object({
status: z.string().optional(),
});
type ApprovalRow = {
id: string; companyId: string; relatedTaskId: string | null; relatedAgentId: string | null;
requestedByAgentId: string | null; type: string; payloadSummary: string | null;
impactSummary: string | null; payloadJson: string | null; decisionNote: string | null;
state: string; createdAt: string; updatedAt: string;
};
function getApproval(db: AppDatabase, approvalId: string): ApprovalRow | null {
const rows = db.queryAll<ApprovalRow>(
"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 where id = ? limit 1",
approvalId,
);
return rows[0] ?? null;
}
export function registerApprovalRoutes(server: AgentApiServer) {
const db = server.db_;
const callbacks = server.callbacks_;
const onDomainChanged = server.onDomainChanged_;
const onWakeAgent = server.onWakeAgent_;
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!;
const companyId = requireParam(params, "companyId");
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
@@ -95,12 +112,12 @@ export function registerApprovalRoutes(server: AgentApiServer) {
});
server.addRoute("GET", "/api/companies/:companyId/approvals", (req, res, params, token) => {
const companyId = params.companyId!;
const companyId = requireParam(params, "companyId");
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const url = new URL(req.url ?? "/", `http://127.0.0.1:${server.port_}`);
const 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,
@@ -111,21 +128,19 @@ export function registerApprovalRoutes(server: AgentApiServer) {
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;
});
let sql = "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 where company_id = ?";
const sqlArgs: unknown[] = [companyId];
if (parsedQuery.data.requestedByAgentId) { sql += " and requested_by_agent_id = ?"; sqlArgs.push(parsedQuery.data.requestedByAgentId); }
if (parsedQuery.data.relatedTaskId) { sql += " and related_task_id = ?"; sqlArgs.push(parsedQuery.data.relatedTaskId); }
if (statuses && statuses.length > 0) { sql += ` and state in (${statuses.map(() => "?").join(", ")})`; sqlArgs.push(...statuses); }
sql += " order by created_at desc";
const approvals = db.queryAll<ApprovalRow>(sql, ...(sqlArgs as SQLInputValue[]));
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) {
const approval = getApproval(db, requireParam(params, "approvalId"));
if (!approval || approval.companyId !== token.companyId) {
server.json(res, 404, { error: "Approval not found" });
return;
}
@@ -137,15 +152,16 @@ export function registerApprovalRoutes(server: AgentApiServer) {
});
server.addRoute("GET", "/api/approvals/:approvalId/comments", (_req, res, params, token) => {
const approval = db.listSnapshot().approvals.find((a) => a.id === params.approvalId);
const approvalId = requireParam(params, "approvalId");
const approval = getApproval(db, 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);
const comments = db.listApprovalComments(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);
const approval = getApproval(db, requireParam(params, "approvalId"));
if (!approval) { server.json(res, 404, { error: "Approval not found" }); return; }
if (approval.companyId !== token.companyId) { server.json(res, 403, { error: "Company mismatch" }); return; }
const data = body as Record<string, unknown>;
@@ -169,7 +185,7 @@ export function registerApprovalRoutes(server: AgentApiServer) {
});
server.addRoute("POST", "/api/approvals/:approvalId/resubmit", (_req, res, params, token) => {
const approval = db.listSnapshot().approvals.find((a) => a.id === params.approvalId);
const approval = getApproval(db, requireParam(params, "approvalId"));
if (!approval) { server.json(res, 404, { error: "Approval not found" }); return; }
if (approval.companyId !== token.companyId) { server.json(res, 403, { error: "Company mismatch" }); return; }
if (approval.state !== "revision_requested") {
@@ -186,15 +202,17 @@ export function registerApprovalRoutes(server: AgentApiServer) {
});
server.addRoute("GET", "/api/approvals/:approvalId/tasks", (_req, res, params, token) => {
const approval = db.listSnapshot().approvals.find((a) => a.id === params.approvalId);
const approvalId = requireParam(params, "approvalId");
const approval = getApproval(db, 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!);
const taskIds = db.listTasksForApproval(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);
const approvalId = requireParam(params, "approvalId");
const approval = getApproval(db, 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>;
@@ -205,7 +223,7 @@ export function registerApprovalRoutes(server: AgentApiServer) {
server.json(res, 400, { error: `Task ${taskId} does not belong to this company` }); return;
}
}
db.linkManyTasksToApproval(params.approvalId!, taskIds, token.companyId, token.agentId ?? null);
db.linkManyTasksToApproval(approvalId, taskIds, token.companyId, token.agentId ?? null);
onDomainChanged();
server.json(res, 200, { linked: taskIds.length });
});
+132 -57
View File
@@ -1,20 +1,89 @@
import type { AgentApiServer } from "./server-core";
import { z } from "zod";
import { requireParam, type AgentApiServer } from "./server-core";
type DocumentRow = {
id: string; companyId: string; type: string; title: string; content: string;
authorAgentId: string; reviewerAgentId: string | null; projectId: string | null;
goalId: string | null; parentDocId: string | null; version: number; status: string;
tagsJson: string; latestRevisionId: string | null; createdAt: string; updatedAt: string;
};
type DocumentLookupRow = {
id: string; companyId: string; type: string; title: string; content: string;
authorAgentId: string; reviewerAgentId: string | null; projectId: string | null;
goalId: string | null; version: number; status: string; tagsJson: string;
};
const createDocumentSchema = z.object({
type: z.string().optional(),
title: z.string().optional(),
content: z.string().optional(),
status: z.string().optional(),
reviewerAgentId: z.string().optional(),
projectId: z.string().optional(),
goalId: z.string().optional(),
tagsJson: z.string().optional(),
});
const updateDocumentSchema = z.object({
type: z.string().optional(),
title: z.string().optional(),
content: z.string().optional(),
status: z.string().optional(),
reviewerAgentId: z.union([z.string(), z.null()]).optional(),
tagsJson: z.string().optional(),
changeSummary: z.string().optional(),
baseRevisionId: z.string().optional(),
});
const createWorkProductSchema = z.object({
type: z.string(),
provider: z.string(),
title: z.string(),
projectId: z.string().optional(),
externalId: z.string().optional(),
url: z.string().optional(),
status: z.string().optional(),
reviewState: z.string().optional(),
isPrimary: z.boolean().optional(),
summary: z.string().optional(),
metadata: z.unknown().optional(),
});
const updateWorkProductSchema = z.object({
type: z.string().optional(),
provider: z.string().optional(),
externalId: z.string().optional(),
title: z.string().optional(),
url: z.string().optional(),
status: z.string().optional(),
reviewState: z.string().optional(),
isPrimary: z.boolean().optional(),
summary: z.string().optional(),
metadataJson: z.string().optional(),
}).strict();
const linkDocumentSchema = z.object({
documentId: z.string(),
});
export function registerFileRoutes(server: AgentApiServer) {
const db = server.db_;
const callbacks = server.callbacks_;
const onDomainChanged = server.onDomainChanged_;
const onWakeAgent = server.onWakeAgent_;
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;
const parsed = createDocumentSchema.safeParse(body);
if (!parsed.success) { server.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() }); return; }
const data = parsed.data;
const reviewerAgentId = data.reviewerAgentId ?? null;
const projectId = data.projectId ?? null;
const goalId = data.goalId ?? null;
if (reviewerAgentId && !db.belongsToCompany("agents", reviewerAgentId, token.companyId)) {
server.json(res, 400, { error: "Reviewer agent does not belong to the current company" });
return;
@@ -27,19 +96,19 @@ export function registerFileRoutes(server: AgentApiServer) {
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 docType = data.type ?? "knowledge_article";
const docTitle = data.title ?? "Untitled";
const id = db.saveDocument({
companyId: token.companyId,
type: docType,
title: docTitle,
content: String(data.content ?? ""),
content: data.content ?? "",
authorAgentId: token.agentId,
reviewerAgentId,
projectId,
goalId,
status: String(data.status ?? "draft"),
tagsJson: data.tagsJson ? String(data.tagsJson) : "[]",
status: data.status ?? "draft",
tagsJson: data.tagsJson ?? "[]",
sourceRunId: token.runId ?? null,
});
onDomainChanged();
@@ -67,7 +136,8 @@ export function registerFileRoutes(server: AgentApiServer) {
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 companySettings = db.getCompanyApprovalSettings(token.companyId);
const company = companySettings ? { reviewDeliverables: companySettings.reviewDeliverables } : null;
const isCofounderMode = db.getCompanyExecutionMode(token.companyId) === "ai_cofounder";
if (company?.reviewDeliverables && !isCofounderMode) {
@@ -101,8 +171,10 @@ export function registerFileRoutes(server: AgentApiServer) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const snapshot = db.listSnapshot();
const docs = snapshot.documents.filter(d => d.companyId === token.companyId);
const docs = db.queryAll<DocumentRow>(
"select id, company_id as companyId, type, title, content, author_agent_id as authorAgentId, reviewer_agent_id as reviewerAgentId, project_id as projectId, goal_id as goalId, parent_doc_id as parentDocId, version, status, tags_json as tagsJson, latest_revision_id as latestRevisionId, created_at as createdAt, updated_at as updatedAt from documents where company_id = ? order by updated_at desc",
token.companyId,
).map((d) => ({ ...d, latestRevisionId: d.latestRevisionId ?? null }));
server.json(res, 200, docs);
});
@@ -116,16 +188,21 @@ export function registerFileRoutes(server: AgentApiServer) {
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);
const docRows = db.queryAll<DocumentLookupRow>(
"select id, company_id as companyId, type, title, content, author_agent_id as authorAgentId, reviewer_agent_id as reviewerAgentId, project_id as projectId, goal_id as goalId, version, status, tags_json as tagsJson from documents where id = ? and company_id = ? limit 1",
documentId, token.companyId,
);
const doc = docRows[0] ?? null;
if (!doc) {
server.json(res, 404, { error: "Document not found in this company" });
return;
}
const data = body as Record<string, unknown>;
const parsed = updateDocumentSchema.safeParse(body);
if (!parsed.success) { server.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() }); return; }
const data = parsed.data;
const VALID_STATUSES = new Set(["draft", "in_review", "approved", "archived", "superseded"]);
const reviewerAgentId = data.reviewerAgentId !== undefined
? (data.reviewerAgentId ? String(data.reviewerAgentId) : null)
? (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" });
@@ -133,7 +210,7 @@ export function registerFileRoutes(server: AgentApiServer) {
}
// Conflict detection: if baseRevisionId provided, verify it matches current
if (typeof data.baseRevisionId === "string") {
if (data.baseRevisionId !== undefined) {
const currentRevId = db.getDocumentLatestRevisionId(documentId);
if (currentRevId && data.baseRevisionId !== currentRevId) {
server.json(res, 409, { error: "Document was updated by someone else", currentRevisionId: currentRevId });
@@ -142,9 +219,8 @@ export function registerFileRoutes(server: AgentApiServer) {
}
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(", ")}` });
if (!VALID_STATUSES.has(data.status)) {
server.json(res, 400, { error: `Invalid status "${data.status}". Must be one of: ${[...VALID_STATUSES].join(", ")}` });
return;
}
}
@@ -152,12 +228,11 @@ export function registerFileRoutes(server: AgentApiServer) {
// 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,
body: data.content,
changeSummary: data.changeSummary ?? null,
createdByAgentId: token.agentId ?? null,
});
updatedVersion = revision.revisionNumber;
@@ -165,21 +240,21 @@ export function registerFileRoutes(server: AgentApiServer) {
// Field updates (title, reviewer, tags, etc.)
if (data.status !== undefined) {
db.updateDocumentStatus(documentId, token.companyId, String(data.status));
db.updateDocumentStatus(documentId, token.companyId, 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,
type: data.type ?? doc.type,
title: data.title ?? doc.title,
content: data.content ?? doc.content,
authorAgentId: doc.authorAgentId,
reviewerAgentId,
projectId: doc.projectId,
goalId: doc.goalId,
version: updatedVersion,
status: data.status ? String(data.status) : doc.status,
tagsJson: data.tagsJson ? String(data.tagsJson) : doc.tagsJson,
status: data.status ?? doc.status,
tagsJson: data.tagsJson ?? doc.tagsJson,
sourceRunId: data.content !== undefined ? (token.runId ?? null) : null,
});
@@ -199,7 +274,7 @@ export function registerFileRoutes(server: AgentApiServer) {
server.addRoute("GET", "/api/issues/:issueId/work-products", (_req, res, params, token) => {
try {
const task = db.getTask(params.issueId!);
const task = db.getTask(requireParam(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);
@@ -208,25 +283,24 @@ export function registerFileRoutes(server: AgentApiServer) {
server.addRoute("POST", "/api/issues/:issueId/work-products", (_req, res, params, token, body) => {
try {
const task = db.getTask(params.issueId!);
const task = db.getTask(requireParam(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 parsed = createWorkProductSchema.safeParse(body);
if (!parsed.success) { server.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() }); return; }
const data = parsed.data;
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",
projectId: data.projectId ?? task.projectId,
type: data.type,
provider: data.provider,
externalId: data.externalId ?? null,
title: data.title,
url: data.url ?? null,
status: data.status ?? "active",
reviewState: data.reviewState ?? "none",
isPrimary: data.isPrimary === true,
summary: typeof data.summary === "string" ? data.summary : null,
summary: data.summary ?? null,
metadataJson: data.metadata ? JSON.stringify(data.metadata) : null,
createdByRunId: token.runId ?? null,
});
@@ -238,8 +312,9 @@ export function registerFileRoutes(server: AgentApiServer) {
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);
const parsed = updateWorkProductSchema.safeParse(body);
if (!parsed.success) { server.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() }); return; }
db.updateWorkProduct(wpId, token.companyId, parsed.data);
onDomainChanged();
server.json(res, 200, { updated: true });
});
@@ -256,7 +331,7 @@ export function registerFileRoutes(server: AgentApiServer) {
server.addRoute("GET", "/api/issues/:issueId/documents", (_req, res, params, token) => {
try {
const task = db.getTask(params.issueId!);
const task = db.getTask(requireParam(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);
@@ -265,15 +340,15 @@ export function registerFileRoutes(server: AgentApiServer) {
server.addRoute("PUT", "/api/issues/:issueId/documents/:key", (_req, res, params, token, body) => {
try {
const task = db.getTask(params.issueId!);
const task = db.getTask(requireParam(params, "issueId"));
if (task.companyId !== token.companyId) { server.json(res, 404, { error: "Task not found" }); return; }
const key = String(params.key ?? "").trim().toLowerCase();
if (!key || !/^[a-z0-9][a-z0-9_-]*$/.test(key) || key.length > 64) {
server.json(res, 400, { error: "Invalid document key. Must be lowercase alphanumeric with _ or -, 1-64 chars." }); return;
}
const 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; }
const parsed = linkDocumentSchema.safeParse(body);
if (!parsed.success) { server.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() }); return; }
const documentId = parsed.data.documentId;
if (!db.belongsToCompany("documents", documentId, token.companyId)) {
server.json(res, 400, { error: "Document does not belong to this company" }); return;
}
@@ -285,7 +360,7 @@ export function registerFileRoutes(server: AgentApiServer) {
server.addRoute("DELETE", "/api/issues/:issueId/documents/:key", (_req, res, params, token) => {
try {
const task = db.getTask(params.issueId!);
const task = db.getTask(requireParam(params, "issueId"));
if (task.companyId !== token.companyId) { server.json(res, 404, { error: "Task not found" }); return; }
const key = String(params.key ?? "").trim().toLowerCase();
db.unlinkDocumentFromTask(task.id, key, task.companyId);
+163
View File
@@ -0,0 +1,163 @@
import { z } from "zod";
import { validateTaskCreation } from "../task-quality-gates";
import type { AgentApiServer } from "./server-core";
type MeetingRow = {
id: string; companyId: string; type: string; title: string;
organizerAgentId: string; participantAgentIds: string;
scheduledAt: string; durationMinutes: number;
agendaJson: string; notesJson: string | null;
decisionsJson: string | null; actionItemsJson: string | null;
status: string; createdAt: string; updatedAt: string;
};
export function registerMeetingRoutes(server: AgentApiServer) {
const db = server.db;
const onDomainChanged = server.onDomainChanged;
const onWakeAgent = server.onWakeAgent;
server.addRoute("POST", "/api/companies/:companyId/meetings", (_req, res, params, token, body) => {
if (params.companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const meetingCreateSchema = z.object({
type: z.string().default("department_sync"),
title: z.string().default("Meeting"),
participantAgentIds: z.string().default("[]"),
scheduledAt: z.string().default(new Date().toISOString()),
durationMinutes: z.number().default(30),
agendaJson: z.string().default("[]"),
});
const parsed = meetingCreateSchema.safeParse(body);
if (!parsed.success) {
server.json(res, 400, { error: "Invalid meeting payload", details: parsed.error.issues });
return;
}
const input = parsed.data;
try {
const parsedParticipants = JSON.parse(input.participantAgentIds) as string[];
for (const agentId of parsedParticipants) {
if (typeof agentId !== "string" || !db.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: input.type,
title: input.title,
organizerAgentId: token.agentId,
participantAgentIds: input.participantAgentIds,
scheduledAt: input.scheduledAt,
durationMinutes: input.durationMinutes,
agendaJson: input.agendaJson,
status: "scheduled",
});
onDomainChanged();
server.json(res, 201, { id });
});
server.addRoute("GET", "/api/companies/:companyId/meetings", (_req, res, params, token) => {
if (params.companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const meetings = db.queryAll<MeetingRow>(
"select id, company_id as companyId, type, title, organizer_agent_id as organizerAgentId, participant_agent_ids as participantAgentIds, scheduled_at as scheduledAt, duration_minutes as durationMinutes, agenda_json as agendaJson, notes_json as notesJson, decisions_json as decisionsJson, action_items_json as actionItemsJson, status, created_at as createdAt, updated_at as updatedAt from meetings where company_id = ? order by scheduled_at desc",
token.companyId,
);
server.json(res, 200, meetings);
});
server.addRoute("PATCH", "/api/meetings/:meetingId", (_req, res, params, token, body) => {
const rows = db.queryAll<MeetingRow>(
"select id, company_id as companyId, type, title, organizer_agent_id as organizerAgentId, participant_agent_ids as participantAgentIds, scheduled_at as scheduledAt, duration_minutes as durationMinutes, agenda_json as agendaJson, notes_json as notesJson, decisions_json as decisionsJson, action_items_json as actionItemsJson, status, created_at as createdAt, updated_at as updatedAt from meetings where id = ? and company_id = ? limit 1",
params.meetingId, token.companyId,
);
const existing = rows[0] ?? null;
if (!existing) {
server.json(res, 404, { error: "Meeting not found" });
return;
}
const meetingUpdateSchema = z.object({
type: z.string().optional(),
title: z.string().optional(),
notesJson: z.string().optional(),
decisionsJson: z.string().optional(),
actionItemsJson: z.string().optional(),
status: z.string().optional(),
scheduledAt: z.string().optional(),
});
const parsed = meetingUpdateSchema.safeParse(body);
if (!parsed.success) {
server.json(res, 400, { error: "Invalid meeting update payload", details: parsed.error.issues });
return;
}
const data = parsed.data;
let actionItems: Array<{ title?: string; assigneeAgentId?: string; description?: string }> = [];
if (data.actionItemsJson) {
try {
actionItems = JSON.parse(data.actionItemsJson) as Array<{ title?: string; assigneeAgentId?: string; description?: string }>;
} catch {
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 ?? "department_sync",
title: data.title ?? "Meeting",
notesJson: data.notesJson,
decisionsJson: data.decisionsJson,
actionItemsJson: data.actionItemsJson,
status: data.status,
scheduledAt: 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 });
});
}
+17 -17
View File
@@ -1,12 +1,12 @@
import { agentMessageInputSchema } from "@shared/contracts";
import { dispatchAgentMessage } from "../message-dispatch";
import type { AgentApiServer } from "./server-core";
import { requireParam, type AgentApiServer } from "./server-core";
export function registerMessageRoutes(server: AgentApiServer) {
const db = server.db_;
const onDomainChanged = server.onDomainChanged_;
const onWakeAgent = server.onWakeAgent_;
const emitEvent = server.emitEvent_;
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;
@@ -19,7 +19,7 @@ export function registerMessageRoutes(server: AgentApiServer) {
return;
}
try {
const task = db.getTask(params.issueId!);
const task = db.getTask(requireParam(params, "issueId"));
if (task.companyId !== token.companyId) {
server.json(res, 404, { error: "Task not found" });
return;
@@ -35,7 +35,7 @@ export function registerMessageRoutes(server: AgentApiServer) {
onDomainChanged();
// Fire comment automation rules
server.callbacks_.onCommentPosted(task.id, task.companyId, token.agentId);
server.callbacks.onCommentPosted(task.id, task.companyId, token.agentId);
const wokenForComment = new Set<string>();
if (task.assigneeAgentId && task.assigneeAgentId !== token.agentId) {
@@ -66,7 +66,7 @@ export function registerMessageRoutes(server: AgentApiServer) {
server.addRoute("GET", "/api/issues/:issueId/comments", (_req, res, params, token) => {
try {
const task = db.getTask(params.issueId!);
const task = db.getTask(requireParam(params, "issueId"));
if (task.companyId !== token.companyId) {
server.json(res, 404, { error: "Task not found" });
return;
@@ -75,12 +75,12 @@ export function registerMessageRoutes(server: AgentApiServer) {
server.json(res, 404, { error: "Task not found" });
return;
}
const comments = db.listComments(params.issueId!);
const comments = db.listComments(requireParam(params, "issueId"));
server.json(res, 200, comments);
});
server.addRoute("POST", "/api/companies/:companyId/messages", (_req, res, params, token, body) => {
const companyId = params.companyId!;
const companyId = requireParam(params, "companyId");
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
@@ -100,13 +100,13 @@ export function registerMessageRoutes(server: AgentApiServer) {
{
db,
emitEvent,
notify: server.callbacks_.notify,
notify: server.callbacks.notify,
publishDomainChanged: onDomainChanged,
wakeAgentIfPossible: (agentId, cid, trigger) => {
if (!agentId) return null;
return onWakeAgent(agentId, cid, trigger);
},
onHumanMessage: server.callbacks_.onHumanMessage,
onHumanMessage: server.callbacks.onHumanMessage,
},
parsed.data,
"message",
@@ -120,13 +120,13 @@ export function registerMessageRoutes(server: AgentApiServer) {
});
server.addRoute("GET", "/api/companies/:companyId/messages", (req, res, params, token) => {
const companyId = params.companyId!;
const companyId = requireParam(params, "companyId");
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const url = new URL(req.url ?? "/", `http://127.0.0.1:${server.port_}`);
const 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";
@@ -149,12 +149,12 @@ export function registerMessageRoutes(server: AgentApiServer) {
});
server.addRoute("POST", "/api/companies/:companyId/messages/:messageId/read", (_req, res, params, token) => {
const companyId = params.companyId!;
const companyId = requireParam(params, "companyId");
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const message = db.getAgentMessage(params.messageId!, companyId);
const message = db.getAgentMessage(requireParam(params, "messageId"), companyId);
if (!message) {
server.json(res, 404, { error: "Message not found" });
return;
@@ -168,7 +168,7 @@ export function registerMessageRoutes(server: AgentApiServer) {
server.json(res, 403, { error: "This message is not visible to the current agent" });
return;
}
db.markAgentMessageRead(params.messageId!, companyId, token.agentId);
db.markAgentMessageRead(requireParam(params, "messageId"), companyId, token.agentId);
onDomainChanged();
server.json(res, 200, { ok: true });
});
+23 -157
View File
@@ -1,6 +1,12 @@
import { z } from "zod";
import { validateTaskCreation } from "../task-quality-gates";
import type { AgentApiServer } from "./server-core";
import { requireParam, type AgentApiServer } from "./server-core";
type KnowledgeRow = {
id: string; companyId: string; category: string; topic: string;
content: string; authorAgentId: string; importance: string;
tagsJson: string; referencedEntityType: string | null;
referencedEntityId: string | null; createdAt: string; updatedAt: string;
};
const socialDraftSchema = z.object({
socialAccountId: z.string().uuid(),
@@ -11,12 +17,11 @@ const socialDraftSchema = z.object({
});
export function registerMiscRoutes(server: AgentApiServer) {
const db = server.db_;
const onDomainChanged = server.onDomainChanged_;
const onWakeAgent = server.onWakeAgent_;
const db = server.db;
const onDomainChanged = server.onDomainChanged;
server.addRoute("POST", "/api/companies/:companyId/browser/action", async (_req, res, params, token, body) => {
const companyId = params.companyId!;
const companyId = requireParam(params, "companyId");
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
@@ -36,8 +41,7 @@ export function registerMiscRoutes(server: AgentApiServer) {
const input = parsed.data;
const snapshot = db.listSnapshot();
const account = snapshot.socialAccounts.find(a => a.id === input.socialAccountId && a.companyId === companyId);
const account = db.getSocialAccount(input.socialAccountId, companyId);
if (!account) {
server.json(res, 404, { error: "Social account not found" });
return;
@@ -98,7 +102,7 @@ export function registerMiscRoutes(server: AgentApiServer) {
onDomainChanged();
const onBrowserAction = server.onBrowserAction_;
const onBrowserAction = server.onBrowserAction;
if (onBrowserAction) {
onBrowserAction(actionId, companyId);
}
@@ -111,7 +115,7 @@ export function registerMiscRoutes(server: AgentApiServer) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const accounts = db.listSocialAccounts(params.companyId!);
const accounts = db.listSocialAccounts(requireParam(params, "companyId"));
const sanitized = accounts.map(a => ({
id: a.id,
platform: a.platform,
@@ -125,7 +129,7 @@ export function registerMiscRoutes(server: AgentApiServer) {
});
server.addRoute("POST", "/api/companies/:companyId/social/draft", async (_req, res, params, token, body) => {
const companyId = params.companyId!;
const companyId = requireParam(params, "companyId");
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
@@ -169,155 +173,15 @@ export function registerMiscRoutes(server: AgentApiServer) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const url = new URL(req.url ?? "/", `http://127.0.0.1:${server.port_}`);
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 });
const actions = db.listBrowserActions(requireParam(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 meetingCreateSchema = z.object({
type: z.string().default("department_sync"),
title: z.string().default("Meeting"),
participantAgentIds: z.string().default("[]"),
scheduledAt: z.string().default(new Date().toISOString()),
durationMinutes: z.number().default(30),
agendaJson: z.string().default("[]"),
});
const parsed = meetingCreateSchema.safeParse(body);
if (!parsed.success) {
server.json(res, 400, { error: "Invalid meeting payload", details: parsed.error.issues });
return;
}
const input = parsed.data;
try {
const parsedParticipants = JSON.parse(input.participantAgentIds) as string[];
for (const agentId of parsedParticipants) {
if (typeof agentId !== "string" || !db.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: input.type,
title: input.title,
organizerAgentId: token.agentId,
participantAgentIds: input.participantAgentIds,
scheduledAt: input.scheduledAt,
durationMinutes: input.durationMinutes,
agendaJson: input.agendaJson,
status: "scheduled",
});
onDomainChanged();
server.json(res, 201, { id });
});
server.addRoute("GET", "/api/companies/:companyId/meetings", (_req, res, params, token) => {
if (params.companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const 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 meetingUpdateSchema = z.object({
type: z.string().optional(),
title: z.string().optional(),
notesJson: z.string().optional(),
decisionsJson: z.string().optional(),
actionItemsJson: z.string().optional(),
status: z.string().optional(),
scheduledAt: z.string().optional(),
});
const parsed = meetingUpdateSchema.safeParse(body);
if (!parsed.success) {
server.json(res, 400, { error: "Invalid meeting update payload", details: parsed.error.issues });
return;
}
const data = parsed.data;
let actionItems: Array<{ title?: string; assigneeAgentId?: string; description?: string }> = [];
if (data.actionItemsJson) {
try {
actionItems = JSON.parse(data.actionItemsJson) as Array<{ title?: string; assigneeAgentId?: string; description?: string }>;
} catch {
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 ?? "department_sync",
title: data.title ?? "Meeting",
notesJson: data.notesJson,
decisionsJson: data.decisionsJson,
actionItemsJson: data.actionItemsJson,
status: data.status,
scheduledAt: 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" });
@@ -362,15 +226,17 @@ export function registerMiscRoutes(server: AgentApiServer) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const snapshot = db.listSnapshot();
const entries = snapshot.knowledgeBase.filter(k => k.companyId === token.companyId);
const entries = db.queryAll<KnowledgeRow>(
"select id, company_id as companyId, category, topic, content, author_agent_id as authorAgentId, importance, tags_json as tagsJson, referenced_entity_type as referencedEntityType, referenced_entity_id as referencedEntityId, created_at as createdAt, updated_at as updatedAt from knowledge_base where company_id = ? order by updated_at desc",
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!;
const companyId = requireParam(params, "companyId");
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
@@ -388,7 +254,7 @@ export function registerMiscRoutes(server: AgentApiServer) {
});
server.addRoute("GET", "/api/skills/:skillId", (_req, res, params, token) => {
const skill = db.getCompanySkill(token.companyId, params.skillId!);
const skill = db.getCompanySkill(token.companyId, requireParam(params, "skillId"));
if (!skill) {
server.json(res, 404, { error: "Skill not found" });
return;
+8 -8
View File
@@ -1,5 +1,5 @@
import type { TokenPayload } from "../agent-auth";
import type { AgentApiServer } from "./server-core";
import { requireParam, type AgentApiServer } from "./server-core";
const DEFAULT_EVENT_LIMIT = 500;
const MAX_EVENT_LIMIT = 5_000;
@@ -15,7 +15,7 @@ function parsePositiveInt(value: string | null, fallback: number) {
}
function resolveAuthorizedRun(server: AgentApiServer, token: TokenPayload, runId: string) {
const runState = server.db_.getRunAuthState(runId);
const runState = server.db.getRunAuthState(runId);
if (!runState || runState.companyId !== token.companyId || runState.agentId !== token.agentId) {
return null;
}
@@ -29,29 +29,29 @@ function writeSse(res: Parameters<AgentApiServer["json"]>[0], event: string, dat
export function registerRunRoutes(server: AgentApiServer) {
server.addRoute("GET", "/api/runs/:runId/events", (req, res, params, token) => {
const runId = params.runId!;
const runId = requireParam(params, "runId");
const runState = resolveAuthorizedRun(server, token, runId);
if (!runState) {
server.json(res, 404, { error: "Run not found for the current agent token" });
return;
}
const url = new URL(req.url ?? "/", `http://127.0.0.1:${server.port_}`);
const 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 });
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 runId = requireParam(params, "runId");
const runState = resolveAuthorizedRun(server, token, runId);
if (!runState) {
server.json(res, 404, { error: "Run not found for the current agent token" });
return;
}
const url = new URL(req.url ?? "/", `http://127.0.0.1:${server.port_}`);
const url = new URL(req.url ?? "/", `http://127.0.0.1:${server.port}`);
let cursor = parsePositiveInt(url.searchParams.get("after"), 0);
res.writeHead(200, {
@@ -64,7 +64,7 @@ export function registerRunRoutes(server: AgentApiServer) {
writeSse(res, "connected", { runId, after: cursor });
const flushPendingEvents = () => {
const events = server.db_
const events = server.db
.listRunEvents(runId, token.companyId, { offset: 0, limit: MAX_EVENT_LIMIT })
.filter((event) => event.seq > cursor);
for (const event of events) {
+28 -14
View File
@@ -3,7 +3,18 @@ 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";
import { requireParam, type AgentApiServer } from "./server-core";
type TaskListRow = {
id: string; companyId: string; projectId: string | null; goalId: string | null;
parentId: string | null; title: string; description: string;
assigneeAgentId: string | null; workspaceId: string | null; status: string;
priority: string; activeRunId: string | null; sessionJson: string | null;
sessionDisplayId: string | null; costUsd: number | null;
requiresUserReview: number; startedAt: string | null;
completedAt: string | null; cancelledAt: string | null;
createdAt: string; updatedAt: string;
};
const taskUpdateSchema = z.object({
status: z.enum(["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"]).optional(),
@@ -19,18 +30,18 @@ const checkoutSchema = z.object({
});
export function registerTaskRoutes(server: AgentApiServer) {
const db = server.db_;
const callbacks = server.callbacks_;
const onDomainChanged = server.onDomainChanged_;
const onWakeAgent = server.onWakeAgent_;
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!;
const companyId = requireParam(params, "companyId");
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const url = new URL(req.url ?? "/", `http://127.0.0.1:${server.port_}`);
const 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"];
@@ -39,15 +50,18 @@ export function registerTaskRoutes(server: AgentApiServer) {
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));
const placeholders = statuses.map(() => "?").join(", ");
const tasks = db.queryAll<TaskListRow>(
`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 company_id = ? and status in (${placeholders}) order by created_at desc`,
companyId, ...statuses,
).map((t) => ({ ...t, requiresUserReview: Boolean(t.requiresUserReview), startedAt: t.startedAt ?? null, completedAt: t.completedAt ?? null, cancelledAt: t.cancelledAt ?? null }));
server.json(res, 200, tasks);
}
});
server.addRoute("GET", "/api/issues/:issueId", (_req, res, params, token) => {
try {
const task = db.getTask(params.issueId!);
const task = db.getTask(requireParam(params, "issueId"));
if (task.companyId !== token.companyId) {
server.json(res, 404, { error: "Task not found" });
return;
@@ -71,7 +85,7 @@ export function registerTaskRoutes(server: AgentApiServer) {
}
let task;
try {
task = db.getTask(params.issueId!);
task = db.getTask(requireParam(params, "issueId"));
} catch {
server.json(res, 404, { error: "Task not found" });
return;
@@ -85,7 +99,7 @@ export function registerTaskRoutes(server: AgentApiServer) {
return;
}
const previousStatus = task.status;
const success = db.checkoutTask(params.issueId!, token.runId);
const success = db.checkoutTask(task.id, token.runId);
if (!success) {
server.json(res, 409, { error: "Task cannot be checked out — already assigned or not in valid status" });
return;
@@ -105,7 +119,7 @@ export function registerTaskRoutes(server: AgentApiServer) {
const update = parsedUpdate.data;
try {
const task = db.getTask(params.issueId!);
const task = db.getTask(requireParam(params, "issueId"));
if (task.companyId !== token.companyId) {
server.json(res, 404, { error: "Task not found" });
return;
@@ -277,7 +291,7 @@ export function registerTaskRoutes(server: AgentApiServer) {
});
server.addRoute("POST", "/api/companies/:companyId/issues", (_req, res, params, token, body) => {
const companyId = params.companyId!;
const companyId = requireParam(params, "companyId");
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
+71 -28
View File
@@ -1,15 +1,41 @@
import { z } from "zod";
import { goalInputSchema, projectInputSchema } from "@shared/contracts";
import { canManageGoal, canManageProject } from "../agent-permissions";
import type { AgentApiServer } from "./server-core";
import { requireParam, type AgentApiServer } from "./server-core";
type ProjectRow = {
id: string; companyId: string; goalId: string | null; name: string;
description: string; leadAgentId: string | null; status: string;
targetDate: string | null; createdAt: string; updatedAt: string;
};
type GoalRow = {
id: string; companyId: string; title: string; description: string;
parentId: string | null; ownerAgentId: string | null; status: string;
createdAt: string; updatedAt: string;
};
type SprintRow = {
id: string; companyId: string; name: string; goal: string;
startDate: string; endDate: string; status: string;
retrospectiveNotes: string | null; velocityPoints: number;
completedPoints: number; createdAt: string; updatedAt: string;
};
type AgentStatusRow = { id: string; status: string };
type TaskStatusRow = { id: string; status: string };
type ActivityRow = {
id: string; companyId: string; actor: string; action: string;
entityType: string; entityId: string; detail: string | null; createdAt: string;
};
export function registerWorkGraphRoutes(server: AgentApiServer) {
const db = server.db_;
const onDomainChanged = server.onDomainChanged_;
const onWakeAgent = server.onWakeAgent_;
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!;
const companyId = requireParam(params, "companyId");
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
@@ -61,7 +87,7 @@ export function registerWorkGraphRoutes(server: AgentApiServer) {
});
server.addRoute("PATCH", "/api/projects/:projectId", (_req, res, params, token, body) => {
const projectId = params.projectId!;
const projectId = requireParam(params, "projectId");
const updateSchema = z.object({
name: z.string().min(2).max(120).optional(),
@@ -78,8 +104,11 @@ export function registerWorkGraphRoutes(server: AgentApiServer) {
return;
}
const snapshot = db.listSnapshot();
const existing = snapshot.projects.find(p => p.id === projectId && p.companyId === token.companyId);
const projectRows = db.queryAll<ProjectRow>(
"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 where id = ? and company_id = ? limit 1",
projectId, token.companyId,
);
const existing = projectRows[0] ?? null;
if (!existing) {
server.json(res, 404, { error: "Project not found" });
return;
@@ -109,7 +138,7 @@ export function registerWorkGraphRoutes(server: AgentApiServer) {
});
server.addRoute("POST", "/api/companies/:companyId/goals", (_req, res, params, token, body) => {
const companyId = params.companyId!;
const companyId = requireParam(params, "companyId");
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
@@ -151,7 +180,7 @@ export function registerWorkGraphRoutes(server: AgentApiServer) {
});
server.addRoute("PATCH", "/api/goals/:goalId", (_req, res, params, token, body) => {
const goalId = params.goalId!;
const goalId = requireParam(params, "goalId");
const updateSchema = z.object({
title: z.string().min(2).max(120).optional(),
@@ -166,8 +195,11 @@ export function registerWorkGraphRoutes(server: AgentApiServer) {
return;
}
const snapshot = db.listSnapshot();
const existing = snapshot.goals.find(g => g.id === goalId && g.companyId === token.companyId);
const goalRows = db.queryAll<GoalRow>(
"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 where id = ? and company_id = ? limit 1",
goalId, token.companyId,
);
const existing = goalRows[0] ?? null;
if (!existing) {
server.json(res, 404, { error: "Goal not found" });
return;
@@ -199,8 +231,10 @@ export function registerWorkGraphRoutes(server: AgentApiServer) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const snapshot = db.listSnapshot();
const goals = snapshot.goals.filter((g) => g.companyId === params.companyId);
const goals = db.queryAll<GoalRow>(
"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 where company_id = ? order by created_at asc",
params.companyId,
);
server.json(res, 200, goals);
});
@@ -209,24 +243,28 @@ export function registerWorkGraphRoutes(server: AgentApiServer) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const snapshot = db.listSnapshot();
const projects = snapshot.projects.filter((p) => p.companyId === params.companyId);
const projects = db.queryAll<ProjectRow>(
"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 where company_id = ? order by created_at desc",
params.companyId,
);
server.json(res, 200, projects);
});
server.addRoute("GET", "/api/companies/:companyId/sprints", (_req, res, params, token) => {
const companyId = params.companyId!;
const companyId = requireParam(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);
const sprints = db.queryAll<SprintRow>(
"select id, company_id as companyId, name, goal, start_date as startDate, end_date as endDate, status, retrospective_notes as retrospectiveNotes, velocity_points as velocityPoints, completed_points as completedPoints, created_at as createdAt, updated_at as updatedAt from sprints where company_id = ? order by start_date desc",
companyId,
);
server.json(res, 200, sprints);
});
server.addRoute("POST", "/api/companies/:companyId/sprints", (_req, res, params, token, body) => {
const companyId = params.companyId!;
const companyId = requireParam(params, "companyId");
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
@@ -252,18 +290,23 @@ export function registerWorkGraphRoutes(server: AgentApiServer) {
});
server.addRoute("GET", "/api/companies/:companyId/dashboard", (_req, res, params, token) => {
const companyId = params.companyId!;
const companyId = requireParam(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 agents = db.queryAll<AgentStatusRow>(
"select id, status from agents where company_id = ?",
companyId,
);
const tasks = db.queryAll<TaskStatusRow>(
"select id, status from tasks where company_id = ?",
companyId,
);
const recentActivity = db.queryAll<ActivityRow>(
"select id, company_id as companyId, actor, action, entity_type as entityType, entity_id as entityId, detail, created_at as createdAt from activity where company_id = ? order by created_at desc limit 20",
companyId,
);
const tasksByStatus: Record<string, number> = {};
for (const t of tasks) {
+53 -47
View File
@@ -27,6 +27,17 @@ export function decodeBodyToUtf8(raw: Buffer): string {
}
}
/**
* Extract a required route parameter or throw a 400-level error.
* The HTTP router guarantees all named params are present when a route
* matches, but TypeScript cannot prove this statically.
*/
export function requireParam(params: Record<string, string>, name: string): string {
const value = params[name];
if (value === undefined) throw Object.assign(new Error(`Missing route parameter: ${name}`), { statusCode: 400 });
return value;
}
export type RequestHandler = (
req: IncomingMessage,
res: ServerResponse,
@@ -58,16 +69,16 @@ export interface ApiServerEventCallbacks {
}
export class AgentApiServer {
private routes: Route[] = [];
private server: ReturnType<typeof createServer> | null = null;
private port = 0;
private db: AppDatabase;
private onDomainChangedCb: () => void;
private onWakeAgentCb: (agentId: string, companyId: string, trigger: string) => string | null;
private emitEventCb: (event: DesktopEvent) => void;
private callbacks: ApiServerEventCallbacks;
private allowedOrigins: Set<string>;
private onBrowserActionCb: ((actionId: string, companyId: string) => void) | null = null;
private _routes: Route[] = [];
private _server: ReturnType<typeof createServer> | null = null;
private _port = 0;
private _db: AppDatabase;
private _onDomainChangedCb: () => void;
private _onWakeAgentCb: (agentId: string, companyId: string, trigger: string) => string | null;
private _emitEventCb: (event: DesktopEvent) => void;
private _callbacks: ApiServerEventCallbacks;
private _allowedOrigins: Set<string>;
private _onBrowserActionCb: ((actionId: string, companyId: string) => void) | null = null;
constructor(
db: AppDatabase,
@@ -76,12 +87,12 @@ export class AgentApiServer {
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 = {
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 ?? (() => {}),
@@ -98,29 +109,26 @@ export class AgentApiServer {
}
setBrowserActionHandler(handler: (actionId: string, companyId: string) => void) {
this.onBrowserActionCb = handler;
}
getPort(): number {
return this.port;
this._onBrowserActionCb = handler;
}
async start(): Promise<number> {
return new Promise((resolve, reject) => {
this.server = createServer((req, res) => void this.handleRequest(req, res));
this.server.listen(0, "127.0.0.1", () => {
const 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 = createServer((req, res) => void this.handleRequest(req, res));
this._server.listen(0, "127.0.0.1", () => {
const srv = this._server;
const addr = srv ? srv.address() : null;
this._port = typeof addr === "object" && addr ? addr.port : 0;
log.info(`[api] Agent API server listening on http://127.0.0.1:${this._port}`);
resolve(this._port);
});
this.server.on("error", reject);
this._server.on("error", reject);
});
}
stop() {
this.server?.close();
this.server = null;
this._server?.close();
this._server = null;
}
addRoute(method: string, path: string, handler: RequestHandler) {
@@ -129,13 +137,13 @@ export class AgentApiServer {
paramNames.push(name);
return "([^/]+)";
});
this.routes.push({ method, pattern: new RegExp(`^${patternStr}$`), paramNames, handler });
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)) {
if (!this._allowedOrigins.has(origin)) {
this.json(res, 403, { error: "Origin not allowed" });
return;
}
@@ -151,7 +159,7 @@ export class AgentApiServer {
return;
}
const url = new URL(req.url ?? "/", `http://127.0.0.1:${this.port}`);
const url = new URL(req.url ?? "/", `http://127.0.0.1:${this._port}`);
const pathname = url.pathname;
const method = (req.method ?? "GET").toUpperCase();
@@ -173,7 +181,7 @@ export class AgentApiServer {
return;
}
const runState = this.db.getRunAuthState(token.runId);
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;
@@ -184,14 +192,14 @@ export class AgentApiServer {
return;
}
for (const route of this.routes) {
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]!;
route.paramNames.forEach((name: string, i: number) => {
params[name] = match[i + 1] ?? "";
});
let body: unknown = null;
@@ -257,20 +265,18 @@ export class AgentApiServer {
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));
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) => void) | null {
return this.onBrowserActionCb;
}
get port_(): number { return this.port; }
get db(): AppDatabase { return this._db; }
get callbacks(): ApiServerEventCallbacks { return this._callbacks; }
get onDomainChanged(): () => void { return this._onDomainChangedCb; }
get onWakeAgent(): (agentId: string, companyId: string, trigger: string) => string | null { return this._onWakeAgentCb; }
get emitEvent(): (event: DesktopEvent) => void { return this._emitEventCb; }
get onBrowserAction(): ((actionId: string, companyId: string) => void) | null { return this._onBrowserActionCb; }
get port(): number { return this._port; }
}
+6 -4
View File
@@ -38,7 +38,8 @@ export function handleTaskStatusChange(
let task: ReturnType<typeof db.getTask> | null = null;
try {
task = db.getTask(taskId);
} catch {
} catch (error) {
deps.logger.warn(`[automation] handleTaskStatusChange: task ${taskId} not found:`, error);
return;
}
if (!task) return;
@@ -64,8 +65,8 @@ export function handleTaskStatusChange(
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.
} catch (error) {
deps.logger.warn(`[automation] Agent ${task.assigneeAgentId} deleted after task update — continuing without department context:`, error);
}
dispatchAutomation("task_status_changed", context);
@@ -114,7 +115,8 @@ export function handleTaskCreated(
let task: ReturnType<typeof db.getTask> | null = null;
try {
task = db.getTask(taskId);
} catch {
} catch (error) {
deps.logger.warn(`[automation] handleTaskCreated: task ${taskId} not found:`, error);
return;
}
if (!task) return;
+130 -61
View File
@@ -1,5 +1,5 @@
import type { AppDatabase } from "../database";
import type { RunRecord } from "@shared/types";
import type { RunRecord, SectionId } from "@shared/types";
import type { NotificationOptions } from "../notification-service";
import type { AutomationEventContext, RunFinishedEvent } from "./types";
import { IMPORTANT_DOCUMENT_TYPES } from "./types";
@@ -25,6 +25,93 @@ export interface RunCompletionCallbacks {
handleTaskStatusChange: (taskId: string, companyId: string, previousStatus: string, newStatus: string) => void;
}
// ─── Pure helpers exported for testing ─────────────────────────────────────
export type DeliverableTaskType = "code" | "research" | "content" | "management" | "general";
/** Returns true for task types that require a tangible artifact before auto-advancing. */
export function needsDeliverableCheck(taskType: DeliverableTaskType | string): boolean {
return taskType === "research" || taskType === "content";
}
export interface DeliverableCheckInput {
taskType: DeliverableTaskType | string;
hasKnowledgeEntry: boolean;
hasDocument: boolean;
hasSubstantialComment: boolean;
}
/** Returns the next task status and comment after a successful run. */
export function determinePostRunStatus(input: DeliverableCheckInput): {
nextStatus: "in_review" | "in_progress";
comment: string;
} {
if (!needsDeliverableCheck(input.taskType)) {
return {
nextStatus: "in_review",
comment: "Run completed successfully. Awaiting review.",
};
}
const delivered = input.hasKnowledgeEntry || input.hasDocument || input.hasSubstantialComment;
if (delivered) {
return {
nextStatus: "in_review",
comment: "Run completed with deliverable. Awaiting review.",
};
}
return {
nextStatus: "in_progress",
comment: `${input.taskType} task run completed but no deliverable artifact was detected. Flagging for manager review.`,
};
}
export interface BudgetThresholdInput {
name: string;
budgetMonthlyUsd: number;
spentMonthlyUsd: number;
}
/**
* Fires budget threshold notifications when utilization >= 80%.
* The notify callback receives a NotificationOptions-style object so callers
* can forward it directly to the notification service.
*/
export function checkBudgetThreshold(
agent: BudgetThresholdInput,
notify: (opts: { title: string; body: string; urgency: "critical" | "informational"; navigation: { section: SectionId }; batchKey?: string }) => void,
localizeNotification: (en: string, zh: string) => string,
): void {
if (agent.budgetMonthlyUsd <= 0) return;
const utilization = agent.spentMonthlyUsd / agent.budgetMonthlyUsd;
if (utilization < 0.8) return;
const pct = Math.round(utilization * 100);
notify({
title: localizeNotification("Budget Warning", "预算警告"),
body: localizeNotification(
`${agent.name} has used ${pct}% of monthly budget`,
`${agent.name} 已使用月度预算的 ${pct}%`,
),
urgency: "informational",
navigation: { section: "costs" },
batchKey: "budget_alerts",
});
if (utilization >= 1.0) {
notify({
title: localizeNotification("Budget Exhausted", "预算耗尽"),
body: localizeNotification(
`${agent.name} has reached 100% of monthly budget and is paused`,
`${agent.name} 已达到月度预算上限并已暂停`,
),
urgency: "critical",
navigation: { section: "costs" },
});
}
}
// ─── Internal DB-backed deliverable check ───────────────────────────────────
function checkRunProducedDeliverable(db: AppDatabase, runId: string, companyId: string, taskId: string): 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
@@ -115,6 +202,36 @@ function createDeliverableReviewApproval(
return approvalId;
}
/** Advances a task to in_review and optionally flags deliverables for user review. */
function advanceToInReview(
deps: RunCompletionDeps,
callbacks: RunCompletionCallbacks,
run: RunRecord,
taskId: string,
statusMessage: string,
): void {
const { db, logger, publishDomainChanged } = deps;
db.advanceTaskStatus(taskId, "in_review", statusMessage);
callbacks.handleTaskStatusChange(taskId, run.companyId, "in_progress", "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(taskId, 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, taskId, run.agentId, importantDocs);
}
}
}
publishDomainChanged();
}
export function processRunCompletion(
deps: RunCompletionDeps,
callbacks: RunCompletionCallbacks,
@@ -123,13 +240,14 @@ export function processRunCompletion(
isQuitting: () => boolean,
): void {
if (isQuitting()) return;
const { db, logger, publishDomainChanged, notify, wakeAgentIfPossible } = deps;
const { db, logger: _logger, publishDomainChanged, notify, wakeAgentIfPossible } = deps;
const { dispatchAutomation, handleTaskStatusChange } = callbacks;
let task: ReturnType<typeof db.getTask> | null = null;
try {
task = db.getTask(run.taskId);
} catch {
} catch (error) {
deps.logger.warn(`[automation] processRunCompletion: task ${run.taskId} not found for run ${run.id}:`, error);
return;
}
if (!task) return;
@@ -141,7 +259,7 @@ export function processRunCompletion(
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 taskType = task.taskType ?? "general";
const needsDeliverable = taskType === "research" || taskType === "content";
if (needsDeliverable) {
@@ -162,49 +280,13 @@ export function processRunCompletion(
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");
advanceToInReview(deps, callbacks, run, task.id, "Run completed with deliverable. Awaiting 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");
advanceToInReview(deps, callbacks, run, task.id, "Run completed successfully. Awaiting 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();
}
}
@@ -264,27 +346,14 @@ export function processRunCompletion(
...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" },
});
}
}
}
const budgetLocale = db.listSnapshot().locale;
checkBudgetThreshold(
agent,
notify,
(en, zh) => localizeNotification(budgetLocale, en, zh),
);
} catch {
// Budget follow-up is best-effort; do not fail run completion if the agent disappeared.
}
+6 -6
View File
@@ -165,7 +165,7 @@ export async function bootstrapApplication({
getMainWindow: () => serviceGraph?.getDesktopShellService()?.getMainWindow() ?? null,
getDb: () => ({
hasCompany: (companyId: string) => db.hasCompany(companyId),
belongsToCompany: (table: string, id: string, companyId: string) => db.belongsToCompany(table as any, id, companyId),
belongsToCompany: (table: string, id: string, companyId: string) => db.belongsToCompany(table as Parameters<typeof db.belongsToCompany>[0], id, companyId),
}),
notificationService,
});
@@ -201,10 +201,10 @@ export async function bootstrapApplication({
automation.handleApprovalResolved(approvalId, companyId, decision);
threadIntegration.onApprovalResolved(approvalId, companyId, decision);
};
const dispatchAutomation = (trigger: string, ctx: Record<string, unknown>) => {
const dispatchAutomation = (trigger: string, ctx: import("./automation/types").AutomationEventContext) => {
const automation = serviceGraph.getAutomationService();
if (!automation) throw new Error("Automation service is not initialized.");
automation.dispatchAutomation(trigger, ctx as any);
automation.dispatchAutomation(trigger, ctx);
};
const handleApprovalCreated = (approvalId: string, companyId: string, taskId: string | null, approvalType: string, summary: string) => {
@@ -332,7 +332,7 @@ export async function bootstrapApplication({
emitEvent({
type: "recovery-needed",
interruptedRuns,
} as any); // DesktopEvent will need extending
});
notify({
title: "Session Recovered",
body: `${interruptedRuns.length} interrupted task(s) detected from previous session`,
@@ -368,8 +368,8 @@ export async function bootstrapApplication({
resumedAgents.add(run.agentId);
wakeAgentIfPossible(run.agentId, run.companyId, "app_restart_resume");
}
} catch {
// Task or agent may have been deleted — skip
} catch (error) {
log.warn(`[bootstrap] Skipped shutdown recovery for run ${run.id} (task/agent may have been deleted):`, error);
}
}
if (resumedAgents.size > 0) {
+54 -7
View File
@@ -143,33 +143,80 @@ export async function openConnectorAuthTerminal(connectorId: ConnectorId, comman
/* Generic terminal command (for install flows, etc.) */
/* ------------------------------------------------------------------ */
export async function openGenericTerminal(input: { title: string; command: string }) {
const TERMINAL_COMMAND_MAP: Record<string, { title: string; unix: string | null; win32: string | null }> = {
install_node_darwin: {
title: "Install Node.js",
unix: "brew install node@22",
win32: null,
},
install_node_win32: {
title: "Install Node.js",
unix: null,
win32: "winget install -e --id OpenJS.NodeJS.LTS",
},
install_node_linux: {
title: "Install Node.js",
unix: `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh | bash && \\. "$HOME/.nvm/nvm.sh" && nvm install 22`,
win32: null,
},
install_claude_unix: {
title: "Install Claude Code",
unix: "curl -fsSL https://claude.ai/install.sh | bash",
win32: null,
},
install_claude_win32: {
title: "Install Claude Code",
unix: null,
win32: "irm https://claude.ai/install.ps1 | iex",
},
install_codex: {
title: "Install Codex",
unix: "npm i -g @openai/codex",
win32: "npm i -g @openai/codex",
},
install_gemini: {
title: "Install Gemini CLI",
unix: "npm install -g @google/gemini-cli",
win32: "npm install -g @google/gemini-cli",
},
};
export async function openGenericTerminal(input: { commandKey: string }) {
const spec = TERMINAL_COMMAND_MAP[input.commandKey];
if (!spec) {
throw new Error(`Unknown terminal command key: ${input.commandKey}`);
}
const cwd = process.env.HOME || homedir() || process.cwd();
const command = process.platform === "win32" ? spec.win32 : spec.unix;
if (!command) {
throw new Error(`Command "${input.commandKey}" is not available on this platform.`);
}
if (process.platform === "win32") {
const winScript = [
`@echo off`,
`echo AgentCompany - ${input.title}`,
`echo AgentCompany - ${spec.title}`,
`echo.`,
`cd /d "${cwd}"`,
input.command,
command,
`echo.`,
`echo Done. You can close this terminal.`,
`pause`,
].join(" & ");
return spawnTerminalWithScript(winScript, input.title, cwd);
return spawnTerminalWithScript(winScript, spec.title, cwd);
}
const shell = process.env.SHELL || "/bin/zsh";
const script = [
"clear",
`echo ${quoteShell(`AgentCompany — ${input.title}`)}`,
`echo ${quoteShell(`AgentCompany — ${spec.title}`)}`,
"echo",
`cd ${quoteShell(cwd)}`,
input.command,
command,
"echo",
`echo ${quoteShell("Done. You can close this terminal.")}`,
`exec ${quoteShell(shell)} -l`,
].join("; ");
return spawnTerminalWithScript(script, input.title, cwd);
return spawnTerminalWithScript(script, spec.title, cwd);
}
+13 -96
View File
@@ -1,21 +1,4 @@
import type { ConnectorId, ConnectorStatus, TaskType } from "@shared/types";
interface ConnectorMatchInput {
taskType: TaskType;
agentRole?: string;
agentDepartment?: string;
availableConnectors: Array<{
id: ConnectorId;
status: ConnectorStatus;
label: string;
}>;
}
interface ConnectorMatchResult {
recommendedConnectorId: ConnectorId;
fallbackConnectorId: ConnectorId | null;
reason: string;
}
import type { ConnectorId, TaskType } from "@shared/types";
export const TASK_TYPE_PREFERENCE: Record<TaskType, ConnectorId[]> = {
code: ["claude_local", "codex_local", "claude_compatible", "claude_compat_2", "claude_compat_3", "claude_compat_4", "claude_compat_5", "claude_compat_6", "claude_compat_7", "claude_compat_8", "claude_compat_9", "claude_compat_10", "gemini_local"],
@@ -40,37 +23,27 @@ export const ROLE_DEPARTMENT_HINTS: Record<string, TaskType> = {
legal: "management",
};
const COMPAT_STRENGTH = "Claude Code CLI with third-party API provider (DeepSeek, OpenRouter, Ollama, etc.). Cost-effective for worker agents.";
export const CONNECTOR_STRENGTHS: Record<ConnectorId, string> = {
claude_local:
"Best for code generation, content writing, complex reasoning. Supports session resume, cost tracking.",
claude_compatible:
"Claude Code CLI with third-party API provider (DeepSeek, OpenRouter, Ollama, etc.). Cost-effective for worker agents.",
claude_compat_2:
"Claude Code CLI with third-party API provider (DeepSeek, OpenRouter, Ollama, etc.). Cost-effective for worker agents.",
claude_compat_3:
"Claude Code CLI with third-party API provider (DeepSeek, OpenRouter, Ollama, etc.). Cost-effective for worker agents.",
claude_compat_4:
"Claude Code CLI with third-party API provider (DeepSeek, OpenRouter, Ollama, etc.). Cost-effective for worker agents.",
claude_compat_5:
"Claude Code CLI with third-party API provider (DeepSeek, OpenRouter, Ollama, etc.). Cost-effective for worker agents.",
claude_compat_6:
"Claude Code CLI with third-party API provider (DeepSeek, OpenRouter, Ollama, etc.). Cost-effective for worker agents.",
claude_compat_7:
"Claude Code CLI with third-party API provider (DeepSeek, OpenRouter, Ollama, etc.). Cost-effective for worker agents.",
claude_compat_8:
"Claude Code CLI with third-party API provider (DeepSeek, OpenRouter, Ollama, etc.). Cost-effective for worker agents.",
claude_compat_9:
"Claude Code CLI with third-party API provider (DeepSeek, OpenRouter, Ollama, etc.). Cost-effective for worker agents.",
claude_compat_10:
"Claude Code CLI with third-party API provider (DeepSeek, OpenRouter, Ollama, etc.). Cost-effective for worker agents.",
claude_compatible: COMPAT_STRENGTH,
claude_compat_2: COMPAT_STRENGTH,
claude_compat_3: COMPAT_STRENGTH,
claude_compat_4: COMPAT_STRENGTH,
claude_compat_5: COMPAT_STRENGTH,
claude_compat_6: COMPAT_STRENGTH,
claude_compat_7: COMPAT_STRENGTH,
claude_compat_8: COMPAT_STRENGTH,
claude_compat_9: COMPAT_STRENGTH,
claude_compat_10: COMPAT_STRENGTH,
codex_local:
"Optimized for code editing and generation. Supports session resume, structured output.",
gemini_local:
"Strong at research, analysis, and large-context tasks. Good for data processing.",
};
const READY_STATUSES: Set<ConnectorStatus> = new Set(["ready"]);
export function inferTaskType(
department: string | null | undefined,
): TaskType {
@@ -82,59 +55,3 @@ export function inferTaskType(
export function getConnectorStrengths(connectorId: ConnectorId): string {
return CONNECTOR_STRENGTHS[connectorId] ?? "General-purpose AI connector.";
}
export function matchConnector(input: ConnectorMatchInput): ConnectorMatchResult {
const { taskType, availableConnectors } = input;
const prefList = TASK_TYPE_PREFERENCE[taskType] ?? TASK_TYPE_PREFERENCE.general;
// Partition into ready vs all
const readySet = new Set<ConnectorId>();
for (const c of availableConnectors) {
if (READY_STATUSES.has(c.status)) {
readySet.add(c.id);
}
}
// If we have ready connectors, walk the preference list
if (readySet.size > 0) {
let recommended: ConnectorId | null = null;
let fallback: ConnectorId | null = null;
for (const connId of prefList) {
if (readySet.has(connId)) {
if (recommended === null) {
recommended = connId;
} else if (fallback === null && connId !== recommended) {
fallback = connId;
break; // We have both recommended and fallback
}
}
}
// recommended should always be set if readySet.size > 0, but guard
if (recommended) {
return {
recommendedConnectorId: recommended,
fallbackConnectorId: fallback,
reason: `Selected ${recommended} for ${taskType} tasks.`,
};
}
}
// No ready connectors -- fall back to first available connector
if (availableConnectors.length > 0) {
const first = availableConnectors[0];
return {
recommendedConnectorId: first.id,
fallbackConnectorId: null,
reason: `No ready connectors available. Using ${first.label} as fallback (status: ${first.status}).`,
};
}
// Ultimate fallback -- no connectors at all
return {
recommendedConnectorId: "claude_local",
fallbackConnectorId: null,
reason: "No connectors available. Defaulting to claude_local.",
};
}
+2 -2
View File
@@ -112,8 +112,8 @@ export function buildAgentCrudMethods(
};
const changedKeys = Object.keys(afterConfig).filter(
(k) =>
JSON.stringify((afterConfig as any)[k]) !==
JSON.stringify((beforeConfig as any)[k]),
JSON.stringify((afterConfig as Record<string, unknown>)[k]) !==
JSON.stringify((beforeConfig as Record<string, unknown>)[k]),
);
if (changedKeys.length > 0) {
try {
+1 -3
View File
@@ -1,11 +1,9 @@
import { randomUUID } from "node:crypto";
import type { DatabaseSync } from "node:sqlite";
import type { WorkflowPipelineRecord } from "@shared/types";
import type { ReadAll } from "./helpers";
import type { ReadAll, SqliteRow } from "./helpers";
import { ensureId, nowIso } from "./helpers";
type SqliteRow = Record<string, any>;
export function buildAutomationMethods(
getDb: () => DatabaseSync,
readAll: ReadAll,
+1 -3
View File
@@ -1,11 +1,9 @@
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 type { ReadAll, SqliteRow, Transaction } from "./helpers";
import { nowIso } from "./helpers";
type SqliteRow = Record<string, any>;
function mapPolicyRow(r: SqliteRow): BudgetPolicyRecord {
return {
id: r.id,
+5 -2
View File
@@ -52,13 +52,16 @@ export function buildCompanySettingsMethods(
).length;
// Avg run duration
const finishedRuns = agentRuns.filter((r) => r.startedAt && r.finishedAt);
const finishedRuns = agentRuns.filter(
(r): r is typeof r & { startedAt: string; finishedAt: string } =>
Boolean(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,
(new Date(r.finishedAt).getTime() - new Date(r.startedAt).getTime()) / 1000,
0,
) / finishedRuns.length
: 0;
@@ -0,0 +1,80 @@
/**
* Pure helpers for crash-recovery logic, extracted for testability.
*
* These encode the behavioral contract of the corresponding AppDatabase methods:
* - interruptStaleRuns
* - retryInterruptedTask
* - markInterruptedTaskFailed
*
* The helpers take narrowly-typed "statement runner" interfaces so they can be
* exercised in unit tests without a live SQLite connection.
*/
export interface StaleRunDb {
/** Update all running runs to 'interrupted' and return number of rows changed. */
interruptRunningRuns(ts: string): number;
/** Mark tasks associated with interrupted runs as 'blocked'. */
blockInterruptedTasks(ts: string): void;
/** Clear active_run_id on tasks whose run is now interrupted. */
clearActiveRunIds(): void;
/** Reset all running agents to idle. */
resetAgentsToIdle(ts: string): void;
/** Release all workspace locks. */
releaseAllWorkspaceLocks(): void;
}
export interface RetryTaskDb {
/** Set task status to 'todo'. */
setTaskTodo(ts: string, taskId: string): void;
/** Reset retry_count and last_failure_at. */
resetTaskRetryFields(ts: string, taskId: string): void;
}
export interface FailTaskDb {
/** Set task status to 'cancelled'. */
setTaskCancelled(ts: string, taskId: string): void;
}
/**
* Transactionally marks all running runs as interrupted and associated tasks
* as blocked. Returns the number of interrupted runs.
*/
export function interruptStaleRunsLogic(
db: StaleRunDb,
transaction: (fn: () => void) => void,
nowIso: () => string,
): number {
let interruptedCount = 0;
transaction(() => {
const ts = nowIso();
interruptedCount = db.interruptRunningRuns(ts);
if (interruptedCount > 0) {
db.blockInterruptedTasks(ts);
}
db.clearActiveRunIds();
db.resetAgentsToIdle(ts);
db.releaseAllWorkspaceLocks();
});
return interruptedCount;
}
/** Resets a task to 'todo' and clears its retry state. */
export function retryInterruptedTaskLogic(
db: RetryTaskDb,
taskId: string,
nowIso: () => string,
): void {
const ts = nowIso();
db.setTaskTodo(ts, taskId);
db.resetTaskRetryFields(ts, taskId);
}
/** Marks a task as 'cancelled'. */
export function markInterruptedTaskFailedLogic(
db: FailTaskDb,
taskId: string,
nowIso: () => string,
): void {
const ts = nowIso();
db.setTaskCancelled(ts, taskId);
}
+23 -10
View File
@@ -1,11 +1,9 @@
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 type { ReadAll, SqliteRow, Transaction } from "./helpers";
import { ensureId, nowIso, parseJson } from "./helpers";
type SqliteRow = Record<string, any>;
export function buildDocumentMethods(
getDb: () => DatabaseSync,
readAll: ReadAll,
@@ -218,10 +216,10 @@ export function buildDocumentMethods(
source: string; rolledBackFromRevisionId: string | null;
createdByAgentId: string | null; createdByUserId: string | null; createdAt: string;
}> {
return readAll<any>(
return readAll<SqliteRow>(
"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) => ({
).map((row) => ({
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,
@@ -238,7 +236,12 @@ export function buildDocumentMethods(
} {
const row = getDb().prepare(
"SELECT * FROM agent_config_revisions WHERE id = ? AND company_id = ?"
).get(revisionId, companyId) as any;
).get(revisionId, companyId) as {
id: string; agent_id: string; revision_number: number; changed_keys: string;
config_before: string; config_after: string; source: string;
rolled_back_from_revision_id: string | null;
created_by_agent_id: string | null; created_by_user_id: string | null; created_at: string;
} | undefined;
if (!row) throw new Error(`Config revision ${revisionId} not found.`);
return {
id: row.id, agentId: row.agent_id, revisionNumber: row.revision_number,
@@ -252,14 +255,24 @@ export function buildDocumentMethods(
},
rollbackConfigRevision(agentId: string, revisionId: string, companyId: string, selfDeps: {
getConfigRevision: (revisionId: string, companyId: string) => any;
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;
};
getAgent: (id: string) => AgentRecord;
recordConfigRevision: (input: any) => string;
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;
}): 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 before = revision.configBefore as Record<string, string | number | null>;
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 = ?"
@@ -270,7 +283,7 @@ export function buildDocumentMethods(
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 };
const currentConfig = { name: agent.name, role: agent.role, title: agent.title, department: agent.department, capabilities: agent.capabilities, connectorId: agent.connectorId, model: agent.model, budgetMonthlyUsd: agent.budgetMonthlyUsd, metadataJson: agent.metadataJson };
return selfDeps.recordConfigRevision({
companyId, agentId,
changedKeys: revision.changedKeys,
+14 -3
View File
@@ -15,7 +15,7 @@ export function buildEntityCrudMethods(
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 = ?",
"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, task_type as taskType, 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}`);
@@ -152,8 +152,19 @@ export function buildEntityCrudMethods(
budgetMonthlyUsd: number; payloadSummary: string; impactSummary: string;
payloadJson?: string | null;
}, selfDeps: {
saveAgent: (input: any) => string;
requestApproval: (input: any) => string;
saveAgent: (input: {
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;
requestApproval: (input: {
companyId: string; requestedByAgentId: string | null;
relatedAgentId: string; type: string;
payloadSummary: string; impactSummary: string;
payloadJson: string | null;
}) => string;
}) {
return transaction(() => {
const agentId = selfDeps.saveAgent({
+2 -4
View File
@@ -4,9 +4,7 @@ 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>;
import { type ReadAll, type SqliteRow, type Transaction, nowIso, toBoolean } from "./helpers";
/**
* Find the last complete UTF-8 character boundary in a buffer.
@@ -325,7 +323,7 @@ export function buildRunMethods(
}> {
const offset = opts?.offset ?? 0;
const limit = opts?.limit ?? 500;
return readAll<any>(
return readAll<SqliteRow>(
"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) => ({
+2 -1
View File
@@ -182,7 +182,8 @@ export function runMigrations(
let skipped = 0;
for (const task of tasksWithSessions) {
try {
JSON.parse(task.sessionJson!);
if (!task.sessionJson) continue;
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();
+1 -3
View File
@@ -1,10 +1,8 @@
import type { DatabaseSync } from "node:sqlite";
import type { SkillRecord, FileInventoryEntry, SkillSourceType, TrustLevel } from "../skill-types.js";
import type { ReadAll } from "./helpers";
import type { ReadAll, SqliteRow } from "./helpers";
import { nowIso, parseJson } from "./helpers";
type SqliteRow = Record<string, any>;
export function buildSkillMethods(
getDb: () => DatabaseSync,
readAll: ReadAll,
+5 -6
View File
@@ -5,6 +5,7 @@ import type {
ApprovalRecord,
CompanyRecord,
CommentRecord,
ConnectorRecord,
CostRecord,
GoalRecord,
ProfileSnapshot,
@@ -14,9 +15,7 @@ import type {
TaskRecord,
WorkspaceRecord,
} from "@shared/types";
import { type ReadAll, toBoolean } from "./helpers";
type SqliteRow = Record<string, any>;
import { type ReadAll, type SqliteRow, toBoolean } from "./helpers";
const BOARD_READER_ID = "__board__";
export function buildSnapshotMethods(
@@ -25,7 +24,7 @@ export function buildSnapshotMethods(
deps: {
getCurrentCompanyId: () => string | null;
getSetting: (key: string) => string | null;
readConnectors: () => any[];
readConnectors: () => ConnectorRecord[];
profileDir: string;
vaultBackendLabel: () => string;
},
@@ -64,7 +63,7 @@ export function buildSnapshotMethods(
`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>(
agents: readAll<SqliteRow>(
`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[],
@@ -77,7 +76,7 @@ export function buildSnapshotMethods(
...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`,
`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, task_type as taskType, 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>(
+1 -3
View File
@@ -1,11 +1,9 @@
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 type { ReadAll, SqliteRow, Transaction } from "./helpers";
import { ensureId, nowIso, toBoolean } from "./helpers";
type SqliteRow = Record<string, any>;
export function buildSocialMethods(
getDb: () => DatabaseSync,
readAll: ReadAll,
+1 -1
View File
@@ -141,7 +141,7 @@ export function buildTaskCrudMethods(
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
`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, task_type as taskType, created_at as createdAt, updated_at as updatedAt
from tasks
where company_id = ? and assignee_agent_id = ? and status in (${placeholders})
order by
+1 -7
View File
@@ -13,13 +13,7 @@ import type {
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();
}
import { type ReadAll, type SqliteRow, nowIso } from "./helpers";
export function buildThreadMethods(getDb: () => DatabaseSync, readAll: ReadAll) {
function getThread(threadId: string, companyId: string): ConversationThreadRecord | null {
+5 -4
View File
@@ -132,13 +132,14 @@ export function buildWakeupMethods(
promoted = deferred.length;
} else {
const [first, ...rest] = deferred;
if (!first) return 0;
getDb().prepare(
`UPDATE wakeup_requests SET status = 'queued', updated_at = ? WHERE id = ?`,
).run(ts, first!.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) : [];
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;
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);
@@ -147,7 +148,7 @@ export function buildWakeupMethods(
}
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);
).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);
}
+1 -1
View File
@@ -14,7 +14,7 @@ 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>([
const COFOUNDER_MANDATORY_APPROVALS = new Set<ApprovalType>([
"deliverable_review",
"document_review",
"plan_review",
+1 -1
View File
@@ -51,7 +51,7 @@ export async function notifyHireApproved(
source: input.source,
sourceId: input.sourceId,
approvedAt: input.approvedAt ?? new Date().toISOString(),
message: db.listSnapshot().locale === "zh"
message: (db.queryAll<{ value: string }>("select value from settings where key = 'locale' limit 1")[0]?.value ?? "en") === "zh"
? `你已被批准为"${agent.title}"。你现在已激活,可以接收任务。`
: `Your hire as "${agent.title}" in the company has been approved. You are now active and ready to receive tasks.`,
};
+4 -10
View File
@@ -26,13 +26,6 @@ export function registerBudgetHandlers({
budgetService,
db,
}: BudgetHandlerDependencies) {
function requireBudgetService() {
if (!budgetService) {
return fail("SERVICE_NOT_READY", "Budget enforcement service is not initialized.");
}
return null;
}
// ---- Save (create/update) a budget policy ----
registerHandle(IPC_CHANNELS.saveBudgetPolicy, async (_event, payload) => {
const parsed = budgetPolicyInputSchema.parse(payload);
@@ -92,8 +85,9 @@ export function registerBudgetHandlers({
const parsed = getBudgetStatusSchema.parse(payload);
const companyError = ensureCompanyExists(parsed.companyId);
if (companyError) return companyError;
const serviceError = requireBudgetService();
if (serviceError) return serviceError;
return ok(budgetService!.getBudgetStatus(parsed.companyId));
if (!budgetService) {
return fail("SERVICE_NOT_READY", "Budget enforcement service is not initialized.");
}
return ok(budgetService.getBudgetStatus(parsed.companyId));
});
}
+1 -7
View File
@@ -1,4 +1,3 @@
import { execSync } from "node:child_process";
import {
IPC_CHANNELS,
companyInputSchema,
@@ -96,12 +95,7 @@ export function registerCoreAdminHandlers({
});
registerHandle(IPC_CHANNELS.checkNodeInstalled, async () => {
try {
const raw = execSync("node --version", { encoding: "utf8", timeout: 10000 }).trim();
return ok({ installed: true, version: raw || null });
} catch {
return ok({ installed: false, version: null });
}
return ok({ installed: true, version: process.version });
});
registerHandle(IPC_CHANNELS.openTerminalCommand, async (_event, payload) => {
+3 -1
View File
@@ -62,7 +62,9 @@ export function registerCoreAgentGraphHandlers({
}
const existingAgent = (() => {
try {
return db.getAgent(parsed.id!);
const agentId = parsed.id;
if (!agentId) return null;
return db.getAgent(agentId);
} catch {
return null;
}
+4 -4
View File
@@ -1,6 +1,6 @@
import type { IpcMainInvokeEvent } from "electron";
import type { DesktopResult } from "@shared/contracts";
import type { DesktopEvent, SectionId } from "@shared/types";
import type { ConnectorId, DesktopEvent, SectionId } from "@shared/types";
import type { AppDatabase } from "./database";
type CompanyScopedTable = "workspaces" | "agents" | "goals" | "projects" | "tasks" | "approvals" | "runs" | "secrets";
@@ -25,9 +25,9 @@ export interface RegisterCoreHandlersDependencies {
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>;
checkConnector: (id: any) => Promise<void>;
openConnectorAuthTerminal: (id: any) => Promise<boolean>;
openGenericTerminal: (input: { title: string; command: string }) => Promise<boolean>;
checkConnector: (id: ConnectorId) => Promise<void>;
openConnectorAuthTerminal: (id: ConnectorId) => Promise<boolean>;
openGenericTerminal: (input: { commandKey: string }) => Promise<boolean>;
checkForUpdates: () => Promise<boolean>;
getWorkerProcess: () => { postMessage: (message: unknown) => void } | null;
resetAfterRestore: () => void;
+3 -1
View File
@@ -109,8 +109,10 @@ export function registerCoreTaskGraphHandlers({
let previousStatus: string | null = null;
let previousAssigneeAgentId: string | null = null;
if (!isNew) {
const taskId = parsed.id;
if (!taskId) return fail("INVALID_TASK_STATE", "Task ID required for updates.");
try {
const existingTask = db.getTask(parsed.id!);
const existingTask = db.getTask(taskId);
previousStatus = existingTask.status;
previousAssigneeAgentId = existingTask.assigneeAgentId;
} catch {
+23 -23
View File
@@ -33,7 +33,7 @@ export function registerSkillHandlers({
if (!skillService) {
return fail("SERVICE_NOT_READY", "Skill service is not initialized.");
}
return null;
return skillService;
}
// ---- Import skill from source (GitHub URL, local path, raw URL, skills.sh) ----
@@ -41,9 +41,9 @@ export function registerSkillHandlers({
const parsed = importSkillSchema.parse(payload);
const companyError = ensureCompanyExists(parsed.companyId);
if (companyError) return companyError;
const serviceError = requireSkillService();
if (serviceError) return serviceError;
const result = await skillService!.importFromSource(parsed.companyId, parsed.source);
const svc = requireSkillService();
if ("ok" in svc) return svc;
const result = await svc.importFromSource(parsed.companyId, parsed.source);
if (result.ok) {
publishDomainChanged();
}
@@ -55,15 +55,15 @@ export function registerSkillHandlers({
const parsed = listSkillsSchema.parse(payload);
const companyError = ensureCompanyExists(parsed.companyId);
if (companyError) return companyError;
const serviceError = requireSkillService();
if (serviceError) return serviceError;
const svc = requireSkillService();
if ("ok" in svc) return svc;
// Scan workspace .agents/skills/ directories for new skills before listing
try {
await skillService!.scanWorkspaceSkills(parsed.companyId);
await svc.scanWorkspaceSkills(parsed.companyId);
} catch {
// Non-fatal: scan failure should not block listing existing skills
}
return ok(skillService!.listForCompany(parsed.companyId));
return ok(svc.listForCompany(parsed.companyId));
});
// ---- Get skill detail ----
@@ -71,9 +71,9 @@ export function registerSkillHandlers({
const parsed = getSkillDetailSchema.parse(payload);
const companyError = ensureCompanyExists(parsed.companyId);
if (companyError) return companyError;
const serviceError = requireSkillService();
if (serviceError) return serviceError;
return skillService!.getDetail(parsed.companyId, parsed.skillId);
const svc = requireSkillService();
if ("ok" in svc) return svc;
return svc.getDetail(parsed.companyId, parsed.skillId);
});
// ---- Delete a skill ----
@@ -81,9 +81,9 @@ export function registerSkillHandlers({
const parsed = deleteSkillSchema.parse(payload);
const companyError = ensureCompanyExists(parsed.companyId);
if (companyError) return companyError;
const serviceError = requireSkillService();
if (serviceError) return serviceError;
const result = await skillService!.deleteSkill(parsed.companyId, parsed.skillId);
const svc = requireSkillService();
if ("ok" in svc) return svc;
const result = await svc.deleteSkill(parsed.companyId, parsed.skillId);
if (result.ok) {
publishDomainChanged();
}
@@ -95,9 +95,9 @@ export function registerSkillHandlers({
const parsed = assignSkillToAgentSchema.parse(payload);
const companyError = ensureCompanyExists(parsed.companyId);
if (companyError) return companyError;
const serviceError = requireSkillService();
if (serviceError) return serviceError;
const result = skillService!.assignToAgent(parsed.companyId, parsed.agentId, parsed.skillId);
const svc = requireSkillService();
if ("ok" in svc) return svc;
const result = svc.assignToAgent(parsed.companyId, parsed.agentId, parsed.skillId);
if (result.ok) {
publishDomainChanged();
}
@@ -109,9 +109,9 @@ export function registerSkillHandlers({
const parsed = removeSkillFromAgentSchema.parse(payload);
const companyError = ensureCompanyExists(parsed.companyId);
if (companyError) return companyError;
const serviceError = requireSkillService();
if (serviceError) return serviceError;
const result = skillService!.removeFromAgent(parsed.companyId, parsed.agentId, parsed.skillId);
const svc = requireSkillService();
if ("ok" in svc) return svc;
const result = svc.removeFromAgent(parsed.companyId, parsed.agentId, parsed.skillId);
if (result.ok) {
publishDomainChanged();
}
@@ -123,9 +123,9 @@ export function registerSkillHandlers({
const parsed = checkSkillUpdateSchema.parse(payload);
const companyError = ensureCompanyExists(parsed.companyId);
if (companyError) return companyError;
const serviceError = requireSkillService();
if (serviceError) return serviceError;
return skillService!.checkUpdateStatus(parsed.companyId, parsed.skillId);
const svc = requireSkillService();
if ("ok" in svc) return svc;
return svc.checkUpdateStatus(parsed.companyId, parsed.skillId);
});
// ---- List agent IDs assigned to a skill ----
+2 -2
View File
@@ -132,7 +132,7 @@ export function createMainRuntimeSupport(state: MainRuntimeState) {
handleTaskStatusChange: (taskId, companyId, previousStatus, newStatus) =>
requireAutomationService().handleTaskStatusChange(taskId, companyId, previousStatus, newStatus),
handleApprovalCreated: (approvalId, companyId, taskId, approvalType, summary) => {
state.threadIntegration!.onApprovalCreated(approvalId, companyId, taskId, approvalType, summary);
state.threadIntegration?.onApprovalCreated(approvalId, companyId, taskId, approvalType, summary);
},
handleApprovalResolved: (approvalId, companyId, decision) => {
requireAutomationService().handleApprovalResolved(approvalId, companyId, decision);
@@ -153,7 +153,7 @@ export function createMainRuntimeSupport(state: MainRuntimeState) {
initializeAgentTokenSigningKey(requireDb().profileDir);
requireBrowserActionService().resetBrowserManager();
},
getApiPort: () => state.apiServer?.getPort() ?? 0,
getApiPort: () => state.apiServer?.port ?? 0,
notify: (options) => requireIpcSupport().notify(options),
listConnectorModels: async (connectorId) => {
const policy = getConnectorModelPolicy(requireDb(), connectorId as ConnectorId);
+49 -36
View File
@@ -182,33 +182,39 @@ export function validateAgentMessageInput(
}
function resolveProjectWakeTargets(
db: AppDatabase,
input: NormalizedAgentMessageInput,
snapshot: ReturnType<AppDatabase["listSnapshot"]>,
): string[] {
if (!input.channelTargetId) return [];
const project = snapshot.projects.find(
(entry) => entry.id === input.channelTargetId && entry.companyId === input.companyId,
const projectRows = db.queryAll<{ leadAgentId: string | null }>(
"select lead_agent_id as leadAgentId from projects where id = ? and company_id = ? limit 1",
input.channelTargetId, input.companyId,
);
const project = projectRows[0] ?? null;
const participantIds = new Set<string>();
if (project?.leadAgentId) participantIds.add(project.leadAgentId);
for (const task of snapshot.tasks) {
if (task.companyId !== input.companyId) continue;
if (task.projectId !== input.channelTargetId) continue;
if (task.assigneeAgentId) participantIds.add(task.assigneeAgentId);
const taskAssignees = db.queryAll<{ assigneeAgentId: string | null }>(
"select assignee_agent_id as assigneeAgentId from tasks where project_id = ? and company_id = ? and assignee_agent_id is not null",
input.channelTargetId, input.companyId,
);
for (const t of taskAssignees) {
if (t.assigneeAgentId) participantIds.add(t.assigneeAgentId);
}
return Array.from(participantIds).filter((agentId) => {
if (agentId === input.fromAgentId) return false;
const agent = snapshot.agents.find((entry) => entry.id === agentId);
return Boolean(agent && agent.companyId === input.companyId && agent.status !== "terminated");
});
if (participantIds.size === 0) return [];
const ids = Array.from(participantIds).filter((id) => id !== input.fromAgentId);
if (ids.length === 0) return [];
const activeAgents = db.queryAll<{ id: string }>(
`select id from agents where id in (${ids.map(() => "?").join(",")}) and company_id = ? and status != 'terminated'`,
...ids, input.companyId,
);
return activeAgents.map((a) => a.id);
}
export function resolveMessageWakeTargets(
db: AppDatabase,
input: NormalizedAgentMessageInput,
): string[] {
const snapshot = db.listSnapshot();
const wakeTargets = new Set<string>();
if (input.channel === "direct" && input.toAgentId && input.toAgentId !== input.fromAgentId) {
@@ -216,39 +222,38 @@ export function resolveMessageWakeTargets(
}
if (input.channel === "department" && input.channelTargetId) {
for (const agent of snapshot.agents) {
if (agent.companyId !== input.companyId) continue;
if (agent.status === "terminated") continue;
if (agent.id === input.fromAgentId) continue;
if (agent.department === input.channelTargetId) {
wakeTargets.add(agent.id);
}
const deptAgents = db.queryAll<{ id: string }>(
"select id from agents where company_id = ? and department = ? and status != 'terminated'",
input.companyId, input.channelTargetId,
);
for (const agent of deptAgents) {
if (agent.id !== input.fromAgentId) wakeTargets.add(agent.id);
}
}
if (input.channel === "company") {
const topLevel = snapshot.agents.filter(
(agent) => agent.companyId === input.companyId && !agent.reportsTo && agent.status !== "terminated",
const topLevel = db.queryAll<{ id: string }>(
"select id from agents where company_id = ? and reports_to is null and status != 'terminated'",
input.companyId,
);
for (const agent of topLevel) {
if (agent.id !== input.fromAgentId) {
wakeTargets.add(agent.id);
}
if (agent.id !== input.fromAgentId) wakeTargets.add(agent.id);
}
}
if (input.channel === "project") {
for (const agentId of resolveProjectWakeTargets(input, snapshot)) {
for (const agentId of resolveProjectWakeTargets(db, input)) {
wakeTargets.add(agentId);
}
}
if (input.channel === "incident") {
for (const agent of snapshot.agents) {
if (agent.companyId !== input.companyId) continue;
if (agent.status === "terminated" || agent.status === "pending_approval") continue;
if (agent.id === input.fromAgentId) continue;
wakeTargets.add(agent.id);
const activeAgents = db.queryAll<{ id: string }>(
"select id from agents where company_id = ? and status not in ('terminated', 'pending_approval')",
input.companyId,
);
for (const agent of activeAgents) {
if (agent.id !== input.fromAgentId) wakeTargets.add(agent.id);
}
}
@@ -291,8 +296,11 @@ export function dispatchAgentMessage(
!normalized.fromHumanUser &&
(message.channel !== "direct" || message.toAgentId === HUMAN_SENDER_ID)
) {
const snapshot = deps.db.listSnapshot();
const senderName = snapshot.agents.find((entry) => entry.id === message.fromAgentId)?.name ?? "Agent";
const senderRows = deps.db.queryAll<{ name: string }>(
"select name from agents where id = ? limit 1",
message.fromAgentId,
);
const senderName = senderRows[0]?.name ?? "Agent";
deps.notify({
title: truncateForNotification(`${senderName} · ${message.subject}`, 80),
body: truncateForNotification(message.body, 140),
@@ -312,10 +320,15 @@ export function dispatchAgentMessage(
const mentionResults: MentionResult[] = [];
if (deps.wakeAgentIfPossible) {
const snapshot = deps.db.listSnapshot();
const agentNameRows = wakeTargets.length > 0
? deps.db.queryAll<{ id: string; name: string }>(
`select id, name from agents where id in (${wakeTargets.map(() => "?").join(",")}) and company_id = ?`,
...wakeTargets, normalized.companyId,
)
: [];
const agentNameMap = new Map(agentNameRows.map((a) => [a.id, a.name]));
for (const wakeTarget of wakeTargets) {
const agent = snapshot.agents.find((a) => a.id === wakeTarget && a.companyId === normalized.companyId);
const agentName = agent?.name ?? wakeTarget;
const agentName = agentNameMap.get(wakeTarget) ?? wakeTarget;
const runId = deps.wakeAgentIfPossible(wakeTarget, normalized.companyId, wakeTrigger);
const hasQueuedWake = !runId && deps.db.listWakeupRequests(normalized.companyId, {
agentId: wakeTarget,
+25 -10
View File
@@ -98,10 +98,14 @@ export function createOnboardingService({
});
}
function getLocale(): string {
return db.queryAll<{ value: string }>("select value from settings where key = 'locale' limit 1")[0]?.value ?? "en";
}
function createDefaultWorkspace(companyId: string, workspacePath: string) {
const trimmedPath = workspacePath.trim();
if (!trimmedPath) return null;
const wsLocale = db.listSnapshot().locale;
const wsLocale = getLocale();
return db.saveWorkspace({
companyId,
name: wsLocale === "zh" ? "默认工作区" : "Default Workspace",
@@ -113,22 +117,33 @@ export function createOnboardingService({
}
function createInitialExecutionLoop(companyId: string, workspaceId: string | null): OnboardingBootstrapSummary {
const snapshot = db.listSnapshot();
const company = snapshot.companies.find((entry) => entry.id === companyId) ?? null;
const companyRows = db.queryAll<{ id: string; name: string; description: string }>(
"select id, name, description from companies where id = ? limit 1",
companyId,
);
const company = companyRows[0] ?? null;
if (!company) {
throw new Error("Company no longer exists during onboarding bootstrap.");
}
const leadAgent = snapshot.agents.find((agent) => agent.companyId === companyId && !agent.reportsTo) ?? null;
const leadAgentRows = db.queryAll<{ id: string; companyId: string; name: string; role: string; title: string; department: string | null; status: string; reportsTo: string | null; connectorId: string; workspaceId: string | null; model: string | null; metadataJson: string; capabilities: string; budgetMonthlyUsd: number; heartbeatEnabled: number; heartbeatIntervalSec: number }>(
"select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, metadata_json as metadataJson, capabilities, budget_monthly_usd as budgetMonthlyUsd, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec from agents where company_id = ? and reports_to is null limit 1",
companyId,
);
const leadAgent = leadAgentRows[0] ?? null;
if (!leadAgent) {
throw new Error("Unable to find the lead agent for the company bootstrap.");
}
updateAgentWorkspaceBinding(leadAgent.id, workspaceId);
const goalRows = db.queryAll<{ id: string; title: string; status: string }>(
"select id, title, status from goals where company_id = ? order by created_at asc",
companyId,
);
const activeGoal =
snapshot.goals.find((goal) => goal.companyId === companyId && goal.status === "active")
?? snapshot.goals.find((goal) => goal.companyId === companyId)
goalRows.find((goal) => goal.status === "active")
?? goalRows[0]
?? null;
const kickoffProjectName = activeGoal ? `Execution plan: ${activeGoal.title}` : "Initial operating plan";
@@ -146,7 +161,7 @@ export function createOnboardingService({
status: "in_progress",
});
const kickoffLocale = db.listSnapshot().locale;
const kickoffLocale = getLocale();
const kickoffTaskTitle = activeGoal
? (kickoffLocale === "zh" ? `落地目标:${activeGoal.title}` : `Operationalize goal: ${activeGoal.title}`)
: (kickoffLocale === "zh" ? "建立首个运营节奏" : "Establish the first operating cadence");
@@ -195,8 +210,8 @@ export function createOnboardingService({
}
function bootstrapOnboarding(input: BootstrapInput): OnboardingBootstrapSummary {
const snapshot = db.listSnapshot();
const selectedConnectorId = resolveOnboardingConnector(snapshot.connectors, input.connectorId);
const connectors = db.listSnapshot({ companyId: null }).connectors;
const selectedConnectorId = resolveOnboardingConnector(connectors, input.connectorId);
const modelSelection = validateConnectorModelSelection(db, selectedConnectorId, input.connectorModel ?? null);
if (!modelSelection.ok) {
throw new Error(modelSelection.error);
@@ -224,7 +239,7 @@ export function createOnboardingService({
});
}
const ceoLocale = db.listSnapshot().locale;
const ceoLocale = getLocale();
const ceoId = db.saveAgent({
companyId,
name: "CEO",
+8 -4
View File
@@ -1,12 +1,14 @@
import { randomUUID } from "node:crypto";
import type { AppDatabase } from "../database";
import type { AgentRecord, ConnectorRecord } from "@shared/types";
import type { OrchestrationDependencies } from "./types";
import type { RunDispatchParams } from "./turn-context";
export function createHeartbeat(
deps: OrchestrationDependencies,
resolveWorkspace: (task: { workspaceId: string | null; projectId?: string | null }, agent: { workspaceId: string | null }, companyId: string) => { workspace: ReturnType<AppDatabase["getWorkspace"]>; lockMode: "exclusive" | "shared" | "none"; source: string },
getRunEligibilityFailure: (agent: any, connector: any, projectId?: string | null) => string | null,
dispatchRunToWorker: (params: any) => void,
getRunEligibilityFailure: (agent: AgentRecord, connector: ConnectorRecord, projectId?: string | null) => string | null,
dispatchRunToWorker: (params: RunDispatchParams) => void,
hasActiveRun: (filter: { taskId?: string; agentId?: string; companyId: string }) => boolean,
) {
const {
@@ -224,12 +226,14 @@ export function createHeartbeat(
logger.warn(`[wake] Refusing cross-company wake for agent ${agentId}: ${agent.companyId} !== ${companyId}`);
return null;
}
} catch {
} catch (error) {
logger.warn(`[wake] Failed to load agent ${agentId}:`, error);
return null;
}
try {
ensureAgentHeartbeatEnabled(agentId);
} catch {
} catch (error) {
logger.warn(`[wake] Failed to ensure heartbeat for agent ${agentId}:`, error);
return null;
}
+10 -9
View File
@@ -1,5 +1,6 @@
import type { OrchestrationDependencies } from "./types";
import { sweepStaleReviews } from "../task-quality-gates";
import type { SweepStaleReviewsDeps } from "../task-quality-gates";
import { resolveApprovalDecision } from "../approval-policy";
import { dispatchAgentMessage } from "../message-dispatch";
@@ -134,12 +135,12 @@ export function createScheduler(
// TASK-04: Stale review escalation sweep
try {
const escalated = sweepStaleReviews(company.id, {
db: db as unknown as import("../task-quality-gates").SweepStaleReviewsDeps["db"],
const sweepDeps: SweepStaleReviewsDeps = {
db,
dispatchAgentMessage,
resolveApprovalDecision,
notify,
getCompanyExecutionMode: (cId: string) => db.getCompanyExecutionMode(cId),
dispatchAgentMessage: dispatchAgentMessage as unknown as import("../task-quality-gates").SweepStaleReviewsDeps["dispatchAgentMessage"],
resolveApprovalDecision: resolveApprovalDecision as unknown as import("../task-quality-gates").SweepStaleReviewsDeps["resolveApprovalDecision"],
notify: notify as unknown as import("../task-quality-gates").SweepStaleReviewsDeps["notify"],
handleTaskStatusChange: (taskId: string, cId: string, previousStatus: string, newStatus: string) => {
deps.dispatchAutomation("task_status_changed", { companyId: cId, taskId, previousStatus, newStatus });
publishDomainChanged();
@@ -150,11 +151,11 @@ export function createScheduler(
},
emitEvent: deps.emitEvent,
publishDomainChanged,
wakeAgentIfPossible: (agentId: string, cId: string, trigger: string) => {
wakeAgentIfPossible(agentId, cId, trigger);
},
wakeAgentIfPossible: (agentId: string | null | undefined, cId: string, trigger: string) =>
agentId != null ? wakeAgentIfPossible(agentId, cId, trigger) ?? null : null,
logger,
});
};
const escalated = sweepStaleReviews(company.id, sweepDeps);
if (escalated > 0) {
logger.info(`[heartbeat] Auto-escalated ${escalated} stale review(s) for company ${company.name}`);
publishDomainChanged();
+4 -2
View File
@@ -1,13 +1,15 @@
import { randomUUID } from "node:crypto";
import type { AppDatabase } from "../database";
import type { AgentRecord, ConnectorRecord } from "@shared/types";
import type { DesktopResult } from "@shared/contracts";
import type { OrchestrationDependencies } from "./types";
import type { RunDispatchParams } from "./turn-context";
export function createTaskRunner(
deps: OrchestrationDependencies,
resolveWorkspace: (task: { workspaceId: string | null; projectId?: string | null }, agent: { workspaceId: string | null }, companyId: string) => { workspace: ReturnType<AppDatabase["getWorkspace"]>; lockMode: "exclusive" | "shared" | "none"; source: string },
getRunEligibilityFailure: (agent: any, connector: any, projectId?: string | null) => string | null,
dispatchRunToWorker: (params: any) => void,
getRunEligibilityFailure: (agent: AgentRecord, connector: ConnectorRecord, projectId?: string | null) => string | null,
dispatchRunToWorker: (params: RunDispatchParams) => void,
hasActiveRun: (filter: { taskId?: string; agentId?: string; companyId: string }) => boolean,
) {
const {
+2 -1
View File
@@ -1,4 +1,5 @@
import type { AppDatabase } from "../database";
import type { DesktopEvent } from "@shared/types";
import type { NotificationOptions } from "../notification-service";
import type { createBudgetEnforcementService } from "../budget-enforcement-service";
import type { RuntimeSkillEntry } from "../skill-types";
@@ -54,7 +55,7 @@ export interface OrchestrationDependencies {
error: (...args: unknown[]) => void;
};
publishDomainChanged: () => void;
emitEvent: (event: any) => void;
emitEvent: (event: DesktopEvent) => void;
notify: (options: NotificationOptions) => void;
createRunToken: (agentId: string, companyId: string, runId: string) => string;
getApiPort: () => number;
@@ -36,7 +36,7 @@ export function resolveAttachmentContent(
}
const docEntries = entries.filter(
(e) => e.type === "document" && typeof e.id === "string",
(e): e is typeof e & { id: string } => e.type === "document" && typeof e.id === "string",
);
if (docEntries.length === 0) return "";
@@ -44,7 +44,7 @@ export function resolveAttachmentContent(
const resolved: Array<{ title: string; content: string }> = [];
for (const entry of docEntries) {
const doc = docMap.get(entry.id!);
const doc = docMap.get(entry.id);
if (!doc || !doc.content) continue;
resolved.push({ title: doc.title || entry.title || "Untitled", content: doc.content });
}
+1 -1
View File
@@ -23,7 +23,7 @@ ${task.projectId ? `- Project: ${projects.find((p) => p.id === task.projectId)?.
${task.goalId ? `- Goal: ${goals.find((g) => g.id === task.goalId)?.title ?? task.goalId}` : ""}
`);
const taskType = ((task as unknown as Record<string, unknown>).task_type as string) ?? inferTaskType(agent.department);
const taskType = task.taskType ?? inferTaskType(agent.department);
if (taskType === "research") {
lines.push(`# Deliverable Requirements (Research Task)
+2 -1
View File
@@ -1,4 +1,5 @@
import { DEPARTMENT_LABELS } from "@shared/constants";
import type { DepartmentId } from "@shared/types";
import { getLocalePromptInstruction } from "@shared/localization";
import {
buildTaskSection,
@@ -229,7 +230,7 @@ ${deptMembers.map((a) => `- ${a.name} (${a.role}) [${a.status}]`).join("\n")}` :
}
if (deptMap.size > 1) {
const deptLines = Array.from(deptMap.entries()).map(([dept, members]) => {
const label = DEPARTMENT_LABELS[dept] ?? dept;
const label = DEPARTMENT_LABELS[dept as DepartmentId] ?? dept;
return ` ${label} (${members.length}): ${members.map((m) => m.name).join(", ")}`;
});
sections.push(`# Company Department Map
-1
View File
@@ -35,7 +35,6 @@ const ENV_ALLOWLIST: ReadonlySet<string> = new Set([
"PROGRAMDATA",
"PATHEXT",
"NODE_EXTRA_CA_CERTS",
"NODE_TLS_REJECT_UNAUTHORIZED",
"SSL_CERT_FILE",
"SSL_CERT_DIR",
"REQUESTS_CA_BUNDLE",
+9 -5
View File
@@ -187,7 +187,8 @@ export function createServiceGraph({
try {
const snapshot = db.listSnapshot();
return snapshot.runs.filter((r) => r.status === "running" || r.status === "queued").length;
} catch {
} catch (error) {
logger.warn("[service-graph] getActiveRunCount failed:", error);
return 0;
}
},
@@ -224,7 +225,7 @@ export function createServiceGraph({
emitEvent,
notify,
createRunToken,
getApiPort: () => apiServerRef.current?.getPort() ?? 0,
getApiPort: () => apiServerRef.current?.port ?? 0,
dispatchAutomation,
hasWorkerReady: () => Boolean(workerService?.getWorker()),
enqueueWorkerRun: (payload) => {
@@ -239,7 +240,8 @@ export function createServiceGraph({
cachedSkillService = createSkillService({ db, profileDir: db.profileDir, logger });
}
return cachedSkillService.listRuntimeSkillEntries(companyId, agentId, connectorId);
} catch {
} catch (error) {
logger.warn(`[service-graph] listRuntimeSkillEntries failed for agent ${agentId}:`, error);
return [];
}
};
@@ -249,7 +251,8 @@ export function createServiceGraph({
getSessionHandoff: (taskId: string) => {
try {
return getOrCreateSessionManagementService().getHandoffContext(taskId);
} catch {
} catch (error) {
logger.warn(`[service-graph] getSessionHandoff failed for task ${taskId}:`, error);
return null;
}
},
@@ -265,7 +268,8 @@ export function createServiceGraph({
return { rotated: true, handoff };
}
return { rotated: false, handoff: null };
} catch {
} catch (error) {
logger.warn(`[service-graph] evaluatePreRunSessionRotation failed for task ${taskId}:`, error);
return { rotated: false, handoff: null };
}
},
+1 -1
View File
@@ -110,7 +110,7 @@ export function createSessionManagementService({ db, logger }: SessionManagement
if (errorRuns.length > 0) {
sections.push("## Errors Encountered");
for (const run of errorRuns) {
sections.push(`- ${run.errorMessage!.slice(0, 150)} (${run.finishedAt ?? "unknown"})`);
sections.push(`- ${(run.errorMessage ?? "").slice(0, 150)} (${run.finishedAt ?? "unknown"})`);
}
sections.push("");
}
+47 -3
View File
@@ -15,6 +15,50 @@ interface Logger {
error: (...args: unknown[]) => void;
}
/** 1 MB — generous upper bound for a single SKILL.md file. */
const MAX_SKILL_BYTES = 1024 * 1024;
/** 10 MB — upper bound for the recursive git tree JSON response. */
const MAX_TREE_BYTES = 10 * 1024 * 1024;
/**
* Read a fetch Response body with a byte-size ceiling.
* Throws if the body exceeds `maxBytes`.
*/
async function readResponseWithLimit(response: Response, maxBytes: number): Promise<string> {
const contentLength = response.headers.get("Content-Length");
if (contentLength !== null && Number(contentLength) > maxBytes) {
throw new Error(`Response too large: ${contentLength} bytes (max ${maxBytes})`);
}
const reader = response.body?.getReader();
if (!reader) {
// Fallback: body not streamable (e.g. older Node fetch polyfill)
const text = await response.text();
if (text.length > maxBytes) {
throw new Error(`Response body too large: ${text.length} bytes (max ${maxBytes})`);
}
return text;
}
const decoder = new TextDecoder();
let totalBytes = 0;
let result = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
totalBytes += value.byteLength;
if (totalBytes > maxBytes) {
await reader.cancel();
throw new Error(`Response body exceeded ${maxBytes} bytes`);
}
result += decoder.decode(value, { stream: true });
}
result += decoder.decode();
return result;
}
/**
* Resolve the latest commit SHA for a given ref (branch, tag, or HEAD).
* Returns the 40-character hex SHA on success, null on error.
@@ -43,7 +87,7 @@ export async function resolveGitHubCommitSha(
}
return null;
}
const sha = (await response.text()).trim();
const sha = (await readResponseWithLimit(response, MAX_SKILL_BYTES)).trim();
return /^[0-9a-f]{40}$/i.test(sha) ? sha : null;
} catch (err) {
logger?.error("GitHub commit resolve network error:", err);
@@ -75,7 +119,7 @@ export async function fetchGitHubSkills(
logger?.warn(`GitHub tree fetch failed: ${treeResponse.status} for ${owner}/${repo}@${ref}`);
return { skills: [], commitSha: ref };
}
const treeData = (await treeResponse.json()) as {
const treeData = JSON.parse(await readResponseWithLimit(treeResponse, MAX_TREE_BYTES)) as {
tree?: Array<{ path: string; type: string }>;
sha?: string;
};
@@ -115,7 +159,7 @@ export async function fetchGitHubSkills(
logger?.warn(`Failed to download ${rawUrl}: ${contentResponse.status}`);
continue;
}
const markdown = await contentResponse.text();
const markdown = await readResponseWithLimit(contentResponse, MAX_SKILL_BYTES);
// Collect sibling files in the same directory (or subdirectories)
const skillDir =
+3 -70
View File
@@ -1,62 +1,6 @@
/**
* Source type for a skill -- matches the company_skills.source_type column.
*/
export type SkillSourceType =
| "github"
| "local_path"
| "url"
| "skills_sh"
| "managed";
/**
* Trust level derived from the file inventory of a skill package.
* Determines what content is injected and how cautiously.
*/
export type TrustLevel =
| "markdown_only"
| "assets"
| "scripts_executables";
/**
* A single file entry within a skill package inventory.
*/
export interface FileInventoryEntry {
path: string;
kind: "skill" | "reference" | "script" | "asset" | "markdown" | "other";
}
/**
* Full row interface matching the company_skills table.
* Property names use camelCase (JS convention) even though DB columns are snake_case.
*/
export interface SkillRecord {
id: string;
companyId: string;
key: string;
slug: string;
name: string;
description: string | null;
markdown: string;
sourceType: SkillSourceType;
sourceLocator: string | null;
sourceRef: string | null;
trustLevel: TrustLevel;
compatibility: string;
fileInventory: FileInventoryEntry[];
metadata: Record<string, unknown> | null;
createdAt: string;
updatedAt: string;
}
/**
* Result returned after importing or updating a skill.
*/
export interface SkillImportResult {
skillId: string;
key: string;
name: string;
isUpdate: boolean;
}
// Re-export shared types that were previously duplicated here.
// Canonical definitions live in @shared/types (records-core.ts & enums.ts).
export type { SkillSourceType, TrustLevel, FileInventoryEntry, SkillRecord, SkillImportResult, SkillUpdateStatus } from "@shared/types";
/**
* Skill execution context mode — controls how the skill is invoked.
@@ -106,17 +50,6 @@ export interface SkillAssignment {
skillId: string;
}
/**
* Update status for a skill -- used to check if a newer version is available.
*/
export interface SkillUpdateStatus {
skillId: string;
key: string;
currentRef: string | null;
latestRef: string | null;
hasUpdate: boolean;
}
/**
* Per-adapter token budgets for skill content injection.
* Values represent the maximum token count of skill content that can be
+23 -3
View File
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
import { mkdir, writeFile, rm } from "node:fs/promises";
import { join, resolve, basename, dirname, relative } from "node:path";
import type { AppDatabase } from "../database";
@@ -18,6 +18,20 @@ import {
} from "../skill-keys.js";
import { createGitHubImporter } from "./import-github";
const BLOCKED_PATH_PREFIXES_UNIX = ["/etc", "/var", "/proc", "/sys", "/dev", "/boot", "/sbin", "/usr/sbin"];
const BLOCKED_PATH_PREFIXES_WIN = ["C:\\Windows", "C:\\Program Files", "C:\\ProgramData"];
function assertNotSystemPath(realPath: string): void {
const normalized = realPath.replace(/\\/g, "/");
const blocked = process.platform === "win32" ? BLOCKED_PATH_PREFIXES_WIN : BLOCKED_PATH_PREFIXES_UNIX;
for (const prefix of blocked) {
const normalizedPrefix = prefix.replace(/\\/g, "/");
if (normalized === normalizedPrefix || normalized.startsWith(normalizedPrefix + "/")) {
throw new Error(`Importing skills from system directory is not allowed: ${realPath}`);
}
}
}
interface ImportDeps {
db: AppDatabase;
profileDir: string;
@@ -97,7 +111,8 @@ export function createSkillImporter({ db, profileDir: _profileDir, logger, skill
}
if (/^https?:\/\//i.test(source)) {
const results = await importFromUrl(companyId, source);
const { fetchWithSsrfProtection } = await import("./url-validation");
const results = await importFromUrl(companyId, source, fetchWithSsrfProtection);
return ok(results);
}
@@ -119,6 +134,10 @@ export function createSkillImporter({ db, profileDir: _profileDir, logger, skill
throw new Error(`Local path does not exist: ${resolvedPath}`);
}
// Resolve symlinks to prevent traversal attacks (agent importing /tmp/evil-link → /etc/shadow)
const realPath = realpathSync(resolvedPath);
assertNotSystemPath(realPath);
const stat = statSync(resolvedPath);
const results: SkillImportResult[] = [];
const nowTs = new Date().toISOString();
@@ -228,8 +247,9 @@ export function createSkillImporter({ db, profileDir: _profileDir, logger, skill
async function importFromUrl(
companyId: string,
url: string,
safeFetch: (target: string) => Promise<Response>,
): Promise<SkillImportResult[]> {
const response = await fetch(url);
const response = await safeFetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch skill from URL: ${response.status} ${url}`);
}
+1 -1
View File
@@ -218,7 +218,7 @@ export function createSkillRuntime({ db, logger, skillsRoot, skillFilesDir, impo
}
if (normalizedRole === "ceo" || normalizedRole.includes("chief")) {
categories = roleCategories.ceo!;
categories = roleCategories.ceo ?? ["basic-for-all"];
}
for (const skill of allSkills) {
+107
View File
@@ -0,0 +1,107 @@
import { resolve4, resolve6 } from "node:dns/promises";
function isPrivateIPv4(ip: string): boolean {
const parts = ip.split(".").map(Number);
if (parts.length !== 4 || parts.some((p) => isNaN(p) || p < 0 || p > 255)) return false;
if (parts[0] === 127) return true;
if (parts[0] === 10) return true;
if (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) return true;
if (parts[0] === 192 && parts[1] === 168) return true;
if (parts[0] === 169 && parts[1] === 254) return true;
if (parts.every((p) => p === 0)) return true;
return false;
}
function isPrivateIPv6(ip: string): boolean {
const normalized = ip.toLowerCase().replace(/[[\]]/g, "");
if (normalized === "::1" || normalized === "0:0:0:0:0:0:0:1") return true;
if (normalized === "::" || normalized === "0:0:0:0:0:0:0:0") return true;
if (normalized.startsWith("fe80")) return true;
if (normalized.startsWith("fc") || normalized.startsWith("fd")) return true;
const v4Mapped = normalized.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
if (v4Mapped && isPrivateIPv4(v4Mapped[1]!)) return true;
return false;
}
function hasDeceptiveHostname(hostname: string): boolean {
const first = hostname.split(".")[0] ?? "";
if (/^0\d/.test(first)) return true;
if (/^0x/i.test(first)) return true;
if (/^\d+$/.test(hostname) && !hostname.includes(".")) return true;
return false;
}
function assertPublicIps(ips: string[]): void {
for (const ip of ips) {
if (isPrivateIPv4(ip) || isPrivateIPv6(ip)) {
throw new Error(`DNS resolved to private IP ${ip} — possible DNS rebinding attack`);
}
}
}
export function validateSkillUrl(url: string): void {
const parsed = new URL(url);
if (parsed.protocol !== "https:") {
throw new Error("Only HTTPS URLs are allowed for skill import");
}
const hostname = parsed.hostname;
if (hasDeceptiveHostname(hostname)) {
throw new Error("Deceptive IP format not allowed for skill import");
}
if (
hostname === "localhost" ||
hostname === "0.0.0.0" ||
isPrivateIPv4(hostname) ||
isPrivateIPv6(hostname)
) {
throw new Error("Private/reserved IP addresses are not allowed for skill import");
}
}
/**
* Resolve DNS and validate that all IPs are public. Returns the resolved IPv4 addresses.
*/
export async function resolveAndValidateIps(url: string): Promise<string[]> {
validateSkillUrl(url);
const { hostname } = new URL(url);
const v4: string[] = [];
const v6: string[] = [];
try { v4.push(...(await resolve4(hostname))); } catch { /* no A records */ }
try { v6.push(...(await resolve6(hostname))); } catch { /* no AAAA records */ }
assertPublicIps([...v4, ...v6]);
return v4;
}
/**
* Fetch a URL with SSRF protection: resolve DNS once, validate IPs, then fetch
* using a resolved IP to prevent DNS rebinding between validation and fetch.
*/
export async function fetchWithSsrfProtection(url: string): Promise<Response> {
const resolvedIps = await resolveAndValidateIps(url);
if (resolvedIps.length === 0) {
throw new Error("DNS resolution returned no usable IPv4 addresses");
}
const parsed = new URL(url);
const resolvedUrl = new URL(url);
resolvedUrl.hostname = resolvedIps[0]!;
const response = await fetch(resolvedUrl.toString(), {
headers: { Host: parsed.host },
redirect: "manual",
});
// Follow redirects manually to prevent redirect-to-private-IP attacks
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get("location");
if (!location) {
throw new Error(`Redirect ${response.status} with no Location header`);
}
const redirectUrl = new URL(location, url).toString();
// Recursively validate the redirect target (prevents redirect to private IP)
return fetchWithSsrfProtection(redirectUrl);
}
return response;
}
/** @deprecated Use resolveAndValidateIps or fetchWithSsrfProtection instead. */
export async function validateSkillUrlResolved(url: string): Promise<void> {
await resolveAndValidateIps(url);
}
+13 -45
View File
@@ -5,6 +5,11 @@
* minimum description quality, acceptance criteria, and capability matching.
*/
import type { DesktopEvent } from "@shared/types";
import type { AppDatabase } from "@main/database";
import type { NotificationOptions } from "@main/notification-service";
import type { dispatchAgentMessage } from "@main/message-dispatch";
import type { resolveApprovalDecision } from "@main/approval-policy";
import { validateTaskRubric } from "@main/task-rubrics";
export interface TaskValidationResult {
@@ -13,12 +18,12 @@ export interface TaskValidationResult {
warnings: string[];
}
export interface CapabilityMatchResult {
interface CapabilityMatchResult {
matched: boolean;
warning?: string;
}
export interface CapabilityMatchDb {
interface CapabilityMatchDb {
getAgent(id: string): { name: string; capabilities: string };
listAgentSkillAssignments(id: string): string[];
getCompanySkill(companyId: string, skillId: string): { name: string; description: string | null; slug: string } | null;
@@ -152,52 +157,15 @@ export function checkCapabilityMatch(
export const REVIEW_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes (D-08)
export interface SweepStaleReviewsDeps {
db: {
queryAll: <T>(sql: string, ...params: unknown[]) => T[];
getManagerForAgent: (agentId: string) => { id: string; name: string; companyId: string } | null;
getChainOfCommand: (agentId: string) => Array<{ id: string; name: string }>;
advanceTaskStatus: (taskId: string, newStatus: string, comment?: string) => { previousStatus: string };
};
dispatchAgentMessage: (
deps: {
db: SweepStaleReviewsDeps["db"];
emitEvent: (...args: unknown[]) => void;
publishDomainChanged: () => void;
wakeAgentIfPossible: (agentId: string, companyId: string, trigger: string) => void;
},
message: {
companyId: string;
fromAgentId: string;
toAgentId: string | null;
channel: string;
subject: string;
body: string;
priority: string;
},
wakeTrigger: string,
) => { ok: boolean };
resolveApprovalDecision: (params: {
db: SweepStaleReviewsDeps["db"];
handleApprovalResolved: (approvalId: string, companyId: string, decision: string) => void;
companyId: string;
approvalId: string;
state: "approved" | "rejected" | "revision_requested";
decisionNote?: string;
detail?: string | null;
actor?: string;
}) => void;
notify: (options: {
title: string;
body: string;
urgency?: string;
navigation?: { section: string; entityId?: string };
batchKey?: string;
}) => void;
db: AppDatabase;
dispatchAgentMessage: typeof dispatchAgentMessage;
resolveApprovalDecision: typeof resolveApprovalDecision;
notify: (options: NotificationOptions) => void;
handleTaskStatusChange: (taskId: string, companyId: string, previousStatus: string, newStatus: string) => void;
handleApprovalResolved: (approvalId: string, companyId: string, decision: string) => void;
emitEvent: (...args: unknown[]) => void;
emitEvent: (event: DesktopEvent) => void;
publishDomainChanged: () => void;
wakeAgentIfPossible: (agentId: string, companyId: string, trigger: string) => void;
wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null;
getCompanyExecutionMode: (companyId: string) => string;
logger: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void };
}
+10 -1
View File
@@ -79,12 +79,21 @@ export function createUpdaterService({
return false;
}
const updateUrl = process.env.AGENTCOMPANY_UPDATE_URL?.trim();
if (updateUrl && !updateUrl.startsWith("https://")) {
logger.error("[updater] AGENTCOMPANY_UPDATE_URL must use HTTPS — refusing insecure update source.");
return false;
}
if (!app.isPackaged && (!updateUrl || updateUrl.includes("example.invalid"))) {
logger.info("[updater] Skipping update check because no real update URL is configured.");
return false;
}
try {
autoUpdater.logger = logger as any;
autoUpdater.logger = {
info: (...args: unknown[]) => logger.info(...args),
warn: (...args: unknown[]) => logger.info("[warn]", ...args),
error: (...args: unknown[]) => logger.error(...args),
debug: () => undefined,
};
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true;
const result = await autoUpdater.checkForUpdates();
+10 -5
View File
@@ -1,5 +1,5 @@
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { closeSync, mkdirSync, openSync, readFileSync, writeSync } from "node:fs";
import { join } from "node:path";
import { safeStorage } from "electron";
@@ -68,13 +68,18 @@ export class SecretVault {
private ensureKey(): Buffer {
mkdirSync(this.profileDir, { recursive: true });
if (!existsSync(this.keyPath)) {
try {
const fd = openSync(this.keyPath, "wx", 0o600);
const nextKey = randomBytes(32);
writeFileSync(this.keyPath, nextKey);
chmodSync(this.keyPath, 0o600);
writeSync(fd, nextKey);
closeSync(fd);
return nextKey;
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === "EEXIST") {
return readFileSync(this.keyPath);
}
throw err;
}
return readFileSync(this.keyPath);
}
private canUseSafeStorage() {
+3 -3
View File
@@ -20,13 +20,13 @@ function safeParseState(filePath: string): StoredWindowState | null {
const parsed = JSON.parse(raw) as Partial<StoredWindowState>;
if (!parsed.bounds || typeof parsed.bounds !== "object") return null;
const { width, height, x, y } = parsed.bounds as Partial<Rectangle>;
if (![width, height].every((value) => typeof value === "number" && Number.isFinite(value))) {
if (typeof width !== "number" || !Number.isFinite(width) || typeof height !== "number" || !Number.isFinite(height)) {
return null;
}
return {
bounds: {
width: width!,
height: height!,
width,
height,
x: typeof x === "number" && Number.isFinite(x) ? x : DEFAULT_BOUNDS.x,
y: typeof y === "number" && Number.isFinite(y) ? y : DEFAULT_BOUNDS.y,
},
+49
View File
@@ -0,0 +1,49 @@
/**
* Self-wake circuit breaker.
*
* Prevents infinite self-wake loops when an agent is stuck on a task.
* After MAX_CONSECUTIVE_WAKES consecutive wakes without progress (i.e.,
* without a successful run), self-wake is suppressed so the manager can
* be notified instead.
*/
export const MAX_CONSECUTIVE_WAKES = 5;
export interface CircuitBreaker {
/** Attempt a self-wake. Returns true if allowed, false if circuit breaker trips. */
tryWake(agentId: string, companyId: string): boolean;
/** Reset the counter for an agent (called on task progress / successful run). */
reset(agentId: string, companyId: string): void;
/** Get the current consecutive-wake count for an agent. */
getCount(agentId: string, companyId: string): number;
}
export function createCircuitBreaker(
consecutiveWakeMap: Map<string, number>,
maxConsecutive: number = MAX_CONSECUTIVE_WAKES,
): CircuitBreaker {
function wakeKey(agentId: string, companyId: string): string {
return `${agentId}:${companyId}`;
}
return {
tryWake(agentId: string, companyId: string): boolean {
const key = wakeKey(agentId, companyId);
const count = consecutiveWakeMap.get(key) ?? 0;
if (count >= maxConsecutive) {
consecutiveWakeMap.delete(key);
return false;
}
consecutiveWakeMap.set(key, count + 1);
return true;
},
reset(agentId: string, companyId: string): void {
consecutiveWakeMap.delete(wakeKey(agentId, companyId));
},
getCount(agentId: string, companyId: string): number {
return consecutiveWakeMap.get(wakeKey(agentId, companyId)) ?? 0;
},
};
}
+118
View File
@@ -0,0 +1,118 @@
/**
* Retry-aware failure escalation pipeline.
*
* State machine:
* 1. Task fails with retry_count < MAX_RETRY_ATTEMPTS -> increment count, re-queue
* 2. Task fails with retry_count >= MAX_RETRY_ATTEMPTS -> escalate to manager chain
* 3. No manager in chain -> fall through to CEO
* 4. No CEO available -> notify user
* 5. Task succeeds -> reset retry count
*/
export interface EscalationDeps {
db: {
getTaskRetryCount: (taskId: string) => number;
incrementTaskRetryCount: (taskId: string) => void;
resetTaskRetryCount: (taskId: string) => void;
getChainOfCommand: (agentId: string) => Array<{ id: string; name: string }>;
getCeoForCompany: (companyId: string) => { id: string; name: string } | null;
advanceTaskStatus: (taskId: string, status: string, note: string) => unknown;
};
wakeAgentIfPossible: (agentId: string, companyId: string, trigger: string) => unknown;
notify: (title: string, body: string) => void;
logger: {
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
}
export interface EscalationResult {
action: "retry" | "escalated" | "user_notified" | "none";
retryScheduled?: boolean;
}
export function handleFailureEscalation(
deps: EscalationDeps,
run: { taskId: string; agentId: string; companyId: string },
task: { title: string } | null,
eventStatus: "failed" | "timed_out",
maxRetryAttempts: number,
retryCooldownMs: number,
scheduleRetry: (fn: () => void, delayMs: number) => void,
): EscalationResult {
const { db, wakeAgentIfPossible, notify, logger } = deps;
const retryCount = db.getTaskRetryCount(run.taskId);
if (retryCount < maxRetryAttempts) {
db.incrementTaskRetryCount(run.taskId);
db.advanceTaskStatus(
run.taskId,
"todo",
`Run ${eventStatus} (attempt ${retryCount + 1}/${maxRetryAttempts}). Auto-retrying after ${retryCooldownMs / 1000}s cooldown.`,
);
logger.info(
`[escalation] Retrying task ${run.taskId} (attempt ${retryCount + 1}/${maxRetryAttempts}) for agent ${run.agentId}`,
);
scheduleRetry(() => {
wakeAgentIfPossible(run.agentId, run.companyId, "retry_failed");
}, retryCooldownMs);
return { action: "retry", retryScheduled: true };
}
// Retry limit reached — escalate up the chain
db.resetTaskRetryCount(run.taskId);
logger.warn(
`[escalation] Task ${run.taskId} failed ${maxRetryAttempts} times. Escalating to manager.`,
);
let escalated = false;
try {
const chain = db.getChainOfCommand(run.agentId);
if (chain.length > 0) {
wakeAgentIfPossible(chain[0].id, run.companyId, "report_failed");
escalated = true;
logger.info(
`[escalation] Escalated task ${run.taskId} to manager ${chain[0].name} (${chain[0].id})`,
);
}
} catch {
// Chain lookup failed (agent deleted)
}
if (!escalated) {
try {
const ceo = db.getCeoForCompany(run.companyId);
if (ceo) {
wakeAgentIfPossible(ceo.id, run.companyId, "report_failed");
escalated = true;
logger.info(
`[escalation] Escalated task ${run.taskId} to CEO ${ceo.name} (${ceo.id})`,
);
}
} catch { /* CEO lookup failed */ }
}
if (!escalated) {
const taskTitle = task?.title ?? run.taskId;
notify(
"Task failed -- manual intervention needed",
`"${taskTitle}" failed ${maxRetryAttempts} times and no manager is available to handle it.`,
);
logger.error(
`[escalation] Task ${run.taskId} failed with no escalation target. User notified.`,
);
return { action: "user_notified" };
}
return { action: "escalated" };
}
export function handleSuccessReset(
db: EscalationDeps["db"],
taskId: string,
): void {
try {
db.resetTaskRetryCount(taskId);
} catch { /* best effort */ }
}
+8 -3
View File
@@ -80,7 +80,12 @@ const activeByAgent = new Map<string, string>();
const activeByWorkspace = new Map<string, string>();
const queue: RunJob[] = [];
const cancelledRuns = new Set<string>();
const allowUnsafeConnectorBypass = process.env.AGENTCOMPANY_ALLOW_UNSAFE_CONNECTOR_BYPASS !== "false";
const allowUnsafeConnectorBypass =
process.env.AGENTCOMPANY_ALLOW_UNSAFE_CONNECTOR_BYPASS === "true" &&
process.env.NODE_ENV === "development";
if (allowUnsafeConnectorBypass) {
process.stderr.write("[SECURITY WARNING] AGENTCOMPANY_ALLOW_UNSAFE_CONNECTOR_BYPASS is enabled (development only) — sandbox, approvals, and permissions checks are disabled.\n");
}
function post(message: ChildMessage) {
parentPort.postMessage(message);
@@ -133,8 +138,8 @@ function buildConnectorExecutionConfig(job: RunJob, sanitizedEnv: Record<string,
case "gemini_local":
return {
...baseConfig,
yolo: true,
approvalMode: "yolo",
yolo: allowUnsafeConnectorBypass,
approvalMode: allowUnsafeConnectorBypass ? "yolo" : "suggest",
sandbox: true,
};
default:
+30 -65
View File
@@ -1,6 +1,8 @@
import { z } from "zod";
import { getConnectorDefinition } from "../connectors";
import type { WorkerServiceDependencies, workerMessageSchema } from "./worker-types";
import { createCircuitBreaker } from "./circuit-breaker";
import { handleFailureEscalation, handleSuccessReset } from "./failure-escalation";
const LEASE_RENEWAL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
@@ -261,61 +263,27 @@ export async function handleWorkerMessage(
}
if ((event.status === "failed" || event.status === "timed_out") && !isQuitting() && !destroyed) {
const retryCount = db.getTaskRetryCount(run.taskId);
if (retryCount < MAX_RETRY_ATTEMPTS) {
// Retry same agent after cooldown
db.incrementTaskRetryCount(run.taskId);
db.advanceTaskStatus(run.taskId, "todo",
`Run ${event.status} (attempt ${retryCount + 1}/${MAX_RETRY_ATTEMPTS}). Auto-retrying after ${RETRY_COOLDOWN_MS / 1000}s cooldown.`);
logger.info(`[escalation] Retrying task ${run.taskId} (attempt ${retryCount + 1}/${MAX_RETRY_ATTEMPTS}) for agent ${run.agentId}`);
const retryTimer = setTimeout(() => {
pendingRetryTimers.delete(retryTimer);
if (isQuitting() || destroyed) return;
wakeAgentIfPossible(run.agentId, run.companyId, "retry_failed");
}, RETRY_COOLDOWN_MS);
pendingRetryTimers.add(retryTimer);
} else {
// Retry limit reached -- escalate up the chain
db.resetTaskRetryCount(run.taskId);
logger.warn(`[escalation] Task ${run.taskId} failed ${MAX_RETRY_ATTEMPTS} times. Escalating to manager.`);
let escalated = false;
try {
const chain = db.getChainOfCommand(run.agentId);
if (chain.length > 0) {
wakeAgentIfPossible(chain[0].id, run.companyId, "report_failed");
escalated = true;
logger.info(`[escalation] Escalated task ${run.taskId} to manager ${chain[0].name} (${chain[0].id})`);
}
} catch {
// Chain lookup failed (agent deleted)
}
if (!escalated) {
// No manager found -- try CEO
try {
const ceo = db.getCeoForCompany(run.companyId);
if (ceo) {
wakeAgentIfPossible(ceo.id, run.companyId, "report_failed");
escalated = true;
logger.info(`[escalation] Escalated task ${run.taskId} to CEO ${ceo.name} (${ceo.id})`);
}
} catch { /* CEO lookup failed */ }
}
if (!escalated) {
// No one to escalate to -- notify user
const taskTitle = task?.title ?? run.taskId;
notify({
title: "Attention Required",
body: `${taskTitle} needs manual intervention`,
urgency: "critical",
navigation: { section: "tasks", entityId: run.taskId },
});
logger.error(`[escalation] Task ${run.taskId} failed with no escalation target. User notified.`);
}
}
handleFailureEscalation(
{
db,
wakeAgentIfPossible,
notify: (title, body) => notify({ title, body, urgency: "critical", navigation: { section: "tasks", entityId: run.taskId } }),
logger,
},
{ taskId: run.taskId, agentId: run.agentId, companyId: run.companyId },
task,
event.status,
MAX_RETRY_ATTEMPTS,
RETRY_COOLDOWN_MS,
(fn, delayMs) => {
const retryTimer = setTimeout(() => {
pendingRetryTimers.delete(retryTimer);
if (isQuitting() || destroyed) return;
fn();
}, delayMs);
pendingRetryTimers.add(retryTimer);
},
);
}
// EXEC-05: Promote deferred wakes after run completion
@@ -348,13 +316,14 @@ export async function handleWorkerMessage(
// Reset retry count on successful completion
if (event.status === "succeeded") {
try { db.resetTaskRetryCount(run.taskId); } catch { /* best effort */ }
handleSuccessReset(db, run.taskId);
}
const cb = createCircuitBreaker(consecutiveWakeMap, MAX_CONSECUTIVE_WAKES);
// Reset circuit breaker when a task actually progressed (successful run)
if (event.status === "succeeded") {
const wakeKey = `${run.agentId}:${run.companyId}`;
consecutiveWakeMap.delete(wakeKey);
cb.reset(run.agentId, run.companyId);
}
// Self-wake with circuit breaker to prevent infinite loops
@@ -362,12 +331,9 @@ export async function handleWorkerMessage(
try {
const actionableTasks = db.listAgentTasks(run.agentId, run.companyId, ["todo", "in_progress"]);
if (actionableTasks.length > 0) {
const wakeKey = `${run.agentId}:${run.companyId}`;
const consecutiveCount = consecutiveWakeMap.get(wakeKey) ?? 0;
if (consecutiveCount >= MAX_CONSECUTIVE_WAKES) {
logger.warn(`[self-wake] Circuit breaker: agent ${run.agentId} has self-woken ${consecutiveCount} times without progress. Pausing self-wake.`);
consecutiveWakeMap.delete(wakeKey);
const allowed = cb.tryWake(run.agentId, run.companyId);
if (!allowed) {
logger.warn(`[self-wake] Circuit breaker: agent ${run.agentId} has self-woken ${MAX_CONSECUTIVE_WAKES} times without progress. Pausing self-wake.`);
// Escalate to manager since agent is stuck
try {
const chain = db.getChainOfCommand(run.agentId);
@@ -376,7 +342,6 @@ export async function handleWorkerMessage(
}
} catch { /* best effort */ }
} else {
consecutiveWakeMap.set(wakeKey, consecutiveCount + 1);
wakeAgentIfPossible(run.agentId, run.companyId, "task_continuation");
}
}
+3 -2
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import type { AppDatabase } from "../database";
import type { DesktopEvent, RunRecord } from "@shared/types";
import type { NotificationOptions } from "../notification-service";
import type { createBudgetEnforcementService } from "../budget-enforcement-service";
import type { createSessionManagementService } from "../session-management-service";
@@ -48,7 +49,7 @@ export interface WorkerServiceDependencies {
workerPath: string;
isQuitting: () => boolean;
publishDomainChanged: () => void;
emitEvent: (event: any) => void;
emitEvent: (event: DesktopEvent) => void;
wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null;
dispatchRunToWorker: (params: {
runId: string;
@@ -63,7 +64,7 @@ export interface WorkerServiceDependencies {
snapshot: ReturnType<AppDatabase["listSnapshot"]>;
trigger: string;
}) => void;
processRunCompletion: (run: any, event: z.infer<typeof workerMessageSchema> & { type: "run-finished" }) => void;
processRunCompletion: (run: RunRecord, event: z.infer<typeof workerMessageSchema> & { type: "run-finished" }) => void;
onRunStarted?: (runId: string, taskId: string, agentId: string, companyId: string) => void;
onRunCompleted?: (runId: string, taskId: string, agentId: string, companyId: string, status: string, summary: string | null, errorMessage: string | null) => void;
notify: (options: NotificationOptions) => void;
+4
View File
@@ -32,9 +32,13 @@ const api: DesktopApi = {
checkForUpdates: () => ipcRenderer.invoke(IPC_CHANNELS.checkForUpdates),
deleteCompany: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.deleteCompany, companyId),
deleteAgent: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteAgent, input),
/** @planned — handler not yet implemented in ipc-core-task-graph-handlers.ts */
deleteTask: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteTask, input),
/** @planned — handler not yet implemented in ipc-core-task-graph-handlers.ts */
deleteGoal: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteGoal, input),
/** @planned — handler not yet implemented in ipc-core-task-graph-handlers.ts */
deleteProject: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteProject, input),
/** @planned — handler not yet implemented in ipc-core-agent-graph-handlers.ts */
deleteWorkspace: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteWorkspace, input),
deleteApproval: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteApproval, input),
deleteSecret: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteSecret, input),
@@ -1,2 +1,2 @@
// Backward-compatible re-exports from decomposed onboarding components
export { CompanyOnboarding } from "./CompanyOnboarding/OnboardingWizard";
export { CompanyOnboarding } from "./company-onboarding/OnboardingWizard";
-5
View File
@@ -1,6 +1 @@
// Backward-compatible re-exports from decomposed automation components
export { AutomationPanel } from "./automation/AutomationPanel"
export { RuleCard } from "./automation/RuleCard"
export { RuleFormModal } from "./automation/RuleEditor"
export { WorkflowCard, StepTimeline } from "./automation/WorkflowList"
export { WorkflowFormModal } from "./automation/WorkflowEditor"
@@ -1,9 +1 @@
// Backward-compatible re-exports from decomposed communication components
export { CommunicationHub } from "./communication/CommunicationHub"
export { ChannelSidebar, FilterBar } from "./communication/ChannelSidebar"
export { MessageList } from "./communication/MessageList"
export { MessageBubble, TaskCommentBubble, AgentAvatar } from "./communication/MessageItem"
export { MessageComposer, TaskDiscussionComposer, NoChannelComposer } from "./communication/MessageComposer"
export { ChannelHeader } from "./communication/ChannelHeader"
export type { ChannelDescriptor, TaskDiscussionDescriptor, AnyChannel } from "./communication/ChannelHeader"
export type { FilterState } from "./communication/ChannelSidebar"
@@ -114,7 +114,7 @@ function AttachmentPicker({ documents, attachments, onAdd, onRemove }: {
function MentionDropdown({ agents, query, onSelect, position }: {
agents: AgentRecord[];
query: string;
onSelect: (agent: AgentRecord) => void;
onSelect: (agent: AgentRecord | null) => void;
position: { top: number; left: number };
}) {
const [selectedIndex, setSelectedIndex] = useState(0)
@@ -136,7 +136,7 @@ function MentionDropdown({ agents, query, onSelect, position }: {
else if (e.key === "Enter" || e.key === "Tab") {
if (filtered[selectedIndex]) { e.preventDefault(); onSelect(filtered[selectedIndex]) }
}
else if (e.key === "Escape") { onSelect(null as unknown as AgentRecord) }
else if (e.key === "Escape") { onSelect(null) }
}
document.addEventListener("keydown", handler, true)
return () => document.removeEventListener("keydown", handler, true)
@@ -51,7 +51,7 @@ export default function WaitStateIndicator({ waitState, onReply, onNavigateAppro
</div>
{isApproval && onNavigateApproval ? (
<button
onClick={() => onNavigateApproval(waitState.relatedApprovalId!)}
onClick={() => { const id = waitState.relatedApprovalId; if (id) onNavigateApproval(id); }}
className="flex shrink-0 items-center gap-1.5 rounded-md bg-amber-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-amber-700 dark:bg-amber-500 dark:hover:bg-amber-600"
>
<CheckCircle size={14} />
@@ -7,27 +7,28 @@ import {
Terminal,
} from "@phosphor-icons/react";
import { ActionButton } from "../ui";
import type { TerminalCommandKey } from "@shared/contracts";
interface NodeCheckStepProps {
nodeStatus: { installed: boolean; version: string | null } | null;
checking: boolean;
onCheck: () => void;
onInstall: (command: string) => void;
onInstall: (commandKey: TerminalCommandKey) => void;
onContinue: () => void;
onBack: () => void;
t: (key: string) => string;
}
const INSTALL_COMMANDS: Record<string, { label: string; code: string; command: string }> = {
const INSTALL_COMMANDS: Record<string, { label: string; code: string; commandKey: TerminalCommandKey }> = {
darwin: {
label: "macOS",
code: "brew install node@22",
command: "brew install node@22",
commandKey: "install_node_darwin",
},
win32: {
label: "Windows",
code: "winget install -e --id OpenJS.NodeJS.LTS",
command: "winget install -e --id OpenJS.NodeJS.LTS",
commandKey: "install_node_win32",
},
linux: {
label: "Linux",
@@ -42,7 +43,7 @@ nvm install 22
# Verify the Node.js version:
node -v`,
command: `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh | bash && \\. "$HOME/.nvm/nvm.sh" && nvm install 22`,
commandKey: "install_node_linux",
},
};
@@ -109,7 +110,7 @@ export function NodeCheckStep({
</pre>
<ActionButton
label={t("onboarding.installNodeTerminal")}
onClick={() => onInstall(platformInfo.command)}
onClick={() => onInstall(platformInfo.commandKey)}
icon={Terminal}
/>
</div>
Loaded 100 of 163 files, more files were not shown because too many files have changed in this diff. Show more