From 53e0f03c0c26dfa1883397433da6b0b02ec40d1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 18 Mar 2026 17:49:10 -0400 Subject: [PATCH] update --- ...026-03-18-cross-feature-integrity-audit.md | 203 ++++++++++++++++++ package.json | 2 +- src/main/api-server.ts | 17 ++ src/main/database.ts | 129 ++++++----- src/main/ipc-core-work-graph-handlers.ts | 29 +++ src/main/onboarding-service.ts | 33 ++- src/main/orchestration-service.ts | 14 +- src/renderer/App.tsx | 1 - src/renderer/components/CompanyOnboarding.tsx | 64 +++--- src/renderer/components/dashboard.tsx | 7 +- src/renderer/components/organization.tsx | 69 +++++- src/shared/connector-policy.ts | 26 +++ tests/e2e/desktop.smoke.spec.ts | 40 ++-- 13 files changed, 509 insertions(+), 125 deletions(-) create mode 100644 docs/2026-03-18-cross-feature-integrity-audit.md create mode 100644 src/shared/connector-policy.ts diff --git a/docs/2026-03-18-cross-feature-integrity-audit.md b/docs/2026-03-18-cross-feature-integrity-audit.md new file mode 100644 index 00000000..f8bba9a5 --- /dev/null +++ b/docs/2026-03-18-cross-feature-integrity-audit.md @@ -0,0 +1,203 @@ +# AgentCompany Cross-Feature Integrity Audit (2026-03-18) + +## Scope + +- `PRD.md` +- Electron runtime code under `src/main`, `src/preload`, `src/renderer`, `src/shared` +- Connector adapters under `packages/*` +- Build and packaging config: `package.json`, `electron-builder.yml`, `electron.vite.config.ts`, `scripts/build-desktop.mjs` +- Existing validation assets under `tests/unit`, `tests/integration`, `tests/e2e` +- Current Electron guidance and packaging guidance from official Electron and electron-builder documentation + +## Pipeline Mapping + +### Phase 1: ProjectManager + +- Confirmed product target remains a local-first Electron desktop control plane, not a browser-shaped admin shell. +- Current milestone for this remediation wave: cross-feature integrity, autonomous execution gating, and release validation. + +### Phase 2: ResearchAnalyst + +- Checked current Electron guidance for: + - context isolation and preload boundaries + - sandbox and navigation restrictions + - utility-process usage + - packaging and update prerequisites + +### Phase 3: TechnicalImplementationResearcher + +- Traced how connector health, onboarding, hiring, orchestration, snapshot loading, and renderer forms interact. +- Focused on defects where one subsystem looked valid locally but corrupted another subsystem at runtime. + +### Phase 4: EnterpriseArchitect + +- Validated that the highest-severity current issues were policy drift and hidden truncation, not absence of feature surfaces. +- Chose to fix shared policy and shared data integrity before any cosmetic refactor. + +## What Is Already Real + +- Main / preload / renderer separation exists. +- Renderer does not depend on direct Node access. +- SQLite-backed local-first persistence exists. +- The app already contains real systems for onboarding, companies, agents, goals, projects, tasks, approvals, runs, costs, meetings, knowledge, documents, social accounts, automation, messaging, and packaging. +- Worker execution already runs outside the renderer. + +## Highest-Severity Findings From This Audit + +### 1. Global snapshot truncation polluted company-scoped behavior + +Source: + +- `src/main/database.ts` + +Problem: + +- `listSnapshot()` hard-capped `runs`, `cost_entries`, `agent_messages`, and `activity`. +- `listRendererSnapshot()` then filtered that already-truncated global result down to the current company. +- This allowed newer rows from other companies to displace current-company runs, messages, costs, and activity from the renderer snapshot. + +Why this matters: + +- Inbox severity, board unread counts, standup summaries, cost totals, run history, and communication history could silently diverge from real persisted state. +- The defect was cross-feature by construction because multiple views derived from the same truncated snapshot. + +### 2. Connector policy drift allowed non-production runtimes onto the main execution path + +Sources: + +- `src/main/onboarding-service.ts` +- `src/main/orchestration-service.ts` +- `src/main/ipc-core-work-graph-handlers.ts` +- `src/main/api-server.ts` +- `src/renderer/components/CompanyOnboarding.tsx` +- `src/renderer/components/organization.tsx` +- `src/main/connectors.ts` +- `packages/adapter-kimi-local/src/server/test.ts` + +Problem: + +- Health checks marked generic or partial runtimes as `degraded`. +- Onboarding and orchestration still treated `degraded` as runnable. +- Hiring and agent editing could still bind agents to connectors that were not suitable for autonomous execution. +- `kimi_local` was explicitly documented as generic downgraded mode but was still able to enter product-critical flows. + +Why this matters: + +- The app could create or keep agents on runtimes that do not guarantee session resume or structured transcripts. +- That breaks the internal coordination model because messaging, run tracking, recovery, and auditability depend on a stable execution contract. + +### 3. Policy enforcement was inconsistent across desktop UI, IPC, and local agent API + +Problem: + +- Even if the renderer restricted a path, API or IPC handlers could still write invalid connector assignments unless the same rule existed server-side. + +Why this matters: + +- The system would appear correct in one surface and remain invalid in another. +- That is an integrity failure, not a pure UI bug. + +### 4. Architecture remains operationally real but still too concentrated + +Largest modules remain heavy: + +- `src/main/database.ts` +- `src/main/api-server.ts` +- `src/main/automation-service.ts` +- `src/main/prompt-builder.ts` +- `src/renderer/App.tsx` +- `src/renderer/components/communication.tsx` +- `src/renderer/components/execution.tsx` + +Interpretation: + +- The app is no longer a blank prototype, but some boundaries are still broader than a stable long-term production shape. +- This is a structural follow-up item, not a reason to leave integrity bugs unfixed. + +## Remediation Implemented In This Wave + +### Shared connector execution policy + +- Added `src/shared/connector-policy.ts`. +- Defined a single execution-readiness rule used by main and renderer. +- Autonomous execution now requires: + - connector status `ready` + - session resume support + - structured transcript support + +### Snapshot integrity correction + +- Kept protective limits on the global snapshot path used by broad system scans. +- Moved renderer and company analytics onto company-scoped SQL reads instead of filtering a global snapshot in memory. +- Added company + `created_at` indexes so the scoped history reads can stay predictable as data grows. + +### Main-side enforcement + +- Onboarding now rejects non-execution-ready connectors instead of silently falling back. +- Orchestration now blocks autonomous runs when the connector is not execution-ready. +- IPC agent save / hire request paths now reject non-execution-ready connectors. +- Local agent API hire requests now reject non-execution-ready connectors. + +### Renderer alignment + +- Onboarding only offers execution-ready connectors and explains why others are blocked. +- Agent creation only offers execution-ready connectors. +- Agent editing keeps blocked current connectors visible for repair, but prevents saving or waking until the agent is moved onto a ready connector. +- Dashboard connector health now uses the same execution-readiness rule as onboarding and orchestration. + +## Verification Executed + +- `pnpm typecheck` +- `pnpm lint` +- `pnpm test:unit` +- `pnpm test:integration` +- `pnpm test:e2e` +- `AGENTCOMPANY_BUILD_VERSION=0.4.39 pnpm build` + +Build artifact produced in this session: + +- `build/release/AgentCompany-0.4.39-arm64-mac.zip` + +## Adversarial Review Outcome + +- Reviewer CLI used: `claude -p` +- Lenses run: skeptic, architect, minimalist +- Accepted findings from that pass: + - global LIMIT removal was the wrong fix; replaced with company-scoped SQL snapshot reads while keeping global caps + - connector lookup failure needed structured `CONNECTOR_NOT_FOUND` handling instead of uncaught throws + - dashboard readiness needed to use the same connector policy as onboarding and orchestration +- Rejected findings from that pass: + - version bump concerns were procedural, not product risk + - pure shared readiness helpers used by renderer were acceptable because they remain data-only and build-safe in the current architecture + +## Residual Risks Still Present After This Wave + +### 1. Preload surface is still broad + +- It is typed, but still larger than ideal for a final hardened desktop app. + +### 2. Several modules are still too large + +- The codebase still needs deeper boundary extraction in `database`, `api-server`, `automation-service`, renderer shell composition, and some feature panels. + +### 3. Cross-platform packaging has not yet been runtime-verified on all target OSes in this session + +- Packaging config exists for macOS, Windows, and Linux. +- This session validated macOS packaging only. + +### 4. Signing / updater production readiness still depends on release credentials and environment + +- The code and config exist, but release trust still depends on actual signing/notarization/update infrastructure material. + +## External References Used + +- Electron security checklist: + - https://www.electronjs.org/docs/latest/tutorial/security +- Electron context bridge: + - https://www.electronjs.org/docs/latest/api/context-bridge +- Electron utility process: + - https://www.electronjs.org/docs/latest/api/utility-process +- electron-builder auto-update: + - https://www.electron.build/auto-update +- electron-builder macOS code signing: + - https://www.electron.build/code-signing-mac diff --git a/package.json b/package.json index 7a9826fc..59b1ffae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-company", - "version": "0.4.36", + "version": "0.4.39", "private": true, "type": "module", "description": "Cross-platform Electron desktop control plane for local-first AI agent companies.", diff --git a/src/main/api-server.ts b/src/main/api-server.ts index 6b87715c..47951056 100644 --- a/src/main/api-server.ts +++ b/src/main/api-server.ts @@ -2,6 +2,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:ht import log from "electron-log/main.js"; import { z } from "zod"; import { agentMessageInputSchema, approvalInputSchema, goalInputSchema, hireRequestSchema, projectInputSchema, taskInputSchema } from "@shared/contracts"; +import { getConnectorExecutionReadinessIssue } from "@shared/connector-policy"; import { validateAgentToken, type TokenPayload } from "./agent-auth"; import { canAssignTask, canControlAgent, canManageGoal, canManageOrganization, canManageProject, canUpdateTask } from "./agent-permissions"; import { resolveApprovalDecision, shouldAutoApproveApproval } from "./approval-policy"; @@ -756,6 +757,22 @@ export class AgentApiServer { ])) { return; } + const connectorRecord = (() => { + try { + return this.db.getConnector(input.connectorId); + } catch { + return null; + } + })(); + if (!connectorRecord) { + this.json(res, 422, { error: "The selected connector does not exist." }); + return; + } + const connectorIssue = getConnectorExecutionReadinessIssue(connectorRecord); + if (connectorIssue) { + this.json(res, 422, { error: connectorIssue }); + return; + } const managerLabel = input.reportsTo ? (() => { diff --git a/src/main/database.ts b/src/main/database.ts index 85650eea..ad8b5cba 100644 --- a/src/main/database.ts +++ b/src/main/database.ts @@ -324,7 +324,13 @@ export class AppDatabase { // Create indexes that depend on columns added by migrations (e.g., department) try { - this.db.exec(`create index if not exists idx_agents_department on agents(company_id, department);`); + this.db.exec(` + create index if not exists idx_agents_department on agents(company_id, department); + create index if not exists idx_runs_company_created_at on runs(company_id, created_at desc); + create index if not exists idx_cost_entries_company_created_at on cost_entries(company_id, created_at desc); + create index if not exists idx_activity_company_created_at on activity(company_id, created_at desc); + create index if not exists idx_agent_messages_company_created_at on agent_messages(company_id, created_at desc); + `); } catch { /* column may not exist if migrations failed */ } this.seedSettings(); @@ -466,7 +472,11 @@ export class AppDatabase { return purged; } - listSnapshot(): ProfileSnapshot { + listSnapshot(options: { companyId?: string | null } = {}): ProfileSnapshot { + const companyId = options.companyId ?? null; + const companyWhere = companyId ? " where company_id = ?" : ""; + const companyArgs: SQLInputValue[] = companyId ? [companyId] : []; + const boardReadReceiptParams: SQLInputValue[] = companyId ? [BOARD_READER_ID, companyId] : [BOARD_READER_ID]; const connectors = this.readConnectors(); const theme = (this.getSetting("theme") as "system" | "light" | "dark" | null) ?? "system"; const boardReadReceiptMap = new Map( @@ -474,8 +484,8 @@ export class AppDatabase { `select reads.message_id as messageId, reads.read_at as readAt from agent_message_reads reads join agent_messages messages on messages.id = reads.message_id - where reads.agent_id = ?`, - BOARD_READER_ID, + where reads.agent_id = ?${companyId ? " and messages.company_id = ?" : ""}`, + ...boardReadReceiptParams, ).map((row) => [row.messageId, row.readAt]), ); return { @@ -491,40 +501,52 @@ export class AppDatabase { ).map((record) => ({ ...record, autoApproveHires: toBoolean(record.autoApproveHires) })) as CompanyRecord[], connectors, workspaces: this.readAll( - "select id, company_id as companyId, name, local_path as localPath, repo_url as repoUrl, repo_ref as repoRef, project_id as projectId, state, is_primary as isPrimary, created_at as createdAt, updated_at as updatedAt from workspaces order by created_at desc", + `select id, company_id as companyId, name, local_path as localPath, repo_url as repoUrl, repo_ref as repoRef, project_id as projectId, state, is_primary as isPrimary, created_at as createdAt, updated_at as updatedAt from workspaces${companyWhere} order by created_at desc`, + ...companyArgs, ).map((record) => ({ ...record, isPrimary: toBoolean(record.isPrimary) })) as WorkspaceRecord[], agents: this.readAll( - "select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, created_at as createdAt, updated_at as updatedAt from agents order by created_at asc", + `select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, created_at as createdAt, updated_at as updatedAt from agents${companyWhere} order by created_at asc`, + ...companyArgs, ).map((record) => ({ ...record, heartbeatEnabled: toBoolean(record.heartbeatEnabled) })) as AgentRecord[], goals: this.readAll( - "select id, company_id as companyId, title, description, parent_id as parentId, owner_agent_id as ownerAgentId, status, created_at as createdAt, updated_at as updatedAt from goals order by created_at asc", + `select id, company_id as companyId, title, description, parent_id as parentId, owner_agent_id as ownerAgentId, status, created_at as createdAt, updated_at as updatedAt from goals${companyWhere} order by created_at asc`, + ...companyArgs, ), projects: this.readAll( - "select id, company_id as companyId, goal_id as goalId, name, description, lead_agent_id as leadAgentId, status, target_date as targetDate, created_at as createdAt, updated_at as updatedAt from projects order by created_at desc", + `select id, company_id as companyId, goal_id as goalId, name, description, lead_agent_id as leadAgentId, status, target_date as targetDate, created_at as createdAt, updated_at as updatedAt from projects${companyWhere} order by created_at desc`, + ...companyArgs, ), tasks: this.readAll( - "select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, created_at as createdAt, updated_at as updatedAt from tasks order by created_at desc", + `select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, created_at as createdAt, updated_at as updatedAt from tasks${companyWhere} order by created_at desc`, + ...companyArgs, ), approvals: this.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 order by created_at desc", + `select id, company_id as companyId, related_task_id as relatedTaskId, related_agent_id as relatedAgentId, requested_by_agent_id as requestedByAgentId, type, payload_summary as payloadSummary, impact_summary as impactSummary, payload_json as payloadJson, decision_note as decisionNote, state, created_at as createdAt, updated_at as updatedAt from approvals${companyWhere} order by created_at desc`, + ...companyArgs, ), runs: this.readAll( - "select id, company_id as companyId, task_id as taskId, agent_id as agentId, workspace_id as workspaceId, connector_id as connectorId, status, summary, error_message as errorMessage, exit_code as exitCode, signal, model, session_display_id as sessionDisplayId, cost_usd as costUsd, unattributed_cost as unattributedCost, started_at as startedAt, finished_at as finishedAt, log_path as logPath, created_at as createdAt, updated_at as updatedAt from runs order by created_at desc limit 500", + `select id, company_id as companyId, task_id as taskId, agent_id as agentId, workspace_id as workspaceId, connector_id as connectorId, status, summary, error_message as errorMessage, exit_code as exitCode, signal, model, session_display_id as sessionDisplayId, cost_usd as costUsd, unattributed_cost as unattributedCost, started_at as startedAt, finished_at as finishedAt, log_path as logPath, created_at as createdAt, updated_at as updatedAt from runs${companyWhere} order by created_at desc${companyId ? "" : " limit 500"}`, + ...companyArgs, ).map((record) => ({ ...record, unattributedCost: Boolean(record.unattributedCost) })) as RunRecord[], costs: this.readAll( - "select id, company_id as companyId, run_id as runId, task_id as taskId, agent_id as agentId, connector_id as connectorId, amount_usd as amountUsd, unattributed, created_at as createdAt from cost_entries order by created_at desc limit 500", + `select id, company_id as companyId, run_id as runId, task_id as taskId, agent_id as agentId, connector_id as connectorId, amount_usd as amountUsd, unattributed, created_at as createdAt from cost_entries${companyWhere} order by created_at desc${companyId ? "" : " limit 500"}`, + ...companyArgs, ).map((entry) => ({ ...entry, unattributed: Boolean(entry.unattributed) })) as CostRecord[], activity: this.readAll( - "select id, company_id as companyId, actor, action, entity_type as entityType, entity_id as entityId, detail, created_at as createdAt from activity order by created_at desc limit 250", + `select id, company_id as companyId, actor, action, entity_type as entityType, entity_id as entityId, detail, created_at as createdAt from activity${companyWhere} order by created_at desc${companyId ? "" : " limit 250"}`, + ...companyArgs, ), secrets: this.readAll( - "select id, company_id as companyId, name, description, backend, updated_at as updatedAt from secrets order by updated_at desc", + `select id, company_id as companyId, name, description, backend, updated_at as updatedAt from secrets${companyWhere} order by updated_at desc`, + ...companyArgs, ), comments: this.readAll( - "select id, company_id as companyId, task_id as taskId, author_agent_id as authorAgentId, author_name as authorName, body, created_at as createdAt from task_comments order by created_at asc", + `select id, company_id as companyId, task_id as taskId, author_agent_id as authorAgentId, author_name as authorName, body, created_at as createdAt from task_comments${companyWhere} order by created_at asc`, + ...companyArgs, ), socialAccounts: this.readAll( - "select id, company_id, platform, account_name, display_name, profile_url, credential_secret_id, status, require_approval, metadata_json, last_used_at, created_at, updated_at from social_accounts order by created_at desc", + `select id, company_id, platform, account_name, display_name, profile_url, credential_secret_id, status, require_approval, metadata_json, last_used_at, created_at, updated_at from social_accounts${companyWhere} order by created_at desc`, + ...companyArgs, ).map((r) => ({ id: r.id, companyId: r.company_id, @@ -541,7 +563,8 @@ export class AppDatabase { updatedAt: r.updated_at, })) as SocialAccountRecord[], meetings: this.readAll( - "select id, company_id, type, title, organizer_agent_id, participant_agent_ids, scheduled_at, duration_minutes, agenda_json, notes_json, decisions_json, action_items_json, status, created_at, updated_at from meetings order by scheduled_at desc", + `select id, company_id, type, title, organizer_agent_id, participant_agent_ids, scheduled_at, duration_minutes, agenda_json, notes_json, decisions_json, action_items_json, status, created_at, updated_at from meetings${companyWhere} order by scheduled_at desc`, + ...companyArgs, ).map((r) => ({ id: r.id, companyId: r.company_id, type: r.type, title: r.title, organizerAgentId: r.organizer_agent_id, participantAgentIds: r.participant_agent_ids, scheduledAt: r.scheduled_at, durationMinutes: r.duration_minutes, @@ -549,7 +572,8 @@ export class AppDatabase { actionItemsJson: r.action_items_json, status: r.status, createdAt: r.created_at, updatedAt: r.updated_at, })), documents: this.readAll( - "select id, company_id, type, title, content, author_agent_id, reviewer_agent_id, project_id, goal_id, parent_doc_id, version, status, tags_json, created_at, updated_at from documents order by updated_at desc", + `select id, company_id, type, title, content, author_agent_id, reviewer_agent_id, project_id, goal_id, parent_doc_id, version, status, tags_json, created_at, updated_at from documents${companyWhere} order by updated_at desc`, + ...companyArgs, ).map((r) => ({ id: r.id, companyId: r.company_id, type: r.type, title: r.title, content: r.content, authorAgentId: r.author_agent_id, reviewerAgentId: r.reviewer_agent_id, projectId: r.project_id, @@ -557,7 +581,8 @@ export class AppDatabase { tagsJson: r.tags_json, createdAt: r.created_at, updatedAt: r.updated_at, })), knowledgeBase: this.readAll( - "select id, company_id, category, topic, content, author_agent_id, importance, tags_json, referenced_entity_type, referenced_entity_id, created_at, updated_at from knowledge_base order by updated_at desc", + `select id, company_id, category, topic, content, author_agent_id, importance, tags_json, referenced_entity_type, referenced_entity_id, created_at, updated_at from knowledge_base${companyWhere} order by updated_at desc`, + ...companyArgs, ).map((r) => ({ id: r.id, companyId: r.company_id, category: r.category, topic: r.topic, content: r.content, authorAgentId: r.author_agent_id, importance: r.importance, tagsJson: r.tags_json, @@ -565,7 +590,8 @@ export class AppDatabase { createdAt: r.created_at, updatedAt: r.updated_at, })), sprints: this.readAll( - "select id, company_id, name, goal, start_date, end_date, status, retrospective_notes, velocity_points, completed_points, created_at, updated_at from sprints order by start_date desc", + `select id, company_id, name, goal, start_date, end_date, status, retrospective_notes, velocity_points, completed_points, created_at, updated_at from sprints${companyWhere} order by start_date desc`, + ...companyArgs, ).map((r) => ({ id: r.id, companyId: r.company_id, name: r.name, goal: r.goal, startDate: r.start_date, endDate: r.end_date, status: r.status, @@ -573,7 +599,8 @@ export class AppDatabase { completedPoints: r.completed_points, createdAt: r.created_at, updatedAt: r.updated_at, })), automationRules: this.readAll( - "select id, company_id, name, description, trigger, conditions_json, action, action_config_json, source_department, target_department, priority, status, execution_count, last_executed_at, created_at, updated_at from automation_rules order by priority desc, created_at desc", + `select id, company_id, name, description, trigger, conditions_json, action, action_config_json, source_department, target_department, priority, status, execution_count, last_executed_at, created_at, updated_at from automation_rules${companyWhere} order by priority desc, created_at desc`, + ...companyArgs, ).map((r) => ({ id: r.id, companyId: r.company_id, name: r.name, description: r.description, trigger: r.trigger, conditionsJson: r.conditions_json, action: r.action, @@ -583,7 +610,8 @@ export class AppDatabase { createdAt: r.created_at, updatedAt: r.updated_at, })), agentMessages: this.readAll( - "select id, company_id, from_agent_id, to_agent_id, channel, channel_target_id, subject, body, priority, read_at, parent_message_id, attachments_json, created_at from agent_messages order by created_at desc limit 500", + `select id, company_id, from_agent_id, to_agent_id, channel, channel_target_id, subject, body, priority, read_at, parent_message_id, attachments_json, created_at from agent_messages${companyWhere} order by created_at desc${companyId ? "" : " limit 500"}`, + ...companyArgs, ).map((r) => ({ id: r.id, companyId: r.company_id, fromAgentId: r.from_agent_id, toAgentId: r.to_agent_id, channel: r.channel, channelTargetId: r.channel_target_id, @@ -592,7 +620,8 @@ export class AppDatabase { createdAt: r.created_at, })), workflows: this.readAll( - "select id, company_id, name, description, steps_json, trigger_type, trigger_config_json, status, current_step_index, run_count, last_run_at, created_at, updated_at from workflow_pipelines order by created_at desc", + `select id, company_id, name, description, steps_json, trigger_type, trigger_config_json, status, current_step_index, run_count, last_run_at, created_at, updated_at from workflow_pipelines${companyWhere} order by created_at desc`, + ...companyArgs, ).map((r) => ({ id: r.id, companyId: r.company_id, name: r.name, description: r.description, stepsJson: r.steps_json, triggerType: r.trigger_type, triggerConfigJson: r.trigger_config_json, @@ -603,34 +632,16 @@ export class AppDatabase { } listRendererSnapshot(): ProfileSnapshot { - const snapshot = this.listSnapshot(); + const currentCompanyId = this.getCurrentCompanyId(); + if (!currentCompanyId) { + return this.listSnapshot(); + } + const snapshot = this.listSnapshot({ companyId: currentCompanyId }); const companyId = snapshot.currentCompanyId; if (!companyId) { return snapshot; } - - return { - ...snapshot, - workspaces: snapshot.workspaces.filter((workspace) => workspace.companyId === companyId), - agents: snapshot.agents.filter((agent) => agent.companyId === companyId), - goals: snapshot.goals.filter((goal) => goal.companyId === companyId), - projects: snapshot.projects.filter((project) => project.companyId === companyId), - tasks: snapshot.tasks.filter((task) => task.companyId === companyId), - approvals: snapshot.approvals.filter((approval) => approval.companyId === companyId), - runs: snapshot.runs.filter((run) => run.companyId === companyId), - costs: snapshot.costs.filter((cost) => cost.companyId === companyId), - activity: snapshot.activity.filter((entry) => entry.companyId === companyId), - secrets: snapshot.secrets.filter((secret) => secret.companyId === companyId), - comments: snapshot.comments.filter((comment) => comment.companyId === companyId), - socialAccounts: snapshot.socialAccounts.filter((account) => account.companyId === companyId), - meetings: snapshot.meetings.filter((meeting) => meeting.companyId === companyId), - documents: snapshot.documents.filter((document) => document.companyId === companyId), - knowledgeBase: snapshot.knowledgeBase.filter((entry) => entry.companyId === companyId), - sprints: snapshot.sprints.filter((sprint) => sprint.companyId === companyId), - automationRules: snapshot.automationRules.filter((rule) => rule.companyId === companyId), - agentMessages: snapshot.agentMessages.filter((message) => message.companyId === companyId), - workflows: snapshot.workflows.filter((workflow) => workflow.companyId === companyId), - }; + return snapshot; } saveCompany(input: { id?: string | null; name: string; description: string; status: string; autoApproveHires?: boolean }) { @@ -3175,8 +3186,8 @@ export class AppDatabase { getInbox(companyId: string): InboxItem[] { const items: InboxItem[] = []; - const snapshot = this.listSnapshot(); - const agents = snapshot.agents.filter(a => a.companyId === companyId); + const snapshot = this.listSnapshot({ companyId }); + const agents = snapshot.agents; const agentMap = new Map(agents.map(a => [a.id, a])); // Pending approvals @@ -3292,11 +3303,11 @@ export class AppDatabase { } getCompanyMetrics(companyId: string, days: number = 30): CompanyMetrics { - const snapshot = this.listSnapshot(); - const agents = snapshot.agents.filter(a => a.companyId === companyId); - const tasks = snapshot.tasks.filter(t => t.companyId === companyId); - const runs = snapshot.runs.filter(r => r.companyId === companyId); - const costs = snapshot.costs.filter(c => c.companyId === companyId); + const snapshot = this.listSnapshot({ companyId }); + const agents = snapshot.agents; + const tasks = snapshot.tasks; + const runs = snapshot.runs; + const costs = snapshot.costs; const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString(); const recentRuns = runs.filter(r => r.createdAt > cutoff); @@ -3368,11 +3379,11 @@ export class AppDatabase { } getStandupReport(companyId: string): StandupReport { - const snapshot = this.listSnapshot(); - const agents = snapshot.agents.filter(a => a.companyId === companyId && a.status !== "terminated" && a.status !== "pending_approval"); - const tasks = snapshot.tasks.filter(t => t.companyId === companyId); - const runs = snapshot.runs.filter(r => r.companyId === companyId); - const costs = snapshot.costs.filter(c => c.companyId === companyId); + const snapshot = this.listSnapshot({ companyId }); + const agents = snapshot.agents.filter(a => a.status !== "terminated" && a.status !== "pending_approval"); + const tasks = snapshot.tasks; + const runs = snapshot.runs; + const costs = snapshot.costs; const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); const recentActivity = snapshot.activity.filter(a => a.companyId === companyId && a.createdAt > oneDayAgo); diff --git a/src/main/ipc-core-work-graph-handlers.ts b/src/main/ipc-core-work-graph-handlers.ts index 3ae73b41..a55de05d 100644 --- a/src/main/ipc-core-work-graph-handlers.ts +++ b/src/main/ipc-core-work-graph-handlers.ts @@ -8,6 +8,7 @@ import { taskInputSchema, workspaceInputSchema, } from "@shared/contracts"; +import { getConnectorExecutionReadinessIssue } from "@shared/connector-policy"; import { resolveApprovalDecision, shouldAutoApproveApproval } from "./approval-policy"; import { fail, ok, type RegisterCoreHandlersDependencies } from "./ipc-core-common"; @@ -96,6 +97,20 @@ export function registerCoreWorkGraphHandlers({ if (invalidReference) { return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`); } + const connectorRecord = (() => { + try { + return db.getConnector(parsed.connectorId); + } catch { + return null; + } + })(); + if (!connectorRecord) { + return fail("CONNECTOR_NOT_FOUND", "The selected connector does not exist."); + } + const connectorIssue = getConnectorExecutionReadinessIssue(connectorRecord); + if (connectorIssue) { + return fail("CONNECTOR_NOT_READY", connectorIssue); + } let id: string; try { id = db.saveAgent({ @@ -141,6 +156,20 @@ export function registerCoreWorkGraphHandlers({ if (invalidReference) { return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`); } + const connectorRecord = (() => { + try { + return db.getConnector(parsed.connectorId); + } catch { + return null; + } + })(); + if (!connectorRecord) { + return fail("CONNECTOR_NOT_FOUND", "The selected connector does not exist."); + } + const connectorIssue = getConnectorExecutionReadinessIssue(connectorRecord); + if (connectorIssue) { + return fail("CONNECTOR_NOT_READY", connectorIssue); + } const managerLabel = parsed.reportsTo ? (() => { try { diff --git a/src/main/onboarding-service.ts b/src/main/onboarding-service.ts index a33920eb..5b60aeb0 100644 --- a/src/main/onboarding-service.ts +++ b/src/main/onboarding-service.ts @@ -1,5 +1,6 @@ import type { AppDatabase } from "./database"; -import type { ConnectorId } from "@shared/types"; +import { getConnectorExecutionReadinessIssue, isConnectorExecutionReady } from "@shared/connector-policy"; +import type { ConnectorId, ConnectorRecord } from "@shared/types"; interface OnboardingBootstrapSummary { companyId: string; @@ -39,11 +40,27 @@ const DEFERRABLE_RUN_ERRORS = new Set([ "AGENT_ALREADY_RUNNING", ]); -function resolveFallbackConnector( - readyConnectors: Array<{ id: ConnectorId }>, +function resolveOnboardingConnector( + connectors: Array>, preferredConnectorId?: ConnectorId, ) { - return preferredConnectorId ?? readyConnectors[0]!.id; + if (preferredConnectorId) { + const preferredConnector = connectors.find((connector) => connector.id === preferredConnectorId); + if (!preferredConnector) { + throw new Error(`Selected connector ${preferredConnectorId} is not available.`); + } + const issue = getConnectorExecutionReadinessIssue(preferredConnector); + if (issue) { + throw new Error(issue); + } + return preferredConnector.id; + } + + const defaultConnector = connectors.find((connector) => isConnectorExecutionReady(connector)); + if (!defaultConnector) { + throw new Error("At least one execution-ready connector is required before onboarding can continue."); + } + return defaultConnector.id; } export function createOnboardingService({ @@ -164,13 +181,7 @@ export function createOnboardingService({ function bootstrapOnboarding(input: BootstrapInput): OnboardingBootstrapSummary { const snapshot = db.listSnapshot(); - const readyConnectors = snapshot.connectors.filter( - (connector) => connector.status === "ready" || connector.status === "degraded", - ); - if (readyConnectors.length === 0) { - throw new Error("At least one ready connector is required before onboarding can continue."); - } - const selectedConnectorId = resolveFallbackConnector(readyConnectors, input.connectorId); + const selectedConnectorId = resolveOnboardingConnector(snapshot.connectors, input.connectorId); const companyId = db.saveCompany({ name: input.companyName.trim(), diff --git a/src/main/orchestration-service.ts b/src/main/orchestration-service.ts index 5b5e172b..1f06b901 100644 --- a/src/main/orchestration-service.ts +++ b/src/main/orchestration-service.ts @@ -1,6 +1,8 @@ import { buildTaskPrompt } from "./prompt-builder"; import type { AppDatabase } from "./database"; +import { getConnectorExecutionReadinessIssue } from "@shared/connector-policy"; import type { DesktopResult } from "@shared/contracts"; +import type { ConnectorRecord } from "@shared/types"; type WorkspaceLockMode = "exclusive" | "shared" | "none"; @@ -140,7 +142,7 @@ export function createOrchestrationService({ function getRunEligibilityFailure( agent: { status: string; budgetMonthlyUsd: number; spentMonthlyUsd: number; name: string; connectorId: string; id: string; companyId: string }, - connector: { id: string; status: string }, + connector: Pick, ) { if (["terminated", "paused", "pending_approval"].includes(agent.status)) { return "AGENT_NOT_RUNNABLE"; @@ -150,8 +152,10 @@ export function createOrchestrationService({ logger.info(`[run] Agent ${agent.name} budget exhausted, skipping`); return "AGENT_BUDGET_EXHAUSTED"; } - if (connector.status !== "ready" && connector.status !== "degraded") { - logger.info(`[run] Agent ${agent.name} connector ${connector.id} not ready (${connector.status})`); + const connectorIssue = getConnectorExecutionReadinessIssue(connector); + if (connectorIssue) { + const issue = connectorIssue ?? `Connector ${connector.id} is not execution-ready.`; + logger.info(`[run] Agent ${agent.name} connector ${connector.id} blocked: ${issue}`); return "CONNECTOR_NOT_READY"; } return null; @@ -174,6 +178,10 @@ export function createOrchestrationService({ if (!hasWorkerReady()) { throw new Error("Orchestration worker is not ready."); } + const connectorIssue = getConnectorExecutionReadinessIssue(connector); + if (connectorIssue) { + throw new Error(connectorIssue); + } const env = db.resolveSecretEnv(connector.envBindingText, companyId); const apiPort = getApiPort(); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index b20cf1a9..91722331 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -43,7 +43,6 @@ import type { InboxItem, OnboardingBootstrapResult, ProfileSnapshot, - RunRecord, SectionId, SocialAccountRecord, BrowserActionRecord, diff --git a/src/renderer/components/CompanyOnboarding.tsx b/src/renderer/components/CompanyOnboarding.tsx index 37baeecb..ef18267f 100644 --- a/src/renderer/components/CompanyOnboarding.tsx +++ b/src/renderer/components/CompanyOnboarding.tsx @@ -11,6 +11,7 @@ import { WarningCircle, XCircle, } from "@phosphor-icons/react"; +import { getConnectorExecutionReadinessIssue, isConnectorExecutionReady } from "@shared/connector-policy"; import type { OnboardingBootstrapResult, ProfileSnapshot } from "@shared/types"; import { unwrap } from "../lib/desktop"; import { ActionButton, Input, Select, TextArea } from "./ui"; @@ -39,23 +40,24 @@ export function CompanyOnboarding({ const [error, setError] = useState(null); const [launchSummary, setLaunchSummary] = useState(null); - const readyConnectors = useMemo( - () => connectors.filter((connector) => connector.status === "ready" || connector.status === "degraded"), + const executionReadyConnectors = useMemo( + () => connectors.filter((connector) => isConnectorExecutionReady(connector)), [connectors], ); - const [selectedConnectorId, setSelectedConnectorId] = useState(readyConnectors[0]?.id ?? ""); - const hasRunnableConnector = readyConnectors.length > 0; - const selectedConnector = readyConnectors.find((connector) => connector.id === selectedConnectorId) ?? readyConnectors[0] ?? null; + const [selectedConnectorId, setSelectedConnectorId] = useState(executionReadyConnectors[0]?.id ?? ""); + const hasRunnableConnector = executionReadyConnectors.length > 0; + const selectedConnector = + executionReadyConnectors.find((connector) => connector.id === selectedConnectorId) ?? executionReadyConnectors[0] ?? null; useEffect(() => { - if (!selectedConnectorId && readyConnectors[0]) { - setSelectedConnectorId(readyConnectors[0].id); + if (!selectedConnectorId && executionReadyConnectors[0]) { + setSelectedConnectorId(executionReadyConnectors[0].id); return; } - if (selectedConnectorId && !readyConnectors.some((connector) => connector.id === selectedConnectorId)) { - setSelectedConnectorId(readyConnectors[0]?.id ?? ""); + if (selectedConnectorId && !executionReadyConnectors.some((connector) => connector.id === selectedConnectorId)) { + setSelectedConnectorId(executionReadyConnectors[0]?.id ?? ""); } - }, [readyConnectors, selectedConnectorId]); + }, [executionReadyConnectors, selectedConnectorId]); const stepLabels = [ { label: "Company", icon: Buildings }, @@ -105,7 +107,7 @@ export function CompanyOnboarding({ return; } if (!selectedConnector) { - setError("At least one ready connector is required before you can continue."); + setError("At least one execution-ready connector is required before you can continue."); return; } setError(null); @@ -160,8 +162,8 @@ export function CompanyOnboarding({ agent company.

- This flow creates one company, binds a real connector, links an optional workspace, writes the first goal, - creates the operating project, and queues the first execution task. + This flow creates one company, binds one execution-ready connector, links an optional workspace, writes + the first goal, creates the operating project, and queues the first execution task.

@@ -192,10 +194,11 @@ export function CompanyOnboarding({
{connectors.map((connector) => { - const isRunnable = connector.status === "ready" || connector.status === "degraded"; + const issue = getConnectorExecutionReadinessIssue(connector); + const isRunnable = !issue; const isMissing = connector.status === "not_installed"; return ( -
+
{isRunnable ? ( ) : isMissing ? ( @@ -203,19 +206,28 @@ export function CompanyOnboarding({ ) : ( )} - - {connector.label} - - - {connector.status.replaceAll("_", " ")} - +
+
+ + {connector.label} + + + {connector.status.replaceAll("_", " ")} + +
+ {issue ? ( +
+ {issue} +
+ ) : null} +
); })}
{!hasRunnableConnector ? (
- Install and authenticate at least one supported CLI runtime before creating the company. + Install, authenticate, and fully validate at least one supported CLI runtime before creating the company.
) : null}
@@ -270,11 +282,15 @@ export function CompanyOnboarding({ label="Primary connector" value={selectedConnector?.id ?? ""} onChange={setSelectedConnectorId} - options={readyConnectors.map((connector) => ({ + options={executionReadyConnectors.map((connector) => ({ value: connector.id, - label: `${connector.label} (${connector.status.replaceAll("_", " ")})`, + label: connector.label, }))} /> +
+ Only connectors in `ready` state with session resume and structured transcripts are offered here. + Re-run connector health checks from Settings if the list is empty. +