From 5547c294916f8cbf2af7dc92c0314031931f39b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 17 Apr 2026 14:03:56 -0400 Subject: [PATCH] update --- .env.example | 1 - .github/workflows/build.yml | 1 - .gitignore | 2 + package.json | 3 +- pnpm-workspace.yaml | 1 - scripts/release-local.mjs | 40 +- src/main/agent-runtime/approval-hook.ts | 65 --- src/main/agent-runtime/cost-writer.ts | 36 +- src/main/agent-runtime/index.ts | 1 - src/main/agent-runtime/runtime-bridge.ts | 79 ++- .../agent-runtime/sqlite-session-store.ts | 7 +- src/main/api-server/routes-agent-control.ts | 22 +- src/main/api-server/routes-agents.ts | 17 +- src/main/api-server/routes-files.ts | 44 +- src/main/api-server/routes-tasks.ts | 13 +- src/main/app-lifecycle-service.ts | 15 +- src/main/automation-service.ts | 2 +- .../automation/automation-action-schemas.ts | 3 +- src/main/automation/automation-actions.ts | 16 +- .../automation/automation-approval-handler.ts | 213 ++++---- .../automation/automation-run-completion.ts | 15 +- src/main/automation/automation-skills.ts | 127 ----- src/main/automation/index.ts | 1 - src/main/bootstrap/prepare-services.ts | 8 + src/main/bootstrap/recover-scheduled-posts.ts | 6 +- src/main/bootstrap/wire-api-server.ts | 7 +- src/main/browser/browser-action-executor.ts | 6 +- src/main/checkpoint-engine.ts | 16 +- src/main/connector-definitions-data-oauth.ts | 13 +- src/main/connector-definitions.ts | 8 +- src/main/credentials/credential-resolver.ts | 11 + src/main/database-base.ts | 19 +- src/main/database.ts | 8 +- src/main/database/agents-hierarchy.ts | 75 +++ src/main/database/approvals.ts | 42 +- src/main/database/backup.ts | 2 - src/main/database/entity-crud.ts | 3 +- src/main/database/runs.ts | 5 +- src/main/database/schema.ts | 15 + src/main/database/settings.ts | 12 +- src/main/database/snapshot.ts | 4 +- src/main/database/tasks-crud.ts | 95 +++- src/main/database/tasks-status.ts | 24 +- src/main/desktop-shell-menu.ts | 30 +- src/main/desktop-shell-service.ts | 9 +- src/main/hire-hook.ts | 6 +- src/main/ipc-core-common.ts | 5 + src/main/ipc-core-operations-handlers.ts | 51 -- src/main/ipc-core-task-graph-handlers.ts | 22 +- src/main/ipc-core-work-graph-handlers.ts | 2 + src/main/ipc-recovery-handlers.ts | 20 +- src/main/main-runtime-support.ts | 1 + src/main/main-runtime.ts | 3 +- src/main/message-dispatch.ts | 57 +- src/main/notification-service.ts | 14 +- src/main/orchestration/heartbeat-task.ts | 12 +- src/main/orchestration/heartbeat.ts | 6 +- src/main/orchestration/turn-context.ts | 42 +- src/main/orchestration/types.ts | 5 +- src/main/prompt-builder/context-injection.ts | 16 +- src/main/prompt-builder/index.ts | 16 +- src/main/prompt-builder/messages/system-en.ts | 14 - src/main/prompt-builder/messages/system-zh.ts | 14 - src/main/prompt-builder/system-prompt.ts | 44 +- src/main/run-housekeeping.ts | 26 +- src/main/service-graph-orchestration-deps.ts | 4 +- src/main/service-graph-pi-runtime.ts | 4 +- src/main/service-graph.ts | 367 +++++-------- .../create-background-services.ts | 95 ++++ .../service-graph/create-desktop-shell.ts | 85 +++ .../service-graph/create-leaf-services.ts | 46 ++ src/main/service-graph/create-lifecycle.ts | 145 ++++++ .../create-orchestration-service.ts | 111 ++++ .../service-graph/create-worker-service.ts | 87 ++++ src/main/service-graph/late-binding.ts | 33 ++ src/main/skill-format.ts | 55 +- src/main/skill-types.ts | 76 +-- src/main/skills/index.ts | 2 - src/main/skills/runtime.ts | 152 ++---- src/main/task-quality-gates.ts | 30 +- src/main/vault.ts | 13 +- src/main/worker-service.ts | 14 +- src/main/worker/failure-escalation.ts | 19 +- .../worker/handlers/run-finished-helpers.ts | 42 +- src/main/worker/worker-crash-recovery.ts | 6 + src/main/worker/worker-entry.ts | 51 +- src/main/worker/worker-helpers.ts | 23 +- src/main/worker/worker-message-handler.ts | 9 +- src/main/worker/worker-rpc.ts | 24 +- src/main/worker/worker-runner.ts | 66 ++- src/main/worker/worker-types.ts | 2 +- src/preload/index.ts | 37 +- src/renderer/App.tsx | 5 +- src/renderer/components/CompanyOnboarding.tsx | 2 - src/renderer/components/TitleBar.tsx | 10 +- src/renderer/components/automation.tsx | 1 - .../components/automation/AutomationPanel.tsx | 110 ++-- src/renderer/components/communication.tsx | 1 - .../communication/ChannelSidebar.tsx | 27 +- .../communication/CommunicationHub.tsx | 29 +- .../communication/MessageComposer.tsx | 491 +++++------------- .../components/communication/MessageItem.tsx | 25 +- .../components/communication/MessageList.tsx | 2 +- .../communication/SearchResults.tsx | 28 +- .../components/communication/ThreadPanel.tsx | 20 +- .../communication/WaitStateIndicator.tsx | 30 +- .../composer/ComposerAttachments.tsx | 160 ++++++ .../composer/ComposerMentionDropdown.tsx | 89 ++++ .../composer/ComposerReplyBanner.tsx | 42 ++ .../composer/ComposerSubjectRow.tsx | 34 ++ .../composer/NoChannelComposer.tsx | 14 + .../composer/TaskDiscussionComposer.tsx | 56 ++ .../composer/useCaretMeasurement.ts | 79 +++ .../communication/composer/useMentionState.ts | 76 +++ .../communication/composer/useMessageSend.ts | 99 ++++ .../company-onboarding/CompanyStep.tsx | 62 ++- .../company-onboarding/ExecutionModeStep.tsx | 14 +- .../company-onboarding/LaunchStep.tsx | 8 +- .../company-onboarding/MissionStep.tsx | 31 +- .../company-onboarding/ModelPoolStep.tsx | 20 +- .../company-onboarding/NodeCheckStep.tsx | 4 +- .../company-onboarding/OnboardingContext.tsx | 75 +-- .../OnboardingStepPanel.tsx | 33 +- .../company-onboarding/OnboardingWizard.tsx | 264 +++++----- .../company-onboarding/ProvidersStep.tsx | 4 +- .../provider-cards/api-key-card.tsx | 9 +- .../provider-cards/shared.tsx | 8 +- .../useConnectorCallbacks.ts | 38 +- .../useConnectorModelSelection.ts | 5 +- .../components/company-operations.tsx | 4 - src/renderer/components/dashboard.tsx | 5 - .../components/dashboard/ChartSection.tsx | 30 +- .../components/dashboard/Dashboard.tsx | 14 +- .../components/dashboard/KanbanBoard.tsx | 29 +- .../components/dashboard/MetricsGrid.tsx | 3 +- .../components/dashboard/RecoveryBanner.tsx | 5 +- .../components/dashboard/charts/ChartCard.tsx | 12 +- src/renderer/components/execution.tsx | 17 - .../execution/ModelManagementShared.tsx | 27 +- .../components/execution/PolicyFormModal.tsx | 34 +- .../components/execution/SettingsPane.tsx | 40 +- .../components/execution/TaskInspector.tsx | 6 +- .../ApprovalDocumentsPanel.tsx | 27 +- .../approval-inspector/ApprovalInspector.tsx | 27 +- .../approval-inspector/ApprovalSummary.tsx | 12 +- .../approval-inspector/DecisionForm.tsx | 14 +- .../DeliverableReviewPanel.tsx | 20 +- .../approval-inspector/ProposedHireCard.tsx | 31 +- .../approval-inspector/SchedulePicker.tsx | 8 +- src/renderer/components/hiring.tsx | 2 - .../components/hiring/HiringPipeline.tsx | 22 +- src/renderer/components/inbox.tsx | 4 - src/renderer/components/inbox/Inbox.tsx | 16 +- .../components/inbox/PerformanceView.tsx | 25 +- src/renderer/components/inbox/StandupView.tsx | 19 +- .../components/inbox/inbox-helpers.tsx | 5 +- src/renderer/components/organization.tsx | 10 - .../components/organization/OrgChart.tsx | 8 +- .../agent-form-fields/CreateFormBody.tsx | 22 +- .../agent-form-fields/EditFormBody.tsx | 33 +- src/renderer/components/runs.tsx | 2 - src/renderer/components/runs/RunDetail.tsx | 6 +- .../settings/dialogs/OAuthPromptDialog.tsx | 24 +- .../components/settings/forms/ApiKeyForm.tsx | 29 +- .../components/settings/forms/AwsForm.tsx | 158 ++---- .../components/settings/forms/AzureForm.tsx | 156 ++---- .../components/settings/forms/VertexForm.tsx | 84 ++- .../components/settings/forms/form-shared.ts | 18 - .../settings/providers/ProviderRow.tsx | 125 +++-- .../settings/providers/ProvidersPanel.tsx | 45 +- .../components/skills/SkillDetail.tsx | 96 ++-- .../components/skills/SkillImportModal.tsx | 39 +- src/renderer/components/social.tsx | 2 - .../components/social/AccountCard.tsx | 5 +- .../components/social/AddAccountDialog.tsx | 16 +- src/renderer/components/ui/primitives.tsx | 14 +- src/renderer/hooks/useCompanyController.ts | 62 ++- src/renderer/hooks/useCompanyData.ts | 68 ++- src/renderer/hooks/useDataLoader.ts | 39 +- src/renderer/hooks/useDesktopEvents.ts | 6 +- src/renderer/hooks/useSectionContext.ts | 56 +- src/renderer/hooks/useUIController.ts | 122 +++-- src/renderer/i18n/en.ts | 444 ++++++++++++++++ src/renderer/i18n/zh.ts | 444 ++++++++++++++++ src/renderer/state/slices/company.ts | 16 +- .../views/sections/ExecutionSections.tsx | 32 +- .../views/sections/OperationsSections.tsx | 27 +- .../views/sections/OrganizationSections.tsx | 24 +- src/shared/connector-catalog.ts | 8 +- src/shared/contracts/channels.ts | 7 - src/shared/contracts/desktop-api.ts | 2 - src/shared/contracts/desktop-api/agents.ts | 7 +- src/shared/contracts/desktop-api/common.ts | 17 - src/shared/contracts/desktop-api/runs.ts | 4 +- src/shared/contracts/helpers.ts | 14 + .../contracts/schemas-agent-permissions.ts | 12 - src/shared/contracts/schemas-automation.ts | 9 +- src/shared/contracts/schemas-company.ts | 8 + .../contracts/schemas-config-revisions.ts | 17 - src/shared/contracts/schemas-execution.ts | 8 +- src/shared/contracts/schemas-misc.ts | 2 - src/shared/contracts/schemas-oauth.ts | 39 +- src/shared/types/enums.ts | 102 ++-- src/shared/types/events.ts | 2 +- src/shared/types/records-core.ts | 11 +- tests/e2e/desktop.smoke.spec.ts | 8 +- .../skill-aware-assignment.spec.ts | 15 +- tests/unit/approval-hook.test.ts | 111 ---- tests/unit/connector-catalog.test.ts | 2 +- tests/unit/credential-resolver.test.ts | 10 +- tests/unit/heartbeat-coalescing.test.ts | 7 +- tests/unit/notification-service.test.ts | 5 +- tests/unit/skill-format.test.ts | 21 - tests/unit/skill-types.test.ts | 70 +-- tests/unit/store.test.ts | 1 + tests/unit/task-quality-gates-sweep.test.ts | 65 +-- 216 files changed, 5043 insertions(+), 3352 deletions(-) delete mode 100644 pnpm-workspace.yaml delete mode 100644 src/main/agent-runtime/approval-hook.ts delete mode 100644 src/main/automation/automation-skills.ts create mode 100644 src/main/service-graph/create-background-services.ts create mode 100644 src/main/service-graph/create-desktop-shell.ts create mode 100644 src/main/service-graph/create-leaf-services.ts create mode 100644 src/main/service-graph/create-lifecycle.ts create mode 100644 src/main/service-graph/create-orchestration-service.ts create mode 100644 src/main/service-graph/create-worker-service.ts create mode 100644 src/main/service-graph/late-binding.ts delete mode 100644 src/renderer/components/CompanyOnboarding.tsx delete mode 100644 src/renderer/components/automation.tsx delete mode 100644 src/renderer/components/communication.tsx create mode 100644 src/renderer/components/communication/composer/ComposerAttachments.tsx create mode 100644 src/renderer/components/communication/composer/ComposerMentionDropdown.tsx create mode 100644 src/renderer/components/communication/composer/ComposerReplyBanner.tsx create mode 100644 src/renderer/components/communication/composer/ComposerSubjectRow.tsx create mode 100644 src/renderer/components/communication/composer/NoChannelComposer.tsx create mode 100644 src/renderer/components/communication/composer/TaskDiscussionComposer.tsx create mode 100644 src/renderer/components/communication/composer/useCaretMeasurement.ts create mode 100644 src/renderer/components/communication/composer/useMentionState.ts create mode 100644 src/renderer/components/communication/composer/useMessageSend.ts delete mode 100644 src/renderer/components/company-operations.tsx delete mode 100644 src/renderer/components/dashboard.tsx delete mode 100644 src/renderer/components/execution.tsx delete mode 100644 src/renderer/components/hiring.tsx delete mode 100644 src/renderer/components/inbox.tsx delete mode 100644 src/renderer/components/organization.tsx delete mode 100644 src/renderer/components/runs.tsx delete mode 100644 src/renderer/components/settings/forms/form-shared.ts delete mode 100644 src/renderer/components/social.tsx delete mode 100644 src/shared/contracts/schemas-agent-permissions.ts delete mode 100644 src/shared/contracts/schemas-config-revisions.ts delete mode 100644 tests/unit/approval-hook.test.ts diff --git a/.env.example b/.env.example index 7dbd2106..eddadb61 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,5 @@ AGENTCOMPANY_UPDATE_URL= AGENTCOMPANY_DISABLE_UPDATES=true -AGENTCOMPANY_ALLOW_UNSAFE_CONNECTOR_BYPASS=false CSC_LINK= CSC_KEY_PASSWORD= WIN_CSC_LINK= diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 16b8137a..775ffd71 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -42,7 +42,6 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm typecheck - run: pnpm lint - - run: pnpm typecheck:all # ───────────────────────────────────────────── # Stage 2: Unit Tests diff --git a/.gitignore b/.gitignore index 110cdbb1..6e661a6c 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,8 @@ playwright-report/ *.pem *.key *.p8 +*.csr +*.certSigningRequest *.provisionprofile *.mobileprovision diff --git a/package.json b/package.json index fb408f1d..7b6529e8 100644 --- a/package.json +++ b/package.json @@ -24,12 +24,11 @@ "lint:fix": "eslint . --fix", "test": "node --disable-warning=ExperimentalWarning ./node_modules/vitest/vitest.mjs run", "typecheck": "tsc --noEmit", - "typecheck:all": "pnpm -r typecheck", "test:unit": "node --disable-warning=ExperimentalWarning ./node_modules/vitest/vitest.mjs run --config vitest.config.ts tests/unit", "test:integration": "node --disable-warning=ExperimentalWarning ./node_modules/vitest/vitest.mjs run --config vitest.config.ts tests/integration", "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:e2e", - "verify": "pnpm typecheck && pnpm lint && pnpm typecheck:all && pnpm test:unit && pnpm test:integration && pnpm test:e2e", + "verify": "pnpm typecheck && pnpm lint && pnpm test:unit && pnpm test:integration && pnpm test:e2e", "verify:release": "pnpm verify && pnpm build:dir", "postinstall": "node scripts/postinstall.mjs" }, diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml deleted file mode 100644 index 3334c0e4..00000000 --- a/pnpm-workspace.yaml +++ /dev/null @@ -1 +0,0 @@ -packages: [] diff --git a/scripts/release-local.mjs b/scripts/release-local.mjs index cf887536..ea7d4c73 100644 --- a/scripts/release-local.mjs +++ b/scripts/release-local.mjs @@ -160,6 +160,30 @@ function getDockerBuildCommands(platform) { ]; } +// Paths under rootDir that the container needs. Credentials, env files, docs, +// screenshots, pi-mono-reference, and other host-only state are deliberately +// excluded to keep secrets out of the Wine/Linux builder image. +const containerSourceMounts = [ + { host: "src", mode: "ro" }, + { host: "scripts", mode: "ro" }, + { host: "assets", mode: "ro" }, + { host: "builtin-skills", mode: "ro" }, + { host: "electron-builder.yml", mode: "ro" }, + { host: "package.json", mode: "ro" }, + { host: "pnpm-lock.yaml", mode: "ro" }, + { host: "electron.vite.config.ts", mode: "ro" }, + { host: "tsconfig.json", mode: "ro" }, + { host: "tsconfig.base.json", mode: "ro" }, + { host: "tsconfig.check.json", mode: "ro" }, + { host: "eslint.config.mjs", mode: "ro" }, +]; + +// Writable host paths — electron-vite writes to out/, electron-builder writes to build/. +const containerOutputMounts = [ + { host: "out" }, + { host: "build" }, +]; + async function runDockerBuild(platform) { const isWindows = platform === "win"; const image = isWindows ? "electronuserland/builder:wine" : "electronuserland/builder"; @@ -172,16 +196,28 @@ async function runDockerBuild(platform) { "pnpm exec electron-vite build", ...getDockerBuildCommands(platform), ].join(" && "); + // Ensure host-side output dirs exist before binding (docker would otherwise create + // them as root-owned on Linux hosts). + for (const mount of containerOutputMounts) { + await mkdir(join(rootDir, mount.host), { recursive: true }); + } const dockerArgs = [ "run", "--rm", "--platform", "linux/amd64", - "-v", `${rootDir}:/project`, + ]; + for (const mount of containerSourceMounts) { + dockerArgs.push("-v", `${join(rootDir, mount.host)}:/project/${mount.host}:${mount.mode}`); + } + for (const mount of containerOutputMounts) { + dockerArgs.push("-v", `${join(rootDir, mount.host)}:/project/${mount.host}`); + } + dockerArgs.push( "-v", `${dockerNodeModulesVolume}:/project/node_modules`, "-v", `${join(cacheDir, "electron")}:/root/.cache/electron`, "-v", `${join(cacheDir, "electron-builder")}:/root/.cache/electron-builder`, "-w", "/project", - ]; + ); for (const [key, value] of Object.entries(env)) { dockerArgs.push("-e", `${key}=${value}`); } diff --git a/src/main/agent-runtime/approval-hook.ts b/src/main/agent-runtime/approval-hook.ts deleted file mode 100644 index a68e518e..00000000 --- a/src/main/agent-runtime/approval-hook.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { AppDatabase } from "@main/database"; -import type { DesktopEvent } from "@shared/types"; - -interface BeforeRequestPayload { - agentId: string; - companyId: string; - taskId: string | null; - toolCallId: string; - toolName: string; - args: unknown; -} - -interface AfterRequestPayload { - toolCallId: string; - toolName: string; - isError: boolean; -} - -export interface HumanApprovalHookDeps { - db: AppDatabase; - emitEvent: (event: DesktopEvent) => void; - logger: { - info: (...args: unknown[]) => void; - warn: (...args: unknown[]) => void; - error: (...args: unknown[]) => void; - }; -} - -/** - * pi `beforeToolCall` / `afterToolCall` bridge. - * - * **Execution-mode contract.** Both AI CEO and AI Co-Founder modes run the - * pi Agent fully autonomously — the framework layer never gates a tool - * call with a synchronous human-approval promise. Under AI Co-Founder, the - * model itself is instructed (via the system prompt + a dedicated - * `request_approval` tool) to pause when it judges human review necessary. - * Blocking every `bash` / `edit` / `write` at the Electron layer would - * defeat the pi-mono runtime and starve the agent of progress. - * - * `before` therefore always approves. `after` is a no-op audit point; the - * agent-event stream persists the real tool record via SqliteSessionStore. - * `cancelAll` remains for shutdown symmetry but has no queued callers. - */ -export class HumanApprovalHook { - constructor(private readonly _deps: HumanApprovalHookDeps) { - void this._deps; - } - - async before(_runId: string, _payload: BeforeRequestPayload): Promise<{ block?: boolean; reason?: string }> { - return {}; - } - - async after(_runId: string, _payload: AfterRequestPayload): Promise { - /* no-op */ - } - - /** - * Retained so `RuntimeBridge.release()` can call it during app shutdown - * without knowing whether any approvals were actually queued. The - * autonomous hook never queues anything, so this is always a no-op. - */ - cancelAll(_reason: string): void { - /* no-op */ - } -} diff --git a/src/main/agent-runtime/cost-writer.ts b/src/main/agent-runtime/cost-writer.ts index 4acd9747..2b9fc0b4 100644 --- a/src/main/agent-runtime/cost-writer.ts +++ b/src/main/agent-runtime/cost-writer.ts @@ -5,7 +5,6 @@ import { resolveModel } from "@main/model-resolver"; export interface CostWriterDeps { db: AppDatabase; - logger: { warn: (...args: unknown[]) => void }; } interface ObserveInput { @@ -26,21 +25,24 @@ export class CostWriter { constructor(private readonly deps: CostWriterDeps) {} observeAssistantMessage(input: ObserveInput): void { - let amountUsd: number | null = null; - try { - const definition = getConnectorDefinition(input.connectorId); - if (definition.provider) { - const model = resolveModel(definition.provider, input.modelId); - if (model) { - const cost = calculateCost(model, input.usage); - amountUsd = Number.isFinite(cost.total) ? cost.total : null; - } - } - } catch (error) { - this.deps.logger.warn( - `[cost-writer] cost calculation failed for ${input.connectorId}/${input.modelId}: ${error instanceof Error ? error.message : String(error)}`, + // Fail closed on cost calculation — budget policies read `cost_entries` + // to enforce spending gates; an "unattributed" row would silently let + // an agent run unbounded. If pricing cannot be resolved, refuse to + // persist the row and let the caller see the error. The failure is + // visible (throws) instead of invisible (null amount, unattributed=1). + const definition = getConnectorDefinition(input.connectorId); + if (!definition.provider) { + throw new Error(`[cost-writer] connector ${input.connectorId} has no provider mapping`); + } + const model = resolveModel(definition.provider, input.modelId); + if (!model) { + throw new Error(`[cost-writer] model ${input.modelId} not found for provider ${definition.provider}`); + } + const cost = calculateCost(model, input.usage); + if (!Number.isFinite(cost.total)) { + throw new Error( + `[cost-writer] cost calculation returned non-finite total for ${input.connectorId}/${input.modelId}`, ); - amountUsd = null; } this.deps.db.queryAll( @@ -51,8 +53,8 @@ export class CostWriter { input.taskId, input.agentId, input.connectorId, - amountUsd, - amountUsd === null ? 1 : 0, + cost.total, + 0, new Date().toISOString(), ); } diff --git a/src/main/agent-runtime/index.ts b/src/main/agent-runtime/index.ts index bce9d9d3..667a9095 100644 --- a/src/main/agent-runtime/index.ts +++ b/src/main/agent-runtime/index.ts @@ -1,4 +1,3 @@ export { SqliteSessionStore } from "./sqlite-session-store"; -export { HumanApprovalHook } from "./approval-hook"; export { CostWriter } from "./cost-writer"; export { RuntimeBridge } from "./runtime-bridge"; diff --git a/src/main/agent-runtime/runtime-bridge.ts b/src/main/agent-runtime/runtime-bridge.ts index b62bec73..d218c9c0 100644 --- a/src/main/agent-runtime/runtime-bridge.ts +++ b/src/main/agent-runtime/runtime-bridge.ts @@ -3,7 +3,6 @@ import type { AppDatabase } from "@main/database"; import type { DesktopEvent } from "@shared/types"; import { ensureTaskThread } from "@main/thread-integration/thread-helpers"; import { SqliteSessionStore } from "./sqlite-session-store"; -import { HumanApprovalHook } from "./approval-hook"; import { CostWriter } from "./cost-writer"; import type { CredentialResolver } from "@main/credentials"; import { getConnectorDefinition } from "@main/connectors"; @@ -132,35 +131,33 @@ interface ActiveRunContext { * - Forwards `agent-event` from the worker to the renderer (push channel). * - Persists each `message_end` message via `SqliteSessionStore`. * - Calls `CostWriter` for assistant messages with usage. - * - Translates `beforeToolCall` RPC into an approval row + waits. - * - Translates `afterToolCall` RPC into best-effort audit hooks. + * + * Tool-call gating runs fully in the model layer (AI CEO and AI Co-Founder + * both autonomous); no `beforeToolCall` / `afterToolCall` IPC hop exists. */ export class RuntimeBridge { - readonly approvalHook: HumanApprovalHook; private readonly costWriter: CostWriter; private readonly activeRuns = new Map(); private readonly sessionStores = new Map(); constructor(private readonly deps: RuntimeBridgeDeps) { - this.approvalHook = new HumanApprovalHook({ - db: deps.db, - emitEvent: deps.emitEvent, - logger: deps.logger, - }); - this.costWriter = new CostWriter({ db: deps.db, logger: deps.logger }); + this.costWriter = new CostWriter({ db: deps.db }); } - registerActiveRun(context: ActiveRunContext): void { + private registerActiveRun(context: ActiveRunContext): void { this.activeRuns.set(context.runId, context); this.getOrCreateStore(context.taskSessionId); } - releaseActiveRun(runId: string): void { + /** + * Evicts the per-run `activeRuns` context and the per-session store cache. + * Callers must invoke after a `run-finished` worker message so the maps + * stay bounded by concurrent-run count rather than process lifetime. + */ + releaseRunResources(runId: string): void { + const ctx = this.activeRuns.get(runId); this.activeRuns.delete(runId); - } - - loadPriorMessages(taskSessionId: string): AgentMessage[] { - return this.getOrCreateStore(taskSessionId).loadMessages(); + if (ctx) this.sessionStores.delete(ctx.taskSessionId); } private getOrCreateStore(taskSessionId: string): SqliteSessionStore { @@ -206,15 +203,26 @@ export class RuntimeBridge { | { role?: string; usage?: unknown; content?: unknown; toolCallId?: string } | undefined; if (ctx && messageRaw?.role === "assistant" && messageRaw.usage) { - this.costWriter.observeAssistantMessage({ - runId: ctx.runId, - companyId: ctx.companyId, - agentId: ctx.agentId, - taskId: ctx.taskId, - connectorId: ctx.connectorId, - modelId: ctx.modelId, - usage: messageRaw.usage as never, - }); + try { + this.costWriter.observeAssistantMessage({ + runId: ctx.runId, + companyId: ctx.companyId, + agentId: ctx.agentId, + taskId: ctx.taskId, + connectorId: ctx.connectorId, + modelId: ctx.modelId, + usage: messageRaw.usage as never, + }); + } catch (error) { + // Cost writer fails closed (RUNTIME-105) — surface the failure + // loudly so operators can see a pricing catalog break, but let + // the run continue so the agent's work is not lost. The missing + // row means budget enforcement under-counts this message; a + // repeated failure in the same run is still visible in logs. + this.deps.logger.error( + `[runtime-bridge] cost recording failed for run ${ctx.runId}: ${error instanceof Error ? error.message : String(error)}`, + ); + } } this.recordThreadEvent(envelope, messageRaw); } @@ -265,30 +273,11 @@ export class RuntimeBridge { /** Handles RPC requests issued by the worker (`rpc-request` envelope). */ async handleRpc(request: { rpcId: string; - method: "beforeToolCall" | "afterToolCall" | "persistMessage" | "prepareRun"; + method: "persistMessage" | "prepareRun"; runId: string; payload: unknown; }): Promise<{ ok: true; result: unknown } | { ok: false; error: string }> { try { - if (request.method === "beforeToolCall") { - const ctx = this.activeRuns.get(request.runId); - if (!ctx) return { ok: true, result: {} }; - const payload = request.payload as { - agentId: string; - companyId: string; - taskId: string | null; - toolCallId: string; - toolName: string; - args: unknown; - }; - const verdict = await this.approvalHook.before(request.runId, payload); - return { ok: true, result: verdict }; - } - if (request.method === "afterToolCall") { - const payload = request.payload as { toolCallId: string; toolName: string; isError: boolean }; - await this.approvalHook.after(request.runId, payload); - return { ok: true, result: undefined }; - } if (request.method === "persistMessage") { const payload = request.payload as { taskSessionId: string; message: AgentMessage }; this.getOrCreateStore(payload.taskSessionId).appendMessage(payload.message); diff --git a/src/main/agent-runtime/sqlite-session-store.ts b/src/main/agent-runtime/sqlite-session-store.ts index d6acbde2..0202daea 100644 --- a/src/main/agent-runtime/sqlite-session-store.ts +++ b/src/main/agent-runtime/sqlite-session-store.ts @@ -19,12 +19,7 @@ export class SqliteSessionStore { const result: AgentMessage[] = []; for (const row of rows) { if (row.kind === "message") { - try { - result.push(JSON.parse(row.payloadJson) as AgentMessage); - } catch { - // Skip malformed entries (defensive) — preserves the rest of the - // transcript instead of failing the entire load. - } + result.push(JSON.parse(row.payloadJson) as AgentMessage); } } return result; diff --git a/src/main/api-server/routes-agent-control.ts b/src/main/api-server/routes-agent-control.ts index 8b7630d4..324b6a1e 100644 --- a/src/main/api-server/routes-agent-control.ts +++ b/src/main/api-server/routes-agent-control.ts @@ -1,3 +1,4 @@ +import log from "electron-log/main.js"; import { z } from "zod"; import { canControlAgent } from "../agent-permissions"; import { requireParam, type AgentApiServer } from "./server-core"; @@ -82,7 +83,12 @@ export function registerAgentControlRoutes(server: AgentApiServer) { server.addRoute("GET", "/api/agents/:agentId/skills", (_req, res, params, token) => { const agentId = requireParam(params, "agentId"); let agent; - try { agent = db.getAgent(agentId); } catch { agent = null; } + try { + agent = db.getAgent(agentId); + } catch (error) { + log.warn(`[api/agent-skills:GET] getAgent(${agentId}) failed: ${error instanceof Error ? error.message : String(error)}`); + agent = null; + } if (!agent || agent.companyId !== token.companyId) { server.json(res, 404, { error: "Agent not found in your company" }); return; @@ -109,7 +115,12 @@ export function registerAgentControlRoutes(server: AgentApiServer) { return; } let agent; - try { agent = db.getAgent(agentId); } catch { agent = null; } + try { + agent = db.getAgent(agentId); + } catch (error) { + log.warn(`[api/agent-skills:POST] getAgent(${agentId}) failed: ${error instanceof Error ? error.message : String(error)}`); + agent = null; + } if (!agent || agent.companyId !== token.companyId) { server.json(res, 404, { error: "Agent not found in your company" }); return; @@ -128,7 +139,12 @@ export function registerAgentControlRoutes(server: AgentApiServer) { const agentId = requireParam(params, "agentId"); const skillId = requireParam(params, "skillId"); let agent; - try { agent = db.getAgent(agentId); } catch { agent = null; } + try { + agent = db.getAgent(agentId); + } catch (error) { + log.warn(`[api/agent-skills:DELETE] getAgent(${agentId}) failed: ${error instanceof Error ? error.message : String(error)}`); + 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 56ee9c78..9866e8ed 100644 --- a/src/main/api-server/routes-agents.ts +++ b/src/main/api-server/routes-agents.ts @@ -166,7 +166,16 @@ export function registerAgentRoutes(server: AgentApiServer) { } const directReports = db.getDirectReports(agent.id, token.companyId); const reportsTo = agent.reportsTo; - const manager = reportsTo ? (() => { try { return db.getAgent(reportsTo); } catch { return null; } })() : null; + const manager = reportsTo + ? (() => { + try { + return db.getAgent(reportsTo); + } catch (error) { + log.warn(`[api/agents] manager lookup failed for ${reportsTo}: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + })() + : null; server.json(res, 200, { ...agent, manager: manager ? { id: manager.id, name: manager.name, role: manager.role } : null, @@ -285,7 +294,11 @@ export function registerAgentRoutes(server: AgentApiServer) { server.json(res, 409, { error: `A hire request for "${input.name}" is already pending approval.` }); return; } - } catch { /* invalid payloadJson, skip */ } + } catch (error) { + log.warn( + `[api/agents] invalid pending-hire payloadJson for company ${companyId}: ${error instanceof Error ? error.message : String(error)}`, + ); + } } const managerLabel = resolvedReportsTo diff --git a/src/main/api-server/routes-files.ts b/src/main/api-server/routes-files.ts index 0dac2f7f..3a9f8809 100644 --- a/src/main/api-server/routes-files.ts +++ b/src/main/api-server/routes-files.ts @@ -1,3 +1,4 @@ +import log from "electron-log/main.js"; import { z } from "zod"; import { IMPORTANT_DOC_TYPE_SET } from "@shared/document-categories"; import { requireParam, type AgentApiServer } from "./server-core"; @@ -109,7 +110,11 @@ export function registerFileRoutes(server: AgentApiServer) { if (run.companyId === token.companyId) { taskId = run.taskId; } - } catch { /* run may have finished or been deleted */ } + } catch (error) { + log.warn( + `[api/documents] getRun(${token.runId}) failed while deriving taskId: ${error instanceof Error ? error.message : String(error)}`, + ); + } } // Fire document creation automation rules callbacks.onDocumentCreated(token.companyId, token.agentId, projectId, id, docTitle, docType, taskId); @@ -143,11 +148,19 @@ export function registerFileRoutes(server: AgentApiServer) { if (agent.heartbeatEnabled) { db.setHeartbeat(token.agentId, false, agent.heartbeatIntervalSec || 120); } - } catch { /* best effort */ } + } catch (error) { + log.warn( + `[api/documents] heartbeat pause for agent ${token.agentId} failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } onDomainChanged(); } } - } catch { /* deliverable review gate is best-effort */ } + } catch (error) { + log.warn( + `[api/documents] deliverable review gate failed for company ${token.companyId}, document ${id}: ${error instanceof Error ? error.message : String(error)}`, + ); + } } server.json(res, 201, { id }); @@ -258,7 +271,10 @@ export function registerFileRoutes(server: AgentApiServer) { if (task.companyId !== token.companyId) { server.json(res, 404, { error: "Task not found" }); return; } const products = db.listWorkProductsForTask(task.id, task.companyId); server.json(res, 200, products); - } catch { server.json(res, 404, { error: "Task not found" }); } + } catch (error) { + log.warn(`[api/work-products] list failed for issue ${params.issueId}: ${error instanceof Error ? error.message : String(error)}`); + server.json(res, 404, { error: "Task not found" }); + } }); server.addRoute("POST", "/api/issues/:issueId/work-products", (_req, res, params, token, body) => { @@ -286,7 +302,10 @@ export function registerFileRoutes(server: AgentApiServer) { }); onDomainChanged(); server.json(res, 201, { id }); - } catch { server.json(res, 404, { error: "Task not found" }); } + } catch (error) { + log.warn(`[api/work-products] create failed for issue ${params.issueId}: ${error instanceof Error ? error.message : String(error)}`); + server.json(res, 404, { error: "Task not found" }); + } }); server.addRoute("PATCH", "/api/work-products/:workProductId", (_req, res, params, token, body) => { @@ -317,7 +336,10 @@ export function registerFileRoutes(server: AgentApiServer) { if (task.companyId !== token.companyId) { server.json(res, 404, { error: "Task not found" }); return; } const links = db.listDocumentsForTask(task.id, task.companyId); server.json(res, 200, links); - } catch { server.json(res, 404, { error: "Task not found" }); } + } catch (error) { + log.warn(`[api/task-documents] list failed for issue ${params.issueId}: ${error instanceof Error ? error.message : String(error)}`); + server.json(res, 404, { error: "Task not found" }); + } }); server.addRoute("PUT", "/api/issues/:issueId/documents/:key", (_req, res, params, token, body) => { @@ -337,7 +359,10 @@ export function registerFileRoutes(server: AgentApiServer) { const linkId = db.linkDocumentToTask(task.id, documentId, task.companyId, key); onDomainChanged(); server.json(res, 200, { id: linkId, key }); - } catch { server.json(res, 404, { error: "Task not found" }); } + } catch (error) { + log.warn(`[api/task-documents] link failed for issue ${params.issueId}: ${error instanceof Error ? error.message : String(error)}`); + server.json(res, 404, { error: "Task not found" }); + } }); server.addRoute("DELETE", "/api/issues/:issueId/documents/:key", (_req, res, params, token) => { @@ -348,6 +373,9 @@ export function registerFileRoutes(server: AgentApiServer) { db.unlinkDocumentFromTask(task.id, key, task.companyId); onDomainChanged(); server.json(res, 200, { unlinked: true }); - } catch { server.json(res, 404, { error: "Task not found" }); } + } catch (error) { + log.warn(`[api/task-documents] unlink failed for issue ${params.issueId}: ${error instanceof Error ? error.message : String(error)}`); + server.json(res, 404, { error: "Task not found" }); + } }); } diff --git a/src/main/api-server/routes-tasks.ts b/src/main/api-server/routes-tasks.ts index 64739ee0..8658c9d2 100644 --- a/src/main/api-server/routes-tasks.ts +++ b/src/main/api-server/routes-tasks.ts @@ -1,9 +1,9 @@ +import log from "electron-log/main.js"; import { z } from "zod"; import { taskInputSchema } from "@shared/contracts"; import { IMPORTANT_DOC_TYPES } from "@shared/document-categories"; import { canAssignTask, canUpdateTask } from "../agent-permissions"; import { inferTaskType } from "../connector-matching"; -import { isHeartbeatTaskTitle } from "../orchestration/heartbeat-task"; import { validateTaskCreation } from "../task-quality-gates"; import { requireParam, type AgentApiServer } from "./server-core"; @@ -221,7 +221,11 @@ export function registerTaskRoutes(server: AgentApiServer) { if (parentTask.assigneeAgentId && parentTask.assigneeAgentId !== token.agentId) { onWakeAgent(parentTask.assigneeAgentId, task.companyId, "subtask_completed"); } - } catch { /* parent task may have been deleted */ } + } catch (error) { + log.warn( + `[api/tasks] parent task lookup failed for ${task.parentId}: ${error instanceof Error ? error.message : String(error)}`, + ); + } } else { const chain = db.getChainOfCommand(token.agentId); if (chain.length > 0) { @@ -304,8 +308,9 @@ export function registerTaskRoutes(server: AgentApiServer) { server.json(res, 403, { error: "Current agent is not allowed to assign this task to the selected agent" }); return; } - const isHeartbeat = isHeartbeatTaskTitle(input.title); - const taskValidation = validateTaskCreation({ title: input.title, description: input.description ?? "" }, isHeartbeat); + // Agent-authored tasks are never heartbeat tasks; the is_heartbeat flag + // is set only by acquireHeartbeatTask in the orchestrator. + const taskValidation = validateTaskCreation({ title: input.title, description: input.description ?? "" }, false); if (!taskValidation.valid) { server.json(res, 422, { error: taskValidation.errors.join("; ") }); return; diff --git a/src/main/app-lifecycle-service.ts b/src/main/app-lifecycle-service.ts index c91729dd..08b9f0a2 100644 --- a/src/main/app-lifecycle-service.ts +++ b/src/main/app-lifecycle-service.ts @@ -4,6 +4,13 @@ interface AppLifecycleDependencies { getMainWindow: () => Electron.BrowserWindow | null; ensureWindow: () => Promise; shouldQuit: () => boolean; + /** + * Invoked on the Electron `before-quit` event. The lifecycle service will + * call `event.preventDefault()` on the first invocation so the owner of + * this hook can perform an awaited drain and then invoke `app.exit()` + * explicitly when the drain resolves. Subsequent `before-quit` events are + * ignored because the drain is already in flight. + */ onBeforeQuit: () => void; } @@ -52,7 +59,13 @@ export function createAppLifecycleService({ app.quit(); }); - app.on("before-quit", () => { + let drainStarted = false; + app.on("before-quit", (event) => { + if (drainStarted) return; + drainStarted = true; + // Block Electron's default quit until the owner's drain resolves. The + // owner must call app.exit() itself when drain is complete. + event.preventDefault(); onBeforeQuit(); }); } diff --git a/src/main/automation-service.ts b/src/main/automation-service.ts index 131aa8e1..80cdd9bd 100644 --- a/src/main/automation-service.ts +++ b/src/main/automation-service.ts @@ -1,2 +1,2 @@ -export { createAutomationService, autoAssignSkillsToAgent } from "./automation/index"; +export { createAutomationService } from "./automation/index"; export type { AutomationEventContext } from "./automation/index"; diff --git a/src/main/automation/automation-action-schemas.ts b/src/main/automation/automation-action-schemas.ts index cb9ebeb1..94c66e2a 100644 --- a/src/main/automation/automation-action-schemas.ts +++ b/src/main/automation/automation-action-schemas.ts @@ -10,6 +10,7 @@ */ import { z } from "zod"; +import { approvalTypeEnum } from "@shared/types"; const messageChannelSchema = z.enum(["direct", "department", "company", "project", "incident"]); const messagePrioritySchema = z.enum(["urgent", "normal", "low"]); @@ -41,7 +42,7 @@ export const triggerHeartbeatConfigSchema = z.object({ }); export const createApprovalConfigSchema = z.object({ - approvalType: z.string().optional(), + approvalType: approvalTypeEnum.optional(), summary: z.string().optional(), impact: z.string().optional(), }); diff --git a/src/main/automation/automation-actions.ts b/src/main/automation/automation-actions.ts index 68d347f4..14fbf681 100644 --- a/src/main/automation/automation-actions.ts +++ b/src/main/automation/automation-actions.ts @@ -3,7 +3,6 @@ import type { AutomationAction, DesktopEvent } from "@shared/types"; import { dispatchAgentMessage } from "../message-dispatch"; import { validateTaskCreation } from "../task-quality-gates"; import type { AutomationEventContext } from "./types"; -import { isHeartbeatTaskTitle } from "../orchestration/heartbeat-task"; import { parseActionConfig, type AutomationActionConfigMap } from "./automation-action-schemas"; // Map trigger type → default wake trigger when automation fires a heartbeat. @@ -116,8 +115,9 @@ export function executeAutomationAction( const description = cfg.description ?? ""; const priority = cfg.priority ?? "medium"; const assigneeAgentId = cfg.assigneeAgentId ?? null; - const isHeartbeat = isHeartbeatTaskTitle(title); - const taskValidation = validateTaskCreation({ title, description }, isHeartbeat); + // Automation rules create user-facing tasks; heartbeat tasks are + // synthesized only by acquireHeartbeatTask, never via create_task. + const taskValidation = validateTaskCreation({ title, description }, false); if (!taskValidation.valid) { logger.warn(`[automation] Skipping create_task — validation failed: ${taskValidation.errors.join("; ")}`); break; @@ -231,7 +231,15 @@ export function executeAutomationAction( // Keep the fallback title when the task no longer exists. } // Heartbeat tasks are internal system tasks — skip escalation messages to avoid flooding channels. - if (isHeartbeatTaskTitle(taskTitle)) { + let isHeartbeatTask = false; + if (ctx.taskId) { + try { + isHeartbeatTask = db.getTask(ctx.taskId).isHeartbeat === true; + } catch { + isHeartbeatTask = false; + } + } + if (isHeartbeatTask) { logger.info(`[automation] Skipping escalation for heartbeat task "${taskTitle}"`); break; } diff --git a/src/main/automation/automation-approval-handler.ts b/src/main/automation/automation-approval-handler.ts index e9341e77..23e7a70d 100644 --- a/src/main/automation/automation-approval-handler.ts +++ b/src/main/automation/automation-approval-handler.ts @@ -3,7 +3,6 @@ import type { AppDatabase } from "../database"; import type { NotificationOptions } from "../notification-service"; import { extractApprovalDocumentIds } from "@shared/approval-documents"; import { notifyHireApproved } from "../hire-hook"; -import { autoAssignSkillsToAgent } from "./automation-skills"; import type { AutomationEventContext } from "./types"; import { normalizeWorkflowSteps } from "./automation-workflow"; @@ -95,140 +94,110 @@ export function handleApprovalResolved( }); if (approval.type === "hire_agent" && decision === "approved" && approval.relatedAgentId) { - try { - const agent = db.getAgent(approval.relatedAgentId); + const agent = db.getAgent(approval.relatedAgentId); - if (agent.status === "idle") { - db.addActivity({ - companyId, - actor: "system", - action: "agent.hired", - entityType: "agent", - entityId: agent.id, - detail: `${agent.name} hire approved and activated.`, - }); + if (agent.status === "idle") { + db.addActivity({ + companyId, + actor: "system", + action: "agent.hired", + entityType: "agent", + entityId: agent.id, + detail: `${agent.name} hire approved and activated.`, + }); - const skillsAssigned = autoAssignSkillsToAgent(db, logger, companyId, agent.id); - if (skillsAssigned > 0) { - db.addActivity({ - companyId, - actor: "system", - action: "skills.auto_assigned", - entityType: "agent", - entityId: agent.id, - detail: `Auto-assigned ${skillsAssigned} skill(s) to ${agent.name} based on role/department matching.`, - }); - } + dispatchAutomation("hire_approved", { + companyId, + agentId: agent.id, + }); + wakeAgentIfPossible(agent.id, companyId, "assignment"); - dispatchAutomation("hire_approved", { - companyId, - agentId: agent.id, - }); - wakeAgentIfPossible(agent.id, companyId, "assignment"); - - const ceo = db.getCeoForCompany(companyId); - if (ceo && ceo.id !== agent.id) { - wakeAgentIfPossible(ceo.id, companyId, "assignment"); - } - - publishDomainChanged(); - const hireLocale = db.listSnapshot().locale; - notify({ - title: localizeNotification(hireLocale, "Agent Hired", "新员工入职"), - body: localizeNotification(hireLocale, `${agent.name} joined as ${agent.role}`, `${agent.name} 已加入,职位:${agent.role}`), - urgency: "informational", - navigation: { section: "orgchart", entityId: agent.id }, - batchKey: "agent_hired", - }); - - // Notify adapter that hire was approved (Paperclip hire-hook parity) - void notifyHireApproved(db, { - companyId, - agentId: agent.id, - source: "approval", - sourceId: approvalId, - approvedAt: new Date().toISOString(), - }).catch(() => {}); + const ceo = db.getCeoForCompany(companyId); + if (ceo && ceo.id !== agent.id) { + wakeAgentIfPossible(ceo.id, companyId, "assignment"); } - } catch { - // Hire follow-up is best-effort; approval state is already committed in storage. + + publishDomainChanged(); + const hireLocale = db.listSnapshot().locale; + notify({ + title: localizeNotification(hireLocale, "Agent Hired", "新员工入职"), + body: localizeNotification(hireLocale, `${agent.name} joined as ${agent.role}`, `${agent.name} 已加入,职位:${agent.role}`), + urgency: "informational", + navigation: { section: "orgchart", entityId: agent.id }, + batchKey: "agent_hired", + }); + + // Notify adapter that hire was approved (Paperclip hire-hook parity) + void notifyHireApproved(db, { + companyId, + agentId: agent.id, + source: "approval", + sourceId: approvalId, + approvedAt: new Date().toISOString(), + }).catch(() => {}); } } if (approval.type === "deliverable_review") { - try { - // Update linked document statuses from payloadJson - const documentIds = extractApprovalDocumentIds(approval.payloadJson); - const docStatus = decision === "approved" - ? "approved" - : decision === "rejected" || decision === "revision_requested" - ? "draft" - : "in_review"; - for (const docId of documentIds) { - db.updateDocumentStatus(docId, companyId, docStatus); - } - - if (approval.relatedTaskId) { - const task = db.getTask(approval.relatedTaskId); - if (task.assigneeAgentId) { - try { - const agent = db.getAgent(task.assigneeAgentId); - if (!agent.heartbeatEnabled) { - db.setHeartbeat(task.assigneeAgentId, true, agent.heartbeatIntervalSec || 120); - logger.info(`[deliverable-review] Resumed heartbeat for ${agent.name} after user ${decision}.`); - } - } catch { /* agent lookup non-fatal */ } - } - if (decision === "approved") { - db.advanceTaskStatus(task.id, "done", "Board approved deliverable."); - handleTaskStatusChange(task.id, companyId, task.status, "done"); - db.addActivity({ companyId, actor: "board", action: "deliverable.approved", entityType: "task", entityId: task.id, detail: approval.decisionNote || "Deliverable approved by board." }); - } else if (decision === "rejected") { - recordReviewFeedbackKnowledge(db, approval, decision); - db.advanceTaskStatus(task.id, "in_progress", `Board rejected deliverable: ${approval.decisionNote || "No reason given."}`); - handleTaskStatusChange(task.id, companyId, task.status, "in_progress"); - if (task.assigneeAgentId) { - wakeAgentIfPossible(task.assigneeAgentId, companyId, "revision_needed"); - } - } else if (decision === "revision_requested") { - db.advanceTaskStatus(task.id, "in_progress", `Board requested revisions: ${approval.decisionNote || "No reason given."}`); - handleTaskStatusChange(task.id, companyId, task.status, "in_progress"); - recordReviewFeedbackKnowledge(db, approval, decision); - if (task.assigneeAgentId) { - wakeAgentIfPossible(task.assigneeAgentId, companyId, "revision_needed"); - } - } - } else if (approval.requestedByAgentId) { - // Document-only review (no task) — resume the producing agent - try { - const agent = db.getAgent(approval.requestedByAgentId); - if (!agent.heartbeatEnabled) { - db.setHeartbeat(approval.requestedByAgentId, true, agent.heartbeatIntervalSec || 120); - logger.info(`[deliverable-review] Resumed heartbeat for ${agent.name} after user ${decision}.`); - } - } catch { /* agent lookup non-fatal */ } - if (decision !== "approved") { - wakeAgentIfPossible(approval.requestedByAgentId, companyId, "revision_needed"); - } - } - - publishDomainChanged(); - } catch { - // Deliverable review follow-up is non-fatal — approval state is already committed. + const documentIds = extractApprovalDocumentIds(approval.payloadJson); + const docStatus = decision === "approved" + ? "approved" + : decision === "rejected" || decision === "revision_requested" + ? "draft" + : "in_review"; + for (const docId of documentIds) { + db.updateDocumentStatus(docId, companyId, docStatus); } + + if (approval.relatedTaskId) { + const task = db.getTask(approval.relatedTaskId); + if (task.assigneeAgentId) { + const agent = db.getAgent(task.assigneeAgentId); + if (!agent.heartbeatEnabled) { + db.setHeartbeat(task.assigneeAgentId, true, agent.heartbeatIntervalSec || 120); + logger.info(`[deliverable-review] Resumed heartbeat for ${agent.name} after user ${decision}.`); + } + } + if (decision === "approved") { + db.advanceTaskStatus(task.id, "done", "Board approved deliverable."); + handleTaskStatusChange(task.id, companyId, task.status, "done"); + db.addActivity({ companyId, actor: "board", action: "deliverable.approved", entityType: "task", entityId: task.id, detail: approval.decisionNote || "Deliverable approved by board." }); + } else if (decision === "rejected") { + recordReviewFeedbackKnowledge(db, approval, decision); + db.advanceTaskStatus(task.id, "in_progress", `Board rejected deliverable: ${approval.decisionNote || "No reason given."}`); + handleTaskStatusChange(task.id, companyId, task.status, "in_progress"); + if (task.assigneeAgentId) { + wakeAgentIfPossible(task.assigneeAgentId, companyId, "revision_needed"); + } + } else if (decision === "revision_requested") { + db.advanceTaskStatus(task.id, "in_progress", `Board requested revisions: ${approval.decisionNote || "No reason given."}`); + handleTaskStatusChange(task.id, companyId, task.status, "in_progress"); + recordReviewFeedbackKnowledge(db, approval, decision); + if (task.assigneeAgentId) { + wakeAgentIfPossible(task.assigneeAgentId, companyId, "revision_needed"); + } + } + } else if (approval.requestedByAgentId) { + const agent = db.getAgent(approval.requestedByAgentId); + if (!agent.heartbeatEnabled) { + db.setHeartbeat(approval.requestedByAgentId, true, agent.heartbeatIntervalSec || 120); + logger.info(`[deliverable-review] Resumed heartbeat for ${agent.name} after user ${decision}.`); + } + if (decision !== "approved") { + wakeAgentIfPossible(approval.requestedByAgentId, companyId, "revision_needed"); + } + } + + publishDomainChanged(); } if (approval.type === "draft_review" || approval.type === "strategy_review") { - try { - const documentIds = extractApprovalDocumentIds(approval.payloadJson); - const docStatus = decision === "approved" ? "approved" : "draft"; - for (const docId of documentIds) { - db.updateDocumentStatus(docId, companyId, docStatus); - } - publishDomainChanged(); - } catch { - // Document checkpoint follow-up is non-fatal — approval state is already committed. + const documentIds = extractApprovalDocumentIds(approval.payloadJson); + const docStatus = decision === "approved" ? "approved" : "draft"; + for (const docId of documentIds) { + db.updateDocumentStatus(docId, companyId, docStatus); } + publishDomainChanged(); } // Handle budget override approvals diff --git a/src/main/automation/automation-run-completion.ts b/src/main/automation/automation-run-completion.ts index 8198fda0..af38c9ba 100644 --- a/src/main/automation/automation-run-completion.ts +++ b/src/main/automation/automation-run-completion.ts @@ -3,7 +3,6 @@ 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"; -import { isHeartbeatTaskTitle } from "../orchestration/heartbeat-task"; function localizeNotification(locale: string | null | undefined, en: string, zh: string): string { return locale === "zh" ? zh : en; @@ -225,11 +224,11 @@ export function processRunCompletion( let effectiveStatus = task.status; if (isSuccess && effectiveStatus === "in_progress") { - // Check if non-code task produced a deliverable before auto-advancing + // Delegate the task-type → deliverable-required decision to the pure + // helper so production and tests share a single source of truth. const taskType = task.taskType ?? "general"; - const needsDeliverable = taskType === "research" || taskType === "content"; - if (needsDeliverable) { + if (needsDeliverableCheck(taskType)) { const hasDeliverable = checkRunProducedDeliverable(db, run.id, run.companyId, task.id); if (!hasDeliverable) { // Don't auto-advance -- flag for manager review @@ -243,7 +242,11 @@ export function processRunCompletion( if (chain.length > 0) { wakeAgentIfPossible(chain[0].id, run.companyId, "review_needed"); } - } catch { /* best effort */ } + } catch (error) { + deps.logger.warn( + `[automation] deliverable-review manager wake failed for agent ${run.agentId}: ${error instanceof Error ? error.message : String(error)}`, + ); + } publishDomainChanged(); // Skip the normal in_review advance } else { @@ -257,7 +260,7 @@ export function processRunCompletion( } } - if (isSuccess && isHeartbeatTaskTitle(task.title) && effectiveStatus !== "done") { + if (isSuccess && task.isHeartbeat && effectiveStatus !== "done") { db.advanceTaskStatus(task.id, "done", "Heartbeat check completed."); handleTaskStatusChange(task.id, run.companyId, effectiveStatus, "done"); } diff --git a/src/main/automation/automation-skills.ts b/src/main/automation/automation-skills.ts deleted file mode 100644 index c4297c0b..00000000 --- a/src/main/automation/automation-skills.ts +++ /dev/null @@ -1,127 +0,0 @@ -import type { AppDatabase } from "../database"; -import { roleMatchesLeadershipKeywords } from "../agent-role-helpers"; -import type { DepartmentId } from "@shared/types/enums"; - -const DEPARTMENT_SKILL_KEYWORDS: Record = { - engineering: ["code", "engineer", "develop", "program", "software", "api", "backend", "frontend", "full-stack", "fullstack", "devops", "infrastructure", "system", "architect", "debug", "test", "ci", "cd", "deploy", "docker", "kubernetes", "database", "sql", "git", "typescript", "javascript", "python", "rust", "go", "java", "react", "node", "css", "html", "web"], - product: ["product", "feature", "requirement", "roadmap", "user story", "ux", "ui", "design", "prototype", "wireframe", "specification", "prd"], - marketing: ["market", "content", "seo", "social media", "brand", "campaign", "analytics", "growth", "copywrite", "blog", "newsletter", "email"], - sales: ["sales", "crm", "lead", "pipeline", "deal", "revenue", "prospect", "outbound", "cold"], - hr: ["hr", "human resource", "hiring", "recruit", "onboard", "performance", "culture", "talent", "people"], - finance: ["finance", "budget", "accounting", "cost", "revenue", "profit", "spend", "invoice", "tax", "payroll"], - design: ["design", "ui", "ux", "visual", "figma", "sketch", "prototype", "illustration", "typography", "layout", "color", "icon"], - research: ["research", "analysis", "data", "study", "survey", "experiment", "insight", "benchmark", "report"], - operations: ["operations", "process", "workflow", "efficiency", "supply chain", "logistics", "coordination"], - customer_support: ["support", "customer", "ticket", "helpdesk", "troubleshoot", "faq", "documentation"], - legal: ["legal", "compliance", "contract", "regulation", "policy", "privacy", "ip", "intellectual property"], - executive: ["strategy", "leadership", "management", "executive", "ceo", "cto", "coo", "cfo", "cmo", "vp"], -}; - -function scoreSkillAgentMatch( - skill: { name: string; description: string | null; markdown: string }, - agent: { role: string; department: string | null; title: string | null; capabilities: string }, -): number { - const agentText = [ - agent.role, - agent.department ?? "", - agent.title ?? "", - agent.capabilities, - ].join(" ").toLowerCase(); - - const skillText = [ - skill.name, - skill.description ?? "", - skill.markdown.slice(0, 500), - ].join(" ").toLowerCase(); - - let score = 0; - - const dept = (agent.department ?? "") as DepartmentId; - const deptKeywords = DEPARTMENT_SKILL_KEYWORDS[dept] ?? []; - for (const keyword of deptKeywords) { - if (skillText.includes(keyword)) { - score += 2; - } - } - - const agentWords = agentText - .replace(/[^a-z0-9\s-]/g, " ") - .split(/\s+/) - .filter((w) => w.length >= 3); - for (const word of agentWords) { - if (skillText.includes(word)) { - score += 1; - } - } - - // Executives need awareness of all domains. Use the shared leadership - // keyword list with word-boundary matching so "lead" does not match - // "cleaner" / "leaderboard" in agent role strings. - if (roleMatchesLeadershipKeywords(agent.role, agent.title)) { - score += 1; - } - - return score; -} - -export function autoAssignSkillsToAgent( - db: AppDatabase, - logger: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void }, - companyId: string, - agentId: string, -): number { - const agent = db.getAgent(agentId); - if (!agent || agent.companyId !== companyId) return 0; - - const allSkills = db.listCompanySkills(companyId); - if (allSkills.length === 0) return 0; - - const existingAssignments = new Set(db.listAgentSkillAssignments(agentId)); - - // Always assign basic-for-all builtin skills regardless of score - let assigned = 0; - for (const skill of allSkills) { - if (existingAssignments.has(skill.id)) continue; - try { - const meta = skill.metadata ? (typeof skill.metadata === "string" ? JSON.parse(skill.metadata) : skill.metadata) : {}; - if (meta.builtinCategory === "basic-for-all") { - db.assignSkillToAgent(agentId, skill.id); - existingAssignments.add(skill.id); - assigned++; - logger.info(`[skill-autoassign] Assigned basic skill "${skill.name}" to ${agent.name}`); - } - } catch { /* skip malformed metadata */ } - } - - const scored = allSkills - .filter((skill) => !existingAssignments.has(skill.id)) - .map((skill) => ({ - skill, - score: scoreSkillAgentMatch(skill, agent), - })) - .sort((a, b) => b.score - a.score); - - const MIN_SCORE = 3; - for (const { skill, score } of scored) { - if (score < MIN_SCORE) continue; - try { - db.assignSkillToAgent(agentId, skill.id); - assigned++; - logger.info( - `[skill-autoassign] Assigned "${skill.name}" to ${agent.name} (${agent.role}) — score ${score}`, - ); - } catch (err) { - logger.warn( - `[skill-autoassign] Failed to assign "${skill.name}" to ${agent.name}: ${err instanceof Error ? err.message : String(err)}`, - ); - } - } - - if (assigned === 0) { - logger.info( - `[skill-autoassign] No matching skills for ${agent.name} (${agent.role}); agent will run without skills until manually assigned`, - ); - } - - return assigned; -} diff --git a/src/main/automation/index.ts b/src/main/automation/index.ts index b7e11e77..50be40e8 100644 --- a/src/main/automation/index.ts +++ b/src/main/automation/index.ts @@ -13,7 +13,6 @@ import { handleTaskStatusChange, handleTaskCreated } from "./automation-handlers import type { HandlerDeps } from "./automation-handlers"; export type { AutomationEventContext } from "./types"; -export { autoAssignSkillsToAgent } from "./automation-skills"; export function createAutomationService({ db, diff --git a/src/main/bootstrap/prepare-services.ts b/src/main/bootstrap/prepare-services.ts index fb47f536..3058bfef 100644 --- a/src/main/bootstrap/prepare-services.ts +++ b/src/main/bootstrap/prepare-services.ts @@ -106,6 +106,14 @@ export function prepareServices(deps: PrepareServicesDeps): ServiceGraphHandles onRunCompleted: (runId, taskId, agentId, companyId, status, summary, errorMessage) => runHousekeeping.onRunCompleted(runId, taskId, agentId, companyId, status, summary, errorMessage), checkpointEngine, + // Lazy forwarder — ipcSupport is constructed below but only invoked + // inside serviceGraph.init(), which runs after that point. + registerHandle: (channel, handler) => { + if (!ipcSupport) { + throw new Error("IPC support is not initialized."); + } + ipcSupport.registerHandle(channel, handler); + }, }); ipcSupport = createIpcSupport({ isDev: deps.isDev, diff --git a/src/main/bootstrap/recover-scheduled-posts.ts b/src/main/bootstrap/recover-scheduled-posts.ts index a37c0640..e193d7ef 100644 --- a/src/main/bootstrap/recover-scheduled-posts.ts +++ b/src/main/bootstrap/recover-scheduled-posts.ts @@ -35,7 +35,11 @@ export function recoverScheduledPosts(deps: RecoverScheduledPostsDeps): Set { diff --git a/src/main/browser/browser-action-executor.ts b/src/main/browser/browser-action-executor.ts index 5f8f1bb8..8363c182 100644 --- a/src/main/browser/browser-action-executor.ts +++ b/src/main/browser/browser-action-executor.ts @@ -313,7 +313,11 @@ export async function runAction( try { const image: NativeImage = await win.webContents.capturePage(); if (!image.isEmpty()) writeFileSync(screenshotPath, image.toPNG()); - } catch { /* ignore */ } + } catch (screenshotError) { + log.warn( + `[browser] failure screenshot capture failed for action ${action.id}: ${screenshotError instanceof Error ? screenshotError.message : String(screenshotError)}`, + ); + } if (!win.isDestroyed()) win.close(); diff --git a/src/main/checkpoint-engine.ts b/src/main/checkpoint-engine.ts index 22400b42..c1af49dc 100644 --- a/src/main/checkpoint-engine.ts +++ b/src/main/checkpoint-engine.ts @@ -44,10 +44,7 @@ export function createCheckpointEngine(deps: CheckpointDeps) { if (mode !== "ai_cofounder") return null; const task = db.getTask(taskId); - const taskType = db.queryAll<{ taskType: string }>( - "SELECT task_type as taskType FROM tasks WHERE id = ? LIMIT 1", - taskId, - )[0]?.taskType; + const taskType = db.getTaskType(taskId); // Only create plan checkpoints for non-code task types if (!taskType || !CHECKPOINT_TASK_TYPES.has(taskType)) return null; @@ -136,16 +133,7 @@ export function createCheckpointEngine(deps: CheckpointDeps) { } // Check for pending approvals related to this agent's tasks - const pendingApprovals = db.queryAll<{ id: string }>( - `SELECT a.id FROM approvals a - JOIN tasks t ON a.related_task_id = t.id - WHERE a.company_id = ? AND a.state = 'pending' - AND t.assignee_agent_id = ? - AND a.type IN ('plan_review','scope_review','draft_review','strategy_review','deliverable_review','exception_review') - LIMIT 1`, - companyId, agentId, - ); - return pendingApprovals.length > 0; + return db.hasPendingReviewApprovalForAgent(companyId, agentId); } catch (err) { logger.error(`[checkpoint] shouldPauseHeartbeat failed: ${err instanceof Error ? err.message : String(err)}`); return false; diff --git a/src/main/connector-definitions-data-oauth.ts b/src/main/connector-definitions-data-oauth.ts index 8a48aced..8dbf5d6b 100644 --- a/src/main/connector-definitions-data-oauth.ts +++ b/src/main/connector-definitions-data-oauth.ts @@ -7,11 +7,10 @@ * token in the `credentials` table, and passes it as the API key when * invoking pi-ai. * - * The `oauthId` surfaced here is the AgentCompany internal id that the - * catalog test pins (`anthropic` / `openai-codex` / `github-copilot` / - * `gemini-cli` / `antigravity`). It is mapped to the pi-ai provider id - * when resolving the actual OAuth flow — e.g. `gemini-cli` → - * `google-gemini-cli`, `antigravity` → `google-antigravity`. + * The `oauthId` surfaced here matches pi-ai's five registered provider ids + * verbatim (`anthropic` / `openai-codex` / `github-copilot` / + * `google-gemini-cli` / `google-antigravity`) and is passed straight to + * `getOAuthProvider`. */ import type { ConnectorDefinition } from "./connector-definitions"; @@ -58,7 +57,7 @@ export const OAUTH_CONNECTORS: ConnectorDefinition[] = [ description: "Google Gemini models via Cloud Code Assist OAuth. Free tier works with any Google account (rate-limited).", defaultModel: "gemini-2.5-pro", - auth: { type: "oauth", oauthId: "gemini-cli" }, + auth: { type: "oauth", oauthId: "google-gemini-cli" }, notes: "For paid Cloud Code Assist, set GOOGLE_CLOUD_PROJECT. Google-gemini-cli OAuth provider id in pi-ai.", }, @@ -69,7 +68,7 @@ export const OAUTH_CONNECTORS: ConnectorDefinition[] = [ description: "Google Antigravity sandbox with Gemini 3, Claude, and GPT-OSS models. Free with any Google account.", defaultModel: "claude-sonnet-4-5", - auth: { type: "oauth", oauthId: "antigravity" }, + auth: { type: "oauth", oauthId: "google-antigravity" }, notes: "Maps to pi-ai OAuth provider id `google-antigravity`.", }, ]; diff --git a/src/main/connector-definitions.ts b/src/main/connector-definitions.ts index 12c8da54..224bceb2 100644 --- a/src/main/connector-definitions.ts +++ b/src/main/connector-definitions.ts @@ -30,16 +30,14 @@ import { CLOUD_CONNECTORS } from "./connector-definitions-data-cloud"; /** * OAuth provider id recognised by the AgentCompany credential layer. These - * map 1:1 to pi-ai's five built-in OAuth handlers, with the Google handlers - * shortened from `google-gemini-cli` / `google-antigravity` to `gemini-cli` / - * `antigravity` in the UI. + * match pi-ai's five built-in OAuth handler ids verbatim. */ export type PiOAuthProviderId = | "anthropic" | "openai-codex" | "github-copilot" - | "gemini-cli" - | "antigravity"; + | "google-gemini-cli" + | "google-antigravity"; export type ConnectorAuthKind = "oauth" | "api_key" | "aws" | "azure" | "gcp_adc"; diff --git a/src/main/credentials/credential-resolver.ts b/src/main/credentials/credential-resolver.ts index d30e8ca3..383ffbf5 100644 --- a/src/main/credentials/credential-resolver.ts +++ b/src/main/credentials/credential-resolver.ts @@ -57,6 +57,17 @@ export class CredentialResolver { if (stored.value.source === "literal") { return { apiKey: stored.value.value }; } + // Only the connector's own declared envKey may be read from process.env. + // Arbitrary env var names would let a malicious payload exfil secrets + // like CSC_KEY_PASSWORD or AWS_SECRET_ACCESS_KEY by routing them to a + // provider as the "api key". + if (stored.value.value !== auth.envKey) { + throw new CredentialError( + "CREDENTIAL_INVALID", + connectorId, + `Env var ${stored.value.value} is not permitted for connector ${connectorId}.`, + ); + } const fromEnv = process.env[stored.value.value]; if (!fromEnv) throw new CredentialError("CREDENTIAL_MISSING", connectorId, `Env var ${stored.value.value} is not set.`); return { apiKey: fromEnv }; diff --git a/src/main/database-base.ts b/src/main/database-base.ts index 5ec788d7..141aefca 100644 --- a/src/main/database-base.ts +++ b/src/main/database-base.ts @@ -69,7 +69,7 @@ export class AppDatabaseBase { public readonly sessionEntries: ReturnType; public readonly credentialsTable: ReturnType; - constructor(rootDir: string) { + constructor(rootDir: string, options: { isPackaged?: boolean } = {}) { this.profileDir = rootDir; this.dbPath = join(rootDir, "profile.sqlite"); this.logsDir = join(rootDir, "logs"); @@ -79,7 +79,7 @@ export class AppDatabaseBase { mkdirSync(this.logsDir, { recursive: true }); mkdirSync(this.artifactsDir, { recursive: true }); mkdirSync(this.backupsDir, { recursive: true }); - this.vault = new SecretVault(rootDir); + this.vault = new SecretVault(rootDir, { isPackaged: options.isPackaged }); this.db = this.openConnection(); const getDb = () => this.db; @@ -241,6 +241,12 @@ export class AppDatabaseBase { getHeartbeatPolicy(agentId: string) { return this.agents.getHeartbeatPolicy(agentId); } setHeartbeatPolicy(agentId: string, companyId: string, policy: { enabled?: boolean; intervalSec?: number; cooldownSec?: number; maxConcurrentRuns?: number; wakeOnDemand?: boolean }) { return this.agents.setHeartbeatPolicy(agentId, companyId, policy); } countActiveRunsForAgent(agentId: string, companyId: string) { return this.agents.countActiveRunsForAgent(agentId, companyId); } + listDepartmentAgentIds(companyId: string, department: string) { return this.agents.listDepartmentAgentIds(companyId, department); } + listTopLevelAgentIds(companyId: string) { return this.agents.listTopLevelAgentIds(companyId); } + listIncidentPingableAgentIds(companyId: string) { return this.agents.listIncidentPingableAgentIds(companyId); } + findAgentName(agentId: string) { return this.agents.findAgentName(agentId); } + listAgentNamesByIds(companyId: string, agentIds: readonly string[]) { return this.agents.listAgentNamesByIds(companyId, agentIds); } + listActiveAgentIdsFromIds(companyId: string, agentIds: readonly string[]) { return this.agents.listActiveAgentIdsFromIds(companyId, agentIds); } // ─── Tasks ───────────────────────────────────────────────────────────────── @@ -258,8 +264,8 @@ export class AppDatabaseBase { checkoutTask(taskId: string, runId: string) { return this.tasks.checkoutTask(taskId, runId); } releaseTaskLock(taskId: string, runId: string) { return this.tasks.releaseTaskLock(taskId, runId); } renewTaskLease(taskId: string, runId: string) { return this.tasks.renewTaskLease(taskId, runId); } - listAgentTasks(agentId: string, companyId: string, statuses: string[]) { return this.tasks.listAgentTasks(agentId, companyId, statuses); } - listTasksByStatuses(companyId: string, statuses: string[]) { return this.tasks.listTasksByStatuses(companyId, statuses); } + listAgentTasks(agentId: string, companyId: string, statuses: string[], options?: { includeHeartbeat?: boolean }) { return this.tasks.listAgentTasks(agentId, companyId, statuses, options); } + listTasksByStatuses(companyId: string, statuses: string[], options?: { includeHeartbeat?: boolean }) { return this.tasks.listTasksByStatuses(companyId, statuses, options); } listTasksForStandup(companyId: string) { return this.tasks.listTasksForStandup(companyId); } listAssigneeTasksForStandup(companyId: string, agentId: string) { return this.tasks.listAssigneeTasksForStandup(companyId, agentId); } listTasksForAssignees(companyId: string, assigneeIds: string[]) { return this.tasks.listTasksForAssignees(companyId, assigneeIds); } @@ -282,6 +288,9 @@ export class AppDatabaseBase { dismissInterruptedTask(taskId: string) { return this.tasks.dismissInterruptedTask(taskId); } markInterruptedTaskFailed(taskId: string) { return this.tasks.markInterruptedTaskFailed(taskId); } setTaskType(taskId: string, taskType: string) { return this.tasks.setTaskType(taskId, taskType); } + listProjectTaskAssigneeIds(projectId: string, companyId: string) { return this.tasks.listProjectTaskAssigneeIds(projectId, companyId); } + listReviewTasksForSweep(companyId: string) { return this.tasks.listReviewTasksForSweep(companyId); } + getTaskType(taskId: string) { return this.tasks.getTaskType(taskId); } // ─── Runs ────────────────────────────────────────────────────────────────── @@ -322,6 +331,8 @@ export class AppDatabaseBase { listAgentResolvedApprovals(companyId: string, agentId: string, limit?: number) { return this.approvalsMod.listAgentResolvedApprovals(companyId, agentId, limit); } listPendingHireApprovalPayloads(companyId: string) { return this.approvalsMod.listPendingHireApprovalPayloads(companyId); } listApprovalsForApi(companyId: string, filters: { requestedByAgentId?: string; relatedTaskId?: string; statuses?: string[] | null }) { return this.approvalsMod.listApprovalsForApi(companyId, filters); } + findPendingApprovalForTask(companyId: string, taskId: string) { return this.approvalsMod.findPendingApprovalForTask(companyId, taskId); } + hasPendingReviewApprovalForAgent(companyId: string, agentId: string) { return this.approvalsMod.hasPendingReviewApprovalForAgent(companyId, agentId); } // ─── Workspaces ──────────────────────────────────────────────────────────── diff --git a/src/main/database.ts b/src/main/database.ts index 7131d5bb..a28e1b2f 100644 --- a/src/main/database.ts +++ b/src/main/database.ts @@ -170,7 +170,13 @@ export class AppDatabase extends AppDatabaseBase { listSubscriptions(threadId: string) { return this.threads.listSubscriptions(threadId); } listSubscribedThreads(subscriberId: string, companyId: string) { return this.threads.listSubscribedThreads(subscriberId, companyId); } - // ─── Public query ────────────────────────────────────────────────────────── + // ─── Internal raw-SQL escape hatch ──────────────────────────────────────── + // + // Not part of the public API. New callers MUST add a typed method on the + // appropriate `database/.ts` and route through there. The existing + // call sites remain only to avoid a repo-wide migration; see + // `.planning/review2/02-persistence-ipc.md` DB-001 for the migration list. + /** @internal */ queryAll(sql: string, ...params: SQLInputValue[]): T[] { return this.readAll(sql, ...params); } } diff --git a/src/main/database/agents-hierarchy.ts b/src/main/database/agents-hierarchy.ts index 0d01cb92..0e2ce7ab 100644 --- a/src/main/database/agents-hierarchy.ts +++ b/src/main/database/agents-hierarchy.ts @@ -408,5 +408,80 @@ export function buildAgentHierarchyMethods( ) .run(merged.enabled ? 1 : 0, merged.intervalSec, policyJson, ts, agentId, companyId); }, + + /** + * Returns ids of non-terminated agents in a department. Used by + * `resolveMessageWakeTargets` to fan out "department" channel messages. + */ + listDepartmentAgentIds(companyId: string, department: string): string[] { + return readAll<{ id: string }>( + "select id from agents where company_id = ? and department = ? and status != 'terminated'", + companyId, + department, + ).map((row) => row.id); + }, + + /** + * Returns ids of non-terminated agents that have no manager (top of the + * reporting tree). Used by `resolveMessageWakeTargets` for "company" + * channel broadcast. + */ + listTopLevelAgentIds(companyId: string): string[] { + return readAll<{ id: string }>( + "select id from agents where company_id = ? and reports_to is null and status != 'terminated'", + companyId, + ).map((row) => row.id); + }, + + /** + * Returns ids of agents that can receive incident pings: everybody not + * terminated or still pending approval. + */ + listIncidentPingableAgentIds(companyId: string): string[] { + return readAll<{ id: string }>( + "select id from agents where company_id = ? and status not in ('terminated', 'pending_approval')", + companyId, + ).map((row) => row.id); + }, + + /** + * Returns the agent's display name, or null if the agent no longer + * exists. Used for notification subject lines. + */ + findAgentName(agentId: string): string | null { + const row = getDb() + .prepare("select name from agents where id = ? limit 1") + .get(agentId) as { name: string } | undefined; + return row?.name ?? null; + }, + + /** + * Returns (id, name) pairs for the given ids, scoped to company. Used + * for batched name resolution when fanning out message wake targets. + */ + listAgentNamesByIds(companyId: string, agentIds: readonly string[]): Array<{ id: string; name: string }> { + if (agentIds.length === 0) return []; + const placeholders = agentIds.map(() => "?").join(","); + return readAll<{ id: string; name: string }>( + `select id, name from agents where id in (${placeholders}) and company_id = ?`, + ...agentIds, + companyId, + ); + }, + + /** + * Returns ids of active agents with the given ids, filtered to the + * company scope. Used by project-channel wake targeting to drop + * terminated members before pinging. + */ + listActiveAgentIdsFromIds(companyId: string, agentIds: readonly string[]): string[] { + if (agentIds.length === 0) return []; + const placeholders = agentIds.map(() => "?").join(","); + return readAll<{ id: string }>( + `select id from agents where id in (${placeholders}) and company_id = ? and status != 'terminated'`, + ...agentIds, + companyId, + ).map((row) => row.id); + }, }; } diff --git a/src/main/database/approvals.ts b/src/main/database/approvals.ts index 5074cf50..e203eae2 100644 --- a/src/main/database/approvals.ts +++ b/src/main/database/approvals.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import type { DatabaseSync, SQLInputValue } from "node:sqlite"; +import log from "electron-log/main.js"; import type { ApprovalRecord } from "@shared/types"; import type { ReadAll, Transaction } from "./helpers"; import { normalizeOptionalId, nowIso, parseJson } from "./helpers"; @@ -54,7 +55,11 @@ export function buildApprovalMethods( try { const payload = JSON.parse(input.payloadJson) as { agentId?: string }; relatedAgentId = payload.agentId ?? null; - } catch { /* ignore malformed payload */ } + } catch (error) { + log.warn( + `[db/approvals] malformed payloadJson on hire_agent request for company ${input.companyId}: ${error instanceof Error ? error.message : String(error)}`, + ); + } } if (input.type === "hire_agent") { if (!relatedAgentId) { @@ -346,5 +351,40 @@ export function buildApprovalMethods( } }); }, + + /** + * Returns the first pending approval linked to a task (id + type), + * or null when none exists. Used by task-quality-gates to decide + * whether an in-review task still has a blocking approval. + */ + findPendingApprovalForTask(companyId: string, taskId: string): { id: string; type: string } | null { + const row = getDb() + .prepare( + `SELECT id, type FROM approvals + WHERE company_id = ? AND related_task_id = ? AND state = 'pending' + LIMIT 1`, + ) + .get(companyId, taskId) as { id: string; type: string } | undefined; + return row ?? null; + }, + + /** + * Returns true when the given agent has a pending review-type approval + * against one of their tasks. Used by checkpoint-engine to decide + * whether heartbeat should pause in co-founder mode. + */ + hasPendingReviewApprovalForAgent(companyId: string, agentId: string): boolean { + const row = getDb() + .prepare( + `SELECT 1 as hit FROM approvals a + JOIN tasks t ON a.related_task_id = t.id + WHERE a.company_id = ? AND a.state = 'pending' + AND t.assignee_agent_id = ? + AND a.type IN ('plan_review','scope_review','draft_review','strategy_review','deliverable_review','exception_review') + LIMIT 1`, + ) + .get(companyId, agentId) as { hit: number } | undefined; + return row !== undefined; + }, }; } diff --git a/src/main/database/backup.ts b/src/main/database/backup.ts index 9f215f82..fe22986b 100644 --- a/src/main/database/backup.ts +++ b/src/main/database/backup.ts @@ -32,8 +32,6 @@ export function buildBackupMethods( backupDatabase(deps.getDb(), join(targetDir, "profile.sqlite")), cp(deps.logsDir, join(targetDir, "logs"), { recursive: true, force: true }), cp(deps.artifactsDir, join(targetDir, "artifacts"), { recursive: true, force: true }), - copyIfExists(join(deps.profileDir, "vault.key"), join(targetDir, "vault.key")), - copyIfExists(join(deps.profileDir, "agent-api-signing.key"), join(targetDir, "agent-api-signing.key")), copyIfExists(`${deps.dbPath}-wal`, join(targetDir, "profile.sqlite-wal")), copyIfExists(`${deps.dbPath}-shm`, join(targetDir, "profile.sqlite-shm")), ]; diff --git a/src/main/database/entity-crud.ts b/src/main/database/entity-crud.ts index 46ec06e4..1c9f2bf2 100644 --- a/src/main/database/entity-crud.ts +++ b/src/main/database/entity-crud.ts @@ -49,11 +49,12 @@ 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, 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 = ?", + "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, is_heartbeat as isHeartbeat, started_at as startedAt, completed_at as completedAt, cancelled_at as cancelledAt, created_at as createdAt, updated_at as updatedAt from tasks where id = ?", ) .get(id) as TaskRecord | undefined; if (!row) throw new Error(`Task not found: ${id}`); row.requiresUserReview = Boolean(row.requiresUserReview); + row.isHeartbeat = Boolean(row.isHeartbeat); row.startedAt = row.startedAt ?? null; row.completedAt = row.completedAt ?? null; row.cancelledAt = row.cancelledAt ?? null; diff --git a/src/main/database/runs.ts b/src/main/database/runs.ts index 382bee02..7a7424ae 100644 --- a/src/main/database/runs.ts +++ b/src/main/database/runs.ts @@ -279,14 +279,15 @@ export function buildRunMethods( getInterruptedRunsForRecovery(): Array<{ runId: string; taskId: string; taskTitle: string; agentId: string; - agentName: string; connectorId: string; interruptedAt: string; + agentName: string; companyId: string; connectorId: string; interruptedAt: string; }> { return readAll<{ runId: string; taskId: string; taskTitle: string; agentId: string; - agentName: string; connectorId: string; interruptedAt: string; + agentName: string; companyId: string; connectorId: string; interruptedAt: string; }>( `select r.id as runId, r.task_id as taskId, t.title as taskTitle, r.agent_id as agentId, a.name as agentName, + r.company_id as companyId, r.connector_id as connectorId, r.finished_at as interruptedAt from runs r join tasks t on t.id = r.task_id diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index 56de77f0..134b3fb9 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -40,6 +40,7 @@ export function ensureOptionalColumns(db: DatabaseSync) { addColumnIfMissing("tasks", "checkout_run_id", "alter table tasks add column checkout_run_id text"); addColumnIfMissing("tasks", "execution_locked_at", "alter table tasks add column execution_locked_at text"); addColumnIfMissing("tasks", "active_session_id", "alter table tasks add column active_session_id text"); + addColumnIfMissing("tasks", "is_heartbeat", "alter table tasks add column is_heartbeat integer not null default 0"); addColumnIfMissing("cost_entries", "project_id", "alter table cost_entries add column project_id text"); db.exec(` create table if not exists task_comments (id text primary key, company_id text not null, task_id text not null, author_agent_id text, author_name text not null, body text not null, created_at text not null); @@ -455,6 +456,20 @@ export function runMigrations( } }, }, + { + // Explicit flag for synthesized heartbeat tasks. Replaces title-prefix + // sniffing (`title LIKE '[Heartbeat]%'`) across queries, filters, and + // automation gates. Backfills from existing `[Heartbeat]` titles so the + // new column agrees with historical data on first boot. + version: 33, name: "add_tasks_is_heartbeat", + fn: () => { + if (!columnExists(db, "tasks", "is_heartbeat")) { + db.prepare("ALTER TABLE tasks ADD COLUMN is_heartbeat INTEGER NOT NULL DEFAULT 0").run(); + } + db.prepare("UPDATE tasks SET is_heartbeat = 1 WHERE is_heartbeat = 0 AND title LIKE '[Heartbeat]%'").run(); + db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_is_heartbeat ON tasks(is_heartbeat)"); + }, + }, { // pi-mono session transcripts + credential vault. Idempotent; safe to // re-apply on fresh DBs or DBs that already have the tables. diff --git a/src/main/database/settings.ts b/src/main/database/settings.ts index 001d9d0b..a20e1ca6 100644 --- a/src/main/database/settings.ts +++ b/src/main/database/settings.ts @@ -1,6 +1,7 @@ import { existsSync, lstatSync, realpathSync, unlinkSync } from "node:fs"; import { extname, resolve, sep } from "node:path"; import type { DatabaseSync } from "node:sqlite"; +import log from "electron-log/main.js"; import type { ReadAll } from "./helpers"; import { nowIso } from "./helpers"; @@ -14,7 +15,10 @@ function isWithinOrEqual(targetPath: string, rootPath: string) { function safeRealpath(targetPath: string) { try { return realpathSync(targetPath); - } catch { + } catch (error) { + log.debug( + `[db/settings] realpathSync fallback for ${targetPath}: ${error instanceof Error ? error.message : String(error)}`, + ); return resolve(targetPath); } } @@ -113,7 +117,11 @@ export function buildSettingsMethods( if (run.logPath) { try { unlinkSync(run.logPath); - } catch { /* Log files may already be gone */ } + } catch (error) { + log.warn( + `[db/settings] purgeExpiredRunLogs: unlink failed for run ${run.id} (${run.logPath}): ${error instanceof Error ? error.message : String(error)}`, + ); + } } getDb() .prepare("update runs set log_path = null, updated_at = ? where id = ?") diff --git a/src/main/database/snapshot.ts b/src/main/database/snapshot.ts index 72907140..c6012985 100644 --- a/src/main/database/snapshot.ts +++ b/src/main/database/snapshot.ts @@ -75,9 +75,9 @@ 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, 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 limit 2000`, + `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, is_heartbeat as isHeartbeat, started_at as startedAt, completed_at as completedAt, cancelled_at as cancelledAt, created_at as createdAt, updated_at as updatedAt from tasks${companyWhere ? `${companyWhere} and is_heartbeat = 0` : " where is_heartbeat = 0"} order by created_at desc limit 2000`, ...companyArgs, - ).map((record) => ({ ...record, requiresUserReview: toBoolean(record.requiresUserReview), startedAt: record.startedAt ?? null, completedAt: record.completedAt ?? null, cancelledAt: record.cancelledAt ?? null })) as TaskRecord[], + ).map((record) => ({ ...record, requiresUserReview: toBoolean(record.requiresUserReview), isHeartbeat: toBoolean(record.isHeartbeat), startedAt: record.startedAt ?? null, completedAt: record.completedAt ?? null, cancelledAt: record.cancelledAt ?? null })) as TaskRecord[], approvals: readAll( `select id, company_id as companyId, related_task_id as relatedTaskId, related_agent_id as relatedAgentId, requested_by_agent_id as requestedByAgentId, type, payload_summary as payloadSummary, impact_summary as impactSummary, payload_json as payloadJson, decision_note as decisionNote, state, created_at as createdAt, updated_at as updatedAt from approvals${companyWhere} order by created_at desc limit 1000`, ...companyArgs, diff --git a/src/main/database/tasks-crud.ts b/src/main/database/tasks-crud.ts index 35ea3be4..7604bdaf 100644 --- a/src/main/database/tasks-crud.ts +++ b/src/main/database/tasks-crud.ts @@ -33,6 +33,7 @@ export type TaskListByStatusesRow = { sessionDisplayId: string | null; costUsd: number | null; requiresUserReview: boolean; + isHeartbeat: boolean; startedAt: string | null; completedAt: string | null; cancelledAt: string | null; @@ -168,12 +169,24 @@ export function buildTaskCrudMethods( }); }, - listAgentTasks(agentId: string, companyId: string, statuses: string[]): TaskRecord[] { + /** + * Returns actionable real work for an agent. Heartbeat tasks are + * excluded by default since they are synthesized wake-up placeholders; + * pass `{ includeHeartbeat: true }` to include them (heartbeat-loop + * callers that already filter by `is_heartbeat` do not need this). + */ + listAgentTasks( + agentId: string, + companyId: string, + statuses: string[], + options: { includeHeartbeat?: boolean } = {}, + ): TaskRecord[] { const placeholders = statuses.map(() => "?").join(","); + const heartbeatClause = options.includeHeartbeat ? "" : " and is_heartbeat = 0"; 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, task_type as taskType, 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, is_heartbeat as isHeartbeat, created_at as createdAt, updated_at as updatedAt from tasks - where company_id = ? and assignee_agent_id = ? and status in (${placeholders}) + where company_id = ? and assignee_agent_id = ? and status in (${placeholders})${heartbeatClause} order by case status when 'in_progress' then 0 @@ -187,20 +200,21 @@ export function buildTaskCrudMethods( companyId, agentId, ...statuses, - ); + ).map((t) => ({ ...t, isHeartbeat: Boolean(t.isHeartbeat) })); }, /** * Returns the slim task projection (id, title, status, priority) for a * single assignee, ordered by created_at desc. Used by the alternate - * me/standup builder in routes-agents-standup.ts. + * me/standup builder in routes-agents-standup.ts. Heartbeat tasks are + * excluded so UI aggregations do not surface synthetic wake tasks. */ listAssigneeTasksForStandup( companyId: string, agentId: string, ): Array<{ id: string; title: string; status: string; priority: string }> { return readAll( - "select id, title, status, priority from tasks where company_id = ? and assignee_agent_id = ? order by created_at desc", + "select id, title, status, priority from tasks where company_id = ? and assignee_agent_id = ? and is_heartbeat = 0 order by created_at desc", companyId, agentId, ); @@ -209,7 +223,8 @@ export function buildTaskCrudMethods( /** * Returns the slim task projection for a set of assignees. Safe on * an empty assignee list (returns []). Used by the alternate - * me/standup builder to fetch reports' tasks in one query. + * me/standup builder to fetch reports' tasks in one query. Heartbeat + * tasks are excluded. */ listTasksForAssignees( companyId: string, @@ -224,7 +239,7 @@ export function buildTaskCrudMethods( if (assigneeIds.length === 0) return []; const placeholders = assigneeIds.map(() => "?").join(","); return readAll( - `select id, title, status, priority, assignee_agent_id as assigneeAgentId from tasks where company_id = ? and assignee_agent_id in (${placeholders}) order by created_at desc`, + `select id, title, status, priority, assignee_agent_id as assigneeAgentId from tasks where company_id = ? and assignee_agent_id in (${placeholders}) and is_heartbeat = 0 order by created_at desc`, companyId, ...assigneeIds, ); @@ -232,7 +247,7 @@ export function buildTaskCrudMethods( /** * Returns the slim task projection used by the me/standup aggregation. - * Ordered by created_at desc. + * Ordered by created_at desc. Heartbeat tasks are excluded. */ listTasksForStandup(companyId: string): Array<{ id: string; @@ -243,23 +258,32 @@ export function buildTaskCrudMethods( priority: string; }> { return readAll( - "select id, company_id as companyId, title, assignee_agent_id as assigneeAgentId, status, priority from tasks where company_id = ? order by created_at desc", + "select id, company_id as companyId, title, assignee_agent_id as assigneeAgentId, status, priority from tasks where company_id = ? and is_heartbeat = 0 order by created_at desc", companyId, ); }, + /** + * Returns tasks filtered by status. Heartbeat tasks are excluded by + * default — they are synthesized wake placeholders and should not + * appear in the user-facing issue list. Pass `{ includeHeartbeat: true }` + * to include them. + */ listTasksByStatuses( companyId: string, statuses: string[], + options: { includeHeartbeat?: boolean } = {}, ): TaskListByStatusesRow[] { 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, 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`, + const heartbeatClause = options.includeHeartbeat ? "" : " and is_heartbeat = 0"; + 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, requires_user_review as requiresUserReview, is_heartbeat as isHeartbeat, 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})${heartbeatClause} order by created_at desc`, companyId, ...statuses, ).map((t) => ({ ...t, requiresUserReview: Boolean(t.requiresUserReview), + isHeartbeat: Boolean(t.isHeartbeat), startedAt: t.startedAt ?? null, completedAt: t.completedAt ?? null, cancelledAt: t.cancelledAt ?? null, @@ -269,7 +293,7 @@ export function buildTaskCrudMethods( countActiveTasksForCompany(companyId: string): number { const row = getDb() .prepare( - "select count(*) as cnt from tasks where company_id = ? and status not in ('done', 'cancelled') and title not like '[Heartbeat]%'", + "select count(*) as cnt from tasks where company_id = ? and status not in ('done', 'cancelled') and is_heartbeat = 0", ) .get(companyId) as { cnt: number }; return row.cnt; @@ -518,5 +542,50 @@ export function buildTaskCrudMethods( .run(ts, taskId); }, + /** + * Returns the set of distinct assignee agent ids on tasks in a project, + * excluding unassigned tasks. Used by message-dispatch to fan out wake + * targets for a project channel. + */ + listProjectTaskAssigneeIds(projectId: string, companyId: string): string[] { + const rows = readAll<{ assigneeAgentId: string | null }>( + "select distinct assignee_agent_id as assigneeAgentId from tasks where project_id = ? and company_id = ? and assignee_agent_id is not null", + projectId, + companyId, + ); + return rows.map((r) => r.assigneeAgentId).filter((id): id is string => id !== null); + }, + + /** + * Returns assignee agent ids for a set of task ids scoped to a company. + * Used by task-quality-gates sweep to find reviewers of in-review tasks + * that have exceeded the review timeout. + */ + listReviewTasksForSweep(companyId: string): Array<{ + id: string; + assigneeAgentId: string | null; + updatedAt: string; + title: string; + }> { + return readAll( + `SELECT id, assignee_agent_id as assigneeAgentId, updated_at as updatedAt, title + FROM tasks + WHERE company_id = ? AND status = 'in_review'`, + companyId, + ); + }, + + /** + * Returns `task_type` for a single task id, or null when the task is + * missing. Used by checkpoint-engine to branch on task type without + * loading the full row. + */ + getTaskType(taskId: string): string | null { + const row = getDb() + .prepare("SELECT task_type as taskType FROM tasks WHERE id = ? LIMIT 1") + .get(taskId) as { taskType: string | null } | undefined; + return row?.taskType ?? null; + }, + }; } diff --git a/src/main/database/tasks-status.ts b/src/main/database/tasks-status.ts index a2d7b19e..c1871c73 100644 --- a/src/main/database/tasks-status.ts +++ b/src/main/database/tasks-status.ts @@ -111,9 +111,10 @@ export function buildTaskStatusMethods( 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, + is_heartbeat as isHeartbeat, created_at as createdAt, updated_at as updatedAt FROM tasks - WHERE company_id = ? AND assignee_agent_id = ? AND title LIKE '[Heartbeat]%' + WHERE company_id = ? AND assignee_agent_id = ? AND is_heartbeat = 1 AND status IN ('done', 'in_review') ORDER BY updated_at DESC, rowid DESC LIMIT 1`, @@ -121,6 +122,7 @@ export function buildTaskStatusMethods( .get(companyId, agentId) as TaskRecord | undefined; if (row) { row.requiresUserReview = Boolean(row.requiresUserReview); + row.isHeartbeat = Boolean(row.isHeartbeat); } return row ?? null; }, @@ -141,7 +143,7 @@ export function buildTaskStatusMethods( const claimable = db .prepare( `SELECT id, status FROM tasks - WHERE company_id = ? AND assignee_agent_id = ? AND title LIKE '[Heartbeat]%' + WHERE company_id = ? AND assignee_agent_id = ? AND is_heartbeat = 1 AND status IN ('todo', 'backlog', 'done', 'in_review') AND checkout_run_id IS NULL ORDER BY @@ -172,7 +174,7 @@ export function buildTaskStatusMethods( const inflight = db .prepare( `SELECT 1 FROM tasks - WHERE company_id = ? AND assignee_agent_id = ? AND title LIKE '[Heartbeat]%' + WHERE company_id = ? AND assignee_agent_id = ? AND is_heartbeat = 1 AND status NOT IN ('done', 'cancelled') LIMIT 1`, ) @@ -184,9 +186,9 @@ export function buildTaskStatusMethods( `INSERT INTO tasks (id, company_id, project_id, goal_id, parent_id, title, description, assignee_agent_id, workspace_id, status, priority, - checkout_run_id, execution_locked_at, started_at, + checkout_run_id, execution_locked_at, started_at, is_heartbeat, created_at, updated_at) - VALUES (?, ?, NULL, NULL, NULL, ?, ?, ?, ?, 'in_progress', ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, NULL, NULL, NULL, ?, ?, ?, ?, 'in_progress', ?, ?, ?, ?, 1, ?, ?)`, ).run( taskId, input.companyId, @@ -211,7 +213,7 @@ export function buildTaskStatusMethods( 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, - task_type as taskType, + is_heartbeat as isHeartbeat, task_type as taskType, started_at as startedAt, completed_at as completedAt, cancelled_at as cancelledAt, created_at as createdAt, updated_at as updatedAt FROM tasks WHERE id = ?`, @@ -219,6 +221,7 @@ export function buildTaskStatusMethods( .get(taskId) as TaskRecord | undefined; if (!row) return null; row.requiresUserReview = Boolean(row.requiresUserReview); + row.isHeartbeat = Boolean(row.isHeartbeat); row.startedAt = row.startedAt ?? null; row.completedAt = row.completedAt ?? null; row.cancelledAt = row.cancelledAt ?? null; @@ -231,13 +234,13 @@ export function buildTaskStatusMethods( const result = getDb() .prepare( `UPDATE tasks SET status = 'cancelled', updated_at = ? - WHERE company_id = ? AND title LIKE '[Heartbeat]%' + WHERE company_id = ? AND is_heartbeat = 1 AND status IN ('done', 'in_review', 'todo', 'blocked', 'backlog') AND id NOT IN ( SELECT id FROM ( SELECT id, ROW_NUMBER() OVER (PARTITION BY assignee_agent_id ORDER BY updated_at DESC, rowid DESC) as rn FROM tasks - WHERE company_id = ? AND title LIKE '[Heartbeat]%' AND status != 'cancelled' + WHERE company_id = ? AND is_heartbeat = 1 AND status != 'cancelled' ) WHERE rn = 1 )`, ) @@ -252,10 +255,11 @@ export function buildTaskStatusMethods( t.assignee_agent_id as assigneeAgentId, t.workspace_id as workspaceId, t.status, t.priority, t.active_run_id as activeRunId, t.session_json as sessionJson, t.session_display_id as sessionDisplayId, - t.cost_usd as costUsd, t.created_at as createdAt, t.updated_at as updatedAt + t.cost_usd as costUsd, t.is_heartbeat as isHeartbeat, + t.created_at as createdAt, t.updated_at as updatedAt from tasks t inner join agents a on t.assignee_agent_id = a.id - where t.company_id = ? and t.status = 'in_review' and a.reports_to = ? + where t.company_id = ? and t.status = 'in_review' and a.reports_to = ? and t.is_heartbeat = 0 order by ${taskPriorityOrderSql("t.priority")} desc, t.created_at asc`, companyId, managerId, diff --git a/src/main/desktop-shell-menu.ts b/src/main/desktop-shell-menu.ts index 48d1ffdc..67c6622f 100644 --- a/src/main/desktop-shell-menu.ts +++ b/src/main/desktop-shell-menu.ts @@ -3,13 +3,15 @@ import { app, BrowserWindow, dialog, - ipcMain, Menu, nativeImage, shell, Tray, } from "electron"; +import type { IpcMainInvokeEvent } from "electron"; import { IPC_CHANNELS } from "@shared/contracts"; +import type { DesktopResult } from "@shared/contracts"; +import { ok } from "./ipc-core-common"; export interface DesktopShellMenuDependencies { iconPath: string; @@ -20,6 +22,10 @@ export interface DesktopShellMenuDependencies { getMainWindow: () => BrowserWindow | null; ensureWindow: () => Promise; requestQuitWithConfirmation: () => Promise; + registerHandle: ( + channel: string, + handler: (event: IpcMainInvokeEvent, ...args: unknown[]) => Promise> | DesktopResult, + ) => void; } export function createDesktopShellMenu(deps: DesktopShellMenuDependencies) { @@ -32,6 +38,7 @@ export function createDesktopShellMenu(deps: DesktopShellMenuDependencies) { getMainWindow, ensureWindow, requestQuitWithConfirmation, + registerHandle, } = deps; let appTray: Tray | null = null; @@ -231,18 +238,29 @@ export function createDesktopShellMenu(deps: DesktopShellMenuDependencies) { } function registerWindowControlIpc() { - // Window control IPC for custom title bar (Windows/Linux frameless windows) - ipcMain.handle(IPC_CHANNELS.windowMinimize, () => { getMainWindow()?.minimize(); }); - ipcMain.handle(IPC_CHANNELS.windowMaximize, () => { + // Window control IPC for custom title bar (Windows/Linux frameless windows). + // Routed through registerHandle so the trusted-sender check blocks + // embedded webContents from closing/minimizing the app. + registerHandle(IPC_CHANNELS.windowMinimize, () => { + getMainWindow()?.minimize(); + return ok(null); + }); + registerHandle(IPC_CHANNELS.windowMaximize, () => { const mainWindow = getMainWindow(); if (mainWindow?.isMaximized()) { mainWindow.unmaximize(); } else { mainWindow?.maximize(); } + return ok(null); + }); + registerHandle(IPC_CHANNELS.windowClose, () => { + getMainWindow()?.close(); + return ok(null); + }); + registerHandle(IPC_CHANNELS.windowIsMaximized, () => { + return ok(getMainWindow()?.isMaximized() ?? false); }); - ipcMain.handle(IPC_CHANNELS.windowClose, () => { getMainWindow()?.close(); }); - ipcMain.handle(IPC_CHANNELS.windowIsMaximized, () => getMainWindow()?.isMaximized() ?? false); } function destroy() { diff --git a/src/main/desktop-shell-service.ts b/src/main/desktop-shell-service.ts index 94665998..d492a62b 100644 --- a/src/main/desktop-shell-service.ts +++ b/src/main/desktop-shell-service.ts @@ -1,7 +1,11 @@ import { createDesktopShellWindow, type DesktopShellWindowDependencies } from "./desktop-shell-window"; -import { createDesktopShellMenu } from "./desktop-shell-menu"; +import { createDesktopShellMenu, type DesktopShellMenuDependencies } from "./desktop-shell-menu"; -export function createDesktopShellService(deps: DesktopShellWindowDependencies) { +export type DesktopShellServiceDependencies = DesktopShellWindowDependencies & { + registerHandle: DesktopShellMenuDependencies["registerHandle"]; +}; + +export function createDesktopShellService(deps: DesktopShellServiceDependencies) { const window = createDesktopShellWindow(deps); const menu = createDesktopShellMenu({ iconPath: deps.iconPath, @@ -12,6 +16,7 @@ export function createDesktopShellService(deps: DesktopShellWindowDependencies) getMainWindow: window.getMainWindow, ensureWindow: window.ensureWindow, requestQuitWithConfirmation: window.requestQuitWithConfirmation, + registerHandle: deps.registerHandle, }); function initialize() { diff --git a/src/main/hire-hook.ts b/src/main/hire-hook.ts index 378245f5..f0117698 100644 --- a/src/main/hire-hook.ts +++ b/src/main/hire-hook.ts @@ -84,6 +84,10 @@ export async function notifyHireApproved( entityId: input.agentId, detail: message, }); - } catch { /* activity logging itself failed — nothing more we can do */ } + } catch (activityError) { + log.error( + `[hire-hook] Activity logging also failed for agent ${input.agentId}: ${activityError instanceof Error ? activityError.message : String(activityError)}`, + ); + } } } diff --git a/src/main/ipc-core-common.ts b/src/main/ipc-core-common.ts index 131491f7..73113adb 100644 --- a/src/main/ipc-core-common.ts +++ b/src/main/ipc-core-common.ts @@ -34,6 +34,11 @@ export interface RegisterCoreHandlersDependencies { getApiPort: () => number; listConnectorModels: (connectorId: string) => Promise>; notify: (options: { title: string; body: string; urgency: "critical" | "informational"; navigation?: { section: SectionId; entityId?: string }; batchKey?: string }) => void; + logger: { + info: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + }; } export function ok(data: T): DesktopResult { diff --git a/src/main/ipc-core-operations-handlers.ts b/src/main/ipc-core-operations-handlers.ts index 4cdfe811..5604d53d 100644 --- a/src/main/ipc-core-operations-handlers.ts +++ b/src/main/ipc-core-operations-handlers.ts @@ -4,18 +4,13 @@ import { approvalInputSchema, cancelRunSchema, commentInputSchema, - getAgentPermissionsSchema, - getConfigRevisionSchema, heartbeatPolicySchema, heartbeatSettingsSchema, listCommentsSchema, - listConfigRevisionsSchema, listRunEventsSchema, - rollbackConfigRevisionSchema, runtimeStateSchema, runLogChunkSchema, runLogSchema, - setAgentPermissionSchema, setHeartbeatPolicySchema, setRuntimeStateSchema, startRunSchema, @@ -257,35 +252,6 @@ export function registerCoreOperationHandlers({ return ok(runId); }); - registerHandle(IPC_CHANNELS.listConfigRevisions, async (_event, payload) => { - const parsed = listConfigRevisionsSchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - if (!db.belongsToCompany("agents", parsed.agentId, parsed.companyId)) { - return fail("AGENT_NOT_FOUND", "Agent not found in the current company."); - } - return ok(db.listConfigRevisions(parsed.agentId, parsed.companyId)); - }); - - registerHandle(IPC_CHANNELS.getConfigRevision, async (_event, payload) => { - const parsed = getConfigRevisionSchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - return ok(db.getConfigRevision(parsed.revisionId, parsed.companyId)); - }); - - registerHandle(IPC_CHANNELS.rollbackConfigRevision, async (_event, payload) => { - const parsed = rollbackConfigRevisionSchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - if (!db.belongsToCompany("agents", parsed.agentId, parsed.companyId)) { - return fail("AGENT_NOT_FOUND", "Agent not found in the current company."); - } - const newRevisionId = db.rollbackConfigRevision(parsed.agentId, parsed.revisionId, parsed.companyId); - publishDomainChanged(); - return ok({ revisionId: newRevisionId }); - }); - registerHandle(IPC_CHANNELS.listRunEvents, async (_event, payload) => { const parsed = listRunEventsSchema.parse(payload); const companyError = ensureCompanyExists(parsed.companyId); @@ -296,23 +262,6 @@ export function registerCoreOperationHandlers({ return ok(db.listRunEvents(parsed.runId, parsed.companyId, { offset: parsed.offset, limit: parsed.limit })); }); - registerHandle(IPC_CHANNELS.getAgentPermissions, async (_event, payload) => { - const parsed = getAgentPermissionsSchema.parse(payload); - return ok(db.getAgentPermissions(parsed.agentId)); - }); - - registerHandle(IPC_CHANNELS.setAgentPermission, async (_event, payload) => { - const parsed = setAgentPermissionSchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - if (!db.belongsToCompany("agents", parsed.agentId, parsed.companyId)) { - return fail("AGENT_NOT_FOUND", "Agent not found in the current company."); - } - db.setAgentPermission(parsed.agentId, parsed.companyId, parsed.permissionKey, parsed.enabled); - publishDomainChanged(); - return ok(null); - }); - registerHandle(IPC_CHANNELS.getHeartbeatPolicy, async (_event, payload) => { const parsed = heartbeatPolicySchema.parse(payload); const companyError = ensureCompanyExists(parsed.companyId); diff --git a/src/main/ipc-core-task-graph-handlers.ts b/src/main/ipc-core-task-graph-handlers.ts index 702cff02..ae278c2d 100644 --- a/src/main/ipc-core-task-graph-handlers.ts +++ b/src/main/ipc-core-task-graph-handlers.ts @@ -6,7 +6,6 @@ import { taskInputSchema, } from "@shared/contracts"; import { fail, ok, type RegisterCoreHandlersDependencies } from "./ipc-core-common"; -import { isHeartbeatTaskTitle } from "./orchestration/heartbeat-task"; import { validateTaskCreation, checkCapabilityMatch } from "./task-quality-gates"; export function registerCoreTaskGraphHandlers({ @@ -19,6 +18,7 @@ export function registerCoreTaskGraphHandlers({ wakeAgentIfPossible, handleTaskCreated, handleTaskStatusChange, + logger, }: Pick< RegisterCoreHandlersDependencies, | "registerHandle" @@ -30,6 +30,7 @@ export function registerCoreTaskGraphHandlers({ | "wakeAgentIfPossible" | "handleTaskCreated" | "handleTaskStatusChange" + | "logger" >) { registerHandle(IPC_CHANNELS.saveGoal, async (_event, payload) => { const parsed = goalInputSchema.parse(payload); @@ -101,8 +102,9 @@ export function registerCoreTaskGraphHandlers({ } const isNew = !parsed.id; if (isNew) { - const isHeartbeat = isHeartbeatTaskTitle(parsed.title); - const validation = validateTaskCreation(parsed, isHeartbeat); + // User-created tasks are never heartbeat tasks; the is_heartbeat flag + // is set only by acquireHeartbeatTask in the orchestrator. + const validation = validateTaskCreation(parsed, false); if (!validation.valid) { return fail("TASK_VALIDATION_FAILED", validation.errors.join("; ")); } @@ -143,7 +145,13 @@ export function registerCoreTaskGraphHandlers({ } // User (board) manually advancing a flagged task to done: clear the review flag if (parsed.status === "done") { - try { db.setTaskRequiresUserReview(id, false); } catch { /* best effort */ } + try { + db.setTaskRequiresUserReview(id, false); + } catch (error) { + logger.warn( + `[ipc-task] setTaskRequiresUserReview(false) failed for task ${id}: ${error instanceof Error ? error.message : String(error)}`, + ); + } } // Wake new assignee when task is reassigned (matches api-server.ts behavior) if (parsed.assigneeAgentId && parsed.assigneeAgentId !== previousAssigneeAgentId) { @@ -157,7 +165,11 @@ export function registerCoreTaskGraphHandlers({ if (!match.matched) { capabilityWarning = match.warning; } - } catch { /* capability check is advisory — never block task creation */ } + } catch (error) { + logger.warn( + `[ipc-task] capability check failed for new task ${id} (advisory, not blocking): ${error instanceof Error ? error.message : String(error)}`, + ); + } } emitEvent({ type: "task-updated", taskId: id, action: isNew ? "created" : "updated" }); return ok({ id, capabilityWarning }); diff --git a/src/main/ipc-core-work-graph-handlers.ts b/src/main/ipc-core-work-graph-handlers.ts index 69e2eac8..accb5d2d 100644 --- a/src/main/ipc-core-work-graph-handlers.ts +++ b/src/main/ipc-core-work-graph-handlers.ts @@ -15,6 +15,7 @@ export function registerCoreWorkGraphHandlers( | "handleApprovalResolved" | "handleTaskCreated" | "handleTaskStatusChange" + | "logger" >, ) { registerCoreAgentGraphHandlers({ @@ -35,5 +36,6 @@ export function registerCoreWorkGraphHandlers( wakeAgentIfPossible: deps.wakeAgentIfPossible, handleTaskCreated: deps.handleTaskCreated, handleTaskStatusChange: deps.handleTaskStatusChange, + logger: deps.logger, }); } diff --git a/src/main/ipc-recovery-handlers.ts b/src/main/ipc-recovery-handlers.ts index 335a640e..830ce9ed 100644 --- a/src/main/ipc-recovery-handlers.ts +++ b/src/main/ipc-recovery-handlers.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { IPC_CHANNELS } from "@shared/contracts"; -import { ok, type RegisterDomainHandlersDependencies } from "./ipc-domain-common"; +import { fail, ok, type RegisterDomainHandlersDependencies } from "./ipc-domain-common"; const recoveryRetrySchema = z.object({ taskId: z.string(), @@ -10,22 +10,30 @@ const recoveryRetrySchema = z.object({ const recoveryTaskSchema = z.object({ taskId: z.string(), + companyId: z.string(), }); export function registerRecoveryHandlers({ registerHandle, db, + ensureCompanyExists, publishDomainChanged, wakeAgentIfPossible, }: Pick< RegisterDomainHandlersDependencies, | "registerHandle" | "db" + | "ensureCompanyExists" | "publishDomainChanged" | "wakeAgentIfPossible" >) { registerHandle(IPC_CHANNELS.recoveryRetry, async (_event, payload) => { const parsed = recoveryRetrySchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + if (!db.belongsToCompany("tasks", parsed.taskId, parsed.companyId)) { + return fail("TASK_NOT_FOUND", "Task not found in the current company."); + } db.retryInterruptedTask(parsed.taskId, parsed.agentId, parsed.companyId); try { wakeAgentIfPossible(parsed.agentId, parsed.companyId, "retry_interrupted"); @@ -38,6 +46,11 @@ export function registerRecoveryHandlers({ registerHandle(IPC_CHANNELS.recoveryDismiss, async (_event, payload) => { const parsed = recoveryTaskSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + if (!db.belongsToCompany("tasks", parsed.taskId, parsed.companyId)) { + return fail("TASK_NOT_FOUND", "Task not found in the current company."); + } db.dismissInterruptedTask(parsed.taskId); publishDomainChanged(); return ok(null); @@ -45,6 +58,11 @@ export function registerRecoveryHandlers({ registerHandle(IPC_CHANNELS.recoveryFail, async (_event, payload) => { const parsed = recoveryTaskSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + if (!db.belongsToCompany("tasks", parsed.taskId, parsed.companyId)) { + return fail("TASK_NOT_FOUND", "Task not found in the current company."); + } db.markInterruptedTaskFailed(parsed.taskId); publishDomainChanged(); return ok(null); diff --git a/src/main/main-runtime-support.ts b/src/main/main-runtime-support.ts index 8d535187..690085be 100644 --- a/src/main/main-runtime-support.ts +++ b/src/main/main-runtime-support.ts @@ -162,6 +162,7 @@ export function createMainRuntimeSupport(state: MainRuntimeState) { const policy = getConnectorModelPolicy(requireDb(), connectorId as ConnectorId); return policy.enabledModels; }, + logger: log, }); registerDomainHandlers({ diff --git a/src/main/main-runtime.ts b/src/main/main-runtime.ts index e52cd656..ae215dab 100644 --- a/src/main/main-runtime.ts +++ b/src/main/main-runtime.ts @@ -1,3 +1,4 @@ +import { app } from "electron"; import { AgentApiServer, createApiServer } from "./api-server"; import { createAgentToken, initializeAgentTokenSigningKey } from "./agent-auth"; import { bootstrapApplication } from "./bootstrap-application"; @@ -53,7 +54,7 @@ export function createMainRuntime({ isDev, devServerOrigin, profileDir, - appDatabaseFactory: (targetProfileDir) => new AppDatabase(targetProfileDir), + appDatabaseFactory: (targetProfileDir) => new AppDatabase(targetProfileDir, { isPackaged: app.isPackaged }), initSigningKey: initializeAgentTokenSigningKey, windowStatePath, preloadPath, diff --git a/src/main/message-dispatch.ts b/src/main/message-dispatch.ts index 376e5b60..d05d8fcc 100644 --- a/src/main/message-dispatch.ts +++ b/src/main/message-dispatch.ts @@ -193,29 +193,17 @@ function resolveProjectWakeTargets( input: NormalizedAgentMessageInput, ): string[] { if (!input.channelTargetId) return []; - 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 project = db.getProjectForUpdate(input.channelTargetId, input.companyId); const participantIds = new Set(); if (project?.leadAgentId) participantIds.add(project.leadAgentId); - 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); + for (const assigneeId of db.listProjectTaskAssigneeIds(input.channelTargetId, input.companyId)) { + participantIds.add(assigneeId); } 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); + return db.listActiveAgentIdsFromIds(input.companyId, ids); } export function resolveMessageWakeTargets( @@ -229,22 +217,14 @@ export function resolveMessageWakeTargets( } if (input.channel === "department" && input.channelTargetId) { - 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); + for (const agentId of db.listDepartmentAgentIds(input.companyId, input.channelTargetId)) { + if (agentId !== input.fromAgentId) wakeTargets.add(agentId); } } if (input.channel === "company") { - 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); + for (const agentId of db.listTopLevelAgentIds(input.companyId)) { + if (agentId !== input.fromAgentId) wakeTargets.add(agentId); } } @@ -255,12 +235,8 @@ export function resolveMessageWakeTargets( } if (input.channel === "incident") { - 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); + for (const agentId of db.listIncidentPingableAgentIds(input.companyId)) { + if (agentId !== input.fromAgentId) wakeTargets.add(agentId); } } @@ -303,11 +279,7 @@ export function dispatchAgentMessage( !normalized.fromHumanUser && (message.channel !== "direct" || message.toAgentId === HUMAN_SENDER_ID) ) { - const senderRows = deps.db.queryAll<{ name: string }>( - "select name from agents where id = ? limit 1", - message.fromAgentId, - ); - const senderName = senderRows[0]?.name ?? "Agent"; + const senderName = deps.db.findAgentName(message.fromAgentId) ?? "Agent"; deps.notify({ title: truncateForNotification(`${senderName} · ${message.subject}`, 80), body: truncateForNotification(message.body, 140), @@ -327,12 +299,7 @@ export function dispatchAgentMessage( const mentionResults: MentionResult[] = []; if (deps.wakeAgentIfPossible) { - 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 agentNameRows = deps.db.listAgentNamesByIds(normalized.companyId, wakeTargets); const agentNameMap = new Map(agentNameRows.map((a) => [a.id, a.name])); for (const wakeTarget of wakeTargets) { const agentName = agentNameMap.get(wakeTarget) ?? wakeTarget; diff --git a/src/main/notification-service.ts b/src/main/notification-service.ts index aaf62ef3..99691511 100644 --- a/src/main/notification-service.ts +++ b/src/main/notification-service.ts @@ -17,7 +17,11 @@ export interface NotificationOptions { interface NotificationServiceDependencies { getMainWindow: () => BrowserWindow | null; emitEvent: (event: DesktopEvent) => void; - logger: { info: (...args: unknown[]) => void; error: (...args: unknown[]) => void }; + logger: { + info: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + }; /** Returns the active UI locale ("en", "zh", ...). Batch summary copy is * localized; fallbacks to "en" when unset. */ getLocale?: () => string | null | undefined; @@ -198,7 +202,13 @@ export function createNotificationService(deps: NotificationServiceDependencies) // Dismiss any still-visible notifications so Windows toast host does not // leave ghost toasts after quit. for (const notification of activeNotifications) { - try { notification.close(); } catch { /* toast may have already closed */ } + try { + notification.close(); + } catch (error) { + deps.logger.warn( + `[notification] notification.close() during destroy failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } } activeNotifications.clear(); } diff --git a/src/main/orchestration/heartbeat-task.ts b/src/main/orchestration/heartbeat-task.ts index 1e5e3d2e..e1b5a7d9 100644 --- a/src/main/orchestration/heartbeat-task.ts +++ b/src/main/orchestration/heartbeat-task.ts @@ -2,14 +2,8 @@ * Heartbeat task title conventions. * * Heartbeat tasks are synthesized by the orchestrator when an agent wakes with - * no actionable work; they carry the `[Heartbeat]` title prefix so the rest of - * the system can detect them without a dedicated `kind` column. - * - * Centralizing the prefix here prevents drift between the writer (heartbeat - * dispatch) and readers (automation, escalation, route filters). + * no actionable work. The `tasks.is_heartbeat` column is the authoritative + * flag; the `[Heartbeat]` title prefix remains only for human-readable display + * so existing runs, logs, and UI affordances stay stable. */ export const HEARTBEAT_TASK_TITLE_PREFIX = "[Heartbeat]"; - -export function isHeartbeatTaskTitle(title: string | null | undefined): boolean { - return !!title && title.startsWith(HEARTBEAT_TASK_TITLE_PREFIX); -} diff --git a/src/main/orchestration/heartbeat.ts b/src/main/orchestration/heartbeat.ts index 889de063..30624500 100644 --- a/src/main/orchestration/heartbeat.ts +++ b/src/main/orchestration/heartbeat.ts @@ -212,7 +212,11 @@ export function createHeartbeat( const actorType = trigger === "timer" || trigger === "bootstrap" ? "system" : "agent"; const actorId = trigger === "timer" || trigger === "bootstrap" ? null : trigger; db.updateRunActor(run.id, actorType, actorId); - } catch { /* Non-fatal */ } + } catch (error) { + logger.warn( + `[heartbeat] updateRunActor failed for run ${run.id}: ${error instanceof Error ? error.message : String(error)}`, + ); + } db.addActivity({ companyId, diff --git a/src/main/orchestration/turn-context.ts b/src/main/orchestration/turn-context.ts index 783ae3f8..51a54917 100644 --- a/src/main/orchestration/turn-context.ts +++ b/src/main/orchestration/turn-context.ts @@ -8,7 +8,7 @@ import { buildTaskPrompt } from "../prompt-builder"; import type { AppDatabase } from "../database"; -import type { RuntimeSkillEntry } from "../skill-types"; +import type { ProjectedSkill } from "../skill-types"; import type { ConnectorId, ExecutionMode } from "@shared/types"; import type { WorkspaceLockMode, EnqueueRunPayload } from "./types"; import { normalizeMessagesForPrompt, normalizeCommentsForPrompt } from "../message-normalize"; @@ -33,7 +33,7 @@ export interface TurnContextDeps { logger: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void }; getApiPort: () => number; createRunToken: (agentId: string, companyId: string, runId: string) => string; - listRuntimeSkillEntries: (companyId: string, agentId: string, connectorId: string) => RuntimeSkillEntry[]; + listRuntimeSkillEntries: (companyId: string, agentId: string, connectorId: string) => ProjectedSkill[]; getSessionHandoff?: (taskId: string) => string | null; evaluatePreRunSessionRotation?: (taskId: string, connectorId: string) => { rotated: boolean; @@ -109,7 +109,7 @@ function resolveSkills( connectorId: string, agentName: string, deps: Pick, -): RuntimeSkillEntry[] { +): ProjectedSkill[] { try { return deps.listRuntimeSkillEntries(companyId, agentId, connectorId); } catch (err) { @@ -162,13 +162,21 @@ function resolveSession( return { taskSession, sessionHandoff: finalHandoff }; } -function extractThinkingEffort(metadataJson: string | null): string | null { +function extractThinkingEffort( + metadataJson: string | null, + logger: TurnContextDeps["logger"], + agentId: string, +): string | null { try { const meta = JSON.parse(metadataJson || "{}"); if (typeof meta.thinkingEffort === "string" && meta.thinkingEffort) { return meta.thinkingEffort; } - } catch { /* malformed metadata */ } + } catch (error) { + logger.warn( + `[run] malformed agent metadataJson for ${agentId}: ${error instanceof Error ? error.message : String(error)}`, + ); + } return null; } @@ -251,13 +259,10 @@ export function buildRunPayload(params: RunDispatchParams, deps: TurnContextDeps const assignedTasks = db.listAgentTasks(agent.id, companyId, ["todo", "in_progress", "blocked"]); const reviewTasks = db.listReviewTasksForManager(agent.id, companyId); const recentComments = assembleRecentComments(assignedTasks, reviewTasks, snapshot); - let sessionHandoff = resolveSessionHandoff(task.id, deps); + const initialHandoff = resolveSessionHandoff(task.id, deps); + const { taskSession, sessionHandoff } = resolveSession(db, task, connector.id, initialHandoff, deps); const skills = resolveSkills(companyId, agent.id, connector.id, agent.name, deps); - const skillNames = skills - .map((s) => s.name ?? s.runtimeName) - .filter(Boolean); - const systemPrompt = apiUrl ? buildTaskPrompt({ agent, apiUrl, @@ -293,14 +298,7 @@ export function buildRunPayload(params: RunDispatchParams, deps: TurnContextDeps recentDocuments: filterRecentDocuments(snapshot.documents, companyId, agent.id), allConnectors: buildConnectorCatalog(db, snapshot.connectors), sessionHandoff, - skillNames, - skillEntries: skills.map((s) => ({ - name: s.name ?? s.runtimeName, - whenToUse: s.whenToUse, - allowedTools: s.allowedTools, - effort: s.effort, - contextMode: s.contextMode, - })), + skills, companySkillCatalog: db.listCompanySkills(companyId).map((skill) => ({ name: skill.name, slug: skill.slug, @@ -312,12 +310,7 @@ export function buildRunPayload(params: RunDispatchParams, deps: TurnContextDeps companyDocuments: snapshot.documents.filter((d) => d.companyId === companyId), }) : null; - const { taskSession, sessionHandoff: finalHandoff } = resolveSession(db, task, connector.id, sessionHandoff, deps); - if (finalHandoff !== sessionHandoff && systemPrompt && finalHandoff) { - sessionHandoff = finalHandoff; - } - - const thinkingEffort = extractThinkingEffort(agent.metadataJson); + const thinkingEffort = extractThinkingEffort(agent.metadataJson, deps.logger, agent.id); const modelSelection = validateConnectorModelSelection(db, connector.id, agent.model); if (!modelSelection.ok) { throw new Error(modelSelection.error); @@ -342,7 +335,6 @@ export function buildRunPayload(params: RunDispatchParams, deps: TurnContextDeps sessionParams: taskSession.sessionParams, systemPrompt, trigger, - managedSkills: skills.map((s) => ({ runtimeName: s.runtimeName, sourcePath: s.sourcePath })), taskSessionId: taskSession.id ?? runId, executionMode: db.getCompanyExecutionMode(companyId) as "ai_ceo" | "ai_cofounder", }; diff --git a/src/main/orchestration/types.ts b/src/main/orchestration/types.ts index e08995bd..7f4a0ef1 100644 --- a/src/main/orchestration/types.ts +++ b/src/main/orchestration/types.ts @@ -2,7 +2,7 @@ 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"; +import type { ProjectedSkill } from "../skill-types"; export type WorkspaceLockMode = "exclusive" | "shared" | "none"; @@ -43,7 +43,6 @@ export interface EnqueueRunPayload { sessionParams: Record | null; systemPrompt: string | null; trigger: string | null; - managedSkills: Array<{ runtimeName: string; sourcePath: string }>; /** Task session id used by SqliteSessionStore to load/append messages. */ taskSessionId: string; /** Execution mode from the owning company. */ @@ -67,7 +66,7 @@ export interface OrchestrationDependencies { hasWorkerReady: () => boolean; enqueueWorkerRun: (payload: EnqueueRunPayload) => void; isQuitting: () => boolean; - listRuntimeSkillEntries: (companyId: string, agentId: string, connectorId: string) => RuntimeSkillEntry[]; + listRuntimeSkillEntries: (companyId: string, agentId: string, connectorId: string) => ProjectedSkill[]; budgetService: ReturnType; /** Checkpoint engine for unified co-founder mode pause decisions. */ checkpointEngine?: { shouldPauseHeartbeat: (companyId: string, agentId: string) => boolean }; diff --git a/src/main/prompt-builder/context-injection.ts b/src/main/prompt-builder/context-injection.ts index 57c94cd9..8ce5bd68 100644 --- a/src/main/prompt-builder/context-injection.ts +++ b/src/main/prompt-builder/context-injection.ts @@ -1,3 +1,4 @@ +import log from "electron-log/main.js"; import type { AgentRecord, AgentMessageRecord, ApprovalRecord, DocumentRecord, KnowledgeEntryRecord, MeetingRecord, TaskRecord } from "@shared/types"; import { inferTaskType } from "../connector-matching"; import { resolveAttachmentContent } from "./attachment-resolver"; @@ -112,7 +113,11 @@ export function buildMessagesSection(ctx: MessagesSectionCtx): string | null { if (Array.isArray(atts) && atts.length > 0) { attachmentNote = ` [Attached: ${atts.map((a) => a.title ?? "document").join(", ")}]`; } - } catch { /* malformed attachment JSON */ } + } catch (error) { + log.warn( + `[prompt-builder] malformed attachmentsJson on message ${m.id}: ${error instanceof Error ? error.message : String(error)}`, + ); + } if (ctx.companyDocuments && ctx.companyDocuments.length > 0) { attachmentContent = resolveAttachmentContent(m.attachmentsJson, ctx.companyDocuments, 4000); } @@ -142,7 +147,14 @@ export function buildMeetingsSection(ctx: MeetingsSectionCtx): string | null { const meetingLines = ctx.upcomingMeetings.slice(0, 5).map((m) => { const organizer = m.organizerAgentId ? (ctx.allAgents.find(a => a.id === m.organizerAgentId)?.name ?? "Unknown") : "TBD"; let participants: string[]; - try { participants = JSON.parse(m.participantAgentIds); } catch { participants = []; } + try { + participants = JSON.parse(m.participantAgentIds); + } catch (error) { + log.warn( + `[prompt-builder] malformed participantAgentIds on meeting ${m.id}: ${error instanceof Error ? error.message : String(error)}`, + ); + participants = []; + } const participantNames = participants .map(id => ctx.allAgents.find(a => a.id === id)?.name ?? id) .join(", "); diff --git a/src/main/prompt-builder/index.ts b/src/main/prompt-builder/index.ts index 2c877b35..3d1f81aa 100644 --- a/src/main/prompt-builder/index.ts +++ b/src/main/prompt-builder/index.ts @@ -1,5 +1,6 @@ import type { AgentRecord, AgentMessageRecord, ApprovalRecord, DocumentRecord, ExecutionMode, GoalRecord, KnowledgeEntryRecord, MeetingRecord, ProjectRecord, SprintRecord, TaskRecord } from "@shared/types"; import type { AppLocale } from "@shared/locale"; +import type { ProjectedSkill } from "../skill-types"; import { buildSystemPrompt } from "./system-prompt"; import { renderWakeReason } from "./wake-reasons"; import { getSystemMessages } from "./messages"; @@ -35,10 +36,8 @@ export interface PromptContext { allConnectors?: Array<{ id: string; label: string; status: string; enabledModels?: string[] }>; /** D-07: Handoff markdown from a rotated session, injected as context for the agent */ sessionHandoff?: string | null; - /** Names of skills injected into this agent's runtime environment */ - skillNames?: string[]; - /** Full skill entries with semantic metadata for capability manifest */ - skillEntries?: Array<{ name: string; whenToUse: string; allowedTools: string[]; effort: string; contextMode: string }>; + /** Skills visible to this agent via pi's progressive-disclosure model. */ + skills?: ProjectedSkill[]; /** All company documents for attachment content resolution in messages */ companyDocuments?: DocumentRecord[]; /** Company-wide skill catalog for capability-aware delegation. */ @@ -52,6 +51,15 @@ export function buildTaskPrompt(ctx: PromptContext): string { const systemPrompt = buildSystemPrompt(ctx); const parts: string[] = [systemPrompt]; + // Volatile per-turn context lives AFTER the cacheable system prefix so the + // Anthropic prompt cache hits across heartbeats. Putting the wall-clock + // time inside the system prompt would invalidate the cache on every call. + const now = new Date(); + const dateStr = now.toISOString().split("T")[0]; + const timeStr = now.toISOString().split("T")[1]?.slice(0, 5) ?? "00:00"; + const dayOfWeek = m.dayNames[now.getUTCDay()] ?? ""; + parts.push(`\n${m.currentDateSection(dayOfWeek, dateStr, timeStr)}`); + if (ctx.wakeReason) { const explanation = renderWakeReason(ctx.wakeReason, { companyId: ctx.agent.companyId, diff --git a/src/main/prompt-builder/messages/system-en.ts b/src/main/prompt-builder/messages/system-en.ts index c0856050..fa2fd406 100644 --- a/src/main/prompt-builder/messages/system-en.ts +++ b/src/main/prompt-builder/messages/system-en.ts @@ -270,20 +270,6 @@ export function importantRulesSection(companyId: string, agentId: string, runId: `; } -export function skillManifestEntries(entries: Array<{ name: string; whenToUse: string; allowedTools: string[]; effort: string }>): string { - const lines = entries.map((s) => { - const parts = [`- ${s.name}`]; - if (s.whenToUse) parts.push(` Use when: ${s.whenToUse}`); - if (s.allowedTools.length > 0) parts.push(` Allowed tools: ${s.allowedTools.join(", ")}`); - if (s.effort !== "medium") parts.push(` Effort: ${s.effort}`); - return parts.join("\n"); - }); - return `Your domain skills:\n${lines.join("\n")}\nThese skills are loaded into your environment — use them when relevant to your work.`; -} - -export function skillManifestNames(names: string[]): string { - return `Your domain skills: ${names.join(", ")}. These skills are loaded into your environment and provide domain expertise — use them when relevant to your work.`; -} export const wakeReasonHeading = "# Wake Reason"; diff --git a/src/main/prompt-builder/messages/system-zh.ts b/src/main/prompt-builder/messages/system-zh.ts index cb9c19f3..d5547987 100644 --- a/src/main/prompt-builder/messages/system-zh.ts +++ b/src/main/prompt-builder/messages/system-zh.ts @@ -265,20 +265,6 @@ export function importantRulesSection(companyId: string, agentId: string, runId: `; } -export function skillManifestEntries(entries: Array<{ name: string; whenToUse: string; allowedTools: string[]; effort: string }>): string { - const lines = entries.map((s) => { - const parts = [`- ${s.name}`]; - if (s.whenToUse) parts.push(` 使用场景:${s.whenToUse}`); - if (s.allowedTools.length > 0) parts.push(` 允许工具:${s.allowedTools.join("、")}`); - if (s.effort !== "medium") parts.push(` 资源消耗:${s.effort}`); - return parts.join("\n"); - }); - return `你的领域 skill:\n${lines.join("\n")}\n这些 skill 已加载到你的运行环境 —— 相关时请使用它们。`; -} - -export function skillManifestNames(names: string[]): string { - return `你的领域 skill:${names.join("、")}。这些 skill 已加载到你的运行环境,为你提供领域专长 —— 相关时请使用它们。`; -} export const wakeReasonHeading = "# 唤醒原因"; diff --git a/src/main/prompt-builder/system-prompt.ts b/src/main/prompt-builder/system-prompt.ts index cd136e71..94b63e11 100644 --- a/src/main/prompt-builder/system-prompt.ts +++ b/src/main/prompt-builder/system-prompt.ts @@ -1,5 +1,6 @@ import { DEPARTMENT_LABELS } from "@shared/constants"; import type { DepartmentId } from "@shared/types"; +import { formatSkillsForPrompt } from "@mariozechner/pi-coding-agent"; import { buildTaskSection, buildReviewSection, @@ -18,9 +19,9 @@ import { buildDepartmentSection, } from "./operating-instructions"; import type { PromptContext } from "./index"; +import type { ProjectedSkill } from "../skill-types"; import { roleMatchesLeadershipKeywords } from "../agent-role-helpers"; import { getSystemMessages } from "./messages"; -import type { SystemMessages } from "./messages"; function isLeadershipRole(ctx: PromptContext): boolean { // HR roles inherit leadership scope for manager-style prompts; @@ -29,14 +30,28 @@ function isLeadershipRole(ctx: PromptContext): boolean { return roleMatchesLeadershipKeywords(ctx.agent.role, ctx.agent.title ?? null); } -function buildSkillManifest( - m: SystemMessages, - skillEntries?: PromptContext["skillEntries"], - skillNames?: string[], -): string { - if (skillEntries && skillEntries.length > 0) return m.skillManifestEntries(skillEntries); - if (skillNames && skillNames.length > 0) return m.skillManifestNames(skillNames); - return ""; +/** + * Render the skill manifest block using pi-coding-agent's XML format. + * + * The full SKILL.md content is NOT injected — only name/description/filePath. + * The agent loads SKILL.md on demand via the `read` tool (progressive + * disclosure). Pi's `formatSkillsForPrompt` expects its full `Skill` shape, so + * we supply synthetic values for the two fields we don't track (`sourceInfo`, + * `disableModelInvocation`). Cast is safe because pi's XML renderer only reads + * `name`, `description`, `filePath`, and `disableModelInvocation`. + */ +function buildSkillManifest(skills: ProjectedSkill[] | undefined): string { + if (!skills || skills.length === 0) return ""; + const piShapedSkills = skills.map((s) => ({ + name: s.name, + description: s.description, + filePath: s.filePath, + baseDir: s.baseDir, + disableModelInvocation: false, + // sourceInfo is required by pi's Skill interface but never read by + // formatSkillsForPrompt. Leaving it undefined and asserting the shape. + })); + return formatSkillsForPrompt(piShapedSkills as never); } export function buildSystemPrompt(ctx: PromptContext): string { @@ -46,11 +61,10 @@ export function buildSystemPrompt(ctx: PromptContext): string { const isCEO = ctx.chainOfCommand.length === 0; const isManager = !isCEO && (ctx.directReports.length > 0 || isLeadershipRole(ctx)); - const now = new Date(); - const dateStr = now.toISOString().split("T")[0]; - const timeStr = now.toISOString().split("T")[1]?.slice(0, 5) ?? "00:00"; - const dayOfWeek = m.dayNames[now.getUTCDay()] ?? ""; - sections.push(m.currentDateSection(dayOfWeek, dateStr, timeStr)); + // NOTE: the current date/time is intentionally NOT rendered here. This + // function's output is intended to be cacheable by the Anthropic prompt + // cache keyed by a stable prefix; per-turn volatile values live in the + // buildTaskPrompt() tail so the cached prefix hits across heartbeats. const budgetPct = ctx.agent.budgetMonthlyUsd > 0 ? Math.round((ctx.agent.spentMonthlyUsd / ctx.agent.budgetMonthlyUsd) * 100) @@ -64,7 +78,7 @@ export function buildSystemPrompt(ctx: PromptContext): string { budgetMonthly: ctx.agent.budgetMonthlyUsd, spentMonthly: ctx.agent.spentMonthlyUsd, budgetPctUsed: budgetPct, - skillManifest: buildSkillManifest(m, ctx.skillEntries, ctx.skillNames), + skillManifest: buildSkillManifest(ctx.skills), })); const capabilityRegistrySection = buildCapabilityRegistrySection({ diff --git a/src/main/run-housekeeping.ts b/src/main/run-housekeeping.ts index 1e2e7271..1458b176 100644 --- a/src/main/run-housekeeping.ts +++ b/src/main/run-housekeeping.ts @@ -3,7 +3,11 @@ import type { ThreadIntegrationService } from "./thread-integration"; interface RunHousekeepingDeps { db: AppDatabase; - logger: { info: (...args: unknown[]) => void; error: (...args: unknown[]) => void }; + logger: { + info: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + }; threadIntegration: ThreadIntegrationService; } @@ -34,6 +38,7 @@ function shouldProduceKnowledge( db: AppDatabase, taskId: string, agentId: string, + logger: RunHousekeepingDeps["logger"], ): boolean { try { const taskTypeRow = db.queryAll<{ taskType: string | null }>( @@ -43,13 +48,18 @@ function shouldProduceKnowledge( if (taskTypeRow?.taskType && KNOWLEDGE_PRODUCING_TASK_TYPES.has(taskTypeRow.taskType)) { return true; } - } catch { - // Task deleted; fall through to agent check + } catch (error) { + logger.warn( + `[run-housekeeping] task lookup failed for ${taskId}; falling back to agent department check: ${error instanceof Error ? error.message : String(error)}`, + ); } try { const agent = db.getAgent(agentId); return Boolean(agent.department && KNOWLEDGE_PRODUCING_DEPARTMENTS.has(agent.department)); - } catch { + } catch (error) { + logger.warn( + `[run-housekeeping] agent lookup failed for ${agentId}: ${error instanceof Error ? error.message : String(error)}`, + ); return false; } } @@ -79,14 +89,18 @@ export function createRunHousekeeping({ db, logger, threadIntegration }: RunHous status === "succeeded" && summary && summary.length > 50 && - shouldProduceKnowledge(db, taskId, agentId) + shouldProduceKnowledge(db, taskId, agentId, logger) ) { try { let topic = `Run ${runId.slice(0, 8)}`; try { const task = db.getTask(taskId); topic = task.title; - } catch { /* task may have been deleted */ } + } catch (error) { + logger.warn( + `[run-housekeeping] getTask failed for ${taskId}; using run id as topic: ${error instanceof Error ? error.message : String(error)}`, + ); + } db.saveKnowledgeEntry({ companyId, diff --git a/src/main/service-graph-orchestration-deps.ts b/src/main/service-graph-orchestration-deps.ts index 70b83b6a..f6754f1f 100644 --- a/src/main/service-graph-orchestration-deps.ts +++ b/src/main/service-graph-orchestration-deps.ts @@ -6,7 +6,7 @@ import type { AppDatabase } from "./database"; import { createSkillService } from "./skill-service"; import type { createSessionManagementService } from "./session-management-service"; -import type { RuntimeSkillEntry } from "./skill-types"; +import type { ProjectedSkill } from "./skill-types"; interface Logger { info: (...args: unknown[]) => void; @@ -16,7 +16,7 @@ interface Logger { export function makeListRuntimeSkillEntries(deps: { db: AppDatabase; logger: Logger }) { let cache: ReturnType | null = null; - return (companyId: string, agentId: string, connectorId: string): RuntimeSkillEntry[] => { + return (companyId: string, agentId: string, connectorId: string): ProjectedSkill[] => { try { if (!cache) cache = createSkillService({ db: deps.db, profileDir: deps.db.profileDir, logger: deps.logger }); return cache.listRuntimeSkillEntries(companyId, agentId, connectorId); diff --git a/src/main/service-graph-pi-runtime.ts b/src/main/service-graph-pi-runtime.ts index 407e0ab0..c89b046c 100644 --- a/src/main/service-graph-pi-runtime.ts +++ b/src/main/service-graph-pi-runtime.ts @@ -51,8 +51,6 @@ export function createPiRuntimeServices(deps: PiRuntimeServicesDeps): PiRuntimeS credentialResolver, oauthLoginService, runtimeBridge, - release: () => { - runtimeBridge.approvalHook.cancelAll("AgentCompany shutting down."); - }, + release: () => {}, }; } diff --git a/src/main/service-graph.ts b/src/main/service-graph.ts index 104c1692..8aabf6b3 100644 --- a/src/main/service-graph.ts +++ b/src/main/service-graph.ts @@ -1,23 +1,25 @@ -import { app, nativeTheme } from "electron"; +import { nativeTheme } from "electron"; +import type { IpcMainInvokeEvent } from "electron"; import type { AgentApiServer } from "./api-server"; import type { AppDatabase } from "./database"; import { BrowserManager } from "./browser-manager"; -import { createAutomationService, autoAssignSkillsToAgent } from "./automation-service"; -import { createBrowserActionService } from "./browser-action-service"; +import type { DesktopResult } from "@shared/contracts"; import { createConnectorHealthService } from "./connector-health-service"; -import { createDesktopShellService } from "./desktop-shell-service"; -import { createOnboardingService } from "./onboarding-service"; -import { createOrchestrationService } from "./orchestration-service"; -import { createSkillService } from "./skill-service"; -import { createUpdaterService } from "./updater-service"; -import { createWorkerService } from "./worker-service"; -import { createAppLifecycleService } from "./app-lifecycle-service"; -import { createBudgetEnforcementService } from "./budget-enforcement-service"; -import { createSessionManagementService } from "./session-management-service"; import { createPiRuntimeServices, type PiRuntimeServices } from "./service-graph-pi-runtime"; -import type { DesktopEvent, RunRecord } from "@shared/types"; -import type { AutomationEventContext } from "./automation-service"; +import type { DesktopEvent } from "@shared/types"; import type { NotificationOptions } from "./notification-service"; +import type { createAutomationService } from "./automation-service"; +import type { createBrowserActionService } from "./browser-action-service"; +import type { createOnboardingService } from "./onboarding-service"; +import type { createOrchestrationService } from "./orchestration-service"; +import type { createUpdaterService } from "./updater-service"; +import { createLeafServices } from "./service-graph/create-leaf-services"; +import { createDesktopShell } from "./service-graph/create-desktop-shell"; +import { buildWorkerService } from "./service-graph/create-worker-service"; +import { buildOrchestrationService } from "./service-graph/create-orchestration-service"; +import { createBackgroundServices } from "./service-graph/create-background-services"; +import { createLifecycle } from "./service-graph/create-lifecycle"; +import { LateBinding } from "./service-graph/late-binding"; interface ServiceGraphDependencies { db: AppDatabase; @@ -46,145 +48,93 @@ interface ServiceGraphDependencies { onRunCompleted?: (runId: string, taskId: string, agentId: string, companyId: string, status: string, summary: string | null, errorMessage: string | null) => void; /** Checkpoint engine for unified co-founder mode pause decisions in scheduler. */ checkpointEngine?: { shouldPauseHeartbeat: (companyId: string, agentId: string) => boolean }; + /** Trusted-sender IPC wrapper from createIpcSupport. Window-control channels use it. */ + registerHandle: ( + channel: string, + handler: (event: IpcMainInvokeEvent, ...args: unknown[]) => Promise> | DesktopResult, + ) => void; } -export function createServiceGraph({ - db, - apiServerRef, - browserManagerRef, - isQuittingRef, - preloadPath, - rendererDist, - iconPath, - isDev, - devServerUrl, - windowStatePath, - workerPath, - logger, - emitEvent, - publishDomainChanged, - notify, - createRunToken, - seedDefaultAutomationRules, - onRunStarted, - onRunCompleted, - checkpointEngine, -}: ServiceGraphDependencies) { +/** + * Thin orchestrator: assembles services in a deterministic topological order + * and exposes them via read-only getters. All wiring is performed via + * explicit dependency passing; late-bound cycles are handled by + * `LateBinding` slots that throw if accessed before assignment. + */ +export function createServiceGraph(deps: ServiceGraphDependencies) { + const { + db, + apiServerRef, + browserManagerRef, + isQuittingRef, + preloadPath, + rendererDist, + iconPath, + isDev, + devServerUrl, + windowStatePath, + workerPath, + logger, + emitEvent, + publishDomainChanged, + notify, + createRunToken, + seedDefaultAutomationRules, + onRunStarted, + onRunCompleted, + checkpointEngine, + registerHandle, + } = deps; + + // Service slots owned by the graph. Null before init() completes and after + // the lifecycle drain has torn services down. + let desktopShellService: ReturnType | null = null; + let connectorHealthService: ReturnType | null = null; + let workerService: ReturnType | null = null; + let orchestrationService: ReturnType | null = null; let automationService: ReturnType | null = null; let browserActionService: ReturnType | null = null; - let connectorHealthService: ReturnType | null = null; let onboardingService: ReturnType | null = null; - let desktopShellService: ReturnType | null = null; - let orchestrationService: ReturnType | null = null; - let workerService: ReturnType | null = null; let updaterService: ReturnType | null = null; - let appLifecycleService: ReturnType | null = null; - // Eagerly constructed inside init() and resolved via getOrCreate* getters. - let skillService: ReturnType | null = null; - let budgetEnforcementService: ReturnType | null = null; - let sessionManagementService: ReturnType | null = null; + let appLifecycleService: ReturnType | null = null; + let leaf: ReturnType | null = null; const piRuntimeRef: { current: PiRuntimeServices | null } = { current: null }; - function getOrCreateSkillService() { - if (!skillService) throw new Error("Skill service is not initialized."); - return skillService; + // Late-bound slots resolve circular construction: worker needs orchestration + // (for dispatchRunToWorker), orchestration needs worker (for enqueueWorkerRun), + // orchestration needs automation (for dispatchAutomation), automation/orchestration + // need browser-action (for dispatchBrowserAction). Each is filled inside + // init() before the corresponding getter closure is ever invoked. + const orchestrationRef = new LateBinding>("orchestrationService"); + const automationRef = new LateBinding>("automationService"); + const browserActionRef = new LateBinding>("browserActionService"); + const updaterRef = new LateBinding>("updaterService"); + + function requireLeaf() { + if (!leaf) throw new Error("Leaf services are not initialized."); + return leaf; } - function getOrCreateBudgetEnforcementService() { - if (!budgetEnforcementService) throw new Error("Budget enforcement service is not initialized."); - return budgetEnforcementService; - } - - function getOrCreateSessionManagementService() { - if (!sessionManagementService) throw new Error("Session management service is not initialized."); - return sessionManagementService; - } - - const wakeAgentIfPossible =(agentId: string | null | undefined, companyId: string, trigger: string) => { - if (!orchestrationService) { - throw new Error("Orchestration service is not initialized."); - } - return orchestrationService.wakeAgentIfPossible(agentId, companyId, trigger); - }; - - const dispatchBrowserAction = async (actionId: string, companyId: string) => { - if (!browserActionService) { - throw new Error("Browser action service is not initialized"); - } - return browserActionService.dispatchBrowserAction(actionId, companyId); - }; - - const dispatchAutomation = (trigger: string, ctx: AutomationEventContext) => { - if (!automationService) { - throw new Error("Automation service is not initialized."); - } - automationService.dispatchAutomation(trigger, ctx); - }; - - const processRunCompletion = (run: RunRecord, event: Parameters["processRunCompletion"]>[1]) => { - if (!automationService) { - throw new Error("Automation service is not initialized."); - } - automationService.processRunCompletion(run, event); - }; - - const queueTaskRun = (taskId: string, trigger: string, actor: string) => { - if (!orchestrationService) { - throw new Error("Orchestration service is not initialized."); - } - return orchestrationService.queueTaskRun(taskId, trigger, actor); - }; - - const dispatchRunToWorker = (params: Parameters["dispatchRunToWorker"]>[0]) => { - if (!orchestrationService) { - throw new Error("Orchestration service is not initialized."); - } - return orchestrationService.dispatchRunToWorker(params); - }; - function init() { nativeTheme.themeSource = db.listSnapshot().theme; - // Eagerly construct leaf services that other services depend on. Keeps - // the graph deterministic and lets getOrCreate* getters stay pure readers. - skillService = createSkillService({ db, profileDir: db.profileDir, logger }); - budgetEnforcementService = createBudgetEnforcementService({ db, logger, notify, publishDomainChanged }); - sessionManagementService = createSessionManagementService({ db, logger }); - desktopShellService = createDesktopShellService({ + leaf = createLeafServices({ db, logger, notify, publishDomainChanged }); + + desktopShellService = createDesktopShell({ + db, preloadPath, rendererDist, iconPath, isDev, devServerUrl, windowStatePath, - shouldQuit: () => isQuittingRef.current, - onRequestQuit: () => { - isQuittingRef.current = true; - }, - onCheckForUpdates: async () => { - if (!updaterService) throw new Error("Updater service is not initialized."); - await updaterService.checkForUpdates(); - }, - onBackupProfile: async () => { - const target = await import("electron").then(({ dialog }) => dialog.showOpenDialog({ - properties: ["openDirectory", "createDirectory"], - })); - if (target.canceled || target.filePaths.length === 0) return; - await db.exportBackup(`${target.filePaths[0]!}/agentcompany-backup-${Date.now()}`); - }, - onShortcut: (action) => emitEvent({ type: "shortcut", action }), + isQuittingRef, + emitEvent, notify, - getActiveRunCount: () => { - try { - const snapshot = db.listSnapshot(); - return snapshot.runs.filter((r) => r.status === "running" || r.status === "queued").length; - } catch (error) { - logger.warn("[service-graph] getActiveRunCount failed:", error); - return 0; - } - }, + registerHandle, + updaterRef, + logger, }); - desktopShellService.initialize(); piRuntimeRef.current = createPiRuntimeServices({ db, @@ -204,25 +154,24 @@ export function createServiceGraph({ credentialStore: piRuntimeRef.current.credentialStore, }); - workerService = createWorkerService({ + workerService = buildWorkerService({ db, logger, workerPath, - isQuitting: () => isQuittingRef.current, + isQuittingRef, publishDomainChanged, emitEvent, - wakeAgentIfPossible, - dispatchRunToWorker, - processRunCompletion: (run, event) => processRunCompletion(run, event), + orchestrationRef, + automationRef, onRunStarted, onRunCompleted, notify, - budgetService: getOrCreateBudgetEnforcementService(), - sessionService: getOrCreateSessionManagementService(), - getRuntimeBridge: () => piRuntimeRef.current?.runtimeBridge ?? null, + budgetService: leaf.budgetEnforcementService, + sessionService: leaf.sessionManagementService, + piRuntimeRef, }); - orchestrationService = createOrchestrationService({ + orchestrationService = buildOrchestrationService({ db, logger, publishDomainChanged, @@ -230,104 +179,52 @@ export function createServiceGraph({ notify, createRunToken, getApiPort: () => apiServerRef.current?.port ?? 0, - dispatchAutomation, - hasWorkerReady: () => Boolean(workerService?.getWorker()), - enqueueWorkerRun: (payload) => { - workerService?.getWorker()?.postMessage({ type: "enqueue-run", payload }); - }, - isQuitting: () => isQuittingRef.current, - listRuntimeSkillEntries: (companyId: string, agentId: string, connectorId: string) => { - try { - return getOrCreateSkillService().listRuntimeSkillEntries(companyId, agentId, connectorId); - } catch (error) { - logger.warn(`[service-graph] listRuntimeSkillEntries failed for agent ${agentId}:`, error); - return []; - } - }, - budgetService: getOrCreateBudgetEnforcementService(), + isQuittingRef, + skillService: leaf.skillService, + budgetService: leaf.budgetEnforcementService, + sessionService: leaf.sessionManagementService, + workerService, + automationRef, + browserActionRef, checkpointEngine, - getSessionHandoff: (taskId: string) => { - try { - return getOrCreateSessionManagementService().getHandoffContext(taskId); - } catch (error) { - logger.warn(`[service-graph] getSessionHandoff failed for task ${taskId}:`, error); - return null; - } - }, - evaluatePreRunSessionRotation: (taskId: string, connectorId: string) => { - try { - const sessionSvc = getOrCreateSessionManagementService(); - const activeSession = db.getActiveTaskSession(taskId); - if (!activeSession) return { rotated: false, handoff: null }; - const result = sessionSvc.evaluateRotation(activeSession, connectorId); - if (result.shouldRotate) { - sessionSvc.rotateSession(activeSession); - const handoff = sessionSvc.getHandoffContext(taskId); - return { rotated: true, handoff }; - } - return { rotated: false, handoff: null }; - } catch (error) { - logger.warn(`[service-graph] evaluatePreRunSessionRotation failed for task ${taskId}:`, error); - return { rotated: false, handoff: null }; - } - }, }); + orchestrationRef.set(orchestrationService); + workerService.createWorker(); - automationService = createAutomationService({ + const background = createBackgroundServices({ db, logger, emitEvent, publishDomainChanged, notify, - wakeAgentIfPossible, - dispatchBrowserAction, - isQuitting: () => isQuittingRef.current, - }); - - onboardingService = createOnboardingService({ - db, - seedDefaultAutomationRules, - queueTaskRun, - publishDomainChanged, - scanAndAssignSkills: async (companyId: string, agentId: string) => { - const svc = getOrCreateSkillService(); - await svc.seedBuiltinSkills(companyId); - svc.assignRoleSkillsToAgent(companyId, agentId, "CEO"); - await svc.scanWorkspaceSkills(companyId); - autoAssignSkillsToAgent(db, logger, companyId, agentId); - }, - }); - - browserActionService = createBrowserActionService({ - db, + isQuittingRef, browserManagerRef, - publishDomainChanged, + skillService: leaf.skillService, + orchestrationService, + seedDefaultAutomationRules, }); - browserActionService.resetBrowserManager(); + automationService = background.automationService; + browserActionService = background.browserActionService; + onboardingService = background.onboardingService; + updaterService = background.updaterService; + automationRef.set(automationService); + browserActionRef.set(browserActionService); + updaterRef.set(updaterService); - updaterService = createUpdaterService({ + appLifecycleService = createLifecycle({ db, logger, - notify, - }); - updaterService.attachListeners(); - - appLifecycleService = createAppLifecycleService({ - getMainWindow: () => desktopShellService?.getMainWindow() ?? null, - ensureWindow: async () => desktopShellService?.ensureWindow() ?? null, - shouldQuit: () => isQuittingRef.current, - onBeforeQuit: () => { - isQuittingRef.current = true; - orchestrationService?.stopAllSchedulers(); - automationService?.destroy(); - apiServerRef.current?.stop(); - apiServerRef.current = null; - browserManagerRef.current?.shutdown(); - browserManagerRef.current = null; - desktopShellService?.destroy(); + isQuittingRef, + apiServerRef, + browserManagerRef, + piRuntimeRef, + orchestrationService, + automationService, + workerService, + desktopShellService, + clearServiceRefs: () => { desktopShellService = null; - workerService?.destroy(); workerService = null; browserActionService = null; automationService = null; @@ -336,16 +233,14 @@ export function createServiceGraph({ orchestrationService = null; updaterService = null; appLifecycleService = null; - skillService = null; - budgetEnforcementService = null; - sessionManagementService = null; - piRuntimeRef.current?.release(); - piRuntimeRef.current = null; - db.close(); - // Force exit after grace period — covers all platforms (macOS Dock, Windows tray, Linux) - // Worker child processes (claude/codex CLI) may keep the event loop alive indefinitely - setTimeout(() => { app.exit(0); }, 3000); + leaf = null; }, + lateBindings: [ + orchestrationRef as LateBinding, + automationRef as LateBinding, + browserActionRef as LateBinding, + updaterRef as LateBinding, + ], }); } @@ -360,9 +255,9 @@ export function createServiceGraph({ getOnboardingService: () => onboardingService, getUpdaterService: () => updaterService, getAppLifecycleService: () => appLifecycleService, - getOrCreateSkillService, - getOrCreateBudgetEnforcementService, - getOrCreateSessionManagementService, + getOrCreateSkillService: () => requireLeaf().skillService, + getOrCreateBudgetEnforcementService: () => requireLeaf().budgetEnforcementService, + getOrCreateSessionManagementService: () => requireLeaf().sessionManagementService, getPiRuntime: () => piRuntimeRef.current, }; } diff --git a/src/main/service-graph/create-background-services.ts b/src/main/service-graph/create-background-services.ts new file mode 100644 index 00000000..773ef603 --- /dev/null +++ b/src/main/service-graph/create-background-services.ts @@ -0,0 +1,95 @@ +import type { AppDatabase } from "../database"; +import { BrowserManager } from "../browser-manager"; +import type { DesktopEvent } from "@shared/types"; +import { createAutomationService } from "../automation-service"; +import { createBrowserActionService } from "../browser-action-service"; +import { createOnboardingService } from "../onboarding-service"; +import { createUpdaterService } from "../updater-service"; +import type { createOrchestrationService } from "../orchestration-service"; +import type { createSkillService } from "../skill-service"; +import type { NotificationOptions } from "../notification-service"; + +export interface BackgroundServicesDeps { + db: AppDatabase; + logger: { + debug: (...args: unknown[]) => void; + info: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + }; + emitEvent: (event: DesktopEvent) => void; + publishDomainChanged: () => void; + notify: (options: NotificationOptions) => void; + isQuittingRef: { current: boolean }; + browserManagerRef: { current: BrowserManager | null }; + skillService: ReturnType; + orchestrationService: ReturnType; + seedDefaultAutomationRules: (companyId: string) => void; +} + +export interface BackgroundServices { + automationService: ReturnType; + browserActionService: ReturnType; + onboardingService: ReturnType; + updaterService: ReturnType; +} + +/** + * Build background services that run off the main dispatch loop: automation + * triggers, browser-action dispatch, onboarding hire flow, and the updater. + * Orchestration is fully assembled at this point, so all dependencies are + * concrete references rather than lazy slots. + */ +export function createBackgroundServices({ + db, + logger, + emitEvent, + publishDomainChanged, + notify, + isQuittingRef, + browserManagerRef, + skillService, + orchestrationService, + seedDefaultAutomationRules, +}: BackgroundServicesDeps): BackgroundServices { + const browserActionService = createBrowserActionService({ + db, + browserManagerRef, + publishDomainChanged, + }); + browserActionService.resetBrowserManager(); + + const dispatchBrowserAction = async (actionId: string, companyId: string) => + browserActionService.dispatchBrowserAction(actionId, companyId); + + const automationService = createAutomationService({ + db, + logger, + emitEvent, + publishDomainChanged, + notify, + wakeAgentIfPossible: (agentId, companyId, trigger) => + orchestrationService.wakeAgentIfPossible(agentId, companyId, trigger), + dispatchBrowserAction, + isQuitting: () => isQuittingRef.current, + }); + + const onboardingService = createOnboardingService({ + db, + seedDefaultAutomationRules, + queueTaskRun: (taskId, trigger, actor) => + orchestrationService.queueTaskRun(taskId, trigger, actor), + publishDomainChanged, + scanAndAssignSkills: async (companyId: string, _agentId: string) => { + // All company skills are visible to every agent via progressive disclosure. + // Seed the builtin catalog + any workspace-local SKILL.md bundles. + await skillService.seedBuiltinSkills(companyId); + await skillService.scanWorkspaceSkills(companyId); + }, + }); + + const updaterService = createUpdaterService({ db, logger, notify }); + updaterService.attachListeners(); + + return { automationService, browserActionService, onboardingService, updaterService }; +} diff --git a/src/main/service-graph/create-desktop-shell.ts b/src/main/service-graph/create-desktop-shell.ts new file mode 100644 index 00000000..5b6f84d9 --- /dev/null +++ b/src/main/service-graph/create-desktop-shell.ts @@ -0,0 +1,85 @@ +import type { IpcMainInvokeEvent } from "electron"; +import type { AppDatabase } from "../database"; +import type { DesktopEvent } from "@shared/types"; +import type { DesktopResult } from "@shared/contracts"; +import { createDesktopShellService } from "../desktop-shell-service"; +import type { NotificationOptions } from "../notification-service"; +import type { LateBinding } from "./late-binding"; +import type { createUpdaterService } from "../updater-service"; + +export interface DesktopShellDeps { + db: AppDatabase; + preloadPath: string; + rendererDist: string; + iconPath: string; + isDev: boolean; + devServerUrl?: string; + windowStatePath: string; + isQuittingRef: { current: boolean }; + emitEvent: (event: DesktopEvent) => void; + notify: (options: NotificationOptions) => void; + registerHandle: ( + channel: string, + handler: (event: IpcMainInvokeEvent, ...args: unknown[]) => Promise> | DesktopResult, + ) => void; + updaterRef: LateBinding>; + logger: { warn: (...args: unknown[]) => void }; +} + +/** + * Build the desktop shell service. `updaterRef` is late-bound because the + * updater is constructed after the shell; the menu's "Check for updates" + * handler resolves it at call time. + */ +export function createDesktopShell({ + db, + preloadPath, + rendererDist, + iconPath, + isDev, + devServerUrl, + windowStatePath, + isQuittingRef, + emitEvent, + notify, + registerHandle, + updaterRef, + logger, +}: DesktopShellDeps) { + const desktopShellService = createDesktopShellService({ + preloadPath, + rendererDist, + iconPath, + isDev, + devServerUrl, + windowStatePath, + registerHandle, + shouldQuit: () => isQuittingRef.current, + onRequestQuit: () => { + isQuittingRef.current = true; + }, + onCheckForUpdates: async () => { + await updaterRef.get().checkForUpdates(); + }, + onBackupProfile: async () => { + const target = await import("electron").then(({ dialog }) => dialog.showOpenDialog({ + properties: ["openDirectory", "createDirectory"], + })); + if (target.canceled || target.filePaths.length === 0) return; + await db.exportBackup(`${target.filePaths[0]!}/agentcompany-backup-${Date.now()}`); + }, + onShortcut: (action) => emitEvent({ type: "shortcut", action }), + notify, + getActiveRunCount: () => { + try { + const snapshot = db.listSnapshot(); + return snapshot.runs.filter((r) => r.status === "running" || r.status === "queued").length; + } catch (error) { + logger.warn("[service-graph] getActiveRunCount failed:", error); + return 0; + } + }, + }); + desktopShellService.initialize(); + return desktopShellService; +} diff --git a/src/main/service-graph/create-leaf-services.ts b/src/main/service-graph/create-leaf-services.ts new file mode 100644 index 00000000..f7b02d31 --- /dev/null +++ b/src/main/service-graph/create-leaf-services.ts @@ -0,0 +1,46 @@ +import type { AppDatabase } from "../database"; +import { createBudgetEnforcementService } from "../budget-enforcement-service"; +import { createSessionManagementService } from "../session-management-service"; +import { createSkillService } from "../skill-service"; +import type { NotificationOptions } from "../notification-service"; + +export interface LeafServicesDeps { + db: AppDatabase; + logger: { + debug: (...args: unknown[]) => void; + info: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + }; + notify: (options: NotificationOptions) => void; + publishDomainChanged: () => void; +} + +export interface LeafServices { + skillService: ReturnType; + budgetEnforcementService: ReturnType; + sessionManagementService: ReturnType; +} + +/** + * Build leaf services that no other service depends on except through this + * bundle. Constructed first so downstream factories can receive concrete + * references without null-checks. + */ +export function createLeafServices({ + db, + logger, + notify, + publishDomainChanged, +}: LeafServicesDeps): LeafServices { + const skillService = createSkillService({ db, profileDir: db.profileDir, logger }); + const budgetEnforcementService = createBudgetEnforcementService({ + db, + logger, + notify, + publishDomainChanged, + }); + const sessionManagementService = createSessionManagementService({ db, logger }); + + return { skillService, budgetEnforcementService, sessionManagementService }; +} diff --git a/src/main/service-graph/create-lifecycle.ts b/src/main/service-graph/create-lifecycle.ts new file mode 100644 index 00000000..fd0d60b0 --- /dev/null +++ b/src/main/service-graph/create-lifecycle.ts @@ -0,0 +1,145 @@ +import { app } from "electron"; +import type { AgentApiServer } from "../api-server"; +import type { AppDatabase } from "../database"; +import { BrowserManager } from "../browser-manager"; +import { createAppLifecycleService } from "../app-lifecycle-service"; +import type { createAutomationService } from "../automation-service"; +import type { createDesktopShellService } from "../desktop-shell-service"; +import type { createOrchestrationService } from "../orchestration-service"; +import type { createWorkerService } from "../worker-service"; +import type { PiRuntimeServices } from "../service-graph-pi-runtime"; +import type { LateBinding } from "./late-binding"; + +/** Overall budget for awaited shutdown drain (worker exit + API close). */ +const SHUTDOWN_BUDGET_MS = 5_000; + +export interface LifecycleDeps { + db: AppDatabase; + logger: { + debug: (...args: unknown[]) => void; + info: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + }; + isQuittingRef: { current: boolean }; + apiServerRef: { current: AgentApiServer | null }; + browserManagerRef: { current: BrowserManager | null }; + piRuntimeRef: { current: PiRuntimeServices | null }; + orchestrationService: ReturnType; + automationService: ReturnType; + workerService: ReturnType; + desktopShellService: ReturnType; + /** + * Clears graph-owned service refs (so getters return null) after the drain + * completes. Mirrors the original synchronous null-out at the tail of the + * old onBeforeQuit. + */ + clearServiceRefs: () => void; + /** + * Late bindings owned by the graph; cleared after drain so any stray + * post-quit access fails loudly rather than returning stale data. + */ + lateBindings: Array>; +} + +/** + * Build the app lifecycle service and wire a deterministic shutdown drain + * that awaits the utility-process worker's exit (and API server close) + * before calling app.exit(). On 5s timeout we log the stuck run ids and + * exit(1) so operators see an unclean shutdown rather than a silent + * force-kill. + */ +export function createLifecycle(deps: LifecycleDeps) { + const { + db, + logger, + isQuittingRef, + apiServerRef, + browserManagerRef, + piRuntimeRef, + orchestrationService, + automationService, + workerService, + desktopShellService, + clearServiceRefs, + lateBindings, + } = deps; + + let drainPromise: Promise | null = null; + + function listActiveRunIds(): string[] { + try { + return db.listSnapshot().runs + .filter((r) => r.status === "running" || r.status === "queued") + .map((r) => r.id); + } catch { + return []; + } + } + + async function drain(): Promise { + // Stop accepting new scheduled work first; cancel in-flight runs in the + // worker via the utility-process shutdown postMessage inside destroy(). + try { orchestrationService.stopAllSchedulers(); } catch (err) { logger.warn("[lifecycle] stopAllSchedulers failed:", err); } + try { automationService.destroy(); } catch (err) { logger.warn("[lifecycle] automationService.destroy failed:", err); } + try { apiServerRef.current?.stop(); } catch (err) { logger.warn("[lifecycle] apiServer.stop failed:", err); } + apiServerRef.current = null; + try { browserManagerRef.current?.shutdown(); } catch (err) { logger.warn("[lifecycle] browserManager.shutdown failed:", err); } + browserManagerRef.current = null; + + // workerService.destroy() sends "shutdown" to the utility process and has + // its own 2s SIGKILL safety net. Awaiting the worker's exit event closes + // the remaining event-loop holder (the utilityProcess handle). + const workerExit = new Promise((resolve) => { + const worker = workerService.getWorker(); + if (!worker) { resolve(); return; } + worker.once("exit", () => resolve()); + }); + workerService.destroy(); + + const timeout = new Promise<"timeout">((resolve) => { + const handle = setTimeout(() => resolve("timeout"), SHUTDOWN_BUDGET_MS); + if (typeof handle.unref === "function") handle.unref(); + }); + const outcome = await Promise.race([ + workerExit.then(() => "drained" as const), + timeout, + ]); + + try { desktopShellService.destroy(); } catch (err) { logger.warn("[lifecycle] desktopShell.destroy failed:", err); } + try { piRuntimeRef.current?.release(); } catch (err) { logger.warn("[lifecycle] piRuntime.release failed:", err); } + piRuntimeRef.current = null; + try { db.close(); } catch (err) { logger.warn("[lifecycle] db.close failed:", err); } + + clearServiceRefs(); + for (const binding of lateBindings) binding.clear(); + + if (outcome === "timeout") { + const stuck = listActiveRunIds(); + logger.error( + `[lifecycle] Drain exceeded ${SHUTDOWN_BUDGET_MS}ms; stuck runs: ${stuck.length ? stuck.join(", ") : ""}`, + ); + return 1; + } + return 0; + } + + const appLifecycleService = createAppLifecycleService({ + getMainWindow: () => desktopShellService.getMainWindow(), + ensureWindow: async () => desktopShellService.ensureWindow(), + shouldQuit: () => isQuittingRef.current, + onBeforeQuit: () => { + if (drainPromise) return; + isQuittingRef.current = true; + drainPromise = drain().catch((err) => { + logger.error("[lifecycle] drain threw unexpectedly:", err); + return 1; + }); + drainPromise.then((code) => { + app.exit(code); + }); + }, + }); + + return appLifecycleService; +} diff --git a/src/main/service-graph/create-orchestration-service.ts b/src/main/service-graph/create-orchestration-service.ts new file mode 100644 index 00000000..5f831b07 --- /dev/null +++ b/src/main/service-graph/create-orchestration-service.ts @@ -0,0 +1,111 @@ +import type { AppDatabase } from "../database"; +import type { DesktopEvent } from "@shared/types"; +import { createOrchestrationService } from "../orchestration-service"; +import type { createBudgetEnforcementService } from "../budget-enforcement-service"; +import type { createSessionManagementService } from "../session-management-service"; +import type { createSkillService } from "../skill-service"; +import type { createWorkerService } from "../worker-service"; +import type { createAutomationService, AutomationEventContext } from "../automation-service"; +import type { createBrowserActionService } from "../browser-action-service"; +import type { NotificationOptions } from "../notification-service"; +import type { LateBinding } from "./late-binding"; + +export interface OrchestrationServiceDeps { + db: AppDatabase; + logger: { + debug: (...args: unknown[]) => void; + info: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + }; + publishDomainChanged: () => void; + emitEvent: (event: DesktopEvent) => void; + notify: (options: NotificationOptions) => void; + createRunToken: (agentId: string, companyId: string, runId: string) => string; + getApiPort: () => number; + isQuittingRef: { current: boolean }; + skillService: ReturnType; + budgetService: ReturnType; + sessionService: ReturnType; + workerService: ReturnType; + automationRef: LateBinding>; + browserActionRef: LateBinding>; + checkpointEngine?: { shouldPauseHeartbeat: (companyId: string, agentId: string) => boolean }; +} + +/** + * Build the orchestration service with resolved dependencies. Late bindings + * for automation and browser-action are required because orchestration's + * dispatchAutomation closure is invoked long after assembly — the slots are + * filled in the same init() pass, before any scheduler tick. + */ +export function buildOrchestrationService({ + db, + logger, + publishDomainChanged, + emitEvent, + notify, + createRunToken, + getApiPort, + isQuittingRef, + skillService, + budgetService, + sessionService, + workerService, + automationRef, + checkpointEngine, +}: OrchestrationServiceDeps) { + const dispatchAutomation = (trigger: string, ctx: AutomationEventContext) => { + automationRef.get().dispatchAutomation(trigger, ctx); + }; + + return createOrchestrationService({ + db, + logger, + publishDomainChanged, + emitEvent, + notify, + createRunToken, + getApiPort, + dispatchAutomation, + hasWorkerReady: () => Boolean(workerService.getWorker()), + enqueueWorkerRun: (payload) => { + workerService.getWorker()?.postMessage({ type: "enqueue-run", payload }); + }, + isQuitting: () => isQuittingRef.current, + listRuntimeSkillEntries: (companyId, agentId, connectorId) => { + try { + return skillService.listRuntimeSkillEntries(companyId, agentId, connectorId); + } catch (error) { + logger.warn(`[service-graph] listRuntimeSkillEntries failed for agent ${agentId}:`, error); + return []; + } + }, + budgetService, + checkpointEngine, + getSessionHandoff: (taskId) => { + try { + return sessionService.getHandoffContext(taskId); + } catch (error) { + logger.warn(`[service-graph] getSessionHandoff failed for task ${taskId}:`, error); + return null; + } + }, + evaluatePreRunSessionRotation: (taskId, connectorId) => { + try { + const activeSession = db.getActiveTaskSession(taskId); + if (!activeSession) return { rotated: false, handoff: null }; + const result = sessionService.evaluateRotation(activeSession, connectorId); + if (result.shouldRotate) { + sessionService.rotateSession(activeSession); + const handoff = sessionService.getHandoffContext(taskId); + return { rotated: true, handoff }; + } + return { rotated: false, handoff: null }; + } catch (error) { + logger.warn(`[service-graph] evaluatePreRunSessionRotation failed for task ${taskId}:`, error); + return { rotated: false, handoff: null }; + } + }, + }); +} diff --git a/src/main/service-graph/create-worker-service.ts b/src/main/service-graph/create-worker-service.ts new file mode 100644 index 00000000..b26464eb --- /dev/null +++ b/src/main/service-graph/create-worker-service.ts @@ -0,0 +1,87 @@ +import type { AppDatabase } from "../database"; +import type { DesktopEvent, RunRecord } from "@shared/types"; +import { createWorkerService } from "../worker-service"; +import type { createOrchestrationService } from "../orchestration-service"; +import type { createAutomationService } from "../automation-service"; +import type { createBudgetEnforcementService } from "../budget-enforcement-service"; +import type { createSessionManagementService } from "../session-management-service"; +import type { PiRuntimeServices } from "../service-graph-pi-runtime"; +import type { NotificationOptions } from "../notification-service"; +import type { LateBinding } from "./late-binding"; + +export interface WorkerServiceDeps { + db: AppDatabase; + logger: { + debug: (...args: unknown[]) => void; + info: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + }; + workerPath: string; + isQuittingRef: { current: boolean }; + publishDomainChanged: () => void; + emitEvent: (event: DesktopEvent) => void; + orchestrationRef: LateBinding>; + automationRef: LateBinding>; + onRunStarted?: (runId: string, taskId: string, agentId: string, companyId: string) => void; + onRunCompleted?: (runId: string, taskId: string, agentId: string, companyId: string, status: string, summary: string | null, errorMessage: string | null) => void; + notify: (options: NotificationOptions) => void; + budgetService: ReturnType; + sessionService: ReturnType; + piRuntimeRef: { current: PiRuntimeServices | null }; +} + +/** + * Build the worker service. Orchestration and automation are late-bound via + * LateBinding slots because they are constructed after the worker but close + * over the worker's dispatch functions. + */ +export function buildWorkerService(deps: WorkerServiceDeps) { + const { + db, + logger, + workerPath, + isQuittingRef, + publishDomainChanged, + emitEvent, + orchestrationRef, + automationRef, + onRunStarted, + onRunCompleted, + notify, + budgetService, + sessionService, + piRuntimeRef, + } = deps; + + const wakeAgentIfPossible = (agentId: string | null | undefined, companyId: string, trigger: string) => + orchestrationRef.get().wakeAgentIfPossible(agentId, companyId, trigger); + + const dispatchRunToWorker: Parameters[0]["dispatchRunToWorker"] = (params) => + orchestrationRef.get().dispatchRunToWorker(params); + + const processRunCompletion = ( + run: RunRecord, + event: Parameters["processRunCompletion"]>[1], + ) => { + automationRef.get().processRunCompletion(run, event); + }; + + return createWorkerService({ + db, + logger, + workerPath, + isQuitting: () => isQuittingRef.current, + publishDomainChanged, + emitEvent, + wakeAgentIfPossible, + dispatchRunToWorker, + processRunCompletion, + onRunStarted, + onRunCompleted, + notify, + budgetService, + sessionService, + getRuntimeBridge: () => piRuntimeRef.current?.runtimeBridge ?? null, + }); +} diff --git a/src/main/service-graph/late-binding.ts b/src/main/service-graph/late-binding.ts new file mode 100644 index 00000000..69271e53 --- /dev/null +++ b/src/main/service-graph/late-binding.ts @@ -0,0 +1,33 @@ +/** + * One-time settable slot for services that participate in a cycle during + * assembly. `set()` may be called exactly once; `get()` throws until the + * slot has been set. Eliminates the lazy-null `let` pattern that service-graph + * previously used to wire `workerService.dispatchRunToWorker` against + * `orchestrationService` before the latter exists. + */ +export class LateBinding { + private value: T | null = null; + private assigned = false; + + constructor(private readonly label: string) {} + + set(value: T): void { + if (this.assigned) { + throw new Error(`LateBinding<${this.label}> is already assigned.`); + } + this.value = value; + this.assigned = true; + } + + get(): T { + if (!this.assigned || this.value === null) { + throw new Error(`LateBinding<${this.label}> accessed before assignment.`); + } + return this.value; + } + + clear(): void { + this.value = null; + this.assigned = false; + } +} diff --git a/src/main/skill-format.ts b/src/main/skill-format.ts index 4fe3794c..c7d24a7d 100644 --- a/src/main/skill-format.ts +++ b/src/main/skill-format.ts @@ -1,5 +1,5 @@ import { extname } from "node:path"; -import type { FileInventoryEntry, SkillContextMode, TrustLevel } from "./skill-types.js"; +import type { FileInventoryEntry, TrustLevel } from "./skill-types.js"; function isPlainRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -220,56 +220,3 @@ export function deriveTrustLevel(inventory: FileInventoryEntry[]): TrustLevel { return "markdown_only"; } -/** - * Estimate the token count for a text string. - * Uses a rough 1:4 character-to-token ratio. - */ -export function estimateTokens(text: string): number { - if (text.length === 0) return 0; - return Math.ceil(text.length / 4); -} - -/** - * Runtime semantics extracted from a skill's YAML frontmatter. - * These fields control how the skill interacts with the execution runtime. - */ -export interface SkillRuntimeSemantics { - allowedTools: string[]; - hooks: string[]; - contextMode: SkillContextMode; - isAgent: boolean; - effort: "low" | "medium" | "high"; - whenToUse: string; -} - -function toStringArray(value: unknown): string[] { - if (Array.isArray(value)) return value.filter((v): v is string => typeof v === "string"); - if (typeof value === "string") return value.split(",").map((s) => s.trim()).filter(Boolean); - return []; -} - -/** - * Extract runtime semantics from parsed frontmatter. - * Missing fields default to safe, passive values. - */ -export function extractRuntimeSemantics( - frontmatter: Record, -): SkillRuntimeSemantics { - const contextRaw = typeof frontmatter.context === "string" ? frontmatter.context : "inline"; - const contextMode: SkillContextMode = contextRaw === "fork" ? "fork" : "inline"; - - const effortRaw = typeof frontmatter.effort === "string" ? frontmatter.effort : "medium"; - const validEfforts = new Set(["low", "medium", "high"]); - const effort: "low" | "medium" | "high" = validEfforts.has(effortRaw) - ? (effortRaw as "low" | "medium" | "high") - : "medium"; - - return { - allowedTools: toStringArray(frontmatter["allowed-tools"] ?? frontmatter.allowedTools), - hooks: toStringArray(frontmatter.hooks), - contextMode, - isAgent: frontmatter.agent === true || frontmatter.agent === "true", - effort, - whenToUse: typeof frontmatter.when_to_use === "string" ? frontmatter.when_to_use : "", - }; -} diff --git a/src/main/skill-types.ts b/src/main/skill-types.ts index 04085edb..b16c7642 100644 --- a/src/main/skill-types.ts +++ b/src/main/skill-types.ts @@ -3,43 +3,22 @@ export type { SkillSourceType, TrustLevel, FileInventoryEntry, SkillRecord, SkillImportResult, SkillUpdateStatus } from "@shared/types"; /** - * Skill execution context mode — controls how the skill is invoked. - * - "inline": skill content is injected into the current prompt (default) - * - "fork": skill runs in an isolated context / sub-conversation + * Runtime projection of a company skill, matching pi-coding-agent's progressive- + * disclosure model: only name/description/filePath/baseDir are exposed to the + * prompt. The full SKILL.md is loaded on-demand by the agent via the `read` tool. + * + * Fields mirror pi's `Skill` interface (the full Skill has sourceInfo + + * disableModelInvocation, which we don't need to forward to the prompt layer). */ -export type SkillContextMode = "inline" | "fork"; - -/** - * Runtime representation of a skill entry for adapter injection. - * Includes strong semantic fields inspired by Reference's skill loader: - * allowed-tools, hooks, context mode, agent flag, effort hint. - */ -export interface RuntimeSkillEntry { - runtimeName: string; - sourcePath: string; +export interface ProjectedSkill { + /** Skill name (Agent Skills spec: lowercase-kebab, matches parent dir). */ name: string; - tokenEstimate: number; - /** Tools this skill is allowed to use. Empty = unrestricted. */ - allowedTools: string[]; - /** Lifecycle hooks the skill registers (e.g., "on_task_start", "on_run_complete"). */ - hooks: string[]; - /** Context mode: "inline" (default) or "fork" (isolated sub-conversation). */ - contextMode: SkillContextMode; - /** Whether this skill acts as an autonomous agent (true) or passive knowledge (false). */ - isAgent: boolean; - /** Effort hint: "low", "medium", "high". Informs scheduling and token budget allocation. */ - effort: "low" | "medium" | "high"; - /** When this skill should be activated. Empty = always active when assigned. */ - whenToUse: string; -} - -/** - * Minimal skill reference passed to worker threads. - * Subset of RuntimeSkillEntry to minimize serialization overhead. - */ -export interface ManagedSkillRef { - runtimeName: string; - sourcePath: string; + /** Skill description shown in the prompt manifest. */ + description: string; + /** Absolute path to the SKILL.md file. */ + filePath: string; + /** Directory containing SKILL.md (used for relative-path resolution). */ + baseDir: string; } /** @@ -49,30 +28,3 @@ export interface SkillAssignment { agentId: string; skillId: string; } - -/** - * Default token budget used when a connector does not declare an override. - */ -const DEFAULT_TOKEN_BUDGET = 30000; - -/** - * Per-connector token budgets for skill content injection. - * Values represent the maximum token count of skill content that can be - * included in a single prompt for a given connector. - */ -export const TOKEN_BUDGETS: Readonly> = { - // High-context Anthropic-style connectors run with a larger injection budget. - "claude-pro": 50000, - "anthropic": 50000, - "amazon-bedrock": 50000, - "gemini-cli": 50000, - "google": 50000, - "google-vertex": 50000, -}; - -/** - * Resolves the token budget for a given connector id. - */ -export function resolveTokenBudget(connectorId: string): number { - return TOKEN_BUDGETS[connectorId] ?? DEFAULT_TOKEN_BUDGET; -} diff --git a/src/main/skills/index.ts b/src/main/skills/index.ts index 6e52c6fe..140b1afd 100644 --- a/src/main/skills/index.ts +++ b/src/main/skills/index.ts @@ -58,7 +58,5 @@ export function createSkillService({ db, profileDir, logger }: SkillServiceDepen listRuntimeSkillEntries: runtime.listRuntimeSkillEntries, scanWorkspaceSkills: runtime.scanWorkspaceSkills, seedBuiltinSkills: runtime.seedBuiltinSkills, - assignBasicSkillsToAgent: runtime.assignBasicSkillsToAgent, - assignRoleSkillsToAgent: runtime.assignRoleSkillsToAgent, }; } diff --git a/src/main/skills/runtime.ts b/src/main/skills/runtime.ts index 10426ed7..85d665d6 100644 --- a/src/main/skills/runtime.ts +++ b/src/main/skills/runtime.ts @@ -1,29 +1,8 @@ import { existsSync, statSync } from "node:fs"; import { join } from "node:path"; +import { loadSkills } from "@mariozechner/pi-coding-agent"; import type { AppDatabase } from "../database"; -import type { SkillImportResult, RuntimeSkillEntry } from "../skill-types.js"; -import { estimateTokens, parseFrontmatterMarkdown, extractRuntimeSemantics } from "../skill-format.js"; -import { resolveTokenBudget } from "../skill-types.js"; - -/** - * Role/department keywords → builtin skill category manifest. Centralized at - * module scope so future changes do not silently drift from the category - * names used by `meta.builtinCategory` on disk. - * - * Category names mirror SKILL.md directory names (mixed-case is - * load-bearing; e.g. "iOS-Dev" and "Video-maker" match file system layout). - */ -const ROLE_SKILL_CATEGORIES: Record = { - ceo: ["basic-for-all", "software-engineer", "document-process", "frontend-design", "marketing", "iOS-Dev", "my-writing-style", "Video-maker"], - engineer: ["basic-for-all", "software-engineer"], - designer: ["basic-for-all", "frontend-design"], - marketing: ["basic-for-all", "marketing", "my-writing-style"], - writer: ["basic-for-all", "document-process", "my-writing-style"], - ios: ["basic-for-all", "software-engineer", "iOS-Dev"], - mobile: ["basic-for-all", "software-engineer", "iOS-Dev"], - video: ["basic-for-all", "Video-maker"], - frontend: ["basic-for-all", "software-engineer", "frontend-design"], -}; +import type { SkillImportResult, ProjectedSkill } from "../skill-types.js"; interface RuntimeDeps { db: AppDatabase; @@ -40,55 +19,35 @@ interface RuntimeDeps { export function createSkillRuntime({ db, logger, skillsRoot, skillFilesDir, importFromLocal }: RuntimeDeps) { /** - * Build the list of RuntimeSkillEntry for adapter injection. - * Applies token budget enforcement: skills sorted by creation date, - * accumulated until budget exceeded, then skipped with warning. + * Project all company skills through pi-coding-agent's progressive-disclosure + * loader. Every agent in the company sees the full catalog — descriptions + * only, full SKILL.md loaded on-demand by the agent via the `read` tool. + * + * The agentId / connectorId parameters are retained for signature + * compatibility with the orchestrator but are intentionally unused: pi's + * model has no per-agent filtering and no per-connector token budget. */ function listRuntimeSkillEntries( companyId: string, - agentId: string, - adapterType: string, - ): RuntimeSkillEntry[] { - const allSkills = db.listCompanySkills(companyId); - const agentAssignments = db.listAgentSkillAssignments(agentId); - const skills = allSkills.filter((s) => agentAssignments.includes(s.id)); + _agentId: string, + _connectorId: string, + ): ProjectedSkill[] { + const companySkills = db.listCompanySkills(companyId); + const skillPaths = companySkills.map((s) => skillFilesDir(companyId, s.slug)); + const { skills, diagnostics } = loadSkills({ skillPaths, includeDefaults: false }); - const sorted = [...skills].sort( - (a, b) => a.createdAt.localeCompare(b.createdAt), - ); - - const budget = resolveTokenBudget(adapterType); - let accumulated = 0; - const entries: RuntimeSkillEntry[] = []; - - for (const skill of sorted) { - const tokenEstimate = estimateTokens(skill.markdown); - if (accumulated + tokenEstimate > budget) { - logger.warn( - `Token budget exceeded for ${adapterType}: skipping skill "${skill.name}" (${tokenEstimate} tokens, budget ${budget}, used ${accumulated})`, - ); - continue; + for (const diag of diagnostics) { + if (diag.type === "warning") { + logger.warn(`[skills] ${diag.message} (${diag.path})`); } - accumulated += tokenEstimate; - - const { frontmatter } = parseFrontmatterMarkdown(skill.markdown); - const semantics = extractRuntimeSemantics(frontmatter); - - entries.push({ - runtimeName: skill.slug, - sourcePath: skillFilesDir(companyId, skill.slug), - name: skill.name, - tokenEstimate, - allowedTools: semantics.allowedTools, - hooks: semantics.hooks, - contextMode: semantics.contextMode, - isAgent: semantics.isAgent, - effort: semantics.effort, - whenToUse: semantics.whenToUse, - }); } - return entries; + return skills.map((s) => ({ + name: s.name, + description: s.description, + filePath: s.filePath, + baseDir: s.baseDir, + })); } /** @@ -162,7 +121,14 @@ export function createSkillRuntime({ db, logger, skillsRoot, skillFilesDir, impo const { readdirSync, statSync: statSyncLocal } = await import("node:fs"); categories = readdirSync(builtinDir).filter(name => { if (name.startsWith(".")) return false; - try { return statSyncLocal(join(builtinDir, name)).isDirectory(); } catch { return false; } + try { + return statSyncLocal(join(builtinDir, name)).isDirectory(); + } catch (error) { + logger.warn( + `[skills] statSync failed for builtin entry ${name}: ${error instanceof Error ? error.message : String(error)}`, + ); + return false; + } }); } catch { logger.warn(`[skills] Failed to read builtin skills directory: ${builtinDir}`); @@ -182,7 +148,11 @@ export function createSkillRuntime({ db, logger, skillsRoot, skillFilesDir, impo existingMeta.sourceKind = "builtin"; db.updateCompanySkillMetadata(companyId, result.skillId, JSON.stringify(existingMeta)); } - } catch { /* non-fatal metadata update */ } + } catch (error) { + logger.warn( + `[skills] metadata update failed for skill ${result.skillId} (category ${category}): ${error instanceof Error ? error.message : String(error)}`, + ); + } } allResults.push(...results); logger.info(`[skills] Seeded ${results.length} builtin skill(s) from category: ${category}`); @@ -195,57 +165,9 @@ export function createSkillRuntime({ db, logger, skillsRoot, skillFilesDir, impo return allResults; } - function assignBasicSkillsToAgent(companyId: string, agentId: string): number { - const allSkills = db.listCompanySkills(companyId); - let assigned = 0; - for (const skill of allSkills) { - try { - const meta = skill.metadata ? JSON.parse(typeof skill.metadata === "string" ? skill.metadata : JSON.stringify(skill.metadata)) : {}; - if (meta.builtinCategory === "basic-for-all") { - db.assignSkillToAgent(agentId, skill.id); - assigned++; - } - } catch { /* skip malformed metadata */ } - } - return assigned; - } - - function assignRoleSkillsToAgent(companyId: string, agentId: string, role: string, department?: string | null): number { - const allSkills = db.listCompanySkills(companyId); - let assigned = 0; - - const normalizedRole = role.toLowerCase(); - const normalizedDept = department?.toLowerCase() ?? ""; - - let categories = ROLE_SKILL_CATEGORIES[normalizedRole] ?? ["basic-for-all"]; - - for (const [key, cats] of Object.entries(ROLE_SKILL_CATEGORIES)) { - if (normalizedDept.includes(key) || normalizedRole.includes(key)) { - categories = [...new Set([...categories, ...cats])]; - } - } - - if (normalizedRole === "ceo" || normalizedRole.includes("chief")) { - categories = ROLE_SKILL_CATEGORIES.ceo ?? ["basic-for-all"]; - } - - for (const skill of allSkills) { - try { - const meta = skill.metadata ? JSON.parse(typeof skill.metadata === "string" ? skill.metadata : JSON.stringify(skill.metadata)) : {}; - if (meta.builtinCategory && categories.includes(meta.builtinCategory)) { - db.assignSkillToAgent(agentId, skill.id); - assigned++; - } - } catch { /* skip */ } - } - return assigned; - } - return { listRuntimeSkillEntries, scanWorkspaceSkills, seedBuiltinSkills, - assignBasicSkillsToAgent, - assignRoleSkillsToAgent, }; } diff --git a/src/main/task-quality-gates.ts b/src/main/task-quality-gates.ts index 3d98c0d0..feb09a8a 100644 --- a/src/main/task-quality-gates.ts +++ b/src/main/task-quality-gates.ts @@ -202,17 +202,7 @@ export function sweepStaleReviews( const isCofounderMode = deps.getCompanyExecutionMode(companyId) === "ai_cofounder"; let escalatedCount = 0; - const reviewTasks = deps.db.queryAll<{ - id: string; - assigneeAgentId: string | null; - updatedAt: string; - title: string; - }>( - `SELECT id, assignee_agent_id as assigneeAgentId, updated_at as updatedAt, title - FROM tasks - WHERE company_id = ? AND status = 'in_review'`, - companyId, - ); + const reviewTasks = deps.db.listReviewTasksForSweep(companyId); for (const task of reviewTasks) { const reviewAge = now - new Date(task.updatedAt).getTime(); @@ -227,26 +217,20 @@ export function sweepStaleReviews( const chain = deps.db.getChainOfCommand(reviewer.id); const escalationTarget = chain.length > 0 ? chain[0] : null; - const pendingApprovals = deps.db.queryAll<{ id: string; type: string }>( - `SELECT id, type FROM approvals - WHERE company_id = ? AND related_task_id = ? AND state = 'pending' - LIMIT 1`, - companyId, - task.id, - ); + const pendingApproval = deps.db.findPendingApprovalForTask(companyId, task.id); // In ai_cofounder mode, ALL approvals require human decision — never auto-advance. - if (isCofounderMode && pendingApprovals.length > 0) { + if (isCofounderMode && pendingApproval) { deps.notify({ title: "Your Review Required", body: `"${task.title}" is waiting for your review. AI Co-Founder mode requires human sign-off.`, urgency: "critical", - navigation: { section: "approvals", entityId: pendingApprovals[0]?.id }, + navigation: { section: "approvals", entityId: pendingApproval.id }, batchKey: "cofounder_review_needed", }); deps.logger.info("[sweepStaleReviews] Skipping auto-approval for co-founder task", { taskId: task.id, - approvalType: pendingApprovals[0]?.type, + approvalType: pendingApproval.type, }); continue; } @@ -283,13 +267,13 @@ export function sweepStaleReviews( // D-10: Auto-approve any pending approval linked to this task. // In ai_cofounder mode, protected approval types are already handled above (continue). - for (const approval of pendingApprovals) { + if (pendingApproval) { try { deps.resolveApprovalDecision({ db: deps.db, handleApprovalResolved: deps.handleApprovalResolved, companyId, - approvalId: approval.id, + approvalId: pendingApproval.id, state: "approved", decisionNote: "Auto-approved: review timeout exceeded 30 minutes.", detail: "Auto-approved by review timeout escalation", diff --git a/src/main/vault.ts b/src/main/vault.ts index b8fce6d8..c780fc67 100644 --- a/src/main/vault.ts +++ b/src/main/vault.ts @@ -3,11 +3,19 @@ import { closeSync, mkdirSync, openSync, readFileSync, writeSync } from "node:fs import { join } from "node:path"; import { safeStorage } from "electron"; +export interface SecretVaultOptions { + /** When true, the AES-GCM file-key fallback is refused even if the env flag + * is set. Packaged main-process callers should pass `app.isPackaged`. */ + isPackaged?: boolean; +} + export class SecretVault { private readonly keyPath: string; + private readonly isPackaged: boolean; - constructor(private readonly profileDir: string) { + constructor(private readonly profileDir: string, options: SecretVaultOptions = {}) { this.keyPath = join(profileDir, "vault.key"); + this.isPackaged = options.isPackaged === true; } getBackendLabel(): string { @@ -104,6 +112,9 @@ export class SecretVault { } private allowInsecureVaultFallback() { + // Hard gate: the AES-GCM fallback is a dev-only escape hatch. Packaged + // builds must refuse it regardless of the env flag. + if (this.isPackaged) return false; return process.env.AGENTCOMPANY_ALLOW_INSECURE_VAULT === "true"; } } diff --git a/src/main/worker-service.ts b/src/main/worker-service.ts index 2031bc20..f798df42 100644 --- a/src/main/worker-service.ts +++ b/src/main/worker-service.ts @@ -151,7 +151,13 @@ export function createWorkerService({ // exits — otherwise the pending timer keeps the event loop alive. const ref = worker; const killTimer = setTimeout(() => { - try { ref.kill(); } catch { /* already dead */ } + try { + ref.kill(); + } catch (error) { + logger.debug( + `[worker-service] worker kill() failed (likely already dead): ${error instanceof Error ? error.message : String(error)}`, + ); + } }, 2000); ref.once("exit", () => clearTimeout(killTimer)); worker = null; @@ -180,7 +186,11 @@ export function createWorkerService({ db.releaseTaskLock(run.taskId, run.id); } } - } catch { /* best effort during shutdown */ } + } catch (error) { + logger.warn( + `[worker-service] interrupted-run cleanup during shutdown failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } } return { diff --git a/src/main/worker/failure-escalation.ts b/src/main/worker/failure-escalation.ts index 4e32c088..dd41b6bc 100644 --- a/src/main/worker/failure-escalation.ts +++ b/src/main/worker/failure-escalation.ts @@ -76,8 +76,10 @@ export function handleFailureEscalation( `[escalation] Escalated task ${run.taskId} to manager ${chain[0].name} (${chain[0].id})`, ); } - } catch { - // Chain lookup failed (agent deleted) + } catch (error) { + logger.warn( + `[escalation] Chain-of-command lookup failed for agent ${run.agentId}: ${error instanceof Error ? error.message : String(error)}`, + ); } if (!escalated) { @@ -90,7 +92,11 @@ export function handleFailureEscalation( `[escalation] Escalated task ${run.taskId} to CEO ${ceo.name} (${ceo.id})`, ); } - } catch { /* CEO lookup failed */ } + } catch (error) { + logger.warn( + `[escalation] CEO lookup failed for company ${run.companyId}: ${error instanceof Error ? error.message : String(error)}`, + ); + } } if (!escalated) { @@ -111,8 +117,13 @@ export function handleFailureEscalation( export function handleSuccessReset( db: EscalationDeps["db"], taskId: string, + logger?: EscalationDeps["logger"], ): void { try { db.resetTaskRetryCount(taskId); - } catch { /* best effort */ } + } catch (error) { + logger?.warn( + `[escalation] resetTaskRetryCount failed for task ${taskId}: ${error instanceof Error ? error.message : String(error)}`, + ); + } } diff --git a/src/main/worker/handlers/run-finished-helpers.ts b/src/main/worker/handlers/run-finished-helpers.ts index 4f4362c8..71e70bd2 100644 --- a/src/main/worker/handlers/run-finished-helpers.ts +++ b/src/main/worker/handlers/run-finished-helpers.ts @@ -26,7 +26,10 @@ export function finalizeRun( let run: RunRecord; try { run = db.getRun(event.runId); - } catch { + } catch (error) { + logger.warn( + `[finalizeRun] getRun(${event.runId}) failed; run may have been deleted: ${error instanceof Error ? error.message : String(error)}`, + ); return null; } @@ -55,7 +58,10 @@ export function finalizeRun( let task: TaskRecord | null = null; try { task = db.getTask(run.taskId); - } catch { + } catch (error) { + logger.warn( + `[finalizeRun] getTask(${run.taskId}) failed; task may have been deleted: ${error instanceof Error ? error.message : String(error)}`, + ); task = null; } @@ -120,7 +126,7 @@ export function emitAndNotifyCompletion( deps: HandlerDeps, rfCtx: RunFinishedContext, ): void { - const { db, emitEvent, notify } = deps; + const { db, emitEvent, notify, logger } = deps; const { run, task, runOutcome } = rfCtx; emitEvent({ type: "run-updated", runId: run.id, action: "completed" }); @@ -143,7 +149,10 @@ export function emitAndNotifyCompletion( let updatedAgent: ReturnType | null = null; try { updatedAgent = db.getAgent(run.agentId); - } catch { + } catch (error) { + logger.warn( + `[emitAndNotifyCompletion] getAgent(${run.agentId}) failed; skipping budget-paused notification: ${error instanceof Error ? error.message : String(error)}`, + ); updatedAgent = null; } @@ -177,14 +186,11 @@ export function runPostCompletionAutomation( const { destroyed, MAX_RETRY_ATTEMPTS, RETRY_COOLDOWN_MS, pendingRetryTimers } = context; const { run, task } = rfCtx; - // Policy-based incident check after run completion (Phase 5) + // Policy-based incident check after run completion (Phase 5). Reuse the + // `task` already resolved in finalizeRun instead of double-fetching with + // its own catch-and-swallow; if task is null the project-scope is null. if (budgetService && typeof event.costUsd === "number" && Number.isFinite(event.costUsd)) { - try { - const taskForBudget = db.getTask(run.taskId); - budgetService.checkAndCreateIncidents(run.companyId, run.agentId, taskForBudget?.projectId ?? null); - } catch (err) { - logger.error(`[budget] Incident check failed: ${err instanceof Error ? err.message : String(err)}`); - } + budgetService.checkAndCreateIncidents(run.companyId, run.agentId, task?.projectId ?? null); } if ((event.status === "failed" || event.status === "timed_out") && !isQuitting() && !destroyed) { @@ -252,7 +258,7 @@ export function runPostCompletionAutomation( // Reset retry count on successful completion if (event.status === "succeeded") { - handleSuccessReset(db, run.taskId); + handleSuccessReset(db, run.taskId, logger); } return { hadDeferredWakes }; @@ -294,13 +300,17 @@ export function triggerSelfWake( if (chain.length > 0) { wakeAgentIfPossible(chain[0].id, run.companyId, "report_stuck"); } - } catch { - /* best effort */ + } catch (error) { + logger.warn( + `[self-wake] chain-of-command lookup failed for agent ${run.agentId}: ${error instanceof Error ? error.message : String(error)}`, + ); } } else { wakeAgentIfPossible(run.agentId, run.companyId, "task_continuation"); } - } catch { - // Best-effort self-wake; agent will pick up work on the next heartbeat cycle. + } catch (error) { + logger.warn( + `[self-wake] self-wake evaluation failed for agent ${run.agentId}: ${error instanceof Error ? error.message : String(error)}`, + ); } } diff --git a/src/main/worker/worker-crash-recovery.ts b/src/main/worker/worker-crash-recovery.ts index 5d1440b9..ebed5018 100644 --- a/src/main/worker/worker-crash-recovery.ts +++ b/src/main/worker/worker-crash-recovery.ts @@ -92,6 +92,12 @@ export function handleWorkerExit( const recoverySnapshot = db.listSnapshot(); for (const candidate of requeueCandidates) { try { + // The crashed run may still own a workspace lock acquired during the + // original dispatch; release it before re-dispatch so the replay + // does not self-collide with "Workspace is locked by another run". + if (candidate.workspaceId) { + db.releaseWorkspaceLock(candidate.workspaceId, candidate.id); + } const task = db.getTask(candidate.taskId); const agent = db.getAgent(candidate.agentId); const connector = db.getConnector(agent.connectorId); diff --git a/src/main/worker/worker-entry.ts b/src/main/worker/worker-entry.ts index 766d4bbf..7191f2a1 100644 --- a/src/main/worker/worker-entry.ts +++ b/src/main/worker/worker-entry.ts @@ -37,33 +37,39 @@ function enqueue(job: RunJob) { status: "queued", message: "Run queued in orchestration worker.", }); - void maybeStartQueuedRuns(); + maybeStartQueuedRuns(); } -async function maybeStartQueuedRuns() { - const readyJobs = [...queue]; - for (const job of readyJobs) { - if (activeByAgent.has(job.agentId)) continue; +/** + * Walks the queue in-place and dispatches every job whose agent + workspace + * are not currently busy. Claims (activeByAgent / activeByWorkspace) are + * written BEFORE the job is removed from the queue and BEFORE `startRun` + * fires — this is the atomic point of commit for orchestrator ordering. + * Hardens against a future refactor that adds an `await` before the claim + * inside `startRun`, which would open a re-entrancy window. + */ +function maybeStartQueuedRuns() { + let i = 0; + while (i < queue.length) { + const job = queue[i]; + if (activeByAgent.has(job.agentId)) { i += 1; continue; } const lockKey = workspaceKey(job); - if (lockKey && activeByWorkspace.has(lockKey)) continue; - const index = queue.findIndex((entry) => entry.runId === job.runId); - if (index >= 0) queue.splice(index, 1); - void startRun(job); + if (lockKey && activeByWorkspace.has(lockKey)) { i += 1; continue; } + activeByAgent.set(job.agentId, job.runId); + if (lockKey) activeByWorkspace.set(lockKey, job.runId); + queue.splice(i, 1); + void startRun(job, lockKey); } } -async function startRun(job: RunJob) { - const lockKey = workspaceKey(job); - activeByAgent.set(job.agentId, job.runId); - if (lockKey) activeByWorkspace.set(lockKey, job.runId); - +async function startRun(job: RunJob, lockKey: string | null) { try { await runner.run(job); } finally { cancelledRuns.delete(job.runId); activeByAgent.delete(job.agentId); if (lockKey) activeByWorkspace.delete(lockKey); - void maybeStartQueuedRuns(); + maybeStartQueuedRuns(); } } @@ -109,8 +115,17 @@ parentPort.on("message", (event) => { return; } if (message.type === "shutdown") { - runner.cancelAll(); - rpc.cancelAll("Worker shutting down."); - process.exit(0); + void (async () => { + // Graceful drain: abort every active run and await its promise + // settlement so that final `run-finished` messages and + // `persistMessage` RPCs reach the parent before we exit. Bounded by + // a 5s budget so a stuck run does not wedge the shutdown handshake + // (the parent has its own kill-timer as a safety net). + const drainBudgetMs = 5_000; + const timeout = new Promise((resolve) => setTimeout(resolve, drainBudgetMs)); + await Promise.race([runner.cancelAll(), timeout]); + rpc.cancelAll("Worker shutting down."); + process.exit(0); + })(); } }); diff --git a/src/main/worker/worker-helpers.ts b/src/main/worker/worker-helpers.ts index 06458045..359ac41e 100644 --- a/src/main/worker/worker-helpers.ts +++ b/src/main/worker/worker-helpers.ts @@ -19,7 +19,6 @@ export interface RunJob { sessionParams: Record | null; systemPrompt: string | null; trigger: string | null; - managedSkills: Array<{ runtimeName: string; sourcePath: string }>; taskSessionId: string; executionMode: "ai_ceo" | "ai_cofounder"; } @@ -80,7 +79,16 @@ export function buildRedactor(env: Record): (text: string) => st }; } -/** Pushes extra env vars onto process.env and returns a restore function. */ +/** + * Pushes extra env vars onto `process.env` and returns a restore function + * that is **infallible** — each individual restore is guarded so a single + * malformed key cannot leave a partial restore that keeps a provider + * secret in the worker's env for the next run. Intended for short-lived + * turn-scope env (`AGENT_COMPANY_*`, connector secrets). The worker runs + * one agent at a time, so this is safe for the current scheduler; do not + * convert it to concurrent run isolation — pass per-spawn env at the + * child-process level instead. + */ export function applyEnvOverrides(overrides: Record): () => void { const saved: Record = {}; for (const [key, value] of Object.entries(overrides)) { @@ -89,8 +97,15 @@ export function applyEnvOverrides(overrides: Record): () => void } return () => { for (const [key, previous] of Object.entries(saved)) { - if (previous === undefined) delete process.env[key]; - else process.env[key] = previous; + try { + if (previous === undefined) delete process.env[key]; + else process.env[key] = previous; + } catch { + // Individual restore failure is never fatal — move on so the + // remaining keys still revert. Should never happen in practice + // (the Node env-var assignment is synchronous and infallible for + // string values), but the guard lets the outer finally complete. + } } }; } diff --git a/src/main/worker/worker-message-handler.ts b/src/main/worker/worker-message-handler.ts index 91352b14..69c59a71 100644 --- a/src/main/worker/worker-message-handler.ts +++ b/src/main/worker/worker-message-handler.ts @@ -42,9 +42,14 @@ export async function handleWorkerMessage( case "run-status": handleRunStatus(event, deps, context); return; - case "run-finished": - handleRunFinished(event, deps, context); + case "run-finished": { + try { + handleRunFinished(event, deps, context); + } finally { + deps.getRuntimeBridge?.()?.releaseRunResources(event.runId); + } return; + } case "agent-event": { const bridge = deps.getRuntimeBridge?.(); if (bridge) { diff --git a/src/main/worker/worker-rpc.ts b/src/main/worker/worker-rpc.ts index 719ac3bb..29c88beb 100644 --- a/src/main/worker/worker-rpc.ts +++ b/src/main/worker/worker-rpc.ts @@ -3,17 +3,33 @@ import type { ChildMessage } from "./worker-helpers"; interface PendingRpc { resolve: (value: { ok: true; result: unknown } | { ok: false; error: string }) => void; + timer: NodeJS.Timeout; } +const DEFAULT_RPC_TIMEOUT_MS = 30_000; + export class WorkerRpcClient { private readonly pending = new Map(); - constructor(private readonly post: (message: ChildMessage) => void) {} + constructor( + private readonly post: (message: ChildMessage) => void, + private readonly timeoutMs: number = DEFAULT_RPC_TIMEOUT_MS, + ) {} - invoke(runId: string, method: string, payload: unknown): Promise<{ ok: true; result: unknown } | { ok: false; error: string }> { + invoke( + runId: string, + method: string, + payload: unknown, + ): Promise<{ ok: true; result: unknown } | { ok: false; error: string }> { const rpcId = randomUUID(); return new Promise((resolve) => { - this.pending.set(rpcId, { resolve }); + const timer = setTimeout(() => { + const pending = this.pending.get(rpcId); + if (!pending) return; + this.pending.delete(rpcId); + pending.resolve({ ok: false, error: `RPC ${method} timed out after ${this.timeoutMs}ms` }); + }, this.timeoutMs); + this.pending.set(rpcId, { resolve, timer }); this.post({ type: "rpc-request", rpcId, runId, method, payload }); }); } @@ -21,12 +37,14 @@ export class WorkerRpcClient { resolve(rpcId: string, response: { ok: true; result: unknown } | { ok: false; error: string }): void { const pending = this.pending.get(rpcId); if (!pending) return; + clearTimeout(pending.timer); this.pending.delete(rpcId); pending.resolve(response); } cancelAll(reason: string): void { for (const [id, pending] of this.pending) { + clearTimeout(pending.timer); pending.resolve({ ok: false, error: reason }); this.pending.delete(id); } diff --git a/src/main/worker/worker-runner.ts b/src/main/worker/worker-runner.ts index 3b3a4111..5e6ab7f3 100644 --- a/src/main/worker/worker-runner.ts +++ b/src/main/worker/worker-runner.ts @@ -23,6 +23,7 @@ interface RunnerDeps { export class WorkerRunner { private readonly abortControllers = new Map(); + private readonly runPromises = new Map>(); constructor(private readonly deps: RunnerDeps) {} @@ -31,21 +32,37 @@ export class WorkerRunner { if (controller) controller.abort(); } - cancelAll(): void { + /** + * Aborts every in-flight run and returns a promise that resolves once + * each `run()` has settled (success, failure, or abort). Callers (the + * worker shutdown handler) must `await` this before `process.exit` so + * pending `persistMessage` RPCs and the final `run-finished` post reach + * the parent before the worker dies. + */ + async cancelAll(): Promise { for (const controller of this.abortControllers.values()) { controller.abort(); } + const pending = [...this.runPromises.values()]; this.abortControllers.clear(); + await Promise.allSettled(pending); } - async run(job: RunJob): Promise { + run(job: RunJob): Promise { + const promise = this.runImpl(job).finally(() => { + this.runPromises.delete(job.runId); + }); + this.runPromises.set(job.runId, promise); + return promise; + } + + private async runImpl(job: RunJob): Promise { const { post, rpc, isCancelled } = this.deps; const redact = buildRedactor(job.env); const abortController = new AbortController(); this.abortControllers.set(job.runId, abortController); post({ type: "run-status", runId: job.runId, status: "running", message: `Running agent ${job.agentName}.` }); - post({ type: "run-log", runId: job.runId, stream: "stdout", chunk: `[runner] starting run for ${job.agentName} (${job.connectorId}/${job.model ?? "?"})\n` }); let resolvedModelId: string | null = job.model; let finalUsage: { inputTokens: number | null; outputTokens: number | null; costUsd: number | null } = { @@ -55,6 +72,14 @@ export class WorkerRunner { }; let errorMessage: string | null = null; + // Inject the AgentCompany job env (AGENT_COMPANY_API_URL / API_KEY / + // AGENT_ID / COMPANY_ID / RUN_ID / TASK_ID / WAKE_REASON + provider + // secret bindings) into the worker's process.env BEFORE Agent + // construction, so the agent's bash-tool children can call back into + // our HTTP API via fetch(process.env.AGENT_COMPANY_API_URL). Restored + // in the outer finally regardless of success / abort / throw. + const restoreJobEnv = applyEnvOverrides(job.env); + try { if (!job.model) { throw new Error(`Run ${job.runId} has no model selected.`); @@ -73,8 +98,7 @@ export class WorkerRunner { } const prepared = prepareResponse.result as PrepareRunResult; - post({ type: "run-log", runId: job.runId, stream: "stdout", chunk: `[runner] prepareRun ok, resolving model\n` }); - const restoreEnv = applyEnvOverrides(prepared.extraEnv); + const restoreProviderEnv = applyEnvOverrides(prepared.extraEnv); try { const model = resolveModel(prepared.provider, job.model); if (!model) { @@ -82,7 +106,6 @@ export class WorkerRunner { } resolvedModelId = model.id; const extraHeaders = resolveExtraStreamHeaders(prepared.provider, prepared.apiKey); - post({ type: "run-log", runId: job.runId, stream: "stdout", chunk: `[runner] model=${model.id} provider=${prepared.provider} — constructing Agent\n` }); const cwd = job.workspacePath ?? process.cwd(); const tools = createCodingTools(cwd) as never; @@ -100,36 +123,10 @@ export class WorkerRunner { apiKey: prepared.apiKey, headers: { ...(options?.headers ?? {}), ...(extraHeaders ?? {}) }, }), - beforeToolCall: async (context) => { - const verdict = await rpc.invoke(job.runId, "beforeToolCall", { - agentId: job.agentId, - companyId: job.companyId, - taskId: job.taskId, - toolCallId: context.toolCall.id, - toolName: context.toolCall.name, - args: context.args, - }); - if (verdict.ok) { - return verdict.result as { block?: boolean; reason?: string } | undefined; - } - return { block: true, reason: verdict.error }; - }, - afterToolCall: async (context) => { - await rpc.invoke(job.runId, "afterToolCall", { - toolCallId: context.toolCall.id, - toolName: context.toolCall.name, - isError: context.isError, - }); - return undefined; - }, }); const unsubscribe = agent.subscribe(async (event) => { const timestamp = new Date().toISOString(); - // Surface every pi AgentEvent type as a raw Console probe so the - // operator can see the live event stream even when projection - // misses a type or the agent is stuck inside a particular phase. - post({ type: "run-log", runId: job.runId, stream: "stdout", chunk: `[pi-event] ${event.type}\n` }); post({ type: "agent-event", runId: job.runId, @@ -166,22 +163,21 @@ export class WorkerRunner { content: [{ type: "text", text: promptContent }], timestamp: Date.now(), }; - post({ type: "run-log", runId: job.runId, stream: "stdout", chunk: `[runner] calling agent.prompt (${promptContent.length} chars)\n` }); const runPromise = agent.prompt(promptMessage); abortController.signal.addEventListener("abort", () => agent.abort(), { once: true }); await runPromise; - post({ type: "run-log", runId: job.runId, stream: "stdout", chunk: `[runner] agent.prompt resolved\n` }); } finally { unsubscribe(); } } finally { - restoreEnv(); + restoreProviderEnv(); } } catch (error) { errorMessage = error instanceof Error ? error.message : String(error); post({ type: "run-log", runId: job.runId, stream: "stderr", chunk: redact(errorMessage) }); } finally { this.abortControllers.delete(job.runId); + restoreJobEnv(); } const status: RunStatus = isCancelled(job.runId) diff --git a/src/main/worker/worker-types.ts b/src/main/worker/worker-types.ts index 2b7f74a4..0669786e 100644 --- a/src/main/worker/worker-types.ts +++ b/src/main/worker/worker-types.ts @@ -50,7 +50,7 @@ export const workerMessageSchema = z.union([ type: z.literal("rpc-request"), rpcId: z.string(), runId: z.string().uuid(), - method: z.enum(["beforeToolCall", "afterToolCall", "persistMessage", "prepareRun"]), + method: z.enum(["persistMessage", "prepareRun"]), payload: z.unknown(), }), ]); diff --git a/src/preload/index.ts b/src/preload/index.ts index 06fbf3db..1fcfdc79 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -107,29 +107,19 @@ const api: DesktopApi = { getBudgetStatus: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.getBudgetStatus, { companyId }), recoveryRetry: (taskId: string, agentId: string, companyId: string) => ipcRenderer.invoke(IPC_CHANNELS.recoveryRetry, { taskId, agentId, companyId }), - recoveryDismiss: (taskId: string) => - ipcRenderer.invoke(IPC_CHANNELS.recoveryDismiss, { taskId }), - recoveryFail: (taskId: string) => - ipcRenderer.invoke(IPC_CHANNELS.recoveryFail, { taskId }), + recoveryDismiss: (taskId: string, companyId: string) => + ipcRenderer.invoke(IPC_CHANNELS.recoveryDismiss, { taskId, companyId }), + recoveryFail: (taskId: string, companyId: string) => + ipcRenderer.invoke(IPC_CHANNELS.recoveryFail, { taskId, companyId }), getTask: (input) => ipcRenderer.invoke(IPC_CHANNELS.getTask, input), getAgent: (input) => ipcRenderer.invoke(IPC_CHANNELS.getAgent, input), getRun: (input) => ipcRenderer.invoke(IPC_CHANNELS.getRun, input), getApproval: (input) => ipcRenderer.invoke(IPC_CHANNELS.getApproval, input), - listConfigRevisions: (companyId: string, agentId: string) => - ipcRenderer.invoke(IPC_CHANNELS.listConfigRevisions, { companyId, agentId }), - getConfigRevision: (companyId: string, revisionId: string) => - ipcRenderer.invoke(IPC_CHANNELS.getConfigRevision, { companyId, revisionId }), - rollbackConfigRevision: (companyId: string, agentId: string, revisionId: string) => - ipcRenderer.invoke(IPC_CHANNELS.rollbackConfigRevision, { companyId, agentId, revisionId }), listRunEvents: (companyId: string, runId: string, opts?: { offset?: number; limit?: number }) => ipcRenderer.invoke(IPC_CHANNELS.listRunEvents, { companyId, runId, ...opts }), - getAgentPermissions: (agentId: string) => - ipcRenderer.invoke(IPC_CHANNELS.getAgentPermissions, { agentId }), - setAgentPermission: (companyId: string, agentId: string, permissionKey: string, enabled: boolean) => - ipcRenderer.invoke(IPC_CHANNELS.setAgentPermission, { companyId, agentId, permissionKey, enabled }), getHeartbeatPolicy: (agentId: string, companyId: string) => ipcRenderer.invoke(IPC_CHANNELS.getHeartbeatPolicy, { companyId, agentId }), - setHeartbeatPolicy: (companyId: string, agentId: string, policy: Record) => + setHeartbeatPolicy: (companyId, agentId, policy) => ipcRenderer.invoke(IPC_CHANNELS.setHeartbeatPolicy, { companyId, agentId, ...policy }), getRuntimeState: (agentId: string, companyId: string) => ipcRenderer.invoke(IPC_CHANNELS.getRuntimeState, { companyId, agentId }), @@ -159,10 +149,19 @@ const api: DesktopApi = { }; }, platform: process.platform as "win32" | "darwin" | "linux", - windowMinimize: () => ipcRenderer.invoke(IPC_CHANNELS.windowMinimize), - windowMaximize: () => ipcRenderer.invoke(IPC_CHANNELS.windowMaximize), - windowClose: () => ipcRenderer.invoke(IPC_CHANNELS.windowClose), - windowIsMaximized: () => ipcRenderer.invoke(IPC_CHANNELS.windowIsMaximized) as Promise, + windowMinimize: async () => { + await ipcRenderer.invoke(IPC_CHANNELS.windowMinimize); + }, + windowMaximize: async () => { + await ipcRenderer.invoke(IPC_CHANNELS.windowMaximize); + }, + windowClose: async () => { + await ipcRenderer.invoke(IPC_CHANNELS.windowClose); + }, + windowIsMaximized: async () => { + const result = (await ipcRenderer.invoke(IPC_CHANNELS.windowIsMaximized)) as { ok: true; data: boolean } | { ok: false }; + return result.ok ? result.data : false; + }, // Credentials (pi-mono) listCredentials: () => ipcRenderer.invoke(IPC_CHANNELS.credentialsList), saveCredential: (input) => ipcRenderer.invoke(IPC_CHANNELS.credentialsSave, input), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 050a7d7c..13e8303f 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -2,7 +2,7 @@ import { useCallback, useMemo, type ReactNode } from "react"; import { CircleNotch, TerminalWindow } from "./components/ui/icons"; import { LocaleContext, TranslationContext, type TranslationFn } from "./i18n"; import type { AppLocale } from "@shared/locale"; -import { CompanyOnboarding } from "./components/CompanyOnboarding"; +import { CompanyOnboarding } from "./components/company-onboarding/OnboardingWizard"; import TitleBar from "./components/TitleBar"; import ToastContainer from "./components/Toast"; import { SidebarView } from "./views/SidebarView"; @@ -14,6 +14,7 @@ import { useUIController } from "./hooks/useUIController"; import { useCompanyController } from "./hooks/useCompanyController"; import { useCommandPaletteHotkeys } from "./hooks/useCommandPaletteHotkeys"; import { AppActionsContext, type AppActions } from "./hooks/useAppActions"; +import { SectionContext } from "./hooks/useSectionContext"; import { unwrap } from "./lib/desktop"; function Shell({ @@ -157,6 +158,7 @@ export function App() { +
{showCustomTitleBar && }
@@ -247,6 +249,7 @@ export function App() {
+
diff --git a/src/renderer/components/CompanyOnboarding.tsx b/src/renderer/components/CompanyOnboarding.tsx deleted file mode 100644 index 18d70c5c..00000000 --- a/src/renderer/components/CompanyOnboarding.tsx +++ /dev/null @@ -1,2 +0,0 @@ -// Backward-compatible re-exports from decomposed onboarding components -export { CompanyOnboarding } from "./company-onboarding/OnboardingWizard"; diff --git a/src/renderer/components/TitleBar.tsx b/src/renderer/components/TitleBar.tsx index 851b5521..f151c3c1 100644 --- a/src/renderer/components/TitleBar.tsx +++ b/src/renderer/components/TitleBar.tsx @@ -1,7 +1,9 @@ import { useState, useEffect, useCallback } from "react"; import { Copy, Minus, Square, X } from "./ui/icons"; +import { useT } from "../i18n"; export default function TitleBar() { + const t = useT(); const [maximized, setMaximized] = useState(false); const refreshMaximized = useCallback(() => { @@ -26,7 +28,7 @@ export default function TitleBar() {
{rules.length > 0 ? (
{[ - { l: "Total Rules", v: counts.total, accent: false }, - { l: "Total Executions", v: rules.reduce((s, r) => s + r.executionCount, 0), accent: true }, - { l: "Recent Activity", v: actLog.length, accent: true }, + { l: t("auto.rules.totalRules"), v: counts.total, accent: false }, + { l: t("auto.rules.totalExecutions"), v: rules.reduce((s, r) => s + r.executionCount, 0), accent: true }, + { l: t("auto.rules.recentActivity"), v: actLog.length, accent: true }, ].map((s) => ( -
-
{s.l}
+ + {s.l}
{s.v}
-
+ ))}
) : null} @@ -103,45 +103,51 @@ function RulesPanel({ rules, companyId, onRefresh }: { rules: AutomationRuleReco onClick={() => setActiveTab("rules")} className={`flex-1 rounded-lg px-3 py-1.5 text-[12px] font-medium transition ${activeTab === "rules" ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`} > - Rules ({rules.length}) + {t("auto.rules.tabRules").replace("{n}", String(rules.length))} {activeTab === "rules" ? ( <> {rules.length > 0 ? (
- {(["all", "active", "paused", "error"] as const).map((s) => ( - - ))} + {(["all", "active", "paused", "error"] as const).map((s) => { + const filterLabel = s === "all" ? t("auto.rules.filterAll") + : s === "active" ? t("auto.rules.filterActive") + : s === "paused" ? t("auto.rules.filterPaused") + : t("auto.rules.filterError"); + return ( + + ); + })}
) : null} {filtered.length === 0 && rules.length === 0 ? (
-
No automation rules
+
{t("auto.rules.noRules")}
- Create your first rule to make the company autonomous. + {t("auto.rules.noRulesDetail")}
- setShowCreate(true)} icon={Lightning} tone="accent" /> + setShowCreate(true)} icon={Lightning} tone="accent" />
) : filtered.length === 0 ? ( -
No rules match the current filter.
+
{t("auto.rules.noRulesFilter")}
) : (
@@ -159,7 +165,7 @@ function RulesPanel({ rules, companyId, onRefresh }: { rules: AutomationRuleReco )} ) : ( - + )} {showCreate ? ( @@ -171,8 +177,8 @@ function RulesPanel({ rules, companyId, onRefresh }: { rules: AutomationRuleReco void handleDelete()} onCancel={() => setDelTarget(null)} /> @@ -180,11 +186,11 @@ function RulesPanel({ rules, companyId, onRefresh }: { rules: AutomationRuleReco ) } -function WorkflowsPanel({ workflows, companyId, onRefresh }: { workflows: WorkflowPipelineRecord[]; companyId: string; onRefresh: () => void }) { +function WorkflowsPanel({ workflows, companyId, onRefresh, t }: { workflows: WorkflowPipelineRecord[]; companyId: string; onRefresh: () => void; t: TranslationFn }) { const [showCreate, setShowCreate] = useState(false); const [editWf, setEditWf] = useState(null); const [delTarget, setDelTarget] = useState(null) const [actionError, setActionError] = useState(null); const [runningId, setRunningId] = useState(null) - const handleDelete = useCallback(async () => { if (!delTarget) return; setActionError(null); try { const r = await window.agentCompany.deleteWorkflow({ id: delTarget.id, companyId }); if (!r.ok) { setActionError(r.error.message); setDelTarget(null); return }; setDelTarget(null); onRefresh() } catch (e) { setActionError(e instanceof Error ? e.message : "Failed"); setDelTarget(null) } }, [delTarget, companyId, onRefresh]) - const handleRun = useCallback(async (wf: WorkflowPipelineRecord) => { setActionError(null); setRunningId(wf.id); try { const r = await window.agentCompany.runWorkflow({ id: wf.id, companyId }); if (!r.ok) { setActionError(r.error.message); return }; onRefresh() } catch (e) { setActionError(e instanceof Error ? e.message : "Failed") } finally { setRunningId(null) } }, [companyId, onRefresh]) + const handleDelete = useCallback(async () => { if (!delTarget) return; setActionError(null); try { const r = await window.agentCompany.deleteWorkflow({ id: delTarget.id, companyId }); if (!r.ok) { setActionError(r.error.message); setDelTarget(null); return }; setDelTarget(null); onRefresh() } catch (e) { setActionError(e instanceof Error ? e.message : t("auto.failedAction")); setDelTarget(null) } }, [delTarget, companyId, onRefresh, t]) + const handleRun = useCallback(async (wf: WorkflowPipelineRecord) => { setActionError(null); setRunningId(wf.id); try { const r = await window.agentCompany.runWorkflow({ id: wf.id, companyId }); if (!r.ok) { setActionError(r.error.message); return }; onRefresh() } catch (e) { setActionError(e instanceof Error ? e.message : t("auto.failedAction")) } finally { setRunningId(null) } }, [companyId, onRefresh, t]) const counts = useMemo(() => ({ total: workflows.length, active: workflows.filter((w) => w.status === "active").length, draft: workflows.filter((w) => w.status === "draft").length }), [workflows]) return (
@@ -202,24 +208,24 @@ function WorkflowsPanel({ workflows, companyId, onRefresh }: { workflows: Workfl
-

Workflow Pipelines

+

{t("auto.workflows.title")}

- {counts.active} active, {counts.draft} draft - {counts.total > counts.active + counts.draft ? `, ${counts.total - counts.active - counts.draft} other` : ""} + {t("auto.workflows.summaryActive").replace("{n}", String(counts.active))},{t("auto.workflows.summaryDraft").replace("{n}", String(counts.draft))} + {counts.total > counts.active + counts.draft ? `,${t("auto.workflows.summaryOther").replace("{n}", String(counts.total - counts.active - counts.draft))}` : ""}

- setShowCreate(true)} icon={Plus} tone="accent" /> + setShowCreate(true)} icon={Plus} tone="accent" /> {workflows.length === 0 ? (
-
No workflow pipelines
+
{t("auto.workflows.noWorkflows")}
- Workflows chain actions into multi-step pipelines. + {t("auto.workflows.noWorkflowsDetail")}
- setShowCreate(true)} icon={ArrowsClockwise} tone="accent" /> + setShowCreate(true)} icon={ArrowsClockwise} tone="accent" />
) : ( @@ -248,8 +254,8 @@ function WorkflowsPanel({ workflows, companyId, onRefresh }: { workflows: Workfl void handleDelete()} onCancel={() => setDelTarget(null)} /> @@ -269,7 +275,7 @@ export function AutomationPanel({ rules, workflows, companyId, onRefresh }: { ru
{t("auto.automationEngine")}
- Rules trigger actions based on company events. Workflows chain multiple steps into pipelines. Together, they let your AI company run fully autonomously. + {t("auto.engineDescription")}
@@ -281,7 +287,7 @@ export function AutomationPanel({ rules, workflows, companyId, onRefresh }: { ru className={`flex flex-1 items-center justify-center gap-2 rounded-lg px-4 py-2 text-[13px] font-medium transition ${activeSection === "rules" ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`} > - Automation Rules ({rules.length}) + {t("auto.tabRules").replace("{n}", String(rules.length))} @@ -301,9 +307,9 @@ export function AutomationPanel({ rules, workflows, companyId, onRefresh }: { ru transition={{ duration: 0.18, ease: [0.16, 1, 0.3, 1] }} > {activeSection === "rules" ? ( - + ) : ( - + )} diff --git a/src/renderer/components/communication.tsx b/src/renderer/components/communication.tsx deleted file mode 100644 index 9e855be7..00000000 --- a/src/renderer/components/communication.tsx +++ /dev/null @@ -1 +0,0 @@ -export { CommunicationHub } from "./communication/CommunicationHub" diff --git a/src/renderer/components/communication/ChannelSidebar.tsx b/src/renderer/components/communication/ChannelSidebar.tsx index c7e2584a..05c7e7e0 100644 --- a/src/renderer/components/communication/ChannelSidebar.tsx +++ b/src/renderer/components/communication/ChannelSidebar.tsx @@ -5,13 +5,16 @@ import type { AgentRecord, MessageChannel } from "@shared/types" import { AgentAvatar } from "./MessageItem" import type { ChannelDescriptor, TaskDiscussionDescriptor } from "./ChannelHeader" import { useT } from "../../i18n" +import { Eyebrow } from "../ui" -const channelConfig: Record = { - direct: { label: "Direct Messages", icon: EnvelopeSimple }, - department: { label: "Department", icon: Buildings }, - company: { label: "Company-wide", icon: Megaphone }, - project: { label: "Project", icon: Hash }, - incident: { label: "Incident", icon: Fire }, +type ChannelConfig = { labelKey: string; icon: React.ElementType } + +const channelConfig: Record = { + direct: { labelKey: "comm.channelLabel.direct", icon: EnvelopeSimple }, + department: { labelKey: "comm.channelLabel.department", icon: Buildings }, + company: { labelKey: "comm.channelLabel.company", icon: Megaphone }, + project: { labelKey: "comm.channelLabel.project", icon: Hash }, + incident: { labelKey: "comm.channelLabel.incident", icon: Fire }, } function ChannelItem({ @@ -111,9 +114,7 @@ export function ChannelSidebar({
{channelGroups.map((group) => (
-
- {group.label} -
+ {group.label} {group.items.map((ch) => { const stats = unreadByChannel.get(ch.key) return ( @@ -133,9 +134,7 @@ export function ChannelSidebar({ {taskDiscussionChannels.length > 0 ? (
-
- {t("comm.taskDiscussions")} -
+ {t("comm.taskDiscussions")} {taskDiscussionChannels.map((discussion) => (
diff --git a/src/renderer/components/communication/CommunicationHub.tsx b/src/renderer/components/communication/CommunicationHub.tsx index d994d1b7..7b669537 100644 --- a/src/renderer/components/communication/CommunicationHub.tsx +++ b/src/renderer/components/communication/CommunicationHub.tsx @@ -11,6 +11,7 @@ import { MessageComposer, TaskDiscussionComposer, NoChannelComposer } from "./Me import { SearchResults } from "./SearchResults" import ThreadPanel from "./ThreadPanel" import { deriveChannels, deriveTaskDiscussionChannels, getChannelMessages } from "./channel-utils" +import { useT, type TranslationFn } from "../../i18n" interface CommunicationHubProps { @@ -19,12 +20,12 @@ interface CommunicationHubProps { onEntityConsumed?: () => void; onNavigateToTask: (taskId: string) => void; onRefresh: () => void } -function SearchScopeToggle({ scope, onChangeScope }: { scope: "global" | "channel"; onChangeScope: (scope: "global" | "channel") => void }) { +function SearchScopeToggle({ scope, onChangeScope, t }: { scope: "global" | "channel"; onChangeScope: (scope: "global" | "channel") => void; t: TranslationFn }) { return (
) } export function CommunicationHub({ messages, comments, tasks, agents, projects, documents, companyId, selectedEntityId, onEntityConsumed, onNavigateToTask, onRefresh }: CommunicationHubProps) { + const t = useT() const [selectedChannelKey, setSelectedChannelKey] = useState(null) const [showSidebar, setShowSidebar] = useState(true) const [replyTo, setReplyTo] = useState(null) @@ -156,7 +158,10 @@ export function CommunicationHub({ messages, comments, tasks, agents, projects, } else { setSearchResults([], currentRequestId) } - } catch { + } catch (error) { + console.warn( + `[communication] searchMessages failed: ${error instanceof Error ? error.message : String(error)}`, + ) setSearchResults([], currentRequestId) } }, 300) @@ -203,7 +208,7 @@ export function CommunicationHub({ messages, comments, tasks, agents, projects, void (async () => { for (const id of ids) pendingReadIdsRef.current.add(id) setOptimisticReadIds((c) => { const n = new Set(c); for (const id of ids) n.add(id); return n }) - const results = await Promise.all(ids.map(async (id) => { try { unwrap(await window.agentCompany.markMessageRead({ messageId: id, companyId })); return { id, ok: true as const } } catch { return { id, ok: false as const } } })) + const results = await Promise.all(ids.map(async (id) => { try { unwrap(await window.agentCompany.markMessageRead({ messageId: id, companyId })); return { id, ok: true as const } } catch (error) { console.warn(`[communication] markMessageRead(${id}) failed: ${error instanceof Error ? error.message : String(error)}`); return { id, ok: false as const } } })) const failed = results.filter((r) => !r.ok).map((r) => r.id) for (const id of ids) pendingReadIdsRef.current.delete(id) if (!cancelled && failed.length > 0) setOptimisticReadIds((c) => { const n = new Set(c); for (const id of failed) n.delete(id); return n }) @@ -220,16 +225,22 @@ export function CommunicationHub({ messages, comments, tasks, agents, projects, const channelGroups = useMemo(() => { const groups: Array<{ type: MessageChannel; label: string; items: ChannelDescriptor[] }> = [] - const cfgs: Record = { company: "Company-wide", department: "Department", project: "Project", incident: "Incident", direct: "Direct Messages" } + const cfgs: Record = { + company: t("comm.channelGroup.company"), + department: t("comm.channelGroup.department"), + project: t("comm.channelGroup.project"), + incident: t("comm.channelGroup.incident"), + direct: t("comm.channelGroup.direct"), + } for (const type of (["company", "department", "project", "incident", "direct"] as MessageChannel[])) { const items = channels.filter((c): c is ChannelDescriptor => c.kind === "message" && c.channel === type) if (items.length > 0) groups.push({ type, label: cfgs[type], items }) } return groups - }, [channels]) + }, [channels, t]) const scopeToggleElement = isSearchActive ? ( - + ) : null return ( diff --git a/src/renderer/components/communication/MessageComposer.tsx b/src/renderer/components/communication/MessageComposer.tsx index de0f507e..55652da4 100644 --- a/src/renderer/components/communication/MessageComposer.tsx +++ b/src/renderer/components/communication/MessageComposer.tsx @@ -1,377 +1,155 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react" -import { AnimatePresence, motion } from "framer-motion" -import { ArrowBendUpLeft, X, ChatCircle, UserCircle, Paperclip, FileText } from "../ui/icons" -import type { AgentMessageRecord, AgentRecord, DocumentRecord, MessageChannel } from "@shared/types" -import { unwrap } from "../../lib/desktop" -import { cmdOrCtrlLabel } from "../../lib/platform" -import { getAgentInitials } from "../../lib/formatters" -import { useAppStore } from "../../state/store" -import { InlineNotice } from "../ui" -import { useT } from "../../i18n" -import { ComposerShell } from "./composer-shell/ComposerShell" +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { + AgentMessageRecord, + AgentRecord, + DocumentRecord, + MessageChannel, +} from "@shared/types"; +import { cmdOrCtrlLabel } from "../../lib/platform"; +import { InlineNotice } from "../ui"; +import { useT } from "../../i18n"; +import { ComposerShell } from "./composer-shell/ComposerShell"; +import { + AttachmentChips, + ComposerAttachments, + type AttachmentItem, +} from "./composer/ComposerAttachments"; +import { ComposerMentionDropdown } from "./composer/ComposerMentionDropdown"; +import { ComposerReplyBanner } from "./composer/ComposerReplyBanner"; +import { ComposerSubjectRow } from "./composer/ComposerSubjectRow"; +import { useMentionState } from "./composer/useMentionState"; +import { useMessageSend } from "./composer/useMessageSend"; + +export { TaskDiscussionComposer } from "./composer/TaskDiscussionComposer"; +export { NoChannelComposer } from "./composer/NoChannelComposer"; const HUMAN_SENDER_ID = "__board__"; -// ─── Attachment Picker ────────────────────────────────────────────────────── - -interface AttachmentItem { type: string; id: string; title: string } - -function AttachmentPicker({ documents, attachments, onAdd, onRemove }: { - documents: DocumentRecord[]; - attachments: AttachmentItem[]; - onAdd: (doc: DocumentRecord) => void; - onRemove: (id: string) => void; -}) { - const [open, setOpen] = useState(false) - const [filter, setFilter] = useState("") - const containerRef = useRef(null) - const attachedIds = useMemo(() => new Set(attachments.map((a) => a.id)), [attachments]) - - const filtered = useMemo(() => { - const q = filter.toLowerCase() - return documents.filter((d) => !attachedIds.has(d.id) && (d.title.toLowerCase().includes(q) || d.type.toLowerCase().includes(q))) - }, [documents, attachedIds, filter]) - - useEffect(() => { - if (!open) return - const handler = (e: MouseEvent) => { - if (containerRef.current && !containerRef.current.contains(e.target as Node)) setOpen(false) - } - document.addEventListener("mousedown", handler) - return () => document.removeEventListener("mousedown", handler) - }, [open]) - - return ( -
- - - {open && ( -
-
- setFilter(e.target.value)} - placeholder="Search documents..." - className="w-full rounded-lg border border-border bg-muted px-2.5 py-1.5 text-[12px] text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1" - autoFocus - /> -
-
- {filtered.length === 0 ? ( -
- {documents.length === 0 ? "No documents in this company" : "No matching documents"} -
- ) : ( - filtered.slice(0, 20).map((doc) => ( - - )) - )} -
-
- )} - - {attachments.length > 0 && ( -
- {attachments.map((att) => ( - - - {att.title} - - - ))} -
- )} -
- ) -} - -// ─── @Mention Autocomplete ────────────────────────────────────────────────── - -/** - * Compute pixel coordinates of the caret inside a textarea relative to the - * textarea's own top-left corner. Uses a hidden mirror div that replicates - * the textarea's type/font metrics so the span we drop at the caret offset - * lays out in the same position the real caret does. - */ -function getTextareaCaretCoords(textarea: HTMLTextAreaElement, caretIndex: number): { top: number; left: number } { - const style = window.getComputedStyle(textarea) - const mirror = document.createElement("div") - const mirrorStyle = mirror.style - - const copyProps = [ - "boxSizing", "width", "height", "paddingTop", "paddingRight", "paddingBottom", "paddingLeft", - "borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth", - "fontFamily", "fontSize", "fontWeight", "fontStyle", "fontVariant", "lineHeight", - "letterSpacing", "wordSpacing", "textTransform", "textIndent", "whiteSpace", "wordWrap", - ] as const - for (const prop of copyProps) { - mirrorStyle[prop as never] = style[prop as never] - } - mirrorStyle.position = "absolute" - mirrorStyle.visibility = "hidden" - mirrorStyle.overflow = "hidden" - mirrorStyle.whiteSpace = "pre-wrap" - mirrorStyle.wordWrap = "break-word" - mirrorStyle.top = "0" - mirrorStyle.left = "-9999px" - - mirror.textContent = textarea.value.slice(0, caretIndex) - const marker = document.createElement("span") - marker.textContent = "\u200b" - mirror.appendChild(marker) - document.body.appendChild(mirror) - - const coords = { top: marker.offsetTop - textarea.scrollTop, left: marker.offsetLeft - textarea.scrollLeft } - document.body.removeChild(mirror) - return coords -} - -function MentionDropdown({ agents, query, onSelect, position }: { - agents: AgentRecord[]; - query: string; - onSelect: (agent: AgentRecord | null) => void; - position: { top: number; left: number }; -}) { - const [selectedIndex, setSelectedIndex] = useState(0) - const filtered = useMemo(() => { - const q = query.toLowerCase() - return agents.filter((a) => a.status !== "terminated" && ( - a.name.toLowerCase().includes(q) || - (a.title?.toLowerCase().includes(q)) || - (a.department?.toLowerCase().includes(q)) - )).slice(0, 8) - }, [agents, query]) - - useEffect(() => { setSelectedIndex(0) }, [query]) - - useEffect(() => { - const handler = (e: KeyboardEvent) => { - if (e.key === "ArrowDown") { e.preventDefault(); setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1)) } - else if (e.key === "ArrowUp") { e.preventDefault(); setSelectedIndex((i) => Math.max(i - 1, 0)) } - else if (e.key === "Enter" || e.key === "Tab") { - if (filtered[selectedIndex]) { e.preventDefault(); onSelect(filtered[selectedIndex]) } - } - else if (e.key === "Escape") { onSelect(null) } - } - document.addEventListener("keydown", handler, true) - return () => document.removeEventListener("keydown", handler, true) - }, [filtered, selectedIndex, onSelect]) - - if (filtered.length === 0) return null - - return ( -
- {filtered.map((agent, i) => ( - - ))} -
- ) -} - -// ─── Main Composer ────────────────────────────────────────────────────────── - interface MessageComposerProps { - agents: AgentRecord[]; companyId: string; channel: MessageChannel; channelTargetId: string | null - directParticipantIds?: string[]; replyTo: AgentMessageRecord | null; onSent: () => void; onCancelReply: () => void - documents?: DocumentRecord[] + agents: AgentRecord[]; + companyId: string; + channel: MessageChannel; + channelTargetId: string | null; + directParticipantIds?: string[]; + replyTo: AgentMessageRecord | null; + onSent: () => void; + onCancelReply: () => void; + documents?: DocumentRecord[]; /** Thread ID for precise checkpoint resolution. */ - threadId?: string | null + threadId?: string | null; /** Wait state ID for precise checkpoint resolution. */ - waitStateId?: string | null + waitStateId?: string | null; } -export function MessageComposer({ agents, companyId, channel, channelTargetId, directParticipantIds = [], replyTo, onSent, onCancelReply, documents = [], threadId = null, waitStateId = null }: MessageComposerProps) { - const [body, setBody] = useState(""); const [subject, setSubject] = useState("") - const [sending, setSending] = useState(false); const [sendError, setSendError] = useState(null) - const [attachments, setAttachments] = useState([]) - const [mentionState, setMentionState] = useState<{ active: boolean; query: string; startIndex: number; caretTop: number; caretLeft: number }>({ active: false, query: "", startIndex: -1, caretTop: 0, caretLeft: 0 }) - const textareaRef = useRef(null) - const addToast = useAppStore((s) => s.addToast) +export function MessageComposer({ + agents, + companyId, + channel, + channelTargetId, + directParticipantIds = [], + replyTo, + onSent, + onCancelReply, + documents = [], + threadId = null, + waitStateId = null, +}: MessageComposerProps) { + const t = useT(); + const [body, setBody] = useState(""); + const [subject, setSubject] = useState(""); + const [attachments, setAttachments] = useState([]); + const textareaRef = useRef(null); - useEffect(() => { if (replyTo) textareaRef.current?.focus() }, [replyTo]) + useEffect(() => { + if (replyTo) textareaRef.current?.focus(); + }, [replyTo]); const directRecipientId = useMemo(() => { - if (channel !== "direct") return null - if (replyTo?.fromAgentId) return replyTo.fromAgentId - if (directParticipantIds.length === 0) return null - const nonBoardParticipant = directParticipantIds.find((id) => id !== HUMAN_SENDER_ID) - return nonBoardParticipant ?? directParticipantIds[0] ?? null - }, [channel, replyTo, directParticipantIds]) + if (channel !== "direct") return null; + if (replyTo?.fromAgentId) return replyTo.fromAgentId; + if (directParticipantIds.length === 0) return null; + const nonBoard = directParticipantIds.find((id) => id !== HUMAN_SENDER_ID); + return nonBoard ?? directParticipantIds[0] ?? null; + }, [channel, replyTo, directParticipantIds]); - const handleAddAttachment = useCallback((doc: DocumentRecord) => { - setAttachments((prev) => [...prev, { type: "document", id: doc.id, title: doc.title }]) - }, []) + const onCleared = useCallback(() => { + setBody(""); + setSubject(""); + setAttachments([]); + onSent(); + }, [onSent]); - const handleRemoveAttachment = useCallback((id: string) => { - setAttachments((prev) => prev.filter((a) => a.id !== id)) - }, []) + const { send, sending, sendError } = useMessageSend({ + body, subject, attachments, channel, channelTargetId, companyId, + directRecipientId, replyTo, threadId, waitStateId, onCleared, + }); - const handleMentionSelect = useCallback((agent: AgentRecord | null) => { - if (!agent) { setMentionState((s) => ({ ...s, active: false, query: "", startIndex: -1 })); return } - setBody((prev) => { - const before = prev.slice(0, mentionState.startIndex) - const after = prev.slice(mentionState.startIndex + mentionState.query.length + 1) - return `${before}@${agent.name} ${after}` - }) - setMentionState((s) => ({ ...s, active: false, query: "", startIndex: -1 })) - textareaRef.current?.focus() - }, [mentionState]) + const { mentionState, detect, selectAgent } = useMentionState({ + setBody, + focusTextarea: () => textareaRef.current?.focus(), + }); - const handleBodyChange = useCallback((value: string, e: React.ChangeEvent) => { - setBody(value) + const handleBodyChange = useCallback( + (value: string, e: React.ChangeEvent) => { + setBody(value); + detect(value, e.target); + }, + [detect], + ); - const cursorPos = e.target.selectionStart - const windowStart = Math.max(0, cursorPos - 50) - const windowText = value.slice(windowStart, cursorPos) - const match = windowText.match(/(?:^|\s)@(\w*)$/) - if (match) { - const query = match[1] - const atIndex = windowStart + match.index! + (match[0].startsWith("@") ? 0 : 1) - const coords = getTextareaCaretCoords(e.target, atIndex) - const style = window.getComputedStyle(e.target) - const lineHeightPx = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.3 - setMentionState({ - active: true, - query, - startIndex: atIndex, - caretTop: coords.top + lineHeightPx, - caretLeft: coords.left, - }) - return - } - setMentionState((s) => ({ ...s, active: false, query: "", startIndex: -1 })) - }, []) - - const handleSend = useCallback(async () => { - if (!body.trim()) return; if (channel === "direct" && !directRecipientId) return - setSending(true); setSendError(null) - const attachmentsJson = attachments.length > 0 ? JSON.stringify(attachments) : "[]" - try { - const result = unwrap(await window.agentCompany.sendAgentMessage({ - companyId, fromAgentId: HUMAN_SENDER_ID, - toAgentId: channel === "direct" ? directRecipientId : null, - channel, channelTargetId, - subject: subject.trim() || (replyTo ? `Re: ${replyTo.subject}` : ""), - body: body.trim(), priority: "normal", - parentMessageId: replyTo?.id ?? null, attachmentsJson, - threadId: threadId ?? undefined, - waitStateId: waitStateId ?? undefined, - })) - if (result.mentionResults && result.mentionResults.length > 0) { - const woken = result.mentionResults.filter((m) => m.outcome === "woken") - const queued = result.mentionResults.filter((m) => m.outcome === "queued") - const busy = result.mentionResults.filter((m) => m.outcome === "busy") - if (woken.length > 0) addToast(`Notified: ${woken.map((m) => m.agentName).join(", ")}`, "success") - if (queued.length > 0) addToast(`Queued: ${queued.map((m) => m.agentName).join(", ")}`, "warn") - if (busy.length > 0) addToast(`Busy: ${busy.map((m) => m.agentName).join(", ")}`, "warn") - } - setBody(""); setSubject(""); setAttachments([]); onSent() - } catch (e) { setSendError(e instanceof Error ? e.message : "Failed to send message.") } - finally { setSending(false) } - }, [body, channel, channelTargetId, companyId, directRecipientId, onSent, replyTo, subject, attachments, threadId, waitStateId, addToast]) - - const handleKeyDownExtra = useCallback(() => { - return mentionState.active // when mention active, ComposerShell should skip its own Enter handler - }, [mentionState.active]) - - const directMissingNotice = channel === "direct" && !directRecipientId - ?
- : null - - const replyBanner = ( - - {replyTo ? ( - - - Replying to {agents.find((a) => a.id === replyTo.fromAgentId)?.name ?? "agent"}{replyTo.subject ? `: ${replyTo.subject}` : ""} - - - ) : null} - - ) - - const subjectRow = ( -
-
- You (Board) - {!replyTo ? setSubject(e.target.value)} placeholder="Subject (optional)" aria-label="Message subject" className="min-w-0 flex-1 rounded-lg border border-border bg-card px-2.5 py-1 text-[11px] text-foreground placeholder:text-muted-foreground" /> : null} -
- ) - - const attachmentsRow = attachments.length > 0 ? ( -
- {attachments.map((att) => ( - - - {att.title} - - - ))} -
- ) : null + const addAttachment = useCallback((doc: DocumentRecord) => { + setAttachments((prev) => [...prev, { type: "document", id: doc.id, title: doc.title }]); + }, []); + const removeAttachment = useCallback((id: string) => { + setAttachments((prev) => prev.filter((a) => a.id !== id)); + }, []); return (
- {directMissingNotice} + {channel === "direct" && !directRecipientId ? ( +
+ +
+ ) : null} void handleSend()} - placeholder="Type a message... (use @ to mention agents)" + onSend={() => void send()} + placeholder={t("composer.typeMessage")} sending={sending} error={sendError} shortcutHint={`Press ${cmdOrCtrlLabel()}+Enter to send`} - sendLabel="Send message" + sendLabel={t("composer.sendMessage")} sendDisabled={!body.trim() || (channel === "direct" && !directRecipientId)} - leading={} - belowHeader={<>{replyBanner}{subjectRow}{attachmentsRow}} - onKeyDownExtra={handleKeyDownExtra} - textareaAriaLabel="Message body" + leading={ + + } + belowHeader={ + <> + + + + + } + onKeyDownExtra={() => mentionState.active} + textareaAriaLabel={t("composer.messageBody")} /> {mentionState.active && textareaRef.current ? ( - ) : null}
- ) -} - -export function TaskDiscussionComposer({ companyId, taskId, onSent }: { companyId: string; taskId: string; onSent: () => void }) { - const [body, setBody] = useState(""); const [sending, setSending] = useState(false); const [sendError, setSendError] = useState(null) - const handleSend = useCallback(async () => { - if (!body.trim()) return; setSending(true); setSendError(null) - try { unwrap(await window.agentCompany.addComment({ companyId, taskId, authorName: "Board", body: body.trim() })); setBody(""); onSent() } catch (e) { setSendError(e instanceof Error ? e.message : "Failed to send.") } finally { setSending(false) } - }, [body, companyId, taskId, onSent]) - return ( -
- setBody(v)} - onSend={() => void handleSend()} - placeholder="Reply in task discussion..." - sending={sending} - error={sendError} - sendLabel="Send task comment" - textareaAriaLabel="Task discussion reply" - /> -
- ) -} - -export function NoChannelComposer() { - const t = useT() - return
{t("comm.selectChannelSend")}
+ ); } diff --git a/src/renderer/components/communication/MessageItem.tsx b/src/renderer/components/communication/MessageItem.tsx index 7d66de25..5aa94097 100644 --- a/src/renderer/components/communication/MessageItem.tsx +++ b/src/renderer/components/communication/MessageItem.tsx @@ -13,14 +13,17 @@ import type { AgentMessageRecord, AgentRecord, CommentRecord, DepartmentId } fro import { getAgentInitials, formatMessageTime } from "../../lib/formatters" import { safeJson } from "../../lib/safe-json" import { DEPARTMENT_COLORS } from "@shared/constants" +import { useT, type TranslationFn } from "../../i18n" const HUMAN_SENDER_ID = "__board__"; -const priorityConfig: Record = { - urgent: { label: "Urgent", className: "bg-destructive-muted text-destructive" }, - normal: { label: "Normal", className: "bg-muted text-muted-foreground" }, - low: { label: "Low", className: "bg-muted text-muted-foreground" }, +function priorityConfig(t: TranslationFn): Record { + return { + urgent: { label: t("msg.priority.urgent"), className: "bg-destructive-muted text-destructive" }, + normal: { label: t("msg.priority.normal"), className: "bg-muted text-muted-foreground" }, + low: { label: t("msg.priority.low"), className: "bg-muted text-muted-foreground" }, + } } function getDepartmentColor(department: DepartmentId | null | undefined): string { @@ -62,11 +65,13 @@ export const MessageBubble = memo(function MessageBubble({ replyCount: number onReply: () => void }) { + const t = useT() + const priorityMap = priorityConfig(t) const isUnread = !message.readAt const isHuman = message.fromAgentId === HUMAN_SENDER_ID const deptColor = agent ? getDepartmentColor(agent.department) : "var(--muted-foreground)" const senderColor = isHuman ? "var(--primary)" : deptColor - const senderName = isHuman ? "You" : (agent?.name ?? "Unknown Agent") + const senderName = isHuman ? t("msg.you") : (agent?.name ?? t("msg.unknownAgent")) return ( {message.priority !== "normal" ? ( {message.priority === "urgent" ? : null} - {priorityConfig[message.priority]?.label} + {priorityMap[message.priority]?.label} ) : null} {isUnread ? ( - + ) : ( - {replyCount} {replyCount === 1 ? "reply" : "replies"} + {replyCount} {t("msg.replies")} ) : null}
diff --git a/src/renderer/components/communication/MessageList.tsx b/src/renderer/components/communication/MessageList.tsx index e30395cf..6c3d8609 100644 --- a/src/renderer/components/communication/MessageList.tsx +++ b/src/renderer/components/communication/MessageList.tsx @@ -132,7 +132,7 @@ export function MessageList({ ref={parentRef} className="h-full overflow-auto" role="listbox" - aria-label="Messages" + aria-label={t("comm.messagesAria")} onKeyDown={handleKeyDown} >
= { - direct: { label: "Direct", icon: EnvelopeSimple, color: "var(--primary)" }, - department: { label: "Department", icon: Buildings, color: "var(--muted-foreground)" }, - company: { label: "Company", icon: Megaphone, color: "var(--primary)" }, - project: { label: "Project", icon: Hash, color: "var(--primary)" }, - incident: { label: "Incident", icon: Fire, color: "var(--destructive)" }, +type ChannelConfig = { labelKey: string; icon: React.ElementType; color: string } + +const channelConfig: Record = { + direct: { labelKey: "comm.channelLabel.directShort", icon: EnvelopeSimple, color: "var(--primary)" }, + department: { labelKey: "comm.channelLabel.department", icon: Buildings, color: "var(--muted-foreground)" }, + company: { labelKey: "comm.channelLabel.companyShort", icon: Megaphone, color: "var(--primary)" }, + project: { labelKey: "comm.channelLabel.project", icon: Hash, color: "var(--primary)" }, + incident: { labelKey: "comm.channelLabel.incident", icon: Fire, color: "var(--destructive)" }, } function highlightMatch(text: string, query: string): React.ReactNode { @@ -50,6 +53,7 @@ interface SearchResultsProps { } export function SearchResults({ results, query, loading, agents, onNavigateToResult }: SearchResultsProps) { + const t = useT() const reducedMotion = useReducedMotion() const parentRef = useRef(null) const agentMap = new Map(agents.map((a) => [a.id, a])) @@ -81,8 +85,8 @@ export function SearchResults({ results, query, loading, agents, onNavigateToRes return (
) @@ -98,7 +102,7 @@ export function SearchResults({ results, query, loading, agents, onNavigateToRes
- {results.length} result{results.length !== 1 ? "s" : ""} + {(results.length === 1 ? t("comm.resultCount") : t("comm.resultCountPlural")).replace("{count}", String(results.length))}
@@ -106,7 +110,7 @@ export function SearchResults({ results, query, loading, agents, onNavigateToRes ref={parentRef} className="flex-1 overflow-auto" role="listbox" - aria-label="Search results" + aria-label={t("comm.searchResultsAria")} onKeyDown={handleKeyDown} >
- {agent?.name ?? "Unknown Agent"} + {agent?.name ?? t("comm.unknownAgent")} - {config.label} + {t(config.labelKey)} {formatMessageTime(message.createdAt)} diff --git a/src/renderer/components/communication/ThreadPanel.tsx b/src/renderer/components/communication/ThreadPanel.tsx index d7b5c3c2..a6d0d69d 100644 --- a/src/renderer/components/communication/ThreadPanel.tsx +++ b/src/renderer/components/communication/ThreadPanel.tsx @@ -12,6 +12,7 @@ import { motion } from "framer-motion" import type { AgentRecord, ConversationEventRecord, ThreadWaitStateRecord, ConversationThreadRecord, DocumentRecord } from "@shared/types" import WaitStateIndicator, { type WaitStateInfo } from "./WaitStateIndicator" import { MessageComposer } from "./MessageComposer" +import { useT, type TranslationFn } from "../../i18n" interface ThreadPanelProps { threadId: string @@ -22,19 +23,19 @@ interface ThreadPanelProps { onNavigateApproval?: (approvalId: string) => void } -function EventItem({ event, agents }: { event: ConversationEventRecord; agents: AgentRecord[] }) { +function EventItem({ event, agents, t }: { event: ConversationEventRecord; agents: AgentRecord[]; t: TranslationFn }) { const actorName = event.actorType === "user" - ? "You" + ? t("thread.actor.you") : agents.find((a) => a.id === event.actorId)?.name ?? event.actorId const time = new Date(event.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) const iconClass = event.eventType === "status_change" - ? "text-blue-500" + ? "text-primary" : event.eventType === "approval_request" - ? "text-amber-500" + ? "text-warning" : event.eventType === "approval_decision" - ? "text-emerald-500" + ? "text-success" : "text-muted-foreground" return ( @@ -56,6 +57,7 @@ function EventItem({ event, agents }: { event: ConversationEventRecord; agents: } export default function ThreadPanel({ threadId, companyId, agents, documents, onClose, onNavigateApproval }: ThreadPanelProps) { + const t = useT() const [thread, setThread] = useState(null) const [events, setEvents] = useState([]) const [waitStates, setWaitStates] = useState([]) @@ -106,12 +108,12 @@ export default function ThreadPanel({ threadId, companyId, agents, documents, on

- {thread?.title ?? "Thread"} + {thread?.title ?? t("thread.title")}

@@ -136,12 +138,12 @@ export default function ThreadPanel({ threadId, companyId, agents, documents, on
{events.length === 0 ? (
- No events yet + {t("thread.noEvents")}
) : (
{events.map((event) => ( - + ))}
)} diff --git a/src/renderer/components/communication/WaitStateIndicator.tsx b/src/renderer/components/communication/WaitStateIndicator.tsx index 430dc74a..5296e34f 100644 --- a/src/renderer/components/communication/WaitStateIndicator.tsx +++ b/src/renderer/components/communication/WaitStateIndicator.tsx @@ -8,6 +8,8 @@ import { Hourglass, ChatCircle, CheckCircle } from "../ui/icons" import type { AgentRecord } from "@shared/types" +import { useT } from "../../i18n" +import { timeAgo } from "../../lib/formatters" export interface WaitStateInfo { id: string @@ -26,44 +28,34 @@ interface WaitStateIndicatorProps { onNavigateApproval?: (approvalId: string) => void } -function formatWaitDuration(createdAt: string): string { - const diff = Date.now() - new Date(createdAt).getTime() - const minutes = Math.floor(diff / 60000) - if (minutes < 1) return "just now" - if (minutes < 60) return `${minutes}m ago` - const hours = Math.floor(minutes / 60) - if (hours < 24) return `${hours}h ago` - const days = Math.floor(hours / 24) - return `${days}d ago` -} - export default function WaitStateIndicator({ waitState, onReply, onNavigateApproval }: WaitStateIndicatorProps) { + const t = useT() const isApproval = waitState.waitingFor === "user_approval" && waitState.relatedApprovalId const isReview = waitState.waitingFor === "user_review" - const duration = formatWaitDuration(waitState.createdAt) + const duration = timeAgo(waitState.createdAt) return ( -
- +
+

{waitState.reason}

-

Waiting since {duration}

+

{t("wait.waitingSince")} {duration}

{isApproval && onNavigateApproval ? ( ) : ( )}
diff --git a/src/renderer/components/communication/composer/ComposerAttachments.tsx b/src/renderer/components/communication/composer/ComposerAttachments.tsx new file mode 100644 index 00000000..39695966 --- /dev/null +++ b/src/renderer/components/communication/composer/ComposerAttachments.tsx @@ -0,0 +1,160 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { FileText, Paperclip, X } from "../../ui/icons"; +import type { DocumentRecord } from "@shared/types"; +import { useT } from "../../../i18n"; + +export interface AttachmentItem { + type: string; + id: string; + title: string; +} + +interface ComposerAttachmentsProps { + documents: DocumentRecord[]; + attachments: AttachmentItem[]; + onAdd: (doc: DocumentRecord) => void; + onRemove: (id: string) => void; +} + +export function ComposerAttachments({ + documents, + attachments, + onAdd, + onRemove, +}: ComposerAttachmentsProps) { + const t = useT(); + const [open, setOpen] = useState(false); + const [filter, setFilter] = useState(""); + const containerRef = useRef(null); + const attachedIds = useMemo(() => new Set(attachments.map((a) => a.id)), [attachments]); + + const filtered = useMemo(() => { + const q = filter.toLowerCase(); + return documents.filter( + (d) => + !attachedIds.has(d.id) && + (d.title.toLowerCase().includes(q) || d.type.toLowerCase().includes(q)), + ); + }, [documents, attachedIds, filter]); + + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) + setOpen(false); + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [open]); + + return ( +
+ + + {open && ( +
+
+ setFilter(e.target.value)} + placeholder={t("composer.searchDocuments")} + className="w-full rounded-lg border border-border bg-muted px-2.5 py-1.5 text-[12px] text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1" + autoFocus + /> +
+
+ {filtered.length === 0 ? ( +
+ {documents.length === 0 + ? t("composer.noDocuments") + : t("composer.noMatchingDocuments")} +
+ ) : ( + filtered.slice(0, 20).map((doc) => ( + + )) + )} +
+
+ )} + + {attachments.length > 0 && ( +
+ {attachments.map((att) => ( + + + {att.title} + + + ))} +
+ )} +
+ ); +} + +export function AttachmentChips({ + attachments, + onRemove, +}: { + attachments: AttachmentItem[]; + onRemove: (id: string) => void; +}) { + if (attachments.length === 0) return null; + return ( +
+ {attachments.map((att) => ( + + + {att.title} + + + ))} +
+ ); +} diff --git a/src/renderer/components/communication/composer/ComposerMentionDropdown.tsx b/src/renderer/components/communication/composer/ComposerMentionDropdown.tsx new file mode 100644 index 00000000..eed4da07 --- /dev/null +++ b/src/renderer/components/communication/composer/ComposerMentionDropdown.tsx @@ -0,0 +1,89 @@ +import { useEffect, useMemo, useState } from "react"; +import type { AgentRecord } from "@shared/types"; +import { getAgentInitials } from "../../../lib/formatters"; + +interface ComposerMentionDropdownProps { + agents: AgentRecord[]; + query: string; + onSelect: (agent: AgentRecord | null) => void; + position: { top: number; left: number }; +} + +export function ComposerMentionDropdown({ + agents, + query, + onSelect, + position, +}: ComposerMentionDropdownProps) { + const [selectedIndex, setSelectedIndex] = useState(0); + const filtered = useMemo(() => { + const q = query.toLowerCase(); + return agents + .filter( + (a) => + a.status !== "terminated" && + (a.name.toLowerCase().includes(q) || + a.title?.toLowerCase().includes(q) || + a.department?.toLowerCase().includes(q)), + ) + .slice(0, 8); + }, [agents, query]); + + useEffect(() => { + setSelectedIndex(0); + }, [query]); + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === "ArrowDown") { + e.preventDefault(); + setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setSelectedIndex((i) => Math.max(i - 1, 0)); + } else if (e.key === "Enter" || e.key === "Tab") { + if (filtered[selectedIndex]) { + e.preventDefault(); + onSelect(filtered[selectedIndex]); + } + } else if (e.key === "Escape") { + onSelect(null); + } + }; + document.addEventListener("keydown", handler, true); + return () => document.removeEventListener("keydown", handler, true); + }, [filtered, selectedIndex, onSelect]); + + if (filtered.length === 0) return null; + + return ( +
+ {filtered.map((agent, i) => ( + + ))} +
+ ); +} diff --git a/src/renderer/components/communication/composer/ComposerReplyBanner.tsx b/src/renderer/components/communication/composer/ComposerReplyBanner.tsx new file mode 100644 index 00000000..e9c0adda --- /dev/null +++ b/src/renderer/components/communication/composer/ComposerReplyBanner.tsx @@ -0,0 +1,42 @@ +import { AnimatePresence, motion } from "framer-motion"; +import { ArrowBendUpLeft, X } from "../../ui/icons"; +import type { AgentMessageRecord, AgentRecord } from "@shared/types"; +import { useT } from "../../../i18n"; + +interface ComposerReplyBannerProps { + replyTo: AgentMessageRecord | null; + agents: AgentRecord[]; + onCancel: () => void; +} + +export function ComposerReplyBanner({ replyTo, agents, onCancel }: ComposerReplyBannerProps) { + const t = useT(); + return ( + + {replyTo ? ( + + + + {t("composer.replyingTo")}{" "} + + {agents.find((a) => a.id === replyTo.fromAgentId)?.name ?? t("composer.replyingToAgent")} + + {replyTo.subject ? `: ${replyTo.subject}` : ""} + + + + ) : null} + + ); +} diff --git a/src/renderer/components/communication/composer/ComposerSubjectRow.tsx b/src/renderer/components/communication/composer/ComposerSubjectRow.tsx new file mode 100644 index 00000000..d04ab97c --- /dev/null +++ b/src/renderer/components/communication/composer/ComposerSubjectRow.tsx @@ -0,0 +1,34 @@ +import { UserCircle } from "../../ui/icons"; +import { useT } from "../../../i18n"; + +interface ComposerSubjectRowProps { + subject: string; + onSubjectChange: (value: string) => void; + showSubjectInput: boolean; +} + +export function ComposerSubjectRow({ + subject, + onSubjectChange, + showSubjectInput, +}: ComposerSubjectRowProps) { + const t = useT(); + return ( +
+
+ +
+ {t("composer.youBoard")} + {showSubjectInput ? ( + onSubjectChange(e.target.value)} + placeholder={t("composer.subjectOptional")} + aria-label={t("composer.messageSubject")} + className="min-w-0 flex-1 rounded-lg border border-border bg-card px-2.5 py-1 text-[11px] text-foreground placeholder:text-muted-foreground" + /> + ) : null} +
+ ); +} diff --git a/src/renderer/components/communication/composer/NoChannelComposer.tsx b/src/renderer/components/communication/composer/NoChannelComposer.tsx new file mode 100644 index 00000000..058bd925 --- /dev/null +++ b/src/renderer/components/communication/composer/NoChannelComposer.tsx @@ -0,0 +1,14 @@ +import { ChatCircle } from "../../ui/icons"; +import { useT } from "../../../i18n"; + +export function NoChannelComposer() { + const t = useT(); + return ( +
+
+ + {t("comm.selectChannelSend")} +
+
+ ); +} diff --git a/src/renderer/components/communication/composer/TaskDiscussionComposer.tsx b/src/renderer/components/communication/composer/TaskDiscussionComposer.tsx new file mode 100644 index 00000000..b972f09d --- /dev/null +++ b/src/renderer/components/communication/composer/TaskDiscussionComposer.tsx @@ -0,0 +1,56 @@ +import { useCallback, useState } from "react"; +import { unwrap } from "../../../lib/desktop"; +import { ComposerShell } from "../composer-shell/ComposerShell"; +import { useT } from "../../../i18n"; + +export function TaskDiscussionComposer({ + companyId, + taskId, + onSent, +}: { + companyId: string; + taskId: string; + onSent: () => void; +}) { + const t = useT(); + const [body, setBody] = useState(""); + const [sending, setSending] = useState(false); + const [sendError, setSendError] = useState(null); + + const handleSend = useCallback(async () => { + if (!body.trim()) return; + setSending(true); + setSendError(null); + try { + unwrap( + await window.agentCompany.addComment({ + companyId, + taskId, + authorName: "Board", + body: body.trim(), + }), + ); + setBody(""); + onSent(); + } catch (e) { + setSendError(e instanceof Error ? e.message : t("composer.sendFailedShort")); + } finally { + setSending(false); + } + }, [body, companyId, taskId, onSent, t]); + + return ( +
+ setBody(v)} + onSend={() => void handleSend()} + placeholder={t("composer.taskDiscussionPlaceholder")} + sending={sending} + error={sendError} + sendLabel={t("composer.sendTaskComment")} + textareaAriaLabel={t("composer.taskDiscussionReply")} + /> +
+ ); +} diff --git a/src/renderer/components/communication/composer/useCaretMeasurement.ts b/src/renderer/components/communication/composer/useCaretMeasurement.ts new file mode 100644 index 00000000..60a0f1dd --- /dev/null +++ b/src/renderer/components/communication/composer/useCaretMeasurement.ts @@ -0,0 +1,79 @@ +/** + * Compute pixel coordinates of the caret inside a textarea relative to the + * textarea's own top-left corner. Uses a hidden mirror div that replicates + * the textarea's type/font metrics so the span we drop at the caret offset + * lays out in the same position the real caret does. + */ +export function getTextareaCaretCoords( + textarea: HTMLTextAreaElement, + caretIndex: number, +): { top: number; left: number } { + const style = window.getComputedStyle(textarea); + const mirror = document.createElement("div"); + const mirrorStyle = mirror.style; + + const copyProps = [ + "boxSizing", + "width", + "height", + "paddingTop", + "paddingRight", + "paddingBottom", + "paddingLeft", + "borderTopWidth", + "borderRightWidth", + "borderBottomWidth", + "borderLeftWidth", + "fontFamily", + "fontSize", + "fontWeight", + "fontStyle", + "fontVariant", + "lineHeight", + "letterSpacing", + "wordSpacing", + "textTransform", + "textIndent", + "whiteSpace", + "wordWrap", + ] as const; + for (const prop of copyProps) { + mirrorStyle[prop as never] = style[prop as never]; + } + mirrorStyle.position = "absolute"; + mirrorStyle.visibility = "hidden"; + mirrorStyle.overflow = "hidden"; + mirrorStyle.whiteSpace = "pre-wrap"; + mirrorStyle.wordWrap = "break-word"; + mirrorStyle.top = "0"; + mirrorStyle.left = "-9999px"; + + mirror.textContent = textarea.value.slice(0, caretIndex); + const marker = document.createElement("span"); + marker.textContent = "\u200b"; + mirror.appendChild(marker); + document.body.appendChild(mirror); + + const coords = { + top: marker.offsetTop - textarea.scrollTop, + left: marker.offsetLeft - textarea.scrollLeft, + }; + document.body.removeChild(mirror); + return coords; +} + +export interface CaretMeasurement { + top: number; + left: number; + lineHeight: number; +} + +export function measureCaret( + textarea: HTMLTextAreaElement, + caretIndex: number, +): CaretMeasurement { + const coords = getTextareaCaretCoords(textarea, caretIndex); + const style = window.getComputedStyle(textarea); + const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.3; + return { top: coords.top, left: coords.left, lineHeight }; +} diff --git a/src/renderer/components/communication/composer/useMentionState.ts b/src/renderer/components/communication/composer/useMentionState.ts new file mode 100644 index 00000000..0eb287b4 --- /dev/null +++ b/src/renderer/components/communication/composer/useMentionState.ts @@ -0,0 +1,76 @@ +import { useCallback, useState } from "react"; +import type { AgentRecord } from "@shared/types"; +import { measureCaret } from "./useCaretMeasurement"; + +export interface MentionState { + active: boolean; + query: string; + startIndex: number; + caretTop: number; + caretLeft: number; +} + +const INITIAL_MENTION: MentionState = { + active: false, + query: "", + startIndex: -1, + caretTop: 0, + caretLeft: 0, +}; + +export function useMentionState({ + setBody, + focusTextarea, +}: { + setBody: (update: (prev: string) => string) => void; + focusTextarea: () => void; +}) { + const [mentionState, setMentionState] = useState(INITIAL_MENTION); + + const dismiss = useCallback(() => { + setMentionState((s) => ({ ...s, active: false, query: "", startIndex: -1 })); + }, []); + + const detect = useCallback( + (value: string, textarea: HTMLTextAreaElement) => { + const cursorPos = textarea.selectionStart; + const windowStart = Math.max(0, cursorPos - 50); + const windowText = value.slice(windowStart, cursorPos); + const match = windowText.match(/(?:^|\s)@(\w*)$/); + if (match) { + const query = match[1]; + const atIndex = windowStart + match.index! + (match[0].startsWith("@") ? 0 : 1); + const { top, left, lineHeight } = measureCaret(textarea, atIndex); + setMentionState({ + active: true, + query, + startIndex: atIndex, + caretTop: top + lineHeight, + caretLeft: left, + }); + return; + } + dismiss(); + }, + [dismiss], + ); + + const selectAgent = useCallback( + (agent: AgentRecord | null) => { + if (!agent) { + dismiss(); + return; + } + setBody((prev) => { + const before = prev.slice(0, mentionState.startIndex); + const after = prev.slice(mentionState.startIndex + mentionState.query.length + 1); + return `${before}@${agent.name} ${after}`; + }); + dismiss(); + focusTextarea(); + }, + [mentionState, setBody, focusTextarea, dismiss], + ); + + return { mentionState, detect, selectAgent }; +} diff --git a/src/renderer/components/communication/composer/useMessageSend.ts b/src/renderer/components/communication/composer/useMessageSend.ts new file mode 100644 index 00000000..48888df3 --- /dev/null +++ b/src/renderer/components/communication/composer/useMessageSend.ts @@ -0,0 +1,99 @@ +import { useCallback, useState } from "react"; +import type { AgentMessageRecord, MessageChannel } from "@shared/types"; +import { unwrap } from "../../../lib/desktop"; +import { useAppStore } from "../../../state/store"; +import { useT } from "../../../i18n"; +import type { AttachmentItem } from "./ComposerAttachments"; + +const HUMAN_SENDER_ID = "__board__"; + +interface UseMessageSendArgs { + body: string; + subject: string; + attachments: AttachmentItem[]; + channel: MessageChannel; + channelTargetId: string | null; + companyId: string; + directRecipientId: string | null; + replyTo: AgentMessageRecord | null; + threadId?: string | null; + waitStateId?: string | null; + onCleared: () => void; +} + +export function useMessageSend({ + body, + subject, + attachments, + channel, + channelTargetId, + companyId, + directRecipientId, + replyTo, + threadId = null, + waitStateId = null, + onCleared, +}: UseMessageSendArgs) { + const t = useT(); + const [sending, setSending] = useState(false); + const [sendError, setSendError] = useState(null); + const addToast = useAppStore((s) => s.addToast); + + const send = useCallback(async () => { + if (!body.trim()) return; + if (channel === "direct" && !directRecipientId) return; + setSending(true); + setSendError(null); + const attachmentsJson = attachments.length > 0 ? JSON.stringify(attachments) : "[]"; + try { + const result = unwrap( + await window.agentCompany.sendAgentMessage({ + companyId, + fromAgentId: HUMAN_SENDER_ID, + toAgentId: channel === "direct" ? directRecipientId : null, + channel, + channelTargetId, + subject: subject.trim() || (replyTo ? `Re: ${replyTo.subject}` : ""), + body: body.trim(), + priority: "normal", + parentMessageId: replyTo?.id ?? null, + attachmentsJson, + threadId: threadId ?? undefined, + waitStateId: waitStateId ?? undefined, + }), + ); + if (result.mentionResults && result.mentionResults.length > 0) { + const woken = result.mentionResults.filter((m) => m.outcome === "woken"); + const queued = result.mentionResults.filter((m) => m.outcome === "queued"); + const busy = result.mentionResults.filter((m) => m.outcome === "busy"); + if (woken.length > 0) + addToast(`${t("composer.notified")}: ${woken.map((m) => m.agentName).join(", ")}`, "success"); + if (queued.length > 0) + addToast(`${t("composer.queued")}: ${queued.map((m) => m.agentName).join(", ")}`, "warn"); + if (busy.length > 0) + addToast(`${t("composer.busy")}: ${busy.map((m) => m.agentName).join(", ")}`, "warn"); + } + onCleared(); + } catch (e) { + setSendError(e instanceof Error ? e.message : t("composer.sendFailed")); + } finally { + setSending(false); + } + }, [ + body, + channel, + channelTargetId, + companyId, + directRecipientId, + onCleared, + replyTo, + subject, + attachments, + threadId, + waitStateId, + addToast, + t, + ]); + + return { send, sending, sendError }; +} diff --git a/src/renderer/components/company-onboarding/CompanyStep.tsx b/src/renderer/components/company-onboarding/CompanyStep.tsx index 2172af3f..6b4ae3c9 100644 --- a/src/renderer/components/company-onboarding/CompanyStep.tsx +++ b/src/renderer/components/company-onboarding/CompanyStep.tsx @@ -1,20 +1,34 @@ +import { useCallback, useState } from "react"; import { Buildings, FolderOpen } from "../ui/icons"; import type { ConnectorId } from "@shared/types"; import { ActionButton, Input, TextArea, Select } from "../ui"; import { OnboardingStep } from "./OnboardingStep"; -import { useOnboarding } from "./OnboardingContext"; +import { + useOnboardingConnectors, + useOnboardingWizard, +} from "./OnboardingContext"; + +export interface CompanyStepValues { + companyName: string; + companyDescription: string; + workspacePath: string; +} interface CompanyStepProps { - onContinue: () => void; + initialValues: CompanyStepValues; + onPickDirectory: () => Promise; + onContinue: (values: CompanyStepValues) => void; onBack: () => void; } -export function CompanyStep({ onContinue, onBack }: CompanyStepProps) { +export function CompanyStep({ + initialValues, + onPickDirectory, + onContinue, + onBack, +}: CompanyStepProps) { + const { currentLocale, t, error } = useOnboardingWizard(); const { - companyName, - setCompanyName, - companyDescription, - setCompanyDescription, selectedConnectorId, setSelectedConnectorId, selectedModel, @@ -22,13 +36,20 @@ export function CompanyStep({ onContinue, onBack }: CompanyStepProps) { executionReadyConnectors, modelOptions, loadingModels, - workspacePath, - setWorkspacePath, - onPickDirectory, - error, - currentLocale, - t, - } = useOnboarding(); + } = useOnboardingConnectors(); + + const [companyName, setCompanyName] = useState(initialValues.companyName); + const [companyDescription, setCompanyDescription] = useState(initialValues.companyDescription); + const [workspacePath, setWorkspacePath] = useState(initialValues.workspacePath); + + const handlePick = useCallback(async () => { + const picked = await onPickDirectory(); + if (picked) setWorkspacePath(picked); + }, [onPickDirectory]); + + const handleContinue = useCallback(() => { + onContinue({ companyName, companyDescription, workspacePath }); + }, [companyName, companyDescription, workspacePath, onContinue]); return ( - {t("onboarding.workspaceFolder")} {t("onboarding.workspaceFolderOptional")} + {t("onboarding.workspaceFolder")}{" "} + + {t("onboarding.workspaceFolderOptional")} +
- + void handlePick()} + icon={FolderOpen} + />
{t("onboarding.workspaceHelp")} @@ -122,7 +150,7 @@ export function CompanyStep({ onContinue, onBack }: CompanyStepProps) { void; + initialValue: ExecutionMode; + onContinue: (mode: ExecutionMode) => void; onBack: () => void; } -export function ExecutionModeStep({ onContinue, onBack }: ExecutionModeStepProps) { - const { executionMode, setExecutionMode, t } = useOnboarding(); +export function ExecutionModeStep({ initialValue, onContinue, onBack }: ExecutionModeStepProps) { + const { t } = useOnboardingWizard(); + const [executionMode, setExecutionMode] = useState(initialValue); return (
- {currentLocale === "zh" - ? `已启用 ${enabledCount} / ${models.length || 0} 个模型` - : `${enabledCount} / ${models.length || 0} models enabled`} + {t("models.poolSummary").replace("{enabled}", String(enabledCount)).replace("{total}", String(models.length || 0))}
- +
void onToggleConnectorModel(connector.id, modelId)} canRemove={false} - zh={currentLocale === "zh"} />
); diff --git a/src/renderer/components/company-onboarding/NodeCheckStep.tsx b/src/renderer/components/company-onboarding/NodeCheckStep.tsx index 6ec700ad..a5f1f07a 100644 --- a/src/renderer/components/company-onboarding/NodeCheckStep.tsx +++ b/src/renderer/components/company-onboarding/NodeCheckStep.tsx @@ -8,7 +8,7 @@ import { import { ActionButton } from "../ui"; import type { TerminalCommandKey } from "@shared/contracts"; import { OnboardingStep } from "./OnboardingStep"; -import { useOnboarding } from "./OnboardingContext"; +import { useOnboardingWizard } from "./OnboardingContext"; interface NodeCheckStepProps { onContinue: () => void; @@ -44,7 +44,7 @@ node -v`, }; export function NodeCheckStep({ onContinue, onBack }: NodeCheckStepProps) { - const { nodeStatus, nodeChecking, onCheckNode, onInstallCommand, t } = useOnboarding(); + const { nodeStatus, nodeChecking, onCheckNode, onInstallCommand, t } = useOnboardingWizard(); const platform = window.agentCompany.platform; const platformInfo = INSTALL_COMMANDS[platform] ?? INSTALL_COMMANDS.linux; diff --git a/src/renderer/components/company-onboarding/OnboardingContext.tsx b/src/renderer/components/company-onboarding/OnboardingContext.tsx index 4ce5c420..7899fccf 100644 --- a/src/renderer/components/company-onboarding/OnboardingContext.tsx +++ b/src/renderer/components/company-onboarding/OnboardingContext.tsx @@ -2,7 +2,6 @@ import { createContext, useContext, type ReactNode } from "react"; import type { AppLocale } from "@shared/locale"; import type { ConnectorId, - ExecutionMode, OnboardingBootstrapResult, ProfileSnapshot, } from "@shared/types"; @@ -10,18 +9,47 @@ import type { ConnectorModelEntry } from "@shared/connector-models"; import type { TerminalCommandKey } from "@shared/contracts"; import type { TranslationFn } from "../../i18n"; -export interface OnboardingContextValue { +/** + * Wizard-level shell state: locale, translation, load/error, finalized + * launch snapshot. Narrow on purpose so that typing in a step does not + * invalidate every consumer. + */ +export interface OnboardingWizardContextValue { currentLocale: AppLocale; t: TranslationFn; - error: string | null; loading: boolean; - + launchSummary: OnboardingBootstrapResult | null; nodeStatus: { installed: boolean; version: string | null } | null; nodeChecking: boolean; onCheckNode: () => void; onInstallCommand: (commandKey: TerminalCommandKey) => void; +} +const OnboardingWizardContext = createContext(null); + +export function OnboardingWizardProvider({ + value, + children, +}: { + value: OnboardingWizardContextValue; + children: ReactNode; +}) { + return {children}; +} + +export function useOnboardingWizard(): OnboardingWizardContextValue { + const ctx = useContext(OnboardingWizardContext); + if (!ctx) { + throw new Error("useOnboardingWizard must be used within an OnboardingWizardProvider"); + } + return ctx; +} + +/** + * Connector/model slice. Shared by CompanyStep, ModelPoolStep, LaunchStep. + */ +export interface OnboardingConnectorsContextValue { connectors: ProfileSnapshot["connectors"]; hasAtLeastOneReady: boolean; onTestConnector: (connectorId: string) => void; @@ -33,11 +61,6 @@ export interface OnboardingContextValue { ) => void; onLoginConnector: (connectorId: string) => void; - companyName: string; - setCompanyName: (value: string) => void; - companyDescription: string; - setCompanyDescription: (value: string) => void; - selectedConnectorId: ConnectorId | ""; setSelectedConnectorId: (value: ConnectorId | "") => void; selectedModel: string; @@ -54,38 +77,30 @@ export interface OnboardingContextValue { totalEnabledModels: number; selectedConnectorEnabledModels: number; onToggleConnectorModel: (connectorId: ConnectorId, modelId: string) => void; - - workspacePath: string; - setWorkspacePath: (value: string) => void; - onPickDirectory: () => void; - - executionMode: ExecutionMode; - setExecutionMode: (value: ExecutionMode) => void; - - goalTitle: string; - setGoalTitle: (value: string) => void; - goalDescription: string; - setGoalDescription: (value: string) => void; - - launchSummary: OnboardingBootstrapResult | null; } -const OnboardingContext = createContext(null); +const OnboardingConnectorsContext = createContext(null); -export function OnboardingProvider({ +export function OnboardingConnectorsProvider({ value, children, }: { - value: OnboardingContextValue; + value: OnboardingConnectorsContextValue; children: ReactNode; }) { - return {children}; + return ( + + {children} + + ); } -export function useOnboarding(): OnboardingContextValue { - const ctx = useContext(OnboardingContext); +export function useOnboardingConnectors(): OnboardingConnectorsContextValue { + const ctx = useContext(OnboardingConnectorsContext); if (!ctx) { - throw new Error("useOnboarding must be used within an OnboardingProvider"); + throw new Error( + "useOnboardingConnectors must be used within an OnboardingConnectorsProvider", + ); } return ctx; } diff --git a/src/renderer/components/company-onboarding/OnboardingStepPanel.tsx b/src/renderer/components/company-onboarding/OnboardingStepPanel.tsx index ce772ddd..22a756d7 100644 --- a/src/renderer/components/company-onboarding/OnboardingStepPanel.tsx +++ b/src/renderer/components/company-onboarding/OnboardingStepPanel.tsx @@ -1,13 +1,14 @@ import { AnimatePresence } from "framer-motion"; +import type { ExecutionMode } from "@shared/types"; import { OnboardingHero, type OnboardingStep } from "./OnboardingHero"; import { NodeCheckStep } from "./NodeCheckStep"; import { ProvidersStep } from "./ProvidersStep"; -import { CompanyStep } from "./CompanyStep"; +import { CompanyStep, type CompanyStepValues } from "./CompanyStep"; import { ModelPoolStep } from "./ModelPoolStep"; import { ExecutionModeStep } from "./ExecutionModeStep"; -import { MissionStep } from "./MissionStep"; +import { MissionStep, type MissionStepValues } from "./MissionStep"; import { LaunchStep } from "./LaunchStep"; -import { useOnboarding } from "./OnboardingContext"; +import { useOnboardingWizard } from "./OnboardingContext"; interface OnboardingStepPanelProps { step: OnboardingStep; @@ -17,11 +18,15 @@ interface OnboardingStepPanelProps { onBack: (step: OnboardingStep) => void; onContinueFromNodeCheck: () => void; onContinueFromProviders: () => void; - onContinueFromCompany: () => void; + onContinueFromCompany: (values: CompanyStepValues) => void; onContinueFromModelPool: () => void; - onContinueFromExecutionMode: () => void; - onLaunchCompany: () => void; + onContinueFromExecutionMode: (mode: ExecutionMode) => void; + onLaunchCompany: (values: MissionStepValues) => void; onFinishLaunch: () => void; + onPickDirectory: () => Promise; + companyValues: CompanyStepValues; + missionValues: MissionStepValues; + executionMode: ExecutionMode; } export function OnboardingStepPanel({ @@ -37,16 +42,16 @@ export function OnboardingStepPanel({ onContinueFromExecutionMode, onLaunchCompany, onFinishLaunch, + onPickDirectory, + companyValues, + missionValues, + executionMode, }: OnboardingStepPanelProps) { - const { currentLocale, t, launchSummary } = useOnboarding(); + const { currentLocale, t, launchSummary } = useOnboardingWizard(); return ( <> - +
@@ -77,6 +82,8 @@ export function OnboardingStepPanel({ {step === "company" ? ( onBack("providers")} /> @@ -91,6 +98,7 @@ export function OnboardingStepPanel({ {step === "executionmode" ? ( onBack("modelpool")} /> @@ -98,6 +106,7 @@ export function OnboardingStepPanel({ {step === "mission" ? ( onBack("executionmode")} /> diff --git a/src/renderer/components/company-onboarding/OnboardingWizard.tsx b/src/renderer/components/company-onboarding/OnboardingWizard.tsx index 40b6b7c3..308b40ee 100644 --- a/src/renderer/components/company-onboarding/OnboardingWizard.tsx +++ b/src/renderer/components/company-onboarding/OnboardingWizard.tsx @@ -1,5 +1,10 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import type { ConnectorId, ExecutionMode, OnboardingBootstrapResult, ProfileSnapshot } from "@shared/types"; +import type { + ConnectorId, + ExecutionMode, + OnboardingBootstrapResult, + ProfileSnapshot, +} from "@shared/types"; import type { AppLocale } from "@shared/locale"; import { useT } from "../../i18n"; import { unwrap } from "../../lib/desktop"; @@ -8,7 +13,22 @@ import { WelcomeStep } from "./WelcomeStep"; import { OnboardingStepPanel } from "./OnboardingStepPanel"; import { useConnectorCallbacks } from "./useConnectorCallbacks"; import { useConnectorModelSelection } from "./useConnectorModelSelection"; -import { OnboardingProvider, type OnboardingContextValue } from "./OnboardingContext"; +import { + OnboardingConnectorsProvider, + OnboardingWizardProvider, + type OnboardingConnectorsContextValue, + type OnboardingWizardContextValue, +} from "./OnboardingContext"; +import type { CompanyStepValues } from "./CompanyStep"; +import type { MissionStepValues } from "./MissionStep"; + +const EMPTY_COMPANY: CompanyStepValues = { + companyName: "", + companyDescription: "", + workspacePath: "", +}; + +const EMPTY_MISSION: MissionStepValues = { goalTitle: "", goalDescription: "" }; export function CompanyOnboarding({ connectors, @@ -32,13 +52,24 @@ export function CompanyOnboarding({ const [step, setStep] = useState("welcome"); const [nodeStatus, setNodeStatus] = useState<{ installed: boolean; version: string | null } | null>(null); const [nodeChecking, setNodeChecking] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [launchSummary, setLaunchSummary] = useState(null); + + // Finalized step-local snapshots. Written only on step "commit" (continue). + const [companyValues, setCompanyValues] = useState(EMPTY_COMPANY); + const [executionMode, setExecutionMode] = useState("ai_ceo"); + const [missionValues, setMissionValues] = useState(EMPTY_MISSION); const checkNode = useCallback(async () => { setNodeChecking(true); try { const result = unwrap(await window.agentCompany.checkNodeInstalled()); setNodeStatus(result); - } catch { + } catch (error) { + console.warn( + `[onboarding/OnboardingWizard] checkNodeInstalled failed; assuming uninstalled: ${error instanceof Error ? error.message : String(error)}`, + ); setNodeStatus({ installed: false, version: null }); } finally { setNodeChecking(false); @@ -54,15 +85,7 @@ export function CompanyOnboarding({ const { handleTestConnector, handleSaveConnector, handleLoginConnector, handleInstallCommand } = useConnectorCallbacks(connectors); - const [companyName, setCompanyName] = useState(""); - const [companyDescription, setCompanyDescription] = useState(""); - const [workspacePath, setWorkspacePath] = useState(""); - const [executionMode, setExecutionMode] = useState("ai_ceo"); - const [goalTitle, setGoalTitle] = useState(""); - const [goalDescription, setGoalDescription] = useState(""); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [launchSummary, setLaunchSummary] = useState(null); + const { connectorModelPools, executionReadyConnectors, @@ -79,70 +102,73 @@ export function CompanyOnboarding({ toggleConnectorModel, } = useConnectorModelSelection(connectors, currentLocale, t, setError); - const pickDirectory = useCallback(async () => { + const pickDirectory = useCallback(async (): Promise => { setError(null); const result = await window.agentCompany.pickDirectory(); if (!result.ok) { setError(result.error.message); - return; - } - if (result.data) { - setWorkspacePath(result.data); + return null; } + return result.data ?? null; }, []); - const bootstrap = useCallback(async () => { - const result = unwrap(await window.agentCompany.bootstrapOnboarding({ - companyName: companyName.trim(), - companyDescription: companyDescription.trim(), - workspacePath: workspacePath.trim(), - autoApproveHires: executionMode === "ai_ceo", - reviewDeliverables: executionMode === "ai_cofounder", - executionMode, - goalTitle: goalTitle.trim(), - goalDescription: goalDescription.trim(), - connectorId: selectedConnector?.id as ConnectorId | undefined, - connectorModel: selectedModel.trim(), - })); - setLaunchSummary(result); - setStep("launch"); - }, [ - executionMode, - companyDescription, - companyName, - goalDescription, - goalTitle, - selectedConnector?.id, - selectedModel, - workspacePath, - ]); + const handleContinueFromCompany = useCallback( + (values: CompanyStepValues) => { + if (loading) return; + if (!values.companyName.trim()) { + setError(t("onboarding.enterCompanyName")); + return; + } + setCompanyValues(values); + setError(null); + setStep("modelpool"); + }, + [loading, t], + ); - const continueToMission = useCallback(() => { - if (loading) return; - if (!companyName.trim()) { - setError(t("onboarding.enterCompanyName")); - return; - } + const handleContinueFromExecutionMode = useCallback((mode: ExecutionMode) => { + setExecutionMode(mode); setError(null); - setStep("modelpool"); - }, [companyName, loading, t]); + setStep("mission"); + }, []); - const launchCompany = useCallback(async () => { - if (loading) return; - if (!goalTitle.trim()) { - setError(t("onboarding.describeFirstGoal")); - return; - } - setLoading(true); - setError(null); - try { - await bootstrap(); - } catch (submitError) { - setError(submitError instanceof Error ? submitError.message : t("onboarding.unableToBootstrap")); - } finally { - setLoading(false); - } - }, [bootstrap, goalTitle, loading, t]); + const launchCompany = useCallback( + async (values: MissionStepValues) => { + if (loading) return; + if (!values.goalTitle.trim()) { + setError(t("onboarding.describeFirstGoal")); + return; + } + setMissionValues(values); + setLoading(true); + setError(null); + try { + const result = unwrap( + await window.agentCompany.bootstrapOnboarding({ + companyName: companyValues.companyName.trim(), + companyDescription: companyValues.companyDescription.trim(), + workspacePath: companyValues.workspacePath.trim(), + autoApproveHires: executionMode === "ai_ceo", + reviewDeliverables: executionMode === "ai_cofounder", + executionMode, + goalTitle: values.goalTitle.trim(), + goalDescription: values.goalDescription.trim(), + connectorId: selectedConnector?.id as ConnectorId | undefined, + connectorModel: selectedModel.trim(), + }), + ); + setLaunchSummary(result); + setStep("launch"); + } catch (submitError) { + setError( + submitError instanceof Error ? submitError.message : t("onboarding.unableToBootstrap"), + ); + } finally { + setLoading(false); + } + }, + [companyValues, executionMode, loading, selectedConnector?.id, selectedModel, t], + ); const finishLaunch = useCallback(async () => { if (!launchSummary) return; @@ -151,34 +177,38 @@ export function CompanyOnboarding({ try { await onDone(launchSummary); } catch (submitError) { - setError(submitError instanceof Error ? submitError.message : t("onboarding.unableToOpenControlCenter")); + setError( + submitError instanceof Error + ? submitError.message + : t("onboarding.unableToOpenControlCenter"), + ); } finally { setLoading(false); } }, [launchSummary, onDone, t]); - const isWelcome = step === "welcome"; - const heroStep: OnboardingStep = step as OnboardingStep; - - const contextValue = useMemo( + const wizardValue = useMemo( () => ({ currentLocale, t, error, loading, + launchSummary, nodeStatus, nodeChecking, onCheckNode: () => void checkNode(), onInstallCommand: (commandKey) => void handleInstallCommand(commandKey), + }), + [currentLocale, t, error, loading, launchSummary, nodeStatus, nodeChecking, checkNode, handleInstallCommand], + ); + + const connectorsValue = useMemo( + () => ({ connectors, hasAtLeastOneReady, onTestConnector: (id) => void handleTestConnector(id), onSaveConnector: (id, env, model, notes) => void handleSaveConnector(id, env, model, notes), onLoginConnector: (id) => void handleLoginConnector(id), - companyName, - setCompanyName, - companyDescription, - setCompanyDescription, selectedConnectorId, setSelectedConnectorId, selectedModel, @@ -190,34 +220,15 @@ export function CompanyOnboarding({ loadingModels, totalEnabledModels, selectedConnectorEnabledModels, - onToggleConnectorModel: (connectorId, modelId) => void toggleConnectorModel(connectorId, modelId), - workspacePath, - setWorkspacePath, - onPickDirectory: () => void pickDirectory(), - executionMode, - setExecutionMode, - goalTitle, - setGoalTitle, - goalDescription, - setGoalDescription, - launchSummary, + onToggleConnectorModel: (connectorId, modelId) => + void toggleConnectorModel(connectorId, modelId), }), [ - currentLocale, - t, - error, - loading, - nodeStatus, - nodeChecking, - checkNode, - handleInstallCommand, connectors, hasAtLeastOneReady, handleTestConnector, handleSaveConnector, handleLoginConnector, - companyName, - companyDescription, selectedConnectorId, setSelectedConnectorId, selectedModel, @@ -230,24 +241,18 @@ export function CompanyOnboarding({ totalEnabledModels, selectedConnectorEnabledModels, toggleConnectorModel, - workspacePath, - pickDirectory, - executionMode, - goalTitle, - goalDescription, - launchSummary, ], ); + const isWelcome = step === "welcome"; + const heroStep: OnboardingStep = step as OnboardingStep; + return (
- {/* Drag region for frameless title bar */}
{isWelcome ? ( @@ -258,22 +263,37 @@ export function CompanyOnboarding({ t={t} /> ) : ( - - { setError(null); setStep("providers"); }} - onContinueFromProviders={() => { setError(null); setStep("company"); }} - onContinueFromCompany={() => void continueToMission()} - onContinueFromModelPool={() => { setError(null); setStep("executionmode"); }} - onContinueFromExecutionMode={() => { setError(null); setStep("mission"); }} - onLaunchCompany={() => void launchCompany()} - onFinishLaunch={() => void finishLaunch()} - /> - + + + { + setError(null); + setStep("providers"); + }} + onContinueFromProviders={() => { + setError(null); + setStep("company"); + }} + onContinueFromCompany={handleContinueFromCompany} + onContinueFromModelPool={() => { + setError(null); + setStep("executionmode"); + }} + onContinueFromExecutionMode={handleContinueFromExecutionMode} + onLaunchCompany={(values) => void launchCompany(values)} + onFinishLaunch={() => void finishLaunch()} + onPickDirectory={pickDirectory} + companyValues={companyValues} + missionValues={missionValues} + executionMode={executionMode} + /> + + )}
); diff --git a/src/renderer/components/company-onboarding/ProvidersStep.tsx b/src/renderer/components/company-onboarding/ProvidersStep.tsx index 45df190b..a5d5b10c 100644 --- a/src/renderer/components/company-onboarding/ProvidersStep.tsx +++ b/src/renderer/components/company-onboarding/ProvidersStep.tsx @@ -7,7 +7,7 @@ import { listFeaturedOnboardingConnectors, } from "@shared/connector-catalog"; import { OnboardingStep } from "./OnboardingStep"; -import { useOnboarding } from "./OnboardingContext"; +import { useOnboardingWizard } from "./OnboardingContext"; import { OnboardingProviderDispatch } from "./provider-cards/ProviderCard"; interface ProvidersStepProps { @@ -20,7 +20,7 @@ const FEATURED_IDS = new Set(FEATURED.map((entry) => entry.id)); const ADVANCED = CONNECTOR_CATALOG.filter((entry) => !FEATURED_IDS.has(entry.id)); export function ProvidersStep({ onContinue, onBack }: ProvidersStepProps) { - const { t, error } = useOnboarding(); + const { t, error } = useOnboardingWizard(); const [credentialsMap, setCredentialsMap] = useState>( new Map(), ); diff --git a/src/renderer/components/company-onboarding/provider-cards/api-key-card.tsx b/src/renderer/components/company-onboarding/provider-cards/api-key-card.tsx index b4cfaf77..3afc1d64 100644 --- a/src/renderer/components/company-onboarding/provider-cards/api-key-card.tsx +++ b/src/renderer/components/company-onboarding/provider-cards/api-key-card.tsx @@ -4,12 +4,12 @@ import type { ConnectorCatalogEntry } from "@shared/connector-catalog"; import type { CredentialSummary } from "@shared/contracts/schemas-credentials"; import type { TranslationFn } from "../../../i18n"; import { - INPUT_CLS, InlineFeedback, OnboardingProviderCard, OnboardingStatusBadge, type OnboardingCardStatus, } from "./shared"; +import { Input } from "../../ui"; interface ApiKeyCardProps { entry: ConnectorCatalogEntry; @@ -96,14 +96,11 @@ export function ApiKeyProviderCard({ {t("onboarding.providers.apiKeyLabel")}{" "} ({envKey}) - setValue(event.target.value)} + onChange={setValue} placeholder={connected ? t("onboarding.providers.apiKeyReplacePlaceholder") : ""} - autoComplete="off" - spellCheck={false} disabled={busy} />
diff --git a/src/renderer/components/company-onboarding/provider-cards/shared.tsx b/src/renderer/components/company-onboarding/provider-cards/shared.tsx index d9dcc32d..885fcf48 100644 --- a/src/renderer/components/company-onboarding/provider-cards/shared.tsx +++ b/src/renderer/components/company-onboarding/provider-cards/shared.tsx @@ -2,9 +2,7 @@ import { CaretDown, CaretRight } from "../../ui/icons"; import type { ReactNode } from "react"; import type { CredentialSummary } from "@shared/contracts/schemas-credentials"; import type { TranslationFn } from "../../../i18n"; - -export const INPUT_CLS = - "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring w-full rounded-lg border border-border bg-card px-3 py-2.5 text-[13px] text-foreground placeholder:text-muted-foreground"; +import { Card } from "../../ui"; export type OnboardingCardStatus = CredentialSummary["status"] | "checking" | "not_configured"; @@ -24,7 +22,7 @@ export function OnboardingProviderCard({ children: ReactNode; }) { return ( -
+
{expanded ?
{children}
: null} -
+ ); } diff --git a/src/renderer/components/company-onboarding/useConnectorCallbacks.ts b/src/renderer/components/company-onboarding/useConnectorCallbacks.ts index 8a7258fd..62bfe8b9 100644 --- a/src/renderer/components/company-onboarding/useConnectorCallbacks.ts +++ b/src/renderer/components/company-onboarding/useConnectorCallbacks.ts @@ -9,8 +9,10 @@ export function useConnectorCallbacks(connectors: ProfileSnapshot["connectors"]) const handleTestConnector = useCallback(async (connectorId: string) => { try { unwrap(await window.agentCompany.testConnector(connectorId as ConnectorId)); - } catch { - // Health check result is stored in DB and reflected via domain-changed + } catch (error) { + console.warn( + `[onboarding/useConnectorCallbacks] testConnector(${connectorId}) failed (result reflected via domain-changed): ${error instanceof Error ? error.message : String(error)}`, + ); } }, []); @@ -27,16 +29,20 @@ export function useConnectorCallbacks(connectors: ProfileSnapshot["connectors"]) })); // Run health check AFTER save completes so the probe uses the new config unwrap(await window.agentCompany.testConnector(connectorId as ConnectorId)); - } catch { - // Error is non-fatal; result reflected via domain-changed + } catch (error) { + console.warn( + `[onboarding/useConnectorCallbacks] saveConnector+testConnector(${connectorId}) failed (result reflected via domain-changed): ${error instanceof Error ? error.message : String(error)}`, + ); } }, [connectors]); const handleLoginConnector = useCallback(async (connectorId: string) => { try { unwrap(await window.agentCompany.openConnectorAuthTerminal(connectorId as ConnectorId)); - } catch { - // Terminal open failure is non-fatal + } catch (error) { + console.warn( + `[onboarding/useConnectorCallbacks] openConnectorAuthTerminal(${connectorId}) failed: ${error instanceof Error ? error.message : String(error)}`, + ); return; } // Poll connector health after login terminal opens (same pattern as ConnectorEditor) @@ -46,15 +52,21 @@ export function useConnectorCallbacks(connectors: ProfileSnapshot["connectors"]) if (attempts > 12) { clearInterval(poll); return; } try { unwrap(await window.agentCompany.testConnector(connectorId as ConnectorId)); - } catch { /* health check result reflected via domain-changed */ } + } catch (error) { + console.warn( + `[onboarding/useConnectorCallbacks] post-login testConnector(${connectorId}) failed (attempt ${attempts}): ${error instanceof Error ? error.message : String(error)}`, + ); + } }, 5000); }, []); const handleInstallCommand = useCallback(async (commandKey: z.infer["commandKey"]) => { try { unwrap(await window.agentCompany.openTerminalCommand({ commandKey })); - } catch { - // Terminal open failure is non-fatal + } catch (error) { + console.warn( + `[onboarding/useConnectorCallbacks] openTerminalCommand(${commandKey}) failed: ${error instanceof Error ? error.message : String(error)}`, + ); return; } // Poll all connectors after install (install may make a new CLI available) @@ -64,7 +76,13 @@ export function useConnectorCallbacks(connectors: ProfileSnapshot["connectors"]) if (attempts > 12) { clearInterval(poll); return; } for (const c of connectors) { if (c.status !== "ready") { - try { unwrap(await window.agentCompany.testConnector(c.id as ConnectorId)); } catch { /* reflected via domain-changed */ } + try { + unwrap(await window.agentCompany.testConnector(c.id as ConnectorId)); + } catch (error) { + console.warn( + `[onboarding/useConnectorCallbacks] post-install testConnector(${c.id}) failed (attempt ${attempts}): ${error instanceof Error ? error.message : String(error)}`, + ); + } } } }, 5000); diff --git a/src/renderer/components/company-onboarding/useConnectorModelSelection.ts b/src/renderer/components/company-onboarding/useConnectorModelSelection.ts index 680ba44e..9a042cb8 100644 --- a/src/renderer/components/company-onboarding/useConnectorModelSelection.ts +++ b/src/renderer/components/company-onboarding/useConnectorModelSelection.ts @@ -136,8 +136,11 @@ export function useConnectorModelSelection( setConnectorModelsById((previous) => connectorCatalogsEqual(previous, next) ? previous : next, ); - } catch { + } catch (error) { if (cancelled) return; + console.warn( + `[onboarding/useConnectorModelSelection] connector model fetch failed; clearing catalog: ${error instanceof Error ? error.message : String(error)}`, + ); setConnectorModelsById((previous) => (Object.keys(previous).length === 0 ? previous : {})); setSelectedModel(""); } finally { diff --git a/src/renderer/components/company-operations.tsx b/src/renderer/components/company-operations.tsx deleted file mode 100644 index 2dfd0f57..00000000 --- a/src/renderer/components/company-operations.tsx +++ /dev/null @@ -1,4 +0,0 @@ -export { DocumentCenter } from "./company-operations/DocumentCenter"; -export { KnowledgeBaseManager } from "./company-operations/KnowledgeBaseManager"; -export { MeetingsManager } from "./company-operations/MeetingsManager"; -export { SprintManager } from "./company-operations/SprintManager"; diff --git a/src/renderer/components/dashboard.tsx b/src/renderer/components/dashboard.tsx deleted file mode 100644 index 7528f833..00000000 --- a/src/renderer/components/dashboard.tsx +++ /dev/null @@ -1,5 +0,0 @@ -export { Dashboard } from "./dashboard/Dashboard"; -export type { DashboardProps } from "./dashboard/Dashboard"; -export { KanbanBoard } from "./dashboard/KanbanBoard"; -export { MetricCard } from "./dashboard/MetricsGrid"; -export { ActivityFeed } from "./dashboard/ActivityFeed"; diff --git a/src/renderer/components/dashboard/ChartSection.tsx b/src/renderer/components/dashboard/ChartSection.tsx index c509ca90..38d17d06 100644 --- a/src/renderer/components/dashboard/ChartSection.tsx +++ b/src/renderer/components/dashboard/ChartSection.tsx @@ -1,7 +1,8 @@ import { useMemo } from "react"; import { motion } from "framer-motion"; import type { RunRecord, CostRecord, AgentRecord } from "@shared/types"; -import { useReducedMotion } from "../ui"; +import { Eyebrow, useReducedMotion } from "../ui"; +import { useT } from "../../i18n"; import { aggregateRunsByDay, aggregateCostsByDay, @@ -22,6 +23,7 @@ interface ChartSectionProps { } export function ChartSection({ runs, costs, agents, companyId }: ChartSectionProps) { + const t = useT(); const reducedMotion = useReducedMotion(); const dailyRuns = useMemo(() => aggregateRunsByDay(runs, 14), [runs]); @@ -34,39 +36,39 @@ export function ChartSection({ runs, costs, agents, companyId }: ChartSectionPro const hasRunData = dailyRuns.some((d) => d.total > 0); const hasCostData = dailyCosts.some((d) => d.total > 0); + const totalRuns = dailyRuns.reduce((s, d) => s + d.total, 0); + const content = (
{/* Section header */} -
- Performance -
+ {t("chart.performance")} {/* 2-column chart grid */}
s + d.total, 0)} total runs over 14 days`} + title={t("chart.runActivity")} + ariaLabel={t("chart.runActivityAria").replace("{count}", String(totalRuns))} isEmpty={!hasRunData} > @@ -74,9 +76,7 @@ export function ChartSection({ runs, costs, agents, companyId }: ChartSectionPro
{/* Budget Utilization section */} -
- Budget Utilization -
+ {t("chart.budgetUtilization")}
); diff --git a/src/renderer/components/dashboard/Dashboard.tsx b/src/renderer/components/dashboard/Dashboard.tsx index dc67ddab..4a849504 100644 --- a/src/renderer/components/dashboard/Dashboard.tsx +++ b/src/renderer/components/dashboard/Dashboard.tsx @@ -27,7 +27,7 @@ import { ActivityFeed } from "./ActivityFeed"; import { RecoveryBanner } from "./RecoveryBanner"; import { BudgetAlertBanner } from "./BudgetAlertBanner"; import { ChartSection } from "./ChartSection"; -import { AttentionRow } from "../ui"; +import { AttentionRow, Eyebrow } from "../ui"; export interface DashboardProps { companyName?: string; @@ -142,10 +142,10 @@ export function Dashboard(props: DashboardProps) { {/* Needs Attention — moved to top, only shows when items exist */} {hasAttentionItems ? (
-
+ {t("dash.needsAttentionSection")} -
+
{pendingApprovals.slice(0, 5).map((approval, i) => { const requester = approval.requestedByAgentId ? agentMap.get(approval.requestedByAgentId) : undefined; @@ -201,9 +201,7 @@ export function Dashboard(props: DashboardProps) { {/* Active Work — what's running right now */}
-
- {t("dash.activeWork")} -
+ {t("dash.activeWork")} @@ -250,9 +248,7 @@ export function Dashboard(props: DashboardProps) { {/* Recent Activity */}
-
- {t("dash.recentActivity")} -
+ {t("dash.recentActivity")}
diff --git a/src/renderer/components/dashboard/KanbanBoard.tsx b/src/renderer/components/dashboard/KanbanBoard.tsx index 5f480979..3fbfcb15 100644 --- a/src/renderer/components/dashboard/KanbanBoard.tsx +++ b/src/renderer/components/dashboard/KanbanBoard.tsx @@ -1,6 +1,8 @@ import { useMemo } from "react"; import { Briefcase, Robot } from "../ui/icons"; import type { AgentRecord, RunRecord, TaskRecord } from "@shared/types"; +import { useT } from "../../i18n"; +import { Eyebrow } from "../ui"; interface KanbanProps { tasks: TaskRecord[]; @@ -9,13 +11,13 @@ interface KanbanProps { onSelectTask: (taskId: string) => void; } -const KANBAN_COLUMNS: Array<{ status: TaskRecord["status"]; label: string; tone: string; dotColor: string }> = [ - { status: "backlog", label: "Backlog", tone: "text-muted-foreground", dotColor: "bg-[color:var(--muted-foreground)]" }, - { status: "todo", label: "Todo", tone: "text-warning", dotColor: "bg-warning" }, - { status: "in_progress", label: "In Progress", tone: "text-primary", dotColor: "bg-primary" }, - { status: "in_review", label: "In Review", tone: "text-warning", dotColor: "bg-warning" }, - { status: "done", label: "Done", tone: "text-success", dotColor: "bg-success" }, - { status: "blocked", label: "Blocked", tone: "text-destructive", dotColor: "bg-destructive" }, +const KANBAN_COLUMNS: Array<{ status: TaskRecord["status"]; labelKey: string; tone: string; dotColor: string }> = [ + { status: "backlog", labelKey: "kanban.backlog", tone: "text-muted-foreground", dotColor: "bg-[color:var(--muted-foreground)]" }, + { status: "todo", labelKey: "kanban.todo", tone: "text-warning", dotColor: "bg-warning" }, + { status: "in_progress", labelKey: "kanban.inProgress", tone: "text-primary", dotColor: "bg-primary" }, + { status: "in_review", labelKey: "kanban.inReview", tone: "text-warning", dotColor: "bg-warning" }, + { status: "done", labelKey: "kanban.done", tone: "text-success", dotColor: "bg-success" }, + { status: "blocked", labelKey: "kanban.blocked", tone: "text-destructive", dotColor: "bg-destructive" }, ]; const priorityColors: Record = { @@ -26,6 +28,7 @@ const priorityColors: Record = { }; export function KanbanBoard({ tasks, agents, runs, onSelectTask }: KanbanProps) { + const t = useT(); const tasksByStatus = useMemo(() => { const grouped: Record = {}; for (const col of KANBAN_COLUMNS) grouped[col.status] = []; @@ -46,8 +49,8 @@ export function KanbanBoard({ tasks, agents, runs, onSelectTask }: KanbanProps) return (
-
No tasks yet
-
Tasks created by agents will appear here on the board
+
{t("kanban.noTasks")}
+
{t("kanban.noTasksDetail")}
); } @@ -60,7 +63,7 @@ export function KanbanBoard({ tasks, agents, runs, onSelectTask }: KanbanProps)
) : ( - Unassigned + {t("kanban.unassigned")} )} {isRunning ? ( ) : null}
{task.priority === "critical" || task.priority === "high" ? (
- {task.priority} + {task.priority === "critical" ? t("kanban.priority.critical") : t("kanban.priority.high")}
) : null} diff --git a/src/renderer/components/dashboard/MetricsGrid.tsx b/src/renderer/components/dashboard/MetricsGrid.tsx index a5d44f0d..7405410c 100644 --- a/src/renderer/components/dashboard/MetricsGrid.tsx +++ b/src/renderer/components/dashboard/MetricsGrid.tsx @@ -1,4 +1,5 @@ import type React from "react"; +import { Eyebrow } from "../ui"; export function MetricCard({ label, @@ -48,7 +49,7 @@ export function MetricCard({
-
{label}
+ {label}
{value}
{subtitle ?
{subtitle}
: null}
diff --git a/src/renderer/components/dashboard/RecoveryBanner.tsx b/src/renderer/components/dashboard/RecoveryBanner.tsx index cb40ee01..93eca034 100644 --- a/src/renderer/components/dashboard/RecoveryBanner.tsx +++ b/src/renderer/components/dashboard/RecoveryBanner.tsx @@ -8,6 +8,7 @@ interface InterruptedRun { taskTitle: string; agentId: string; agentName: string; + companyId: string; connectorId: string; interruptedAt: string; } @@ -30,12 +31,12 @@ export function RecoveryBanner() { if (runs.length === 0 || dismissed) return null; const handleRetry = async (run: InterruptedRun) => { - await window.agentCompany?.recoveryRetry(run.taskId, run.agentId, ""); + await window.agentCompany?.recoveryRetry(run.taskId, run.agentId, run.companyId); setRuns(prev => prev.filter(r => r.runId !== run.runId)); }; const handleFail = async (run: InterruptedRun) => { - await window.agentCompany?.recoveryFail(run.taskId); + await window.agentCompany?.recoveryFail(run.taskId, run.companyId); setRuns(prev => prev.filter(r => r.runId !== run.runId)); }; diff --git a/src/renderer/components/dashboard/charts/ChartCard.tsx b/src/renderer/components/dashboard/charts/ChartCard.tsx index eca8f0da..4b713b12 100644 --- a/src/renderer/components/dashboard/charts/ChartCard.tsx +++ b/src/renderer/components/dashboard/charts/ChartCard.tsx @@ -1,4 +1,5 @@ import { ChartLine } from "../../ui/icons"; +import { useT } from "../../../i18n"; interface ChartCardProps { title: string; @@ -14,11 +15,14 @@ export function ChartCard({ title, period = "14d", isEmpty = false, - emptyHeading = "No activity yet", - emptyBody = "Charts will appear once your company starts running tasks.", + emptyHeading, + emptyBody, ariaLabel, children, }: ChartCardProps) { + const t = useT(); + const resolvedHeading = emptyHeading ?? t("chart.emptyHeading"); + const resolvedBody = emptyBody ?? t("chart.emptyBody"); return (
- {emptyHeading} + {resolvedHeading} - {emptyBody} + {resolvedBody}
) : ( diff --git a/src/renderer/components/execution.tsx b/src/renderer/components/execution.tsx deleted file mode 100644 index 1f5487aa..00000000 --- a/src/renderer/components/execution.tsx +++ /dev/null @@ -1,17 +0,0 @@ -// Backward-compatible re-exports from decomposed execution components -export { TaskQueue } from "./execution/TaskQueue" -export { TaskInspector } from "./execution/TaskInspector" -export { TaskCreator } from "./execution/TaskCreator" -export { ApprovalList } from "./execution/ApprovalList" -export { ApprovalInspector } from "./execution/approval-inspector" -export { ActivityList } from "./execution/ActivityList" -export { CostList } from "./execution/CostList" -export { BudgetSummary } from "./execution/BudgetSummary" -export { WorkspaceList } from "./execution/WorkspaceList" -export { WorkspaceInspector } from "./execution/WorkspaceInspector" -export { WorkspaceCreator } from "./execution/WorkspaceCreator" -export { SettingsPane } from "./execution/SettingsPane" -export { PendingReviewList } from "./execution/PendingReviewList" -export { PolicyList } from "./execution/PolicyList" -export { PolicyFormModal } from "./execution/PolicyFormModal" -export { IncidentList } from "./execution/IncidentList" diff --git a/src/renderer/components/execution/ModelManagementShared.tsx b/src/renderer/components/execution/ModelManagementShared.tsx index 2623eab6..a06f2810 100644 --- a/src/renderer/components/execution/ModelManagementShared.tsx +++ b/src/renderer/components/execution/ModelManagementShared.tsx @@ -1,12 +1,13 @@ import { CaretDown, CaretRight } from "../ui/icons"; import type { ProfileSnapshot } from "@shared/types"; +import { Input } from "../ui"; +import { useT } from "../../i18n"; /* ------------------------------------------------------------------ */ /* Shared */ /* ------------------------------------------------------------------ */ -export const INPUT_CLS = - "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring w-full rounded-lg border border-border bg-card px-3 py-2.5 text-[13px] text-foreground placeholder:text-muted-foreground"; +export { Input }; export interface ModelEntry { id: string; @@ -25,12 +26,13 @@ export function parseEnvValue(text: string, key: string): string { /* StatusBadge */ /* ------------------------------------------------------------------ */ -export function StatusBadge({ status, zh }: { status: ConnectorStatus; zh: boolean }) { +export function StatusBadge({ status }: { status: ConnectorStatus }) { + const t = useT(); if (status === "ready") { return ( - {zh ? "就绪" : "Ready"} + {t("models.ready")} ); } @@ -38,7 +40,7 @@ export function StatusBadge({ status, zh }: { status: ConnectorStatus; zh: boole return ( - {zh ? "未安装" : "Not installed"} + {t("models.notInstalled")} ); } @@ -46,7 +48,7 @@ export function StatusBadge({ status, zh }: { status: ConnectorStatus; zh: boole return ( - {zh ? "需登录" : "Auth required"} + {t("models.authRequired")} ); } @@ -54,7 +56,7 @@ export function StatusBadge({ status, zh }: { status: ConnectorStatus; zh: boole return ( - {zh ? "检测中" : "Checking"} + {t("models.checking")} ); } @@ -137,18 +139,17 @@ export function ModelToggleList({ onToggle, onRemove, canRemove, - zh, }: { models: ModelEntry[]; onToggle: (id: string) => void; onRemove?: (id: string) => void; canRemove: boolean; - zh: boolean; }) { + const t = useT(); if (models.length === 0) { return (
- {zh ? "暂无模型" : "No models available"} + {t("models.noModels")}
); } @@ -156,7 +157,7 @@ export function ModelToggleList({ return (
- {zh ? "模型" : "Models"} ({enabledCount}/{models.length} {zh ? "已启用" : "enabled"}) + {t("models.label")} ({t("models.enabledCount").replace("{enabled}", String(enabledCount)).replace("{total}", String(models.length))})
{models.map((m) => ( @@ -168,7 +169,7 @@ export function ModelToggleList({ onToggle(m.id)} - label={`${m.enabled ? "Disable" : "Enable"} ${m.label}`} + label={(m.enabled ? t("models.disableToggle") : t("models.enableToggle")).replace("{name}", m.label)} /> onRemove(m.id)} className="text-muted-foreground transition hover:text-destructive" - aria-label={`Remove ${m.label}`} + aria-label={t("models.removeModel").replace("{name}", m.label)} > diff --git a/src/renderer/components/execution/PolicyFormModal.tsx b/src/renderer/components/execution/PolicyFormModal.tsx index dab551e2..3a4cce81 100644 --- a/src/renderer/components/execution/PolicyFormModal.tsx +++ b/src/renderer/components/execution/PolicyFormModal.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import type { BudgetPolicyRecord } from "@shared/types"; import { ActionButton, Input, Modal, Select } from "../ui"; import { buildBudgetPolicyInput, getBudgetPolicyAmountDollars } from "../../lib/budget-policy-input"; +import { useT } from "../../i18n"; interface PolicyFormModalProps { open: boolean; @@ -14,6 +15,7 @@ interface PolicyFormModalProps { } export function PolicyFormModal({ open, onClose, onSaved, companyId, editingPolicy, agents, projects }: PolicyFormModalProps) { + const t = useT(); const [scopeType, setScopeType] = useState<"company" | "agent" | "project">("company"); const [scopeId, setScopeId] = useState(companyId); const [amountDollars, setAmountDollars] = useState("100"); @@ -79,23 +81,23 @@ export function PolicyFormModal({ open, onClose, onSaved, companyId, editingPoli
({ value: a.id, label: a.name }))} @@ -104,7 +106,7 @@ export function PolicyFormModal({ open, onClose, onSaved, companyId, editingPoli {scopeType === "project" ? ( setHardStopEnabled(e.target.checked)} className="h-4 w-4 rounded border-border accent-[color:var(--primary)]" /> - Enable hard stop (block new runs at 100%) + {t("policy.hardStop")}
- + void handleSave()} diff --git a/src/renderer/components/execution/SettingsPane.tsx b/src/renderer/components/execution/SettingsPane.tsx index cb2779f9..fab7153c 100644 --- a/src/renderer/components/execution/SettingsPane.tsx +++ b/src/renderer/components/execution/SettingsPane.tsx @@ -7,7 +7,7 @@ import { useLocale, useT } from "../../i18n" import { formatTime } from "../../lib/formatters" import { unwrap } from "../../lib/desktop" import { isMac } from "../../lib/platform" -import { ActionButton, ConfirmDialog, EmptyState, InlineNotice, Select } from "../ui" +import { ActionButton, ConfirmDialog, EmptyState, Eyebrow, InlineNotice, Select } from "../ui" import { describeActionError } from "./execution-utils" import { useAppStore } from "../../state/store" @@ -19,25 +19,25 @@ export function SettingsPane({ snapshot, secrets, companyId, onSaved }: { snapsh const secretToDelete = secrets.find((s) => s.id === confirmDeleteSecretId) ?? null useEffect(() => { setTheme(snapshot.theme); setUpdatesEnabled(snapshot.updatesEnabled); if (snapshot.locale) setSelectedLocale(snapshot.locale) }, [snapshot.theme, snapshot.updatesEnabled, snapshot.locale]) - async function handleDeleteSecret(id: string) { setDeleteSecretError(null); setActionError(null); setActionNotice(null); try { unwrap(await window.agentCompany.deleteSecret({ id, companyId })); setConfirmDeleteSecretId(null); setActionNotice("Secret deleted."); addToast("Secret deleted", "success"); await onSaved() } catch (e) { setDeleteSecretError(describeActionError(e)) } } - async function handleSaveSettings() { setActionError(null); setActionNotice(null); setLoadingAction("save"); try { unwrap(await window.agentCompany.updateSettings({ theme, locale: selectedLocale, updatesEnabled })); await onSaved(); setActionNotice("Settings saved."); addToast("Settings saved", "success") } catch (e) { setActionError(describeActionError(e)) } finally { setLoadingAction(null) } } - async function handleCheckForUpdates() { setActionError(null); setActionNotice(null); setLoadingAction("check"); try { const started = unwrap(await window.agentCompany.checkForUpdates()); setActionNotice(started ? "Update check started." : "Update check skipped because no release update feed is configured.") } catch (e) { setActionError(describeActionError(e)) } finally { setLoadingAction(null) } } - async function handleBackupProfile() { setActionError(null); setActionNotice(null); setLoadingAction("backup"); try { const backupPath = unwrap(await window.agentCompany.backupProfile()); if (backupPath) { setActionNotice(`Profile backup created at ${backupPath}.`); addToast("Profile backup created", "success") } } catch (e) { setActionError(describeActionError(e)) } finally { setLoadingAction(null) } } - async function handleRestoreProfile() { setActionError(null); setActionNotice(null); setLoadingAction("restore"); try { const restored = unwrap(await window.agentCompany.restoreProfile()); if (!restored) return; await onSaved(); setActionNotice("Profile restored."); addToast("Profile restored", "success") } catch (e) { setActionError(describeActionError(e)) } finally { setLoadingAction(null) } } + async function handleDeleteSecret(id: string) { setDeleteSecretError(null); setActionError(null); setActionNotice(null); try { unwrap(await window.agentCompany.deleteSecret({ id, companyId })); setConfirmDeleteSecretId(null); setActionNotice(t("settings.secretDeleted")); addToast(t("settings.secretDeleted"), "success"); await onSaved() } catch (e) { setDeleteSecretError(describeActionError(e)) } } + async function handleSaveSettings() { setActionError(null); setActionNotice(null); setLoadingAction("save"); try { unwrap(await window.agentCompany.updateSettings({ theme, locale: selectedLocale, updatesEnabled })); await onSaved(); setActionNotice(t("settings.settingsSaved")); addToast(t("settings.settingsSaved"), "success") } catch (e) { setActionError(describeActionError(e)) } finally { setLoadingAction(null) } } + async function handleCheckForUpdates() { setActionError(null); setActionNotice(null); setLoadingAction("check"); try { const started = unwrap(await window.agentCompany.checkForUpdates()); setActionNotice(started ? t("settings.updateCheckStarted") : t("settings.updateCheckSkipped")) } catch (e) { setActionError(describeActionError(e)) } finally { setLoadingAction(null) } } + async function handleBackupProfile() { setActionError(null); setActionNotice(null); setLoadingAction("backup"); try { const backupPath = unwrap(await window.agentCompany.backupProfile()); if (backupPath) { setActionNotice(t("settings.profileBackupAt").replace("{path}", backupPath)); addToast(t("settings.profileBackupCreated"), "success") } } catch (e) { setActionError(describeActionError(e)) } finally { setLoadingAction(null) } } + async function handleRestoreProfile() { setActionError(null); setActionNotice(null); setLoadingAction("restore"); try { const restored = unwrap(await window.agentCompany.restoreProfile()); if (!restored) return; await onSaved(); setActionNotice(t("settings.profileRestored")); addToast(t("settings.profileRestored"), "success") } catch (e) { setActionError(describeActionError(e)) } finally { setLoadingAction(null) } } return (
- confirmDeleteSecretId && void handleDeleteSecret(confirmDeleteSecretId)} onCancel={() => { setDeleteSecretError(null); setConfirmDeleteSecretId(null) }} /> + confirmDeleteSecretId && void handleDeleteSecret(confirmDeleteSecretId)} onCancel={() => { setDeleteSecretError(null); setConfirmDeleteSecretId(null) }} /> {actionError ? : null} {!actionError && actionNotice ? : null} -
{t("exec.profile")}
{t("exec.profilePath")}
{snapshot.profilePath}
-
{t("exec.preferences")}
+
{t("exec.profile")}
{t("exec.profilePath")}
{snapshot.profilePath}
+
{t("exec.preferences")} setSelectedLocale(v as AppLocale)} options={APP_LOCALES.map((loc) => ({ value: loc, label: LOCALE_LABELS[loc] }))} />
{(() => { const company = snapshot.companies.find((c) => c.id === companyId); if (!company) return null; const isCeo = company.executionMode !== "ai_cofounder"; return ( -
{t("exec.executionMode")}
+
{t("exec.executionMode")}
) })()} -
Keyboard Shortcuts
+
{t("settings.keyboardShortcuts")}
{([ - [isMac() ? "⌘K" : "Ctrl+K", "Open command palette"], - [isMac() ? "⌘Enter" : "Ctrl+Enter", "Send message"], - ["Escape", "Close modal / palette"], - ["↑ / ↓", "Navigate palette results"], - ["Enter", "Select palette result"], - ["Tab", "Move focus between elements"], + [isMac() ? "⌘K" : "Ctrl+K", t("settings.shortcut.commandPalette")], + [isMac() ? "⌘Enter" : "Ctrl+Enter", t("settings.shortcut.sendMessage")], + ["Escape", t("settings.shortcut.closeModal")], + ["↑ / ↓", t("settings.shortcut.navigatePalette")], + ["Enter", t("settings.shortcut.selectPaletteResult")], + ["Tab", t("settings.shortcut.moveFocus")], ] as const).map(([key, desc]) => (
{desc} @@ -65,9 +65,9 @@ export function SettingsPane({ snapshot, secrets, companyId, onSaved }: { snapsh ))}
-
{t("exec.actions")}
void handleSaveSettings()} icon={CheckCircle} loading={loadingAction === "save"} /> void handleCheckForUpdates()} icon={Download} loading={loadingAction === "check"} /> void handleBackupProfile()} icon={ClockCounterClockwise} loading={loadingAction === "backup"} /> void handleRestoreProfile()} icon={ArrowClockwise} loading={loadingAction === "restore"} />
-
{t("exec.secretBackend")}
{snapshot.backend.secretBackend}
-
{t("exec.secrets")} ({secrets.length})
{secrets.length === 0 ? :
{secrets.map((s) => )}
{t("exec.secretName")}{t("exec.secretDescription")}{t("exec.secretBackendCol")}{t("exec.secretUpdated")}
{s.name}{s.description || "\u2014"}{s.backend}{formatTime(s.updatedAt)} setConfirmDeleteSecretId(s.id)} icon={Trash} tone="danger" />
}
+
{t("exec.actions")}
void handleSaveSettings()} icon={CheckCircle} loading={loadingAction === "save"} /> void handleCheckForUpdates()} icon={Download} loading={loadingAction === "check"} /> void handleBackupProfile()} icon={ClockCounterClockwise} loading={loadingAction === "backup"} /> void handleRestoreProfile()} icon={ArrowClockwise} loading={loadingAction === "restore"} />
+
{t("exec.secretBackend")}
{snapshot.backend.secretBackend}
+
{t("exec.secrets")} ({secrets.length}){secrets.length === 0 ? :
{secrets.map((s) => )}
{t("exec.secretName")}{t("exec.secretDescription")}{t("exec.secretBackendCol")}{t("exec.secretUpdated")}
{s.name}{s.description || "\u2014"}{s.backend}{formatTime(s.updatedAt)} setConfirmDeleteSecretId(s.id)} icon={Trash} tone="danger" />
}
) } diff --git a/src/renderer/components/execution/TaskInspector.tsx b/src/renderer/components/execution/TaskInspector.tsx index 840a5fe9..e30b6803 100644 --- a/src/renderer/components/execution/TaskInspector.tsx +++ b/src/renderer/components/execution/TaskInspector.tsx @@ -4,7 +4,7 @@ import type { CommentRecord, GoalRecord, ProfileSnapshot, ProjectRecord, RunReco import { useT } from "../../i18n" import { formatMoney, formatTime } from "../../lib/formatters" import { unwrap } from "../../lib/desktop" -import { ActionButton, ConfirmDialog, InlineNotice, Input, Select, StatusPill, TextArea } from "../ui" +import { ActionButton, ConfirmDialog, Eyebrow, InlineNotice, Input, Select, StatusPill, TextArea } from "../ui" import { describeActionError } from "./execution-utils" import { TaskComments } from "./TaskComments" import { useAppStore } from "../../state/store" @@ -80,7 +80,7 @@ export function TaskInspector({ task, tasks, runs, agents, goals, projects, work )} {(() => { const subtasks = tasks.filter((t) => t.parentId === task.id); if (subtasks.length === 0) return null; return (
-
{t("exec.subtasks")} ({subtasks.length}){t("exec.doneCount").replace("{0}", String(subtasks.filter((st) => st.status === "done").length)).replace("{1}", String(subtasks.length))}
+
{t("exec.subtasks")} ({subtasks.length}){t("exec.doneCount").replace("{0}", String(subtasks.filter((st) => st.status === "done").length)).replace("{1}", String(subtasks.length))}
0 ? (subtasks.filter((t) => t.status === "done").length / subtasks.length) * 100 : 0}%` }} />
{subtasks.map((sub) => { const subAssignee = agentMap.get(sub.assigneeAgentId ?? ""); return (
@@ -89,7 +89,7 @@ export function TaskInspector({ task, tasks, runs, agents, goals, projects, work {subAssignee ? {subAssignee.name} : null}
) })}
) })()} - {runs.length > 0 &&
{t("exec.runHistory")}
{runs.slice(0, 8).map((run) => { const runAgent = agentMap.get(run.agentId); const isActive = run.status === "running" || run.status === "queued"; return ( + {runs.length > 0 &&
{t("exec.runHistory")}
{runs.slice(0, 8).map((run) => { const runAgent = agentMap.get(run.agentId); const isActive = run.status === "running" || run.status === "queued"; return (
{isActive ? : null}{run.summary || (isActive ? t("exec.runningEllipsis") : t("exec.completedRun"))}
{runAgent ? {runAgent.name} : null}{run.connectorId.replace("_local", "")}{run.model ? {run.model} : null}{formatTime(run.startedAt)}{run.costUsd != null && run.costUsd > 0 ? {formatMoney(run.costUsd)} : null}
{run.errorMessage ?
{run.errorMessage}
: null} diff --git a/src/renderer/components/execution/approval-inspector/ApprovalDocumentsPanel.tsx b/src/renderer/components/execution/approval-inspector/ApprovalDocumentsPanel.tsx index 3fcf4aad..60bd6f46 100644 --- a/src/renderer/components/execution/approval-inspector/ApprovalDocumentsPanel.tsx +++ b/src/renderer/components/execution/approval-inspector/ApprovalDocumentsPanel.tsx @@ -4,7 +4,8 @@ import type { ApprovalRecord, DocumentRecord } from "@shared/types" import { extractApprovalDocumentReferences, type ApprovalDocumentReference } from "@shared/approval-documents" import { unwrap } from "../../../lib/desktop" import { useAppStore } from "../../../state/store" -import { ActionButton, InlineNotice, Input, TextArea } from "../../ui" +import { ActionButton, Card, Eyebrow, InlineNotice, Input, TextArea } from "../../ui" +import { useT } from "../../../i18n" import { describeActionError } from "../execution-utils" function ApprovalDocumentEditor({ @@ -16,6 +17,7 @@ function ApprovalDocumentEditor({ document: DocumentRecord fallback?: ApprovalDocumentReference }) { + const t = useT() const addToast = useAppStore((s) => s.addToast) const [title, setTitle] = useState(document.title) const [content, setContent] = useState(document.content ?? "") @@ -24,7 +26,7 @@ function ApprovalDocumentEditor({ async function handleSave() { if (!title.trim()) { - setSaveError("Document title is required.") + setSaveError(t("approval.documentTitleRequired")) return } setSaving(true) @@ -47,7 +49,7 @@ function ApprovalDocumentEditor({ tagsJson: document.tagsJson, }), ) - addToast(`Saved document: ${title.trim()}`, "success") + addToast(t("approval.documentSavedToast").replace("{title}", title.trim()), "success") } catch (error) { setSaveError(describeActionError(error)) } finally { @@ -56,7 +58,7 @@ function ApprovalDocumentEditor({ } return ( -
+
{fallback?.title ?? document.title}
@@ -65,7 +67,7 @@ function ApprovalDocumentEditor({
void handleSave()} @@ -73,9 +75,9 @@ function ApprovalDocumentEditor({ />
{saveError ? : null} - -