From 9145572422c5428e24fef27301f6f4119f614525 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 5 Apr 2026 20:14:48 -0400 Subject: [PATCH] update --- .github/workflows/build.yml | 40 +- build/electron-builder.runtime.json | 18 +- electron-builder.yml | 1 - eslint.config.mjs | 1 + package.json | 5 +- .../src/server/execute-helpers.ts | 177 +----- .../src/server/execute.ts | 18 +- .../src/server/execute-helpers.ts | 41 +- .../adapter-codex-local/src/server/execute.ts | 162 +----- .../adapter-codex-local/src/server/parse.ts | 6 + packages/adapter-gemini-local/package.json | 2 +- .../src/server/execute-helpers.ts | 36 +- .../src/server/execute.ts | 105 +--- packages/adapter-utils/src/execute-helpers.ts | 232 ++++++++ packages/adapter-utils/src/index.ts | 15 +- packages/adapter-utils/src/log-redaction.ts | 32 -- .../adapter-utils/src/server-utils-process.ts | 4 +- .../src/server-utils-skills-sync.ts | 24 - packages/adapter-utils/src/test-utils.ts | 8 - src/main/api-server/index.ts | 2 + src/main/api-server/routes-agent-control.ts | 66 ++- src/main/api-server/routes-agents.ts | 161 ++++-- src/main/api-server/routes-approvals.ts | 72 ++- src/main/api-server/routes-files.ts | 189 ++++-- src/main/api-server/routes-meetings.ts | 163 ++++++ src/main/api-server/routes-messages.ts | 34 +- src/main/api-server/routes-misc.ts | 180 +----- src/main/api-server/routes-runs.ts | 16 +- src/main/api-server/routes-tasks.ts | 42 +- src/main/api-server/routes-work-graph.ts | 99 +++- src/main/api-server/server-core.ts | 100 ++-- src/main/automation/automation-handlers.ts | 10 +- .../automation/automation-run-completion.ts | 191 +++++-- src/main/bootstrap-application.ts | 12 +- src/main/connector-auth-service.ts | 61 +- src/main/connector-matching.ts | 109 +--- src/main/database/agents-crud.ts | 4 +- src/main/database/automation.ts | 4 +- src/main/database/budget.ts | 4 +- src/main/database/company-settings.ts | 7 +- src/main/database/crash-recovery-helpers.ts | 80 +++ src/main/database/documents.ts | 33 +- src/main/database/entity-crud.ts | 17 +- src/main/database/runs.ts | 6 +- src/main/database/schema.ts | 3 +- src/main/database/skills.ts | 4 +- src/main/database/snapshot.ts | 11 +- src/main/database/social.ts | 4 +- src/main/database/tasks-crud.ts | 2 +- src/main/database/threads.ts | 8 +- src/main/database/wakeup.ts | 9 +- src/main/execution-policy.ts | 2 +- src/main/hire-hook.ts | 2 +- src/main/ipc-budget-handlers.ts | 14 +- src/main/ipc-core-admin-handlers.ts | 8 +- src/main/ipc-core-agent-graph-handlers.ts | 4 +- src/main/ipc-core-common.ts | 8 +- src/main/ipc-core-task-graph-handlers.ts | 4 +- src/main/ipc-skill-handlers.ts | 46 +- src/main/main-runtime-support.ts | 4 +- src/main/message-dispatch.ts | 85 +-- src/main/onboarding-service.ts | 35 +- src/main/orchestration/heartbeat.ts | 12 +- src/main/orchestration/scheduler.ts | 19 +- src/main/orchestration/task-runner.ts | 6 +- src/main/orchestration/types.ts | 3 +- .../prompt-builder/attachment-resolver.ts | 4 +- src/main/prompt-builder/context-injection.ts | 2 +- src/main/prompt-builder/system-prompt.ts | 3 +- src/main/runtime-env.ts | 1 - src/main/service-graph.ts | 14 +- src/main/session-management-service.ts | 2 +- src/main/skill-github.ts | 50 +- src/main/skill-types.ts | 73 +-- src/main/skills/import.ts | 26 +- src/main/skills/runtime.ts | 2 +- src/main/skills/url-validation.ts | 107 ++++ src/main/task-quality-gates.ts | 58 +- src/main/updater-service.ts | 11 +- src/main/vault.ts | 15 +- src/main/window-state.ts | 6 +- src/main/worker/circuit-breaker.ts | 49 ++ src/main/worker/failure-escalation.ts | 118 ++++ src/main/worker/worker-entry.ts | 11 +- src/main/worker/worker-message-handler.ts | 95 +--- src/main/worker/worker-types.ts | 5 +- src/preload/index.ts | 4 + src/renderer/components/CompanyOnboarding.tsx | 2 +- src/renderer/components/automation.tsx | 5 - src/renderer/components/communication.tsx | 8 - .../communication/MessageComposer.tsx | 4 +- .../communication/WaitStateIndicator.tsx | 2 +- .../AsciiAnimation.tsx | 0 .../CompanyStep.tsx | 0 .../ExecutionModeStep.tsx | 0 .../LaunchStep.tsx | 0 .../MissionStep.tsx | 0 .../ModelPoolStep.tsx | 0 .../NodeCheckStep.tsx | 13 +- .../OnboardingHero.tsx | 0 .../OnboardingStepPanel.tsx | 5 +- .../OnboardingWizard.tsx | 2 +- .../ProvidersStep.tsx | 12 +- .../ProvidersStepShared.tsx | 20 +- .../WelcomeStep.tsx | 0 .../useConnectorCallbacks.ts | 6 +- .../useConnectorModelSelection.ts | 0 .../company-operations/DocumentCenter.tsx | 5 +- .../KnowledgeBaseManager.tsx | 5 +- .../company-operations/MeetingsManager.tsx | 5 +- .../company-operations/SprintManager.tsx | 5 +- .../components/dashboard/Dashboard.tsx | 16 +- .../components/dashboard/RecoveryBanner.tsx | 3 +- src/renderer/components/execution.tsx | 1 - .../execution/ApprovalInspector.tsx | 6 +- .../execution/ModelManagementShared.tsx | 24 +- .../components/execution/SecretCreator.tsx | 31 - .../components/execution/TaskCreator.tsx | 2 +- .../components/execution/TaskInspector.tsx | 22 +- .../components/hiring/HiringPipeline.tsx | 5 +- .../components/organization/AgentCreator.tsx | 40 +- .../components/organization/AgentRoster.tsx | 6 +- .../components/organization/GoalCreator.tsx | 18 +- .../components/organization/GoalEditor.tsx | 28 +- .../components/organization/ProjectList.tsx | 8 +- src/renderer/components/runs/RunDetail.tsx | 2 +- .../components/social/ActionHistory.tsx | 6 +- .../components/social/SocialPanel.tsx | 14 +- src/renderer/hooks/useAppController.ts | 536 ++++++------------ .../hooks/useCommunicationController.ts | 37 ++ src/renderer/hooks/useCompanyController.ts | 175 ++++++ src/renderer/hooks/useExecutionController.ts | 30 + .../hooks/useOrganizationController.ts | 27 + src/renderer/hooks/useUIController.ts | 205 +++++++ src/renderer/i18n/en.ts | 35 ++ src/renderer/i18n/zh.ts | 35 ++ src/renderer/index.html | 8 + src/renderer/lib/constants.ts | 2 + src/renderer/views/ConsoleView.tsx | 2 +- src/renderer/views/SidebarView.tsx | 5 +- .../views/sections/ExecutionSections.tsx | 29 +- .../views/sections/OperationsSections.tsx | 6 +- .../views/sections/OrganizationSections.tsx | 31 +- src/shared/constants.ts | 17 +- src/shared/contracts/desktop-api.ts | 52 +- src/shared/contracts/helpers.ts | 11 + src/shared/contracts/schemas-agents.ts | 6 +- src/shared/contracts/schemas-company.ts | 2 +- src/shared/contracts/schemas-execution.ts | 15 +- src/shared/contracts/schemas-messages.ts | 8 +- src/shared/contracts/schemas-misc.ts | 12 +- src/shared/types/enums.ts | 7 - src/shared/types/events.ts | 11 +- src/shared/types/records-core.ts | 1 + src/shared/types/records-extended.ts | 56 -- tests/unit/budget-alert.test.ts | 182 +++--- tests/unit/connector-matching.test.ts | 135 ----- tests/unit/crash-recovery.test.ts | 302 ++++------ tests/unit/failure-escalation.test.ts | 229 +++----- tests/unit/selective-ipc.test.ts | 2 + tests/unit/self-wake.test.ts | 80 +-- tests/unit/task-completion.test.ts | 59 +- tests/unit/task-quality-gates-sweep.test.ts | 3 +- 163 files changed, 3348 insertions(+), 2874 deletions(-) create mode 100644 packages/adapter-utils/src/execute-helpers.ts create mode 100644 src/main/api-server/routes-meetings.ts create mode 100644 src/main/database/crash-recovery-helpers.ts create mode 100644 src/main/skills/url-validation.ts create mode 100644 src/main/worker/circuit-breaker.ts create mode 100644 src/main/worker/failure-escalation.ts rename src/renderer/components/{CompanyOnboarding => company-onboarding}/AsciiAnimation.tsx (100%) rename src/renderer/components/{CompanyOnboarding => company-onboarding}/CompanyStep.tsx (100%) rename src/renderer/components/{CompanyOnboarding => company-onboarding}/ExecutionModeStep.tsx (100%) rename src/renderer/components/{CompanyOnboarding => company-onboarding}/LaunchStep.tsx (100%) rename src/renderer/components/{CompanyOnboarding => company-onboarding}/MissionStep.tsx (100%) rename src/renderer/components/{CompanyOnboarding => company-onboarding}/ModelPoolStep.tsx (100%) rename src/renderer/components/{CompanyOnboarding => company-onboarding}/NodeCheckStep.tsx (92%) rename src/renderer/components/{CompanyOnboarding => company-onboarding}/OnboardingHero.tsx (100%) rename src/renderer/components/{CompanyOnboarding => company-onboarding}/OnboardingStepPanel.tsx (97%) rename src/renderer/components/{CompanyOnboarding => company-onboarding}/OnboardingWizard.tsx (98%) rename src/renderer/components/{CompanyOnboarding => company-onboarding}/ProvidersStep.tsx (96%) rename src/renderer/components/{CompanyOnboarding => company-onboarding}/ProvidersStepShared.tsx (95%) rename src/renderer/components/{CompanyOnboarding => company-onboarding}/WelcomeStep.tsx (100%) rename src/renderer/components/{CompanyOnboarding => company-onboarding}/useConnectorCallbacks.ts (89%) rename src/renderer/components/{CompanyOnboarding => company-onboarding}/useConnectorModelSelection.ts (100%) delete mode 100644 src/renderer/components/execution/SecretCreator.tsx create mode 100644 src/renderer/hooks/useCommunicationController.ts create mode 100644 src/renderer/hooks/useCompanyController.ts create mode 100644 src/renderer/hooks/useExecutionController.ts create mode 100644 src/renderer/hooks/useOrganizationController.ts create mode 100644 src/renderer/hooks/useUIController.ts create mode 100644 src/renderer/lib/constants.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cb06b6f1..16b8137a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/build/electron-builder.runtime.json b/build/electron-builder.runtime.json index ecf3d432..4f12ac4a 100644 --- a/build/electron-builder.runtime.json +++ b/build/electron-builder.runtime.json @@ -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" + ] + } + ] } } diff --git a/electron-builder.yml b/electron-builder.yml index da409870..ca3c8bf9 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -39,7 +39,6 @@ win: - target: nsis arch: - x64 - - ia32 - arm64 linux: diff --git a/eslint.config.mjs b/eslint.config.mjs index fe588538..d136bede 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -14,6 +14,7 @@ export default tseslint.config( "oldreference/**", "Reference/**", "boss-build/**", + "local-release/**", "builtin-skills/**", "node_modules/**", "**/node_modules/**", diff --git a/package.json b/package.json index ef5d91c3..d6397193 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/packages/adapter-claude-local/src/server/execute-helpers.ts b/packages/adapter-claude-local/src/server/execute-helpers.ts index e08cda1a..d3a15ec5 100644 --- a/packages/adapter-claude-local/src/server/execute-helpers.ts +++ b/packages/adapter-claude-local/src/server/execute-helpers.ts @@ -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: /dist/server/ -> /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 { - 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, key: string): boolean { - const raw = env[key]; - return typeof raw === "string" && raw.trim().length > 0; -} - export function resolveClaudeBillingType(env: Record): "api" | "subscription" { - // Claude uses API-key auth when ANTHROPIC_API_KEY is present; otherwise rely on local login/session auth. return hasNonEmptyEnvValue(env, "ANTHROPIC_API_KEY") ? "api" : "subscription"; } @@ -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 => typeof value === "object" && value !== null, - ) - : []; - const runtimeServiceIntents = Array.isArray(context.agentCompanyRuntimeServiceIntents) - ? context.agentCompanyRuntimeServiceIntents.filter( - (value): value is Record => typeof value === "object" && value !== null, - ) - : []; - const runtimeServices = Array.isArray(context.agentCompanyRuntimeServices) - ? context.agentCompanyRuntimeServices.filter( - (value): value is Record => typeof value === "object" && value !== null, - ) - : []; - const runtimePrimaryUrl = asString(context.agentCompanyRuntimePrimaryUrl, ""); - const configuredCwd = asString(config.cwd, ""); - const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0; - const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; - const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd(); - await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); - - const envConfig = parseObject(config.env); - const hasExplicitApiKey = - typeof envConfig.AGENT_COMPANY_API_KEY === "string" && envConfig.AGENT_COMPANY_API_KEY.trim().length > 0; - const env: Record = { ...buildAgentCompanyEnv(agent) }; - env.AGENT_COMPANY_RUN_ID = runId; - - const wakeTaskId = - (typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) || - (typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) || - null; - const wakeReason = - typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0 - ? context.wakeReason.trim() - : null; - const wakeCommentId = - (typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) || - (typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) || - null; - const approvalId = - typeof context.approvalId === "string" && context.approvalId.trim().length > 0 - ? context.approvalId.trim() - : null; - const approvalStatus = - typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0 - ? context.approvalStatus.trim() - : null; - const linkedIssueIds = Array.isArray(context.issueIds) - ? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0) - : []; - - if (wakeTaskId) { - env.AGENT_COMPANY_TASK_ID = wakeTaskId; - } - if (wakeReason) { - env.AGENT_COMPANY_WAKE_REASON = wakeReason; - } - if (wakeCommentId) { - env.AGENT_COMPANY_WAKE_COMMENT_ID = wakeCommentId; - } - if (approvalId) { - env.AGENT_COMPANY_APPROVAL_ID = approvalId; - } - if (approvalStatus) { - env.AGENT_COMPANY_APPROVAL_STATUS = approvalStatus; - } - if (linkedIssueIds.length > 0) { - env.AGENT_COMPANY_LINKED_ISSUE_IDS = linkedIssueIds.join(","); - } - if (effectiveWorkspaceCwd) { - env.AGENT_COMPANY_WORKSPACE_CWD = effectiveWorkspaceCwd; - } - if (workspaceSource) { - env.AGENT_COMPANY_WORKSPACE_SOURCE = workspaceSource; - } - if (workspaceStrategy) { - env.AGENT_COMPANY_WORKSPACE_STRATEGY = workspaceStrategy; - } - if (workspaceId) { - env.AGENT_COMPANY_WORKSPACE_ID = workspaceId; - } - if (workspaceRepoUrl) { - env.AGENT_COMPANY_WORKSPACE_REPO_URL = workspaceRepoUrl; - } - if (workspaceRepoRef) { - env.AGENT_COMPANY_WORKSPACE_REPO_REF = workspaceRepoRef; - } - if (workspaceBranch) { - env.AGENT_COMPANY_WORKSPACE_BRANCH = workspaceBranch; - } - if (workspaceWorktreePath) { - env.AGENT_COMPANY_WORKSPACE_WORKTREE_PATH = workspaceWorktreePath; - } - if (workspaceHints.length > 0) { - env.AGENT_COMPANY_WORKSPACES_JSON = JSON.stringify(workspaceHints); - } - if (runtimeServiceIntents.length > 0) { - env.AGENT_COMPANY_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents); - } - if (runtimeServices.length > 0) { - env.AGENT_COMPANY_RUNTIME_SERVICES_JSON = JSON.stringify(runtimeServices); - } - if (runtimePrimaryUrl) { - env.AGENT_COMPANY_RUNTIME_PRIMARY_URL = runtimePrimaryUrl; - } - - for (const [key, value] of Object.entries(envConfig)) { - if (typeof value === "string") env[key] = value; - } - - if (!hasExplicitApiKey && authToken) { - env.AGENT_COMPANY_API_KEY = authToken; - } + const { 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, diff --git a/packages/adapter-claude-local/src/server/execute.ts b/packages/adapter-claude-local/src/server/execute.ts index fbdb989c..e62ad6a5 100644 --- a/packages/adapter-claude-local/src/server/execute.ts +++ b/packages/adapter-claude-local/src/server/execute.ts @@ -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 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 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", diff --git a/packages/adapter-codex-local/src/server/execute-helpers.ts b/packages/adapter-codex-local/src/server/execute-helpers.ts index dba277fb..baac1d8a 100644 --- a/packages/adapter-codex-local/src/server/execute-helpers.ts +++ b/packages/adapter-codex-local/src/server/execute-helpers.ts @@ -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: /dist/server/ -> /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, key: string): boolean { - const raw = env[key]; - return typeof raw === "string" && raw.trim().length > 0; -} - export function resolveCodexBillingType(env: Record): "api" | "subscription" { - // Codex uses API-key auth when OPENAI_API_KEY is present; otherwise rely on local login/session auth. return hasNonEmptyEnvValue(env, "OPENAI_API_KEY") ? "api" : "subscription"; } @@ -48,11 +54,7 @@ export function codexHomeDir(): string { } export async function resolveAgentCompanySkillsDir(): Promise { - for (const candidate of SKILLS_CANDIDATES) { - const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false); - if (isDir) return candidate; - } - return null; + 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 }); diff --git a/packages/adapter-codex-local/src/server/execute.ts b/packages/adapter-codex-local/src/server/execute.ts index 606b9798..6fa5b632 100644 --- a/packages/adapter-codex-local/src/server/execute.ts +++ b/packages/adapter-codex-local/src/server/execute.ts @@ -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 { @@ -43,155 +44,32 @@ export async function execute(ctx: AdapterExecutionContext): Promise => typeof value === "object" && value !== null, - ) - : []; - const runtimeServiceIntents = Array.isArray(context.agentCompanyRuntimeServiceIntents) - ? context.agentCompanyRuntimeServiceIntents.filter( - (value): value is Record => typeof value === "object" && value !== null, - ) - : []; - const runtimeServices = Array.isArray(context.agentCompanyRuntimeServices) - ? context.agentCompanyRuntimeServices.filter( - (value): value is Record => typeof value === "object" && value !== null, - ) - : []; - const runtimePrimaryUrl = asString(context.agentCompanyRuntimePrimaryUrl, ""); - const configuredCwd = asString(config.cwd, ""); - const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0; - const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; - const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd(); - await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); - const 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 = { ...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 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, 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): "api" | "subscription" { return hasNonEmptyEnvValue(env, "GEMINI_API_KEY") || hasNonEmptyEnvValue(env, "GOOGLE_API_KEY") @@ -54,11 +61,7 @@ export function renderApiAccessNote(env: Record): string { } export async function resolveAgentCompanySkillsDir(): Promise { - for (const candidate of SKILLS_CANDIDATES) { - const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false); - if (isDir) return candidate; - } - return null; + return resolveSkillsDir(SKILLS_CANDIDATES); } export function geminiSkillsHome(): string { @@ -75,13 +78,12 @@ export async function ensureGeminiSkillsInjected( ): Promise { const skillsHome = geminiSkillsHome(); - // Use managed skills when available; fall back to static repo scan if (managedSkills.length > 0) { await createManagedSkillSymlinks(managedSkills, skillsHome, onLog); return; } - const skillsDir = await resolveAgentCompanySkillsDir(); + const skillsDir = await resolveSkillsDir(SKILLS_CANDIDATES); if (!skillsDir) return; try { diff --git a/packages/adapter-gemini-local/src/server/execute.ts b/packages/adapter-gemini-local/src/server/execute.ts index e98489df..1cb18eeb 100644 --- a/packages/adapter-gemini-local/src/server/execute.ts +++ b/packages/adapter-gemini-local/src/server/execute.ts @@ -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 { @@ -44,95 +45,25 @@ export async function execute(ctx: AdapterExecutionContext): Promise => 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 = { ...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 SAFE_ARG_LIMIT; const commandNotes = (() => { @@ -295,7 +222,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise {}); } diff --git a/packages/adapter-utils/src/execute-helpers.ts b/packages/adapter-utils/src/execute-helpers.ts new file mode 100644 index 00000000..2db183f0 --- /dev/null +++ b/packages/adapter-utils/src/execute-helpers.ts @@ -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, 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: /dist/server/ -> /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 { + 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): 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[] { + 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; + context: Record; + authToken?: string; +} + +export interface CommonAgentCompanyEnvResult { + env: Record; + 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; + }, +): Promise { + 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 => typeof value === "object" && value !== null, + ) + : []; + const runtimeServiceIntents = Array.isArray(context.agentCompanyRuntimeServiceIntents) + ? context.agentCompanyRuntimeServiceIntents.filter( + (value): value is Record => typeof value === "object" && value !== null, + ) + : []; + const runtimeServices = Array.isArray(context.agentCompanyRuntimeServices) + ? context.agentCompanyRuntimeServices.filter( + (value): value is Record => typeof value === "object" && value !== null, + ) + : []; + const runtimePrimaryUrl = asString(context.agentCompanyRuntimePrimaryUrl 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 = { ...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 | 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, + }; +} diff --git a/packages/adapter-utils/src/index.ts b/packages/adapter-utils/src/index.ts index e816100c..a82746be 100644 --- a/packages/adapter-utils/src/index.ts +++ b/packages/adapter-utils/src/index.ts @@ -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"; diff --git a/packages/adapter-utils/src/log-redaction.ts b/packages/adapter-utils/src/log-redaction.ts index 037e279e..1d96ccbd 100644 --- a/packages/adapter-utils/src/log-redaction.ts +++ b/packages/adapter-utils/src/log-redaction.ts @@ -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(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; - } -} diff --git a/packages/adapter-utils/src/server-utils-process.ts b/packages/adapter-utils/src/server-utils-process.ts index 05dec7e3..215d4542 100644 --- a/packages/adapter-utils/src/server-utils-process.ts +++ b/packages/adapter-utils/src/server-utils-process.ts @@ -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 { diff --git a/packages/adapter-utils/src/server-utils-skills-sync.ts b/packages/adapter-utils/src/server-utils-skills-sync.ts index f5f03ae6..43a7cc78 100644 --- a/packages/adapter-utils/src/server-utils-skills-sync.ts +++ b/packages/adapter-utils/src/server-utils-skills-sync.ts @@ -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, - desiredSkills: string[], -): Record { - const next = { ...config }; - const raw = next.agentCompanySkillSync; - const current = - typeof raw === "object" && raw !== null && !Array.isArray(raw) - ? { ...(raw as Record) } - : {}; - current.desiredSkills = Array.from( - new Set( - desiredSkills - .map((value) => value.trim()) - .filter(Boolean), - ), - ); - next.agentCompanySkillSync = current; - return next; -} - /** * Create or repair a symlink from source to target skill directory. * Returns the action taken: "created", "repaired", or "skipped". diff --git a/packages/adapter-utils/src/test-utils.ts b/packages/adapter-utils/src/test-utils.ts index 26280a81..d600b651 100644 --- a/packages/adapter-utils/src/test-utils.ts +++ b/packages/adapter-utils/src/test-utils.ts @@ -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 { - if (typeof input !== "object" || input === null || Array.isArray(input)) return {}; - const env: Record = {}; - for (const [key, value] of Object.entries(input as Record)) { - if (typeof value === "string") env[key] = value; - } - return env; -} diff --git a/src/main/api-server/index.ts b/src/main/api-server/index.ts index 3d7d989a..16aa2044 100644 --- a/src/main/api-server/index.ts +++ b/src/main/api-server/index.ts @@ -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; } diff --git a/src/main/api-server/routes-agent-control.ts b/src/main/api-server/routes-agent-control.ts index ce6246cc..8b7630d4 100644 --- a/src/main/api-server/routes-agent-control.ts +++ b/src/main/api-server/routes-agent-control.ts @@ -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 => 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; } diff --git a/src/main/api-server/routes-agents.ts b/src/main/api-server/routes-agents.ts index d5cb59f4..4fd5cbb2 100644 --- a/src/main/api-server/routes-agents.ts +++ b/src/main/api-server/routes-agents.ts @@ -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( + "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( + "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( + "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( + `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( + "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 { diff --git a/src/main/api-server/routes-approvals.ts b/src/main/api-server/routes-approvals.ts index eca1cd70..c62fe24c 100644 --- a/src/main/api-server/routes-approvals.ts +++ b/src/main/api-server/routes-approvals.ts @@ -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( + "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(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; @@ -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; @@ -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 }); }); diff --git a/src/main/api-server/routes-files.ts b/src/main/api-server/routes-files.ts index 5108e91b..db5a8545 100644 --- a/src/main/api-server/routes-files.ts +++ b/src/main/api-server/routes-files.ts @@ -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; - 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( + "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( + "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; + 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; - 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; - 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; - 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); diff --git a/src/main/api-server/routes-meetings.ts b/src/main/api-server/routes-meetings.ts new file mode 100644 index 00000000..8020d048 --- /dev/null +++ b/src/main/api-server/routes-meetings.ts @@ -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( + "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( + "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 }); + }); +} diff --git a/src/main/api-server/routes-messages.ts b/src/main/api-server/routes-messages.ts index 7b394656..7558f285 100644 --- a/src/main/api-server/routes-messages.ts +++ b/src/main/api-server/routes-messages.ts @@ -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(); 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 }); }); diff --git a/src/main/api-server/routes-misc.ts b/src/main/api-server/routes-misc.ts index ebfd61d0..9368b0d5 100644 --- a/src/main/api-server/routes-misc.ts +++ b/src/main/api-server/routes-misc.ts @@ -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( + "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; diff --git a/src/main/api-server/routes-runs.ts b/src/main/api-server/routes-runs.ts index a79b7c98..2936d4f5 100644 --- a/src/main/api-server/routes-runs.ts +++ b/src/main/api-server/routes-runs.ts @@ -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[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) { diff --git a/src/main/api-server/routes-tasks.ts b/src/main/api-server/routes-tasks.ts index 0cc70e9b..6bd08edc 100644 --- a/src/main/api-server/routes-tasks.ts +++ b/src/main/api-server/routes-tasks.ts @@ -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( + `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; diff --git a/src/main/api-server/routes-work-graph.ts b/src/main/api-server/routes-work-graph.ts index 15fc8083..5fa31d80 100644 --- a/src/main/api-server/routes-work-graph.ts +++ b/src/main/api-server/routes-work-graph.ts @@ -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( + "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( + "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( + "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( + "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( + "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( + "select id, status from agents where company_id = ?", + companyId, + ); + const tasks = db.queryAll( + "select id, status from tasks where company_id = ?", + companyId, + ); + const recentActivity = db.queryAll( + "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 = {}; for (const t of tasks) { diff --git a/src/main/api-server/server-core.ts b/src/main/api-server/server-core.ts index 6d638812..fa8e4726 100644 --- a/src/main/api-server/server-core.ts +++ b/src/main/api-server/server-core.ts @@ -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, 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 | null = null; - private port = 0; - private db: AppDatabase; - private onDomainChangedCb: () => void; - private onWakeAgentCb: (agentId: string, companyId: string, trigger: string) => string | null; - private emitEventCb: (event: DesktopEvent) => void; - private callbacks: ApiServerEventCallbacks; - private allowedOrigins: Set; - private onBrowserActionCb: ((actionId: string, companyId: string) => void) | null = null; + private _routes: Route[] = []; + private _server: ReturnType | null = null; + private _port = 0; + private _db: AppDatabase; + private _onDomainChangedCb: () => void; + private _onWakeAgentCb: (agentId: string, companyId: string, trigger: string) => string | null; + private _emitEventCb: (event: DesktopEvent) => void; + private _callbacks: ApiServerEventCallbacks; + private _allowedOrigins: Set; + private _onBrowserActionCb: ((actionId: string, companyId: string) => void) | null = null; constructor( db: AppDatabase, @@ -76,12 +87,12 @@ export class AgentApiServer { callbacks?: Partial, allowedOrigins: Iterable = ["app://-"], ) { - this.db = db; - this.onDomainChangedCb = onDomainChanged; - this.onWakeAgentCb = onWakeAgent; - this.emitEventCb = callbacks?.emitEvent ?? (() => {}); - this.allowedOrigins = new Set(Array.from(allowedOrigins).filter(Boolean)); - this.callbacks = { + 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 { 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 = {}; - 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; } } diff --git a/src/main/automation/automation-handlers.ts b/src/main/automation/automation-handlers.ts index 69060383..46d0b06f 100644 --- a/src/main/automation/automation-handlers.ts +++ b/src/main/automation/automation-handlers.ts @@ -38,7 +38,8 @@ export function handleTaskStatusChange( let task: ReturnType | 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 | null = null; try { task = db.getTask(taskId); - } catch { + } catch (error) { + deps.logger.warn(`[automation] handleTaskCreated: task ${taskId} not found:`, error); return; } if (!task) return; diff --git a/src/main/automation/automation-run-completion.ts b/src/main/automation/automation-run-completion.ts index 5ad57d82..c5df9cd2 100644 --- a/src/main/automation/automation-run-completion.ts +++ b/src/main/automation/automation-run-completion.ts @@ -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 | 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).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. } diff --git a/src/main/bootstrap-application.ts b/src/main/bootstrap-application.ts index cb3cd85b..cb4a2933 100644 --- a/src/main/bootstrap-application.ts +++ b/src/main/bootstrap-application.ts @@ -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[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) => { + 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) { diff --git a/src/main/connector-auth-service.ts b/src/main/connector-auth-service.ts index ac5c489b..5fbfd5a4 100644 --- a/src/main/connector-auth-service.ts +++ b/src/main/connector-auth-service.ts @@ -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 = { + 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); } diff --git a/src/main/connector-matching.ts b/src/main/connector-matching.ts index 654c8889..1aef39d7 100644 --- a/src/main/connector-matching.ts +++ b/src/main/connector-matching.ts @@ -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 = { 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 = { 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 = { 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 = 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(); - 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.", - }; -} diff --git a/src/main/database/agents-crud.ts b/src/main/database/agents-crud.ts index e5e39186..6d585578 100644 --- a/src/main/database/agents-crud.ts +++ b/src/main/database/agents-crud.ts @@ -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)[k]) !== + JSON.stringify((beforeConfig as Record)[k]), ); if (changedKeys.length > 0) { try { diff --git a/src/main/database/automation.ts b/src/main/database/automation.ts index 5384dcc2..50651af1 100644 --- a/src/main/database/automation.ts +++ b/src/main/database/automation.ts @@ -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; - export function buildAutomationMethods( getDb: () => DatabaseSync, readAll: ReadAll, diff --git a/src/main/database/budget.ts b/src/main/database/budget.ts index bb47b430..ae04b13e 100644 --- a/src/main/database/budget.ts +++ b/src/main/database/budget.ts @@ -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; - function mapPolicyRow(r: SqliteRow): BudgetPolicyRecord { return { id: r.id, diff --git a/src/main/database/company-settings.ts b/src/main/database/company-settings.ts index 955eb81d..c38d962a 100644 --- a/src/main/database/company-settings.ts +++ b/src/main/database/company-settings.ts @@ -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; diff --git a/src/main/database/crash-recovery-helpers.ts b/src/main/database/crash-recovery-helpers.ts new file mode 100644 index 00000000..df868b16 --- /dev/null +++ b/src/main/database/crash-recovery-helpers.ts @@ -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); +} diff --git a/src/main/database/documents.ts b/src/main/database/documents.ts index bed2d88b..54e844e0 100644 --- a/src/main/database/documents.ts +++ b/src/main/database/documents.ts @@ -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; - 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( + return readAll( "SELECT id, agent_id, revision_number, changed_keys, source, rolled_back_from_revision_id, created_by_agent_id, created_by_user_id, created_at FROM agent_config_revisions WHERE agent_id = ? AND company_id = ? ORDER BY revision_number DESC", agentId, companyId - ).map((row: SqliteRow) => ({ + ).map((row) => ({ id: row.id, agentId: row.agent_id, revisionNumber: row.revision_number, changedKeys: parseJson(row.changed_keys, []), source: row.source, rolledBackFromRevisionId: row.rolled_back_from_revision_id ?? null, @@ -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; configAfter: Record; + 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; configAfter: Record; + 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; + const before = revision.configBefore as Record; const ts = nowIso(); getDb().prepare( "UPDATE agents SET name = ?, role = ?, title = ?, department = ?, capabilities = ?, connector_id = ?, model = ?, budget_monthly_usd = ?, metadata_json = ?, updated_at = ? WHERE id = ? AND company_id = ?" @@ -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, diff --git a/src/main/database/entity-crud.ts b/src/main/database/entity-crud.ts index 212b4c2d..e1ac751a 100644 --- a/src/main/database/entity-crud.ts +++ b/src/main/database/entity-crud.ts @@ -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({ diff --git a/src/main/database/runs.ts b/src/main/database/runs.ts index 1c7778f0..a77c7dd6 100644 --- a/src/main/database/runs.ts +++ b/src/main/database/runs.ts @@ -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; +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( + return readAll( "SELECT id, run_id, agent_id, seq, event_type, stream, level, message, payload, created_at FROM run_events WHERE run_id = ? AND company_id = ? ORDER BY seq ASC LIMIT ? OFFSET ?", runId, companyId, limit, offset ).map((row: SqliteRow) => ({ diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index 9c167b16..1a781cd7 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -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(); diff --git a/src/main/database/skills.ts b/src/main/database/skills.ts index ad38a28a..786e9ff4 100644 --- a/src/main/database/skills.ts +++ b/src/main/database/skills.ts @@ -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; - export function buildSkillMethods( getDb: () => DatabaseSync, readAll: ReadAll, diff --git a/src/main/database/snapshot.ts b/src/main/database/snapshot.ts index 7e90e9dc..f089efd8 100644 --- a/src/main/database/snapshot.ts +++ b/src/main/database/snapshot.ts @@ -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; +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( + agents: readAll( `select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, created_at as createdAt, updated_at as updatedAt from agents${companyWhere} order by created_at asc`, ...companyArgs, ).map((record: SqliteRow) => ({ ...record, heartbeatEnabled: toBoolean(record.heartbeatEnabled) })) as AgentRecord[], @@ -77,7 +76,7 @@ export function buildSnapshotMethods( ...companyArgs, ), tasks: readAll( - `select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, requires_user_review as requiresUserReview, started_at as startedAt, completed_at as completedAt, cancelled_at as cancelledAt, created_at as createdAt, updated_at as updatedAt from tasks${companyWhere} order by created_at desc`, + `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( diff --git a/src/main/database/social.ts b/src/main/database/social.ts index c282a872..289579b3 100644 --- a/src/main/database/social.ts +++ b/src/main/database/social.ts @@ -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; - export function buildSocialMethods( getDb: () => DatabaseSync, readAll: ReadAll, diff --git a/src/main/database/tasks-crud.ts b/src/main/database/tasks-crud.ts index 500e225d..8eb7947a 100644 --- a/src/main/database/tasks-crud.ts +++ b/src/main/database/tasks-crud.ts @@ -141,7 +141,7 @@ export function buildTaskCrudMethods( listAgentTasks(agentId: string, companyId: string, statuses: string[]): TaskRecord[] { const placeholders = statuses.map(() => "?").join(","); return readAll( - `select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, created_at as createdAt, updated_at as updatedAt + `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 diff --git a/src/main/database/threads.ts b/src/main/database/threads.ts index 43b7b6f0..04f6265f 100644 --- a/src/main/database/threads.ts +++ b/src/main/database/threads.ts @@ -13,13 +13,7 @@ import type { ThreadWaitingFor, ThreadWaitStateRecord, } from "@shared/types"; - -type SqliteRow = Record; -type ReadAll = (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 { diff --git a/src/main/database/wakeup.ts b/src/main/database/wakeup.ts index 8f5aa3c8..8dde389a 100644 --- a/src/main/database/wakeup.ts +++ b/src/main/database/wakeup.ts @@ -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); } diff --git a/src/main/execution-policy.ts b/src/main/execution-policy.ts index 051271b8..352af7b1 100644 --- a/src/main/execution-policy.ts +++ b/src/main/execution-policy.ts @@ -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([ +const COFOUNDER_MANDATORY_APPROVALS = new Set([ "deliverable_review", "document_review", "plan_review", diff --git a/src/main/hire-hook.ts b/src/main/hire-hook.ts index fff1af56..6634418a 100644 --- a/src/main/hire-hook.ts +++ b/src/main/hire-hook.ts @@ -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.`, }; diff --git a/src/main/ipc-budget-handlers.ts b/src/main/ipc-budget-handlers.ts index fd8726c7..1693640b 100644 --- a/src/main/ipc-budget-handlers.ts +++ b/src/main/ipc-budget-handlers.ts @@ -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)); }); } diff --git a/src/main/ipc-core-admin-handlers.ts b/src/main/ipc-core-admin-handlers.ts index 2d38659c..57309521 100644 --- a/src/main/ipc-core-admin-handlers.ts +++ b/src/main/ipc-core-admin-handlers.ts @@ -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) => { diff --git a/src/main/ipc-core-agent-graph-handlers.ts b/src/main/ipc-core-agent-graph-handlers.ts index 96d800c5..6fc32b1f 100644 --- a/src/main/ipc-core-agent-graph-handlers.ts +++ b/src/main/ipc-core-agent-graph-handlers.ts @@ -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; } diff --git a/src/main/ipc-core-common.ts b/src/main/ipc-core-common.ts index f85e3912..dbc7fad7 100644 --- a/src/main/ipc-core-common.ts +++ b/src/main/ipc-core-common.ts @@ -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; - checkConnector: (id: any) => Promise; - openConnectorAuthTerminal: (id: any) => Promise; - openGenericTerminal: (input: { title: string; command: string }) => Promise; + checkConnector: (id: ConnectorId) => Promise; + openConnectorAuthTerminal: (id: ConnectorId) => Promise; + openGenericTerminal: (input: { commandKey: string }) => Promise; checkForUpdates: () => Promise; getWorkerProcess: () => { postMessage: (message: unknown) => void } | null; resetAfterRestore: () => void; diff --git a/src/main/ipc-core-task-graph-handlers.ts b/src/main/ipc-core-task-graph-handlers.ts index 25dd10a3..f9bcec71 100644 --- a/src/main/ipc-core-task-graph-handlers.ts +++ b/src/main/ipc-core-task-graph-handlers.ts @@ -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 { diff --git a/src/main/ipc-skill-handlers.ts b/src/main/ipc-skill-handlers.ts index aadce65e..d2a77567 100644 --- a/src/main/ipc-skill-handlers.ts +++ b/src/main/ipc-skill-handlers.ts @@ -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 ---- diff --git a/src/main/main-runtime-support.ts b/src/main/main-runtime-support.ts index 2341cbf9..4bb9ace5 100644 --- a/src/main/main-runtime-support.ts +++ b/src/main/main-runtime-support.ts @@ -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); diff --git a/src/main/message-dispatch.ts b/src/main/message-dispatch.ts index 79b2a0f1..c7ea0c74 100644 --- a/src/main/message-dispatch.ts +++ b/src/main/message-dispatch.ts @@ -182,33 +182,39 @@ export function validateAgentMessageInput( } function resolveProjectWakeTargets( + db: AppDatabase, input: NormalizedAgentMessageInput, - snapshot: ReturnType, ): 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(); 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(); 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, diff --git a/src/main/onboarding-service.ts b/src/main/onboarding-service.ts index 881d40f4..1dc203c6 100644 --- a/src/main/onboarding-service.ts +++ b/src/main/onboarding-service.ts @@ -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", diff --git a/src/main/orchestration/heartbeat.ts b/src/main/orchestration/heartbeat.ts index aa2617a5..40c74aa2 100644 --- a/src/main/orchestration/heartbeat.ts +++ b/src/main/orchestration/heartbeat.ts @@ -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; 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; } diff --git a/src/main/orchestration/scheduler.ts b/src/main/orchestration/scheduler.ts index 07118f74..8746df6b 100644 --- a/src/main/orchestration/scheduler.ts +++ b/src/main/orchestration/scheduler.ts @@ -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(); diff --git a/src/main/orchestration/task-runner.ts b/src/main/orchestration/task-runner.ts index 928d22b0..cf2eb14f 100644 --- a/src/main/orchestration/task-runner.ts +++ b/src/main/orchestration/task-runner.ts @@ -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; 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 { diff --git a/src/main/orchestration/types.ts b/src/main/orchestration/types.ts index 443f1296..577fc52b 100644 --- a/src/main/orchestration/types.ts +++ b/src/main/orchestration/types.ts @@ -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; diff --git a/src/main/prompt-builder/attachment-resolver.ts b/src/main/prompt-builder/attachment-resolver.ts index c22dde2f..673fdb47 100644 --- a/src/main/prompt-builder/attachment-resolver.ts +++ b/src/main/prompt-builder/attachment-resolver.ts @@ -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 }); } diff --git a/src/main/prompt-builder/context-injection.ts b/src/main/prompt-builder/context-injection.ts index b367ba95..57c94cd9 100644 --- a/src/main/prompt-builder/context-injection.ts +++ b/src/main/prompt-builder/context-injection.ts @@ -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).task_type as string) ?? inferTaskType(agent.department); + const taskType = task.taskType ?? inferTaskType(agent.department); if (taskType === "research") { lines.push(`# Deliverable Requirements (Research Task) diff --git a/src/main/prompt-builder/system-prompt.ts b/src/main/prompt-builder/system-prompt.ts index 8812c4de..0632b528 100644 --- a/src/main/prompt-builder/system-prompt.ts +++ b/src/main/prompt-builder/system-prompt.ts @@ -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 diff --git a/src/main/runtime-env.ts b/src/main/runtime-env.ts index 34925209..886ddac2 100644 --- a/src/main/runtime-env.ts +++ b/src/main/runtime-env.ts @@ -35,7 +35,6 @@ const ENV_ALLOWLIST: ReadonlySet = new Set([ "PROGRAMDATA", "PATHEXT", "NODE_EXTRA_CA_CERTS", - "NODE_TLS_REJECT_UNAUTHORIZED", "SSL_CERT_FILE", "SSL_CERT_DIR", "REQUESTS_CA_BUNDLE", diff --git a/src/main/service-graph.ts b/src/main/service-graph.ts index 5efaa102..6887bb49 100644 --- a/src/main/service-graph.ts +++ b/src/main/service-graph.ts @@ -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 }; } }, diff --git a/src/main/session-management-service.ts b/src/main/session-management-service.ts index bec12491..78125f43 100644 --- a/src/main/session-management-service.ts +++ b/src/main/session-management-service.ts @@ -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(""); } diff --git a/src/main/skill-github.ts b/src/main/skill-github.ts index 01172422..6345e750 100644 --- a/src/main/skill-github.ts +++ b/src/main/skill-github.ts @@ -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 { + 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 = diff --git a/src/main/skill-types.ts b/src/main/skill-types.ts index d781e350..ad1334de 100644 --- a/src/main/skill-types.ts +++ b/src/main/skill-types.ts @@ -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 | 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 diff --git a/src/main/skills/import.ts b/src/main/skills/import.ts index c9c6ea42..09ac600a 100644 --- a/src/main/skills/import.ts +++ b/src/main/skills/import.ts @@ -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, ): Promise { - 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}`); } diff --git a/src/main/skills/runtime.ts b/src/main/skills/runtime.ts index 7b0219c8..ead2410f 100644 --- a/src/main/skills/runtime.ts +++ b/src/main/skills/runtime.ts @@ -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) { diff --git a/src/main/skills/url-validation.ts b/src/main/skills/url-validation.ts new file mode 100644 index 00000000..0af74848 --- /dev/null +++ b/src/main/skills/url-validation.ts @@ -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 { + 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 { + 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 { + await resolveAndValidateIps(url); +} diff --git a/src/main/task-quality-gates.ts b/src/main/task-quality-gates.ts index fa172065..046bed7b 100644 --- a/src/main/task-quality-gates.ts +++ b/src/main/task-quality-gates.ts @@ -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: (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 }; } diff --git a/src/main/updater-service.ts b/src/main/updater-service.ts index 7c4982d4..52c22f91 100644 --- a/src/main/updater-service.ts +++ b/src/main/updater-service.ts @@ -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(); diff --git a/src/main/vault.ts b/src/main/vault.ts index 3b11f827..cf4e6051 100644 --- a/src/main/vault.ts +++ b/src/main/vault.ts @@ -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() { diff --git a/src/main/window-state.ts b/src/main/window-state.ts index 24d07f68..56b65aec 100644 --- a/src/main/window-state.ts +++ b/src/main/window-state.ts @@ -20,13 +20,13 @@ function safeParseState(filePath: string): StoredWindowState | null { const parsed = JSON.parse(raw) as Partial; if (!parsed.bounds || typeof parsed.bounds !== "object") return null; const { width, height, x, y } = parsed.bounds as Partial; - 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, }, diff --git a/src/main/worker/circuit-breaker.ts b/src/main/worker/circuit-breaker.ts new file mode 100644 index 00000000..dcf0f00a --- /dev/null +++ b/src/main/worker/circuit-breaker.ts @@ -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, + 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; + }, + }; +} diff --git a/src/main/worker/failure-escalation.ts b/src/main/worker/failure-escalation.ts new file mode 100644 index 00000000..4e32c088 --- /dev/null +++ b/src/main/worker/failure-escalation.ts @@ -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 */ } +} diff --git a/src/main/worker/worker-entry.ts b/src/main/worker/worker-entry.ts index d66d0f6b..7bc78ba1 100644 --- a/src/main/worker/worker-entry.ts +++ b/src/main/worker/worker-entry.ts @@ -80,7 +80,12 @@ const activeByAgent = new Map(); const activeByWorkspace = new Map(); const queue: RunJob[] = []; const cancelledRuns = new Set(); -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 { - 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"); } } diff --git a/src/main/worker/worker-types.ts b/src/main/worker/worker-types.ts index 8924ba4e..a617bbbd 100644 --- a/src/main/worker/worker-types.ts +++ b/src/main/worker/worker-types.ts @@ -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; trigger: string; }) => void; - processRunCompletion: (run: any, event: z.infer & { type: "run-finished" }) => void; + processRunCompletion: (run: RunRecord, event: z.infer & { type: "run-finished" }) => void; onRunStarted?: (runId: string, taskId: string, agentId: string, companyId: string) => void; onRunCompleted?: (runId: string, taskId: string, agentId: string, companyId: string, status: string, summary: string | null, errorMessage: string | null) => void; notify: (options: NotificationOptions) => void; diff --git a/src/preload/index.ts b/src/preload/index.ts index 7f14f395..be023faf 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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), diff --git a/src/renderer/components/CompanyOnboarding.tsx b/src/renderer/components/CompanyOnboarding.tsx index 63898664..18d70c5c 100644 --- a/src/renderer/components/CompanyOnboarding.tsx +++ b/src/renderer/components/CompanyOnboarding.tsx @@ -1,2 +1,2 @@ // Backward-compatible re-exports from decomposed onboarding components -export { CompanyOnboarding } from "./CompanyOnboarding/OnboardingWizard"; +export { CompanyOnboarding } from "./company-onboarding/OnboardingWizard"; diff --git a/src/renderer/components/automation.tsx b/src/renderer/components/automation.tsx index 18718ad8..4ace0b23 100644 --- a/src/renderer/components/automation.tsx +++ b/src/renderer/components/automation.tsx @@ -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" diff --git a/src/renderer/components/communication.tsx b/src/renderer/components/communication.tsx index d5ec24c4..9e855be7 100644 --- a/src/renderer/components/communication.tsx +++ b/src/renderer/components/communication.tsx @@ -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" diff --git a/src/renderer/components/communication/MessageComposer.tsx b/src/renderer/components/communication/MessageComposer.tsx index 525e4520..090ce0b6 100644 --- a/src/renderer/components/communication/MessageComposer.tsx +++ b/src/renderer/components/communication/MessageComposer.tsx @@ -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) diff --git a/src/renderer/components/communication/WaitStateIndicator.tsx b/src/renderer/components/communication/WaitStateIndicator.tsx index fb56d89d..870b227e 100644 --- a/src/renderer/components/communication/WaitStateIndicator.tsx +++ b/src/renderer/components/communication/WaitStateIndicator.tsx @@ -51,7 +51,7 @@ export default function WaitStateIndicator({ waitState, onReply, onNavigateAppro {isApproval && onNavigateApproval ? ( {activeWork.length > 0 ? ( @@ -263,7 +263,7 @@ export function Dashboard(props: DashboardProps) { ) : (
{t("dash.allQuiet")}
-
No tasks currently running
+
{t("dash.noTasksRunning")}
)} diff --git a/src/renderer/components/dashboard/RecoveryBanner.tsx b/src/renderer/components/dashboard/RecoveryBanner.tsx index 55702b38..7cf778b3 100644 --- a/src/renderer/components/dashboard/RecoveryBanner.tsx +++ b/src/renderer/components/dashboard/RecoveryBanner.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from "react"; import { Warning, ArrowClockwise, X, Trash } from "@phosphor-icons/react"; +import type { DesktopEvent } from "@shared/types/events"; interface InterruptedRun { runId: string; @@ -16,7 +17,7 @@ export function RecoveryBanner() { const [dismissed, setDismissed] = useState(false); useEffect(() => { - const unsubscribe = window.agentCompany?.subscribe?.((event: any) => { + const unsubscribe = window.agentCompany?.subscribe?.((event: DesktopEvent) => { if (event.type === "recovery-needed" && event.interruptedRuns) { setRuns(event.interruptedRuns); } diff --git a/src/renderer/components/execution.tsx b/src/renderer/components/execution.tsx index a9f8d713..f613a716 100644 --- a/src/renderer/components/execution.tsx +++ b/src/renderer/components/execution.tsx @@ -10,7 +10,6 @@ export { BudgetSummary } from "./execution/BudgetSummary" export { WorkspaceList } from "./execution/WorkspaceList" export { WorkspaceInspector } from "./execution/WorkspaceInspector" export { WorkspaceCreator } from "./execution/WorkspaceCreator" -export { SecretCreator } from "./execution/SecretCreator" export { SettingsPane } from "./execution/SettingsPane" export { ModelManagement } from "./execution/ModelManagement" export { PendingReviewList } from "./execution/PendingReviewList" diff --git a/src/renderer/components/execution/ApprovalInspector.tsx b/src/renderer/components/execution/ApprovalInspector.tsx index 9f40e044..021441b0 100644 --- a/src/renderer/components/execution/ApprovalInspector.tsx +++ b/src/renderer/components/execution/ApprovalInspector.tsx @@ -104,7 +104,7 @@ function ApprovalDocumentsPanel({ reference, document: documents.find((entry) => entry.id === reference.id) ?? null, })) - .filter((entry) => entry.document) + .filter((entry): entry is typeof entry & { document: NonNullable } => entry.document != null) : [] return ( @@ -112,9 +112,9 @@ function ApprovalDocumentsPanel({
Linked Documents
{fullDocs.length > 0 ? fullDocs.map(({ reference, document }) => ( )) : references.map((reference) => ( diff --git a/src/renderer/components/execution/ModelManagementShared.tsx b/src/renderer/components/execution/ModelManagementShared.tsx index 67d0e3ee..c575be3b 100644 --- a/src/renderer/components/execution/ModelManagementShared.tsx +++ b/src/renderer/components/execution/ModelManagementShared.tsx @@ -1,28 +1,8 @@ import { CaretDown, CaretRight } from "@phosphor-icons/react"; import type { ProfileSnapshot } from "@shared/types"; +import { COMPATIBLE_PROVIDERS } from "../company-onboarding/ProvidersStepShared"; -/* ------------------------------------------------------------------ */ -/* Compatible-API provider presets (mirrors ProvidersStep) */ -/* ------------------------------------------------------------------ */ - -export const COMPATIBLE_PROVIDERS = [ - { id: "minimax", label: "MiniMax", baseUrl: "https://api.minimax.io/anthropic", docUrl: "https://platform.minimax.io/docs/token-plan/claude-code" }, - { id: "minimax_cn", label: "MiniMax (CN)", baseUrl: "https://api.minimaxi.com/anthropic", docUrl: "https://platform.minimax.io/docs/token-plan/claude-code" }, - { id: "kimi", label: "Kimi", baseUrl: "https://api.kimi.com/coding/", docUrl: "https://www.kimi.com/code/docs/en/more/third-party-agents.html" }, - { id: "kimi_cn", label: "Kimi CN", baseUrl: "https://api.moonshot.cn/anthropic", docUrl: "https://platform.moonshot.cn/docs/guide/agent-support" }, - { id: "glm", label: "GLM (ZhiPu)", baseUrl: "https://api.z.ai/api/anthropic", docUrl: "https://docs.z.ai/devpack/tool/claude" }, - { id: "openrouter", label: "OpenRouter", baseUrl: "https://openrouter.ai/api", docUrl: "https://openrouter.ai/docs/guides/coding-agents/claude-code-integration" }, - { id: "tencent", label: "Tencent Coding Plan", baseUrl: "https://api.lkeap.cloud.tencent.com/coding/anthropic", docUrl: "https://cloud.tencent.com/document/product/1772/128951" }, - { id: "poe", label: "POE", baseUrl: "https://api.poe.com", docUrl: "https://creator.poe.com/docs/external-applications/claude-code" }, - { id: "deepseek", label: "DeepSeek", baseUrl: "https://api.deepseek.com/anthropic", docUrl: "https://api-docs.deepseek.com/guides/anthropic_api" }, - { id: "alibaba", label: "Alibaba Coding Plan", baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic", docUrl: "https://www.alibabacloud.com/help/en/model-studio/claude-code-coding-plan" }, - { id: "alibaba_cn", label: "Alibaba Coding Plan (CN)", baseUrl: "https://coding.dashscope.aliyuncs.com/apps/anthropic", docUrl: "https://help.aliyun.com/zh/model-studio/claude-code-coding-plan" }, - { id: "bailing", label: "BaiLing", baseUrl: "https://api.tbox.cn/api/anthropic", docUrl: "https://alipaytbox.yuque.com/sxs0ba/ling/best_practices_claudecode" }, - { id: "nanogpt", label: "NanoGPT", baseUrl: "https://nano-gpt.com/api/v1", docUrl: "https://docs.nano-gpt.com/integrations/claude-code#manual-configuration-all-platforms" }, - { id: "zhipu_cn", label: "ZhiPu (CN)", baseUrl: "https://open.bigmodel.cn/api/anthropic", docUrl: "https://docs.bigmodel.cn/cn/coding-plan/tool/claude" }, - { id: "ollama", label: "Ollama (Local)", baseUrl: "http://localhost:11434", docUrl: "https://docs.ollama.com/integrations/claude-code" }, - { id: "custom", label: "Custom", baseUrl: "", docUrl: "" }, -] as const; +export { COMPATIBLE_PROVIDERS }; export const COMPAT_SLOTS = [ "claude_compatible", "claude_compat_2", "claude_compat_3", "claude_compat_4", "claude_compat_5", diff --git a/src/renderer/components/execution/SecretCreator.tsx b/src/renderer/components/execution/SecretCreator.tsx deleted file mode 100644 index b8eb2da4..00000000 --- a/src/renderer/components/execution/SecretCreator.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { useState } from "react" -import { ShieldCheck } from "@phosphor-icons/react" -import { useT } from "../../i18n" -import { unwrap } from "../../lib/desktop" -import { ActionButton, InlineNotice, Input, TextArea } from "../ui" -import { describeActionError } from "./execution-utils" - -export function SecretCreator({ companyId, onSaved }: { companyId: string; onSaved: () => Promise }) { - const t = useT() - const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [value, setValue] = useState("") - const [errors, setErrors] = useState<{ name?: string; value?: string }>({}); const [submitError, setSubmitError] = useState(null); const [submitting, setSubmitting] = useState(false) - - function validate(): boolean { - const next: typeof errors = {} - if (!name.trim()) next.name = "Secret name is required." - else if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name.trim())) next.name = "Name must be alphanumeric with underscores, starting with a letter or underscore." - if (!value.trim()) next.value = "Secret value cannot be empty." - setErrors(next); return Object.keys(next).length === 0 - } - - return ( -
-
{ setName(v); if (errors.name) setErrors((e) => ({ ...e, name: undefined })) }} placeholder="e.g. ANTHROPIC_API_KEY" />{errors.name ?
{errors.name}
: null}
- -