update
This commit is contained in:
1 parent
09971cabc2
commit
9145572422
163 files changed
+3348
-2874
No files matched your search
@@ -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
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,6 @@ win:
|
||||
- target: nsis
|
||||
arch:
|
||||
- x64
|
||||
- ia32
|
||||
- arm64
|
||||
|
||||
linux:
|
||||
|
||||
@@ -14,6 +14,7 @@ export default tseslint.config(
|
||||
"oldreference/**",
|
||||
"Reference/**",
|
||||
"boss-build/**",
|
||||
"local-release/**",
|
||||
"builtin-skills/**",
|
||||
"node_modules/**",
|
||||
"**/node_modules/**",
|
||||
|
||||
+2
-3
@@ -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,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,
|
||||
};
|
||||
}
|
||||
@@ -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".
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.",
|
||||
};
|
||||
}
|
||||
@@ -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,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,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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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,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 +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,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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.`,
|
||||
};
|
||||
|
||||
@@ -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,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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ----
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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("");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 */ }
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
@@ -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} />
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+7
-6
@@ -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>
|
||||
File renamed without changes.
Loaded 100 of 163 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user