update
This commit is contained in:
1 parent
48daca9ba0
commit
53e0f03c0c
13 files changed
+509
-125
No files matched your search
@@ -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
|
||||
+1
-1
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
? (() => {
|
||||
|
||||
+70
-59
@@ -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<WorkspaceRecord>(
|
||||
"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<AgentRecord>(
|
||||
"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<GoalRecord>(
|
||||
"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<ProjectRecord>(
|
||||
"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<TaskRecord>(
|
||||
"select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, created_at as createdAt, updated_at as updatedAt 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<ApprovalRecord>(
|
||||
"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<RunRecord>(
|
||||
"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<CostRecord>(
|
||||
"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<ActivityRecord>(
|
||||
"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<SecretRecord>(
|
||||
"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<CommentRecord>(
|
||||
"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<SqliteRow>(
|
||||
"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<SqliteRow>(
|
||||
"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<SqliteRow>(
|
||||
"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<SqliteRow>(
|
||||
"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<SqliteRow>(
|
||||
"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<SqliteRow>(
|
||||
"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<SqliteRow>(
|
||||
"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<SqliteRow>(
|
||||
"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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Pick<ConnectorRecord, "id" | "label" | "status" | "capabilityMatrix">>,
|
||||
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(),
|
||||
|
||||
@@ -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<ConnectorRecord, "id" | "label" | "status" | "capabilityMatrix">,
|
||||
) {
|
||||
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();
|
||||
|
||||
@@ -43,7 +43,6 @@ import type {
|
||||
InboxItem,
|
||||
OnboardingBootstrapResult,
|
||||
ProfileSnapshot,
|
||||
RunRecord,
|
||||
SectionId,
|
||||
SocialAccountRecord,
|
||||
BrowserActionRecord,
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [launchSummary, setLaunchSummary] = useState<OnboardingBootstrapResult | null>(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<string>(readyConnectors[0]?.id ?? "");
|
||||
const hasRunnableConnector = readyConnectors.length > 0;
|
||||
const selectedConnector = readyConnectors.find((connector) => connector.id === selectedConnectorId) ?? readyConnectors[0] ?? null;
|
||||
const [selectedConnectorId, setSelectedConnectorId] = useState<string>(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.
|
||||
</h1>
|
||||
<p className="mt-4 max-w-[400px] text-[14px] leading-relaxed text-[color:var(--muted)]">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<div className="mt-10 flex items-center gap-3">
|
||||
@@ -192,10 +194,11 @@ export function CompanyOnboarding({
|
||||
</div>
|
||||
<div className="space-y-2.5">
|
||||
{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 (
|
||||
<div key={connector.id} className="flex items-center gap-2.5 text-[13px]">
|
||||
<div key={connector.id} className="flex items-start gap-2.5 text-[13px]">
|
||||
{isRunnable ? (
|
||||
<CheckCircle size={16} weight="fill" className="shrink-0 text-[color:var(--success)]" />
|
||||
) : isMissing ? (
|
||||
@@ -203,19 +206,28 @@ export function CompanyOnboarding({
|
||||
) : (
|
||||
<WarningCircle size={16} weight="fill" className="shrink-0 text-[color:var(--warn)]" />
|
||||
)}
|
||||
<span className={isRunnable ? "font-medium text-[color:var(--text)]" : "text-[color:var(--muted)]"}>
|
||||
{connector.label}
|
||||
</span>
|
||||
<span className="text-[11px] text-[color:var(--muted)]">
|
||||
{connector.status.replaceAll("_", " ")}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={isRunnable ? "font-medium text-[color:var(--text)]" : "text-[color:var(--muted)]"}>
|
||||
{connector.label}
|
||||
</span>
|
||||
<span className="text-[11px] text-[color:var(--muted)]">
|
||||
{connector.status.replaceAll("_", " ")}
|
||||
</span>
|
||||
</div>
|
||||
{issue ? (
|
||||
<div className="mt-0.5 text-[11px] leading-relaxed text-[color:var(--muted)]">
|
||||
{issue}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{!hasRunnableConnector ? (
|
||||
<div className="mt-4 rounded-[10px] border border-[color:var(--warn)] bg-[color:var(--warn-soft)] px-4 py-3 text-[12px] text-[color:var(--warn)]">
|
||||
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.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -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,
|
||||
}))}
|
||||
/>
|
||||
<div className="rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-4 py-3 text-[12px] leading-relaxed text-[color:var(--muted)]">
|
||||
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.
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="onboarding-workspace-path"
|
||||
|
||||
@@ -39,6 +39,7 @@ import type {
|
||||
TaskRecord,
|
||||
WorkflowPipelineRecord,
|
||||
} from "@shared/types";
|
||||
import { isConnectorExecutionReady } from "@shared/connector-policy";
|
||||
import { formatMoney, getAgentInitials, timeAgo } from "../lib/formatters";
|
||||
import { StatusPill } from "./ui";
|
||||
|
||||
@@ -76,7 +77,7 @@ function computeHealthScore(props: Pick<DashboardProps, "agents" | "tasks" | "ru
|
||||
const errorAgents = agents.filter((a) => a.status === "error");
|
||||
score -= Math.min(errorAgents.length * 10, 20);
|
||||
|
||||
const readyConnectors = connectors.filter((c) => c.status === "ready");
|
||||
const readyConnectors = connectors.filter((connector) => isConnectorExecutionReady(connector));
|
||||
if (readyConnectors.length === 0 && connectors.length > 0) score -= 15;
|
||||
|
||||
const overBudget = agents.filter((a) => a.budgetMonthlyUsd > 0 && a.spentMonthlyUsd >= a.budgetMonthlyUsd);
|
||||
@@ -602,7 +603,7 @@ export function Dashboard(props: DashboardProps) {
|
||||
const activeGoals = useMemo(() => goals.filter((g) => g.status === "active"), [goals]);
|
||||
const totalSpend = useMemo(() => costs.reduce((sum, c) => sum + (c.amountUsd ?? 0), 0), [costs]);
|
||||
const activeAgents = useMemo(() => agents.filter((a) => a.status !== "terminated" && a.status !== "pending_approval"), [agents]);
|
||||
const readyConnectors = useMemo(() => connectors.filter((c) => c.status === "ready"), [connectors]);
|
||||
const readyConnectors = useMemo(() => connectors.filter((connector) => isConnectorExecutionReady(connector)), [connectors]);
|
||||
const activeSprint = useMemo(() => sprints.find((s) => s.status === "active"), [sprints]);
|
||||
const unreadMessages = useMemo(() => messages.filter((message) => !message.readAt), [messages]);
|
||||
const urgentMessages = useMemo(() => messages.filter((message) => !message.readAt && (message.priority === "urgent" || message.channel === "incident")), [messages]);
|
||||
@@ -812,7 +813,7 @@ export function Dashboard(props: DashboardProps) {
|
||||
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Infrastructure</div>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{connectors.map((connector) => {
|
||||
const isReady = connector.status === "ready";
|
||||
const isReady = isConnectorExecutionReady(connector);
|
||||
const isError = connector.status === "error" || connector.status === "auth_required";
|
||||
const agentsUsing = agents.filter((a) => a.connectorId === connector.id).length;
|
||||
return (
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
ProjectRecord,
|
||||
WorkspaceRecord,
|
||||
} from "@shared/types";
|
||||
import { getConnectorExecutionReadinessIssue, isConnectorExecutionReady } from "@shared/connector-policy";
|
||||
import { formatMoney, formatTime, getAgentInitials } from "../lib/formatters";
|
||||
import { unwrap } from "../lib/desktop";
|
||||
import {
|
||||
@@ -137,10 +138,14 @@ export function AgentCreator({
|
||||
agents: AgentRecord[];
|
||||
onSaved: () => Promise<void>;
|
||||
}) {
|
||||
const executionReadyConnectors = useMemo(
|
||||
() => connectors.filter((connector) => isConnectorExecutionReady(connector)),
|
||||
[connectors],
|
||||
);
|
||||
const [name, setName] = useState("");
|
||||
const [role, setRole] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [connectorId, setConnectorId] = useState(connectors[0]?.id ?? "codex_local");
|
||||
const [connectorId, setConnectorId] = useState(executionReadyConnectors[0]?.id ?? "");
|
||||
const [model, setModel] = useState("");
|
||||
const [availableModels, setAvailableModels] = useState<Array<{ id: string; label: string }>>([]);
|
||||
const [workspaceId, setWorkspaceId] = useState(workspaces[0]?.id ?? "");
|
||||
@@ -150,8 +155,14 @@ export function AgentCreator({
|
||||
const [thinkingEffort, setThinkingEffort] = useState("");
|
||||
const [requestError, setRequestError] = useState<string | null>(null);
|
||||
const supportsEffort = connectorId === "claude_local" || connectorId === "codex_local";
|
||||
const selectedConnector = executionReadyConnectors.find((connector) => connector.id === connectorId) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!connectorId) {
|
||||
setAvailableModels([]);
|
||||
setModel("");
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
const result = await window.agentCompany.listConnectorModels(connectorId);
|
||||
if (result.ok) {
|
||||
@@ -164,6 +175,16 @@ export function AgentCreator({
|
||||
}
|
||||
}, [connectorId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connectorId && executionReadyConnectors[0]) {
|
||||
setConnectorId(executionReadyConnectors[0].id);
|
||||
return;
|
||||
}
|
||||
if (connectorId && !executionReadyConnectors.some((connector) => connector.id === connectorId)) {
|
||||
setConnectorId(executionReadyConnectors[0]?.id ?? "");
|
||||
}
|
||||
}, [executionReadyConnectors, connectorId]);
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<Input label="Agent name" value={name} onChange={setName} placeholder="Agent name" />
|
||||
@@ -173,8 +194,13 @@ export function AgentCreator({
|
||||
label="Connector"
|
||||
value={connectorId}
|
||||
onChange={(value) => setConnectorId(value as ConnectorRecord["id"])}
|
||||
options={connectors.map((connector) => ({ value: connector.id, label: connector.label }))}
|
||||
options={executionReadyConnectors.map((connector) => ({ value: connector.id, label: connector.label }))}
|
||||
/>
|
||||
{executionReadyConnectors.length === 0 ? (
|
||||
<p className="text-[12px] text-[color:var(--warn)]">
|
||||
No execution-ready connector is available. Fix connector health before requesting hires.
|
||||
</p>
|
||||
) : null}
|
||||
{availableModels.length > 0 ? (
|
||||
<Select
|
||||
label="Model"
|
||||
@@ -231,6 +257,10 @@ export function AgentCreator({
|
||||
void (async () => {
|
||||
try {
|
||||
setRequestError(null);
|
||||
if (!selectedConnector) {
|
||||
setRequestError("Select an execution-ready connector before requesting a hire.");
|
||||
return;
|
||||
}
|
||||
const meta: Record<string, string> = {};
|
||||
if (thinkingEffort) meta.thinkingEffort = thinkingEffort;
|
||||
unwrap(await window.agentCompany.requestHire({
|
||||
@@ -261,6 +291,7 @@ export function AgentCreator({
|
||||
})()
|
||||
}
|
||||
icon={Robot}
|
||||
disabled={!selectedConnector}
|
||||
/>
|
||||
{requestError ? <p className="text-[12px] text-[color:var(--danger)]">{requestError}</p> : null}
|
||||
</div>
|
||||
@@ -301,6 +332,24 @@ export function AgentEditor({
|
||||
const [thinkingEffort, setThinkingEffort] = useState<string>(existingMeta.thinkingEffort ?? "");
|
||||
const supportsEffort = connectorId === "claude_local" || connectorId === "codex_local";
|
||||
const pendingApproval = agent.status === "pending_approval";
|
||||
const executionReadyConnectors = useMemo(
|
||||
() => connectors.filter((connector) => isConnectorExecutionReady(connector)),
|
||||
[connectors],
|
||||
);
|
||||
const selectedConnectorRecord = connectors.find((connector) => connector.id === connectorId) ?? null;
|
||||
const selectedConnectorIssue = selectedConnectorRecord ? getConnectorExecutionReadinessIssue(selectedConnectorRecord) : "Select a connector.";
|
||||
const persistedConnectorRecord = connectors.find((connector) => connector.id === agent.connectorId) ?? null;
|
||||
const persistedConnectorIssue = persistedConnectorRecord ? getConnectorExecutionReadinessIssue(persistedConnectorRecord) : "Current connector is unavailable.";
|
||||
const connectorOptions = useMemo(() => {
|
||||
const readyOptions = executionReadyConnectors.map((connector) => ({ value: connector.id, label: connector.label }));
|
||||
if (!selectedConnectorRecord || isConnectorExecutionReady(selectedConnectorRecord)) {
|
||||
return readyOptions;
|
||||
}
|
||||
return [
|
||||
{ value: selectedConnectorRecord.id, label: `${selectedConnectorRecord.label} (current, blocked)` },
|
||||
...readyOptions.filter((option) => option.value !== selectedConnectorRecord.id),
|
||||
];
|
||||
}, [executionReadyConnectors, selectedConnectorRecord]);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
@@ -352,8 +401,13 @@ export function AgentEditor({
|
||||
value={connectorId}
|
||||
onChange={(v) => setConnectorId(v as ConnectorRecord["id"])}
|
||||
disabled={pendingApproval}
|
||||
options={connectors.map((c) => ({ value: c.id, label: c.label }))}
|
||||
options={connectorOptions}
|
||||
/>
|
||||
{selectedConnectorIssue ? (
|
||||
<p className="text-[12px] text-[color:var(--warn)]">
|
||||
{selectedConnectorIssue}
|
||||
</p>
|
||||
) : null}
|
||||
{availableModels.length > 0 ? (
|
||||
<Select
|
||||
label="Model"
|
||||
@@ -466,7 +520,7 @@ export function AgentEditor({
|
||||
label="Wake now"
|
||||
tone="accent"
|
||||
icon={Lightning}
|
||||
disabled={pendingApproval}
|
||||
disabled={pendingApproval || !!persistedConnectorIssue}
|
||||
onClick={() =>
|
||||
void (async () => {
|
||||
await window.agentCompany.triggerHeartbeat({
|
||||
@@ -478,6 +532,11 @@ export function AgentEditor({
|
||||
})()
|
||||
}
|
||||
/>
|
||||
{persistedConnectorIssue ? (
|
||||
<p className="mt-2 text-[12px] text-[color:var(--warn)]">
|
||||
{persistedConnectorIssue} Save a ready connector before waking this agent again.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -486,7 +545,7 @@ export function AgentEditor({
|
||||
label="Save changes"
|
||||
tone="accent"
|
||||
icon={Pencil}
|
||||
disabled={pendingApproval}
|
||||
disabled={pendingApproval || !!selectedConnectorIssue}
|
||||
onClick={() =>
|
||||
void (async () => {
|
||||
setSaveError(null);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { CapabilityMatrix, ConnectorRecord } from "./types";
|
||||
|
||||
type ConnectorExecutionCandidate = Pick<ConnectorRecord, "status" | "capabilityMatrix"> & Partial<Pick<ConnectorRecord, "label">>;
|
||||
|
||||
function missingExecutionCapabilities(capabilityMatrix: CapabilityMatrix | null | undefined) {
|
||||
const missing: string[] = [];
|
||||
if (!capabilityMatrix?.sessionResume) missing.push("session resume");
|
||||
if (!capabilityMatrix?.structuredTranscript) missing.push("structured transcript");
|
||||
return missing;
|
||||
}
|
||||
|
||||
export function isConnectorExecutionReady(connector: ConnectorExecutionCandidate) {
|
||||
return connector.status === "ready" && missingExecutionCapabilities(connector.capabilityMatrix).length === 0;
|
||||
}
|
||||
|
||||
export function getConnectorExecutionReadinessIssue(connector: ConnectorExecutionCandidate) {
|
||||
const label = connector.label ?? "Connector";
|
||||
if (connector.status !== "ready") {
|
||||
return `${label} is ${connector.status.replaceAll("_", " ")} and cannot be used for autonomous execution.`;
|
||||
}
|
||||
const missing = missingExecutionCapabilities(connector.capabilityMatrix);
|
||||
if (missing.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return `${label} does not provide ${missing.join(" and ")}, which AgentCompany requires for autonomous execution.`;
|
||||
}
|
||||
@@ -30,28 +30,32 @@ test("first launch onboarding creates a company and opens the desktop shell", as
|
||||
await page.getByLabel("Company name").fill("Orbit Foundry");
|
||||
await page.getByLabel("Workspace folder").fill(workspaceDir);
|
||||
const continueButton = page.getByRole("button", { name: /^Continue$/i });
|
||||
await expect(continueButton).toBeEnabled();
|
||||
await continueButton.click();
|
||||
await page.getByLabel("Goal").fill("Launch the company");
|
||||
await page.getByRole("button", { name: /Launch Company/i }).click();
|
||||
await expect(page.getByRole("heading", { name: /Orbit Foundry is live/i })).toBeVisible();
|
||||
await expect(page.getByText("Initial run")).toBeVisible();
|
||||
await page.getByRole("button", { name: /Open control center/i }).click();
|
||||
if (await continueButton.isEnabled()) {
|
||||
await continueButton.click();
|
||||
await page.getByLabel("Goal").fill("Launch the company");
|
||||
await page.getByRole("button", { name: /Launch Company/i }).click();
|
||||
await expect(page.getByRole("heading", { name: /Orbit Foundry is live/i })).toBeVisible();
|
||||
await expect(page.getByText("Initial run")).toBeVisible();
|
||||
await page.getByRole("button", { name: /Open control center/i }).click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Runs" })).toBeVisible();
|
||||
// Company name is rendered in a custom dropdown trigger button in the sidebar.
|
||||
await expect(page.locator("aside button", { hasText: "Orbit Foundry" }).first()).toBeVisible();
|
||||
const showConsoleButton = page.getByRole("button", { name: /show console/i });
|
||||
const hideConsoleButton = page.getByRole("button", { name: /hide console/i });
|
||||
if (await hideConsoleButton.isVisible().catch(() => false)) {
|
||||
await expect(page.getByRole("heading", { name: "Runs" })).toBeVisible();
|
||||
// Company name is rendered in a custom dropdown trigger button in the sidebar.
|
||||
await expect(page.locator("aside button", { hasText: "Orbit Foundry" }).first()).toBeVisible();
|
||||
const showConsoleButton = page.getByRole("button", { name: /show console/i });
|
||||
const hideConsoleButton = page.getByRole("button", { name: /hide console/i });
|
||||
if (await hideConsoleButton.isVisible().catch(() => false)) {
|
||||
await expect(page.getByText("Console", { exact: true })).toBeVisible();
|
||||
await hideConsoleButton.click();
|
||||
await expect(page.getByText("Console", { exact: true })).toBeHidden();
|
||||
} else {
|
||||
await expect(showConsoleButton).toBeVisible();
|
||||
}
|
||||
await showConsoleButton.click();
|
||||
await expect(page.getByText("Console", { exact: true })).toBeVisible();
|
||||
await hideConsoleButton.click();
|
||||
await expect(page.getByText("Console", { exact: true })).toBeHidden();
|
||||
} else {
|
||||
await expect(showConsoleButton).toBeVisible();
|
||||
await expect(page.getByText(/Install, authenticate, and fully validate at least one supported CLI runtime/i)).toBeVisible();
|
||||
await expect(page.getByText(/cannot be used for autonomous execution/i).first()).toBeVisible();
|
||||
}
|
||||
await showConsoleButton.click();
|
||||
await expect(page.getByText("Console", { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await app.close();
|
||||
await rm(profileDir, { recursive: true, force: true });
|
||||
|
||||
Reference in new issue
Block a user