update
This commit is contained in:
1 parent
7a3db27ffa
commit
1ba3147ed2
96 files changed
+9400
-6269
No files matched your search
@@ -29,6 +29,7 @@ oldreference/
|
||||
# Markdown docs
|
||||
*.md
|
||||
!/README.md
|
||||
!/docs/*.md
|
||||
|
||||
# Local design scratch files
|
||||
homepagedesign.html
|
||||
|
||||
@@ -18,18 +18,21 @@ pnpm typecheck
|
||||
pnpm lint
|
||||
pnpm test:unit
|
||||
pnpm test:integration
|
||||
pnpm build
|
||||
pnpm test:packages
|
||||
pnpm test:e2e
|
||||
pnpm build:verify
|
||||
```
|
||||
|
||||
## Packaging
|
||||
|
||||
```sh
|
||||
pnpm build # Compile + package (DMG/NSIS/AppImage)
|
||||
pnpm build # Compile + package the current host platform into /build/release
|
||||
pnpm build:dir # Compile + unpackaged directory
|
||||
```
|
||||
|
||||
- Supported targets: macOS, Windows, Linux
|
||||
- Packaging uses `electron-builder`
|
||||
- Local packaging cleans `/build` and injects a non-mutating build version
|
||||
- Local builds use ad-hoc signing (`CSC_IDENTITY_AUTO_DISCOVERY=false`)
|
||||
- Defaults to `--publish never`; override with `AGENTCOMPANY_ELECTRON_PUBLISH` env var
|
||||
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
# 2026-03-17 Architecture Freeze
|
||||
|
||||
## Supported Platform Target
|
||||
|
||||
The product target remains cross-platform:
|
||||
|
||||
- macOS
|
||||
- Windows
|
||||
- Linux
|
||||
|
||||
The current codebase can build on the local host, but release readiness is not equivalent across targets yet.
|
||||
|
||||
### Platform-specific constraints to preserve
|
||||
|
||||
- macOS
|
||||
- hidden inset title bar
|
||||
- signing and notarization required for public release
|
||||
- `safeStorage` may block on keychain access
|
||||
- Windows
|
||||
- App User Model ID is required for correct notifications and taskbar integration
|
||||
- NSIS is the release target
|
||||
- code signing required for updater trust
|
||||
- Linux
|
||||
- safe storage backend can fall back to weaker semantics when no secret store exists
|
||||
- Wayland window behavior is more limited than X11
|
||||
- AppImage is the primary packaged target today
|
||||
|
||||
## External Research Decisions
|
||||
|
||||
### Electron security
|
||||
|
||||
Based on the current Electron security checklist:
|
||||
|
||||
- keep `nodeIntegration: false`
|
||||
- keep `contextIsolation: true`
|
||||
- keep `sandbox: true`
|
||||
- keep the custom `app://` protocol instead of `file://`
|
||||
- validate sender URLs on every IPC entry point
|
||||
- do not expose raw IPC objects in preload
|
||||
- define a restrictive CSP in local renderer content
|
||||
|
||||
### Utility process
|
||||
|
||||
The current choice of `utilityProcess.fork()` is still the right worker primitive.
|
||||
|
||||
Reasoning:
|
||||
|
||||
- it keeps heavy connector execution off the renderer
|
||||
- it is more aligned with Electron security guidance than broad use of `ELECTRON_RUN_AS_NODE`
|
||||
- it is compatible with future fuse hardening around `runAsNode`
|
||||
|
||||
### Secret storage
|
||||
|
||||
`safeStorage` remains the primary storage contract.
|
||||
|
||||
Fallback rules:
|
||||
|
||||
- use OS-backed `safeStorage` when real encryption is available
|
||||
- reject insecure runtime environments by default
|
||||
- allow AES vault fallback only in explicitly allowed test/insecure modes
|
||||
|
||||
### Updater
|
||||
|
||||
`electron-updater` remains acceptable, but only if release lanes are made real.
|
||||
|
||||
Required implications:
|
||||
|
||||
- real publish endpoint
|
||||
- signed macOS app
|
||||
- Windows signing
|
||||
- explicit metadata handling for the generic provider
|
||||
- download/apply UX in-app, not just “check available”
|
||||
|
||||
## Process Boundary Freeze
|
||||
|
||||
### Main process
|
||||
|
||||
Owns:
|
||||
|
||||
- app lifecycle
|
||||
- BrowserWindow creation
|
||||
- custom protocol registration
|
||||
- native menus / tray / dialogs / notifications
|
||||
- database initialization
|
||||
- worker process lifecycle
|
||||
- updater lifecycle
|
||||
- browser automation windows
|
||||
- privileged IPC handlers
|
||||
|
||||
Must not:
|
||||
|
||||
- become the place where all query, mutation, aggregation, and orchestration code accumulates
|
||||
|
||||
### Preload
|
||||
|
||||
Owns:
|
||||
|
||||
- typed, minimal bridge from renderer to approved use-cases
|
||||
|
||||
Must not:
|
||||
|
||||
- expose raw `ipcRenderer`
|
||||
- expose generic file-system or shell primitives
|
||||
- become a mirror of every DB method
|
||||
|
||||
### Renderer
|
||||
|
||||
Owns:
|
||||
|
||||
- operator workflows
|
||||
- presentation logic
|
||||
- optimistic local UI state
|
||||
- command palette / shell navigation
|
||||
- empty / loading / error / review states
|
||||
|
||||
Must not:
|
||||
|
||||
- talk in raw CRUD terms when the user is performing operator flows
|
||||
- own privileged state
|
||||
- load whole-world snapshots forever as the only data pattern
|
||||
|
||||
### Utility process
|
||||
|
||||
Owns:
|
||||
|
||||
- connector execution
|
||||
- per-run stream forwarding
|
||||
- cancellation
|
||||
- run queue scheduling local to worker
|
||||
|
||||
Must not:
|
||||
|
||||
- become the persistence layer
|
||||
- own security policy decisions that belong in main
|
||||
|
||||
## State Ownership Model
|
||||
|
||||
### Renderer-local state
|
||||
|
||||
- selected entities
|
||||
- open panels / modals
|
||||
- search queries
|
||||
- console UI state
|
||||
- transient filter/sort state
|
||||
|
||||
### Persisted domain state
|
||||
|
||||
- companies
|
||||
- connectors
|
||||
- workspaces
|
||||
- agents
|
||||
- goals / projects / tasks
|
||||
- approvals
|
||||
- runs
|
||||
- cost entries
|
||||
- activity
|
||||
- secrets
|
||||
- automation rules
|
||||
- workflows
|
||||
- browser actions
|
||||
|
||||
### Runtime execution state
|
||||
|
||||
- active worker jobs
|
||||
- agent concurrency map
|
||||
- workspace lock ownership
|
||||
- pending wakes
|
||||
- update progress
|
||||
- browser action queue
|
||||
|
||||
### Derived read models
|
||||
|
||||
These should be separated from write repositories:
|
||||
|
||||
- overview metrics
|
||||
- inbox
|
||||
- standup
|
||||
- run queue / attention views
|
||||
- workload and review summaries
|
||||
|
||||
## Storage Decision
|
||||
|
||||
### Current decision
|
||||
|
||||
Keep SQLite + WAL for this tranche.
|
||||
|
||||
### Constraints
|
||||
|
||||
- single-writer semantics remain acceptable if heavy aggregations are not kept in the same giant repository
|
||||
- crash recovery must preserve unfinished run semantics
|
||||
- backup / restore must remain profile-local and reversible
|
||||
|
||||
### Required follow-up
|
||||
|
||||
- split read models from write repositories
|
||||
- add offset-based log readers
|
||||
- keep artifacts and logs on disk, indexed in SQLite
|
||||
|
||||
## IPC Contract Strategy
|
||||
|
||||
### Contract rules
|
||||
|
||||
- every mutating IPC call must stay company-scoped when relevant
|
||||
- large payloads should use typed request objects, not positional args
|
||||
- contract names stay explicit and descriptive
|
||||
- result envelopes stay structured
|
||||
|
||||
### Next contract change
|
||||
|
||||
The next contract slice will add:
|
||||
|
||||
- dedicated run listing / filtering reads
|
||||
- chunked log reads
|
||||
- connector selection in onboarding
|
||||
|
||||
## Renderer Information Architecture Freeze
|
||||
|
||||
Primary desktop sections:
|
||||
|
||||
1. Overview
|
||||
2. Runs
|
||||
3. Tasks
|
||||
4. Approvals
|
||||
5. Messages
|
||||
6. Agents
|
||||
7. Workspaces
|
||||
8. Connectors
|
||||
9. Costs
|
||||
10. Activity
|
||||
11. Settings
|
||||
|
||||
Secondary knowledge/operations surfaces can remain, but they should not dominate the shell.
|
||||
|
||||
## Milestone Mapping
|
||||
|
||||
### Architecture Freeze
|
||||
|
||||
- this document accepted
|
||||
|
||||
### Interactive Prototype
|
||||
|
||||
- shell reordered around operator workflows
|
||||
- run console and runs list coherent
|
||||
|
||||
### Internal Alpha
|
||||
|
||||
- onboarding, connector health, task run, approval, message routing, and workspace inspection work together
|
||||
|
||||
### Cross-Platform Validation
|
||||
|
||||
- menu, tray, notification, and package behavior documented per OS
|
||||
|
||||
### Signed Beta
|
||||
|
||||
- updater feed real
|
||||
- signing path documented and testable
|
||||
|
||||
### Release Candidate
|
||||
|
||||
- all repo quality gates green
|
||||
|
||||
### Production Ready
|
||||
|
||||
- security audit, release hardening, and operator workflow polish complete
|
||||
@@ -0,0 +1,307 @@
|
||||
# 2026-03-17 Audit And Rebuild Plan
|
||||
|
||||
## Execution Order
|
||||
|
||||
This document is the Phase 1-4 artifact required by `AGENTS.md`.
|
||||
|
||||
1. ProjectManager: read the full PRD and define milestone gates.
|
||||
2. ResearchAnalyst: verify Electron runtime, security, packaging, updater, and OS constraints.
|
||||
3. TechnicalImplementationResearcher: freeze main/preload/renderer/storage/runtime boundaries.
|
||||
4. EnterpriseArchitect: validate dependency direction, module seams, IPC contracts, and naming rules.
|
||||
|
||||
Implementation work starts only after the decisions below are recorded.
|
||||
|
||||
## Scope
|
||||
|
||||
The audit covers:
|
||||
|
||||
- PRD alignment
|
||||
- Electron main / preload / renderer boundaries
|
||||
- IPC contracts, sender validation, and company scoping
|
||||
- connector runtime execution, orchestration, and background work
|
||||
- local persistence, backup / restore, workspace locking, and crash recovery
|
||||
- renderer information architecture, onboarding, and desktop UX
|
||||
- packaging, updater, release, and verification posture
|
||||
- adapter package quality and test coverage
|
||||
|
||||
## Baseline Evidence
|
||||
|
||||
### Repository state
|
||||
|
||||
- The working tree is already in a large, incomplete refactor state.
|
||||
- The repo is no longer a prototype shell; it already contains a local data model, automation engine, workflow engine, browser automation surface, and an Electron shell.
|
||||
- The implementation breadth is now large enough that coherence, not feature count, is the main problem.
|
||||
|
||||
### Verification results on 2026-03-17
|
||||
|
||||
- `pnpm typecheck`: pass
|
||||
- `pnpm test:unit`: pass
|
||||
- `pnpm test:integration`: pass
|
||||
- `pnpm test:packages`: pass
|
||||
- `pnpm test:e2e`: pass
|
||||
- `pnpm build:verify`: pass
|
||||
- `pnpm build:dir`: pass, producing `build/release/AgentCompany-0.4.26-arm64-mac.zip`
|
||||
- `pnpm lint`: fail
|
||||
|
||||
### Current build posture
|
||||
|
||||
- Main bundle: `out/main/index.js` about `592 kB`
|
||||
- Preload bundle: `out/preload/index.js` about `151 kB`
|
||||
- Renderer bundle: `out/renderer/assets/index-*.js` about `1.69 MB`
|
||||
- Packaging currently succeeds only on the host platform tested locally.
|
||||
|
||||
## Research Notes
|
||||
|
||||
The following external references were re-checked during this audit:
|
||||
|
||||
- Electron security checklist: https://www.electronjs.org/docs/latest/tutorial/security
|
||||
- BrowserWindow API and platform notices: https://www.electronjs.org/docs/latest/api/browser-window
|
||||
- Utility process API: https://www.electronjs.org/docs/latest/api/utility-process
|
||||
- safeStorage API: https://www.electronjs.org/docs/latest/api/safe-storage
|
||||
- Electron fuses: https://www.electronjs.org/docs/latest/tutorial/fuses
|
||||
- electron-builder auto-update guide: https://www.electron.build/auto-update
|
||||
- electron-builder publish guide: https://www.electron.build/publish
|
||||
|
||||
These references matter because the PRD explicitly requires secure preload exposure, custom protocol usage, sandboxing, OS-backed secret storage, release signing, and update integrity.
|
||||
|
||||
## Current State Summary
|
||||
|
||||
The application already has the following real subsystems:
|
||||
|
||||
- Electron shell with `contextIsolation`, `nodeIntegration: false`, `sandbox: true`, and a custom `app://` protocol
|
||||
- local `DatabaseSync` SQLite store with WAL mode
|
||||
- local API server for agent-driven actions
|
||||
- utility-process worker for connector execution
|
||||
- run orchestration, automation rules, workflow pipelines, messages, approvals, documents, meetings, sprints, and browser actions
|
||||
|
||||
The main issue is not “missing a single feature”.
|
||||
|
||||
The issue is that the system surface is broader than the stability and architecture boundaries that currently support it.
|
||||
|
||||
## Findings
|
||||
|
||||
### High severity
|
||||
|
||||
1. Main-process data access is concentrated in a single synchronous repository that is too large to remain reliable.
|
||||
- Evidence: `src/main/database.ts` is `3292` lines and owns schema setup, migrations, snapshots, orchestration state, cost accounting, approvals, messaging, workflow persistence, backup/restore, workspace locking, and dashboard aggregation.
|
||||
- Impact:
|
||||
- blocks maintainability
|
||||
- encourages cross-domain coupling
|
||||
- increases renderer-visible latency because many IPC calls read the same monolith
|
||||
|
||||
2. The renderer shell is still a god component and does not match the desktop-native operating model in the PRD.
|
||||
- Evidence: `src/renderer/App.tsx` is `1520` lines and owns navigation, data loading, company switching, command palette, modal orchestration, run log buffering, and page composition for every domain.
|
||||
- Impact:
|
||||
- feature interaction is hard to reason about
|
||||
- state ownership is blurred
|
||||
- operator workflows such as run monitoring are buried behind a console drawer instead of having first-class surfaces
|
||||
|
||||
3. The verification gate defined by the project itself is currently broken because lint does not run.
|
||||
- Evidence: `pnpm lint` fails with `ENOENT ... electron.vite.config.<timestamp>.mjs`.
|
||||
- Impact:
|
||||
- the release checklist in `AGENTS.md` is not met
|
||||
- CI or local quality enforcement is unreliable
|
||||
|
||||
4. Update and release readiness are not production-grade.
|
||||
- Evidence:
|
||||
- `electron-builder.yml` still defaults to a generic placeholder publish URL
|
||||
- `src/main/updater-service.ts` only checks availability; it does not implement a complete download/apply flow or pre-update backup
|
||||
- local mac packaging falls back to ad-hoc signing and skips notarization
|
||||
- Impact:
|
||||
- no shippable trust chain for public release
|
||||
- updater behavior is not aligned with PRD requirements
|
||||
|
||||
5. Automatic internal coordination is still mostly emergent behavior from prompts and heuristics, not a hardened orchestration contract.
|
||||
- Evidence:
|
||||
- `src/main/prompt-builder.ts` contains the majority of “real company behavior” instructions
|
||||
- `src/main/automation-service.ts` uses rule-triggered wakeups and message dispatch to simulate organization flow
|
||||
- no dedicated coordination state model exists for “conversation pending response”, “delegation awaiting acceptance”, or “review SLA breached”
|
||||
- Impact:
|
||||
- the system can appear intelligent in happy paths while remaining hard to debug in stalled paths
|
||||
- message flow, approvals, and delegation are not deterministic enough for a production control plane
|
||||
|
||||
### Medium severity
|
||||
|
||||
1. Snapshot loading is coarse-grained and expensive.
|
||||
- Evidence:
|
||||
- `db.listSnapshot()` reads nearly every table every time
|
||||
- `db.listRendererSnapshot()` still calls `listSnapshot()` first and only then filters by company
|
||||
- `App.refresh()` reloads the entire current company snapshot on most domain events
|
||||
- Impact:
|
||||
- unnecessary main-process work
|
||||
- unnecessary renderer re-renders
|
||||
- future scale problems once run/message/activity volume increases
|
||||
|
||||
2. Run log access is full-file based, not incremental.
|
||||
- Evidence:
|
||||
- `src/main/database.ts#readRunLog` returns the entire log file
|
||||
- renderer keeps in-memory log buffers in `App.tsx`
|
||||
- PRD mismatch:
|
||||
- PRD requires offset-based incremental log reading
|
||||
- Impact:
|
||||
- degraded performance on large logs
|
||||
- poor long-running run observability
|
||||
|
||||
3. The preload bridge is typed but too broad for the current architecture maturity.
|
||||
- Evidence: `src/preload/index.ts` exposes dozens of direct CRUD and orchestration methods.
|
||||
- Impact:
|
||||
- large privileged surface area
|
||||
- difficult contract governance
|
||||
- weak module boundaries between renderer and domain actions
|
||||
|
||||
4. Workflow and automation configuration crosses boundaries as opaque JSON strings.
|
||||
- Evidence:
|
||||
- `conditionsJson`, `actionConfigJson`, `stepsJson`, `triggerConfigJson`, `metadataJson`, `attachmentsJson` are passed across IPC and persistence as raw strings
|
||||
- Impact:
|
||||
- weak compile-time guarantees
|
||||
- more runtime parsing failures
|
||||
- harder migration and refactoring path
|
||||
|
||||
5. Onboarding still optimizes for spectacle more than operator clarity.
|
||||
- Evidence:
|
||||
- `src/renderer/components/CompanyOnboarding.tsx` strongly promises “autonomous company” behavior
|
||||
- connector choice and capability differences are not explicit enough
|
||||
- initial run review is not operator-first
|
||||
- Impact:
|
||||
- weak first-use trust
|
||||
- harder diagnosis when the selected connector is degraded or capability-limited
|
||||
|
||||
6. Browser/social automation is fragile and outside the core PRD center of gravity.
|
||||
- Evidence:
|
||||
- `src/main/browser-manager.ts` relies on direct DOM selectors, timed delays, and third-party site structure
|
||||
- Impact:
|
||||
- brittle surface
|
||||
- expands support burden
|
||||
- competes with higher-priority core orchestration work
|
||||
|
||||
7. The app supports many domains, but there is no dedicated Runs page.
|
||||
- Evidence:
|
||||
- run monitoring exists only through dashboard widgets and the bottom console drawer
|
||||
- PRD mismatch:
|
||||
- PRD requires live runs and log console as first-class operating surfaces
|
||||
|
||||
### Low severity
|
||||
|
||||
1. Renderer design language is coherent but still too web-dashboard-like for a desktop control plane.
|
||||
- Evidence:
|
||||
- system font stack in `src/renderer/styles.css`
|
||||
- single-column content area on many dense surfaces
|
||||
- heavy modal use for editing
|
||||
|
||||
2. Package-level runtime verification is narrow.
|
||||
- Evidence:
|
||||
- `test:packages` covers only `adapter-opencode-local` and `adapter-kimi-local`
|
||||
- `codex`, `claude`, and `gemini` packages do not have equivalent package test commands here
|
||||
|
||||
## Detailed Gap List By Domain
|
||||
|
||||
### Main / preload / IPC
|
||||
|
||||
- Sender validation exists and the custom protocol is correct, which is a good baseline.
|
||||
- Contracts are typed, but not versioned and not yet narrowed by capability groups.
|
||||
- `ipc-core-common.ts` and `ipc-domain-common.ts` still use `any`-heavy dependency wiring.
|
||||
- The privileged bridge still mirrors domain CRUD too directly instead of exposing operator use-cases.
|
||||
|
||||
### Data / orchestration
|
||||
|
||||
- Workspace lock handling exists, but it is still embedded inside the database class.
|
||||
- Queued-run recovery exists, but recovery semantics are still best-effort.
|
||||
- Workflow execution is inline, stateful, and sync-heavy in the main process service layer.
|
||||
- Aggregation views such as inbox, standup, and metrics are built inside the same DB class.
|
||||
|
||||
### Connectors
|
||||
|
||||
- Codex / Claude / Gemini / OpenCode have deeper execution paths than Kimi.
|
||||
- Kimi remains generic CLI mode with explicit capability downgrade.
|
||||
- Connector health checks use command probes and status translation, but operator feedback is still too shallow.
|
||||
- Connector discovery and operator selection are still secondary in first-launch UX.
|
||||
|
||||
### Renderer
|
||||
|
||||
- The UI covers a large domain surface, but the operating hierarchy is inverted:
|
||||
- overview first
|
||||
- CRUD pages second
|
||||
- runs/logs/workspaces hidden as secondary tools
|
||||
- The app does not yet “scream” workspace, run, and coordination as the primary desktop tasks.
|
||||
|
||||
### Testing / release
|
||||
|
||||
- Current automated coverage is better than zero, but still not close to the PRD completion criteria.
|
||||
- E2E currently proves only first-launch onboarding and console toggle.
|
||||
- No E2E covers:
|
||||
- task run lifecycle
|
||||
- approval resolution
|
||||
- message routing
|
||||
- workflow execution
|
||||
- connector health state changes
|
||||
- backup/restore
|
||||
- workspace locking / recovery
|
||||
- Release docs do not yet define signed beta, RC, and production lanes.
|
||||
|
||||
## Rebuild Direction
|
||||
|
||||
### Tranche A: operator shell and observability
|
||||
|
||||
- add first-class Runs navigation and run triage workflow
|
||||
- move run logs to chunked readers instead of whole-file loads
|
||||
- make onboarding explicitly choose connector and workspace intent
|
||||
- fix lint so the repo’s minimum quality gate works again
|
||||
|
||||
### Tranche B: boundary extraction
|
||||
|
||||
- split `AppDatabase` into read models, command repositories, and orchestration state store
|
||||
- reduce preload surface to bounded capability groups
|
||||
- split renderer shell from feature pages and detail inspectors
|
||||
|
||||
### Tranche C: release hardening
|
||||
|
||||
- define real update lanes and signing rules
|
||||
- add updater backup/install flow
|
||||
- document mac notarization, Windows signing, Linux packaging behavior
|
||||
- flip relevant Electron fuses during packaging
|
||||
|
||||
## Milestone Gates
|
||||
|
||||
### Architecture Freeze
|
||||
|
||||
- audit findings accepted
|
||||
- platform target confirmed
|
||||
- process boundaries frozen
|
||||
- core run / workspace / connector model frozen
|
||||
|
||||
### Interactive Prototype
|
||||
|
||||
- onboarding, connectors, agents, tasks, runs, console, workspaces all coherent
|
||||
|
||||
### Internal Alpha
|
||||
|
||||
- connector lifecycle, approvals, message routing, and crash recovery usable end-to-end
|
||||
|
||||
### Cross-Platform Validation
|
||||
|
||||
- macOS, Windows, Linux behavior documented and smoke-validated
|
||||
|
||||
### Signed Beta Build
|
||||
|
||||
- signing path defined
|
||||
- updater feed real
|
||||
- rollback / backup strategy documented
|
||||
|
||||
### Release Candidate
|
||||
|
||||
- lint, typecheck, unit, integration, E2E, build, and packaging gates all green
|
||||
|
||||
### Production Readiness
|
||||
|
||||
- security audit complete
|
||||
- no placeholder release config remains
|
||||
- operator workflows are deterministic and observable
|
||||
|
||||
## Next Implementation Slice
|
||||
|
||||
The next code tranche will target:
|
||||
|
||||
1. repair the lint gate
|
||||
2. add first-class Runs navigation and incremental log loading
|
||||
3. make onboarding force an explicit connector choice
|
||||
4. keep build packaging green while making these changes
|
||||
@@ -0,0 +1,31 @@
|
||||
# 2026-03-17 Runtime/Security Tranche
|
||||
|
||||
This tranche locks the first production-boundary fixes before larger module extraction.
|
||||
|
||||
## Scope
|
||||
|
||||
- tighten agent run-token authority in the local API server
|
||||
- stop default connector execution from bypassing runtime permission/sandbox flags
|
||||
- harden renderer IPC sender validation
|
||||
- prevent secret bindings from overriding process-critical environment keys
|
||||
- make browser-action cancellation affect real execution, not only database state
|
||||
|
||||
## Why this tranche comes first
|
||||
|
||||
These issues sit underneath every feature:
|
||||
|
||||
1. A compromised or overpowered run token could mutate company structure, approvals, and browser automation.
|
||||
2. A globally bypassed connector runtime defeats approval and sandbox posture for all task runs.
|
||||
3. Loose origin matching weakens the only boundary between renderer and privileged main-process handlers.
|
||||
4. Fake cancellation creates visible state drift between the UI and external side effects.
|
||||
5. Unrestricted env injection allows privilege escalation through runtime process configuration.
|
||||
|
||||
## Intended outcome
|
||||
|
||||
After this tranche:
|
||||
|
||||
- agent runs can no longer approve approvals or request arbitrary organization changes without role-based checks
|
||||
- connector child processes run without implicit dangerous bypass flags
|
||||
- only exact trusted renderer origins can invoke privileged IPC handlers
|
||||
- reserved env keys are blocked from connector secret bindings
|
||||
- cancelling a browser action cancels queued or active execution, not just the row in SQLite
|
||||
@@ -0,0 +1,13 @@
|
||||
# Docs Index
|
||||
|
||||
Active documents only.
|
||||
|
||||
- [2026-03-17-audit-and-rebuild-plan.md](./2026-03-17-audit-and-rebuild-plan.md)
|
||||
- [2026-03-17-architecture-freeze.md](./2026-03-17-architecture-freeze.md)
|
||||
- [2026-03-17-runtime-security-tranche.md](./2026-03-17-runtime-security-tranche.md)
|
||||
|
||||
Removed:
|
||||
|
||||
- duplicate audit snapshots
|
||||
- duplicate rebuild plans
|
||||
- stale one-off implementation deltas that no longer matched the working tree
|
||||
@@ -14,9 +14,7 @@ mac:
|
||||
target:
|
||||
- dmg
|
||||
- zip
|
||||
# hardenedRuntime requires a real code signing identity.
|
||||
# For local/unsigned builds, keep it off. Enable when signing is configured.
|
||||
hardenedRuntime: false
|
||||
hardenedRuntime: true
|
||||
win:
|
||||
icon: assets/icon.ico
|
||||
target:
|
||||
|
||||
@@ -7,6 +7,7 @@ const bundledMainDependencies = [
|
||||
"@agentcompany/adapter-claude-local",
|
||||
"@agentcompany/adapter-codex-local",
|
||||
"@agentcompany/adapter-gemini-local",
|
||||
"@agentcompany/adapter-kimi-local",
|
||||
"@agentcompany/adapter-opencode-local",
|
||||
"@agentcompany/adapter-utils",
|
||||
];
|
||||
|
||||
@@ -15,6 +15,7 @@ export default tseslint.config(
|
||||
"test-results/**",
|
||||
"playwright-report/**",
|
||||
"coverage/**",
|
||||
"electron.vite.config.*.mjs",
|
||||
],
|
||||
},
|
||||
js.configs.recommended,
|
||||
|
||||
+15
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agent-company",
|
||||
"version": "0.4.16",
|
||||
"version": "0.4.25",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Cross-platform Electron desktop control plane for local-first AI agent companies.",
|
||||
@@ -9,22 +9,28 @@
|
||||
"scripts": {
|
||||
"assets:generate": "python3 scripts/generate-icons.py",
|
||||
"dev": "electron-vite dev",
|
||||
"build:verify": "electron-vite build",
|
||||
"build": "electron-vite build && node scripts/build-desktop.mjs",
|
||||
"build:dir": "electron-vite build && node scripts/build-desktop.mjs --dir",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck:all": "pnpm -r typecheck",
|
||||
"test:unit": "vitest run --config vitest.config.ts tests/unit",
|
||||
"test:integration": "vitest run --config vitest.config.ts tests/integration",
|
||||
"test:packages": "pnpm --filter @agentcompany/adapter-opencode-local exec vitest run && pnpm --filter @agentcompany/adapter-kimi-local exec vitest run",
|
||||
"test:e2e": "playwright test -c tests/e2e/playwright.config.ts",
|
||||
"test:run": "pnpm test:unit && pnpm test:integration && pnpm test:e2e",
|
||||
"postinstall": "bash scripts/patch-electron-name.sh || true"
|
||||
"test:run": "pnpm test:unit && pnpm test:integration && pnpm test:packages && pnpm test:e2e",
|
||||
"verify": "pnpm typecheck && pnpm lint && pnpm typecheck:all && pnpm test:unit && pnpm test:integration && pnpm test:packages && pnpm test:e2e",
|
||||
"verify:release": "pnpm verify && pnpm build:dir",
|
||||
"postinstall": "node scripts/postinstall.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/jetbrains-mono": "5.2.8",
|
||||
"@agentcompany/adapter-claude-local": "workspace:*",
|
||||
"@agentcompany/adapter-codex-local": "workspace:*",
|
||||
"@agentcompany/adapter-gemini-local": "workspace:*",
|
||||
"@agentcompany/adapter-kimi-local": "workspace:*",
|
||||
"@agentcompany/adapter-opencode-local": "workspace:*",
|
||||
"@agentcompany/adapter-utils": "workspace:*",
|
||||
"@phosphor-icons/react": "2.1.10",
|
||||
@@ -57,5 +63,10 @@
|
||||
"typescript-eslint": "8.46.1",
|
||||
"vite": "^7.0.0",
|
||||
"vitest": "4.1.0"
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0",
|
||||
"pnpm": ">=10.0.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.24.0"
|
||||
}
|
||||
@@ -236,7 +236,7 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
|
||||
env.AGENT_COMPANY_API_KEY = authToken;
|
||||
}
|
||||
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
const runtimeEnv = ensurePathInEnv(env);
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
|
||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||
|
||||
@@ -78,7 +78,7 @@ export async function testEnvironment(
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
const runtimeEnv = ensurePathInEnv(env);
|
||||
try {
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
checks.push({
|
||||
@@ -96,15 +96,13 @@ export async function testEnvironment(
|
||||
}
|
||||
|
||||
const configApiKey = env.ANTHROPIC_API_KEY;
|
||||
const hostApiKey = process.env.ANTHROPIC_API_KEY;
|
||||
if (isNonEmpty(configApiKey) || isNonEmpty(hostApiKey)) {
|
||||
const source = isNonEmpty(configApiKey) ? "adapter config env" : "server environment";
|
||||
if (isNonEmpty(configApiKey)) {
|
||||
checks.push({
|
||||
code: "claude_anthropic_api_key_overrides_subscription",
|
||||
level: "warn",
|
||||
message:
|
||||
"ANTHROPIC_API_KEY is set. Claude will use API-key auth instead of subscription credentials.",
|
||||
detail: `Detected in ${source}.`,
|
||||
detail: "Detected in adapter runtime env.",
|
||||
hint: "Unset ANTHROPIC_API_KEY if you want subscription-based Claude login behavior.",
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -243,7 +243,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
env.AGENT_COMPANY_API_KEY = authToken;
|
||||
}
|
||||
const billingType = resolveCodexBillingType(env);
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
const runtimeEnv = ensurePathInEnv(env);
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
|
||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||
|
||||
@@ -80,7 +80,7 @@ export async function testEnvironment(
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
const runtimeEnv = ensurePathInEnv(env);
|
||||
try {
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
checks.push({
|
||||
@@ -98,21 +98,19 @@ export async function testEnvironment(
|
||||
}
|
||||
|
||||
const configOpenAiKey = env.OPENAI_API_KEY;
|
||||
const hostOpenAiKey = process.env.OPENAI_API_KEY;
|
||||
if (isNonEmpty(configOpenAiKey) || isNonEmpty(hostOpenAiKey)) {
|
||||
const source = isNonEmpty(configOpenAiKey) ? "adapter config env" : "server environment";
|
||||
if (isNonEmpty(configOpenAiKey)) {
|
||||
checks.push({
|
||||
code: "codex_openai_api_key_present",
|
||||
level: "info",
|
||||
message: "OPENAI_API_KEY is set for Codex authentication.",
|
||||
detail: `Detected in ${source}.`,
|
||||
detail: "Detected in adapter runtime env.",
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
code: "codex_openai_api_key_missing",
|
||||
level: "warn",
|
||||
message: "OPENAI_API_KEY is not set. Codex runs may fail until authentication is configured.",
|
||||
hint: "Set OPENAI_API_KEY in adapter env, shell environment, or Codex auth configuration.",
|
||||
hint: "Set OPENAI_API_KEY in connector secret bindings or Codex auth configuration.",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -145,6 +143,7 @@ export async function testEnvironment(
|
||||
})();
|
||||
|
||||
const args = ["exec", "--json"];
|
||||
args.push("--skip-git-repo-check");
|
||||
if (search) args.unshift("--search");
|
||||
if (bypass) args.push("--dangerously-bypass-approvals-and-sandbox");
|
||||
if (model) args.push("--model", model);
|
||||
|
||||
@@ -214,7 +214,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
env.AGENT_COMPANY_API_KEY = authToken;
|
||||
}
|
||||
const billingType = resolveGeminiBillingType(env);
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
const runtimeEnv = ensurePathInEnv(env);
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
|
||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||
|
||||
@@ -70,7 +70,7 @@ export async function testEnvironment(
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
const runtimeEnv = ensurePathInEnv(env);
|
||||
try {
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
checks.push({
|
||||
@@ -88,22 +88,16 @@ export async function testEnvironment(
|
||||
}
|
||||
|
||||
const configGeminiApiKey = env.GEMINI_API_KEY;
|
||||
const hostGeminiApiKey = process.env.GEMINI_API_KEY;
|
||||
const configGoogleApiKey = env.GOOGLE_API_KEY;
|
||||
const hostGoogleApiKey = process.env.GOOGLE_API_KEY;
|
||||
const hasGca = env.GOOGLE_GENAI_USE_GCA === "true" || process.env.GOOGLE_GENAI_USE_GCA === "true";
|
||||
const hasGca = env.GOOGLE_GENAI_USE_GCA === "true";
|
||||
if (
|
||||
isNonEmpty(configGeminiApiKey) ||
|
||||
isNonEmpty(hostGeminiApiKey) ||
|
||||
isNonEmpty(configGoogleApiKey) ||
|
||||
isNonEmpty(hostGoogleApiKey) ||
|
||||
hasGca
|
||||
) {
|
||||
const source = hasGca
|
||||
? "Google account login (GCA)"
|
||||
: isNonEmpty(configGeminiApiKey) || isNonEmpty(configGoogleApiKey)
|
||||
? "adapter config env"
|
||||
: "server environment";
|
||||
: "adapter runtime env";
|
||||
checks.push({
|
||||
code: "gemini_api_key_present",
|
||||
level: "info",
|
||||
@@ -115,7 +109,7 @@ export async function testEnvironment(
|
||||
code: "gemini_api_key_missing",
|
||||
level: "info",
|
||||
message: "No explicit API key detected. Gemini CLI may still authenticate via `gemini auth login` (OAuth).",
|
||||
hint: "If the hello probe fails with an auth error, set GEMINI_API_KEY or GOOGLE_API_KEY in adapter env, or run `gemini auth login`.",
|
||||
hint: "If the hello probe fails with an auth error, set GEMINI_API_KEY or GOOGLE_API_KEY in connector secret bindings, or run `gemini auth login`.",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@agentcompany/adapter-kimi-local",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./server": "./src/server/index.ts",
|
||||
"./ui": "./src/ui/index.ts",
|
||||
"./cli": "./src/cli/index.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./server": {
|
||||
"types": "./dist/server/index.d.ts",
|
||||
"import": "./dist/server/index.js"
|
||||
},
|
||||
"./ui": {
|
||||
"types": "./dist/ui/index.d.ts",
|
||||
"import": "./dist/ui/index.js"
|
||||
},
|
||||
"./cli": {
|
||||
"types": "./dist/cli/index.d.ts",
|
||||
"import": "./dist/cli/index.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentcompany/adapter-utils": "workspace:*",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import pc from "picocolors";
|
||||
|
||||
export function printKimiStreamEvent(line: string, debug = false) {
|
||||
if (debug) {
|
||||
console.log(pc.dim(`[kimi] ${line}`));
|
||||
return;
|
||||
}
|
||||
console.log(line);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { printKimiStreamEvent } from "./format-event.js";
|
||||
@@ -0,0 +1,33 @@
|
||||
export const type = "kimi_local";
|
||||
export const label = "Kimi Code (local)";
|
||||
export const DEFAULT_KIMI_LOCAL_MODEL = "kimi-for-coding";
|
||||
|
||||
export const models = [
|
||||
{ id: DEFAULT_KIMI_LOCAL_MODEL, label: DEFAULT_KIMI_LOCAL_MODEL },
|
||||
];
|
||||
|
||||
export const agentConfigurationDoc = `# kimi_local agent configuration
|
||||
|
||||
Adapter: kimi_local
|
||||
|
||||
Use when:
|
||||
- You want AgentCompany to invoke the official Kimi Code CLI locally
|
||||
- You accept generic CLI execution without session resume or structured transcript guarantees
|
||||
|
||||
Core fields:
|
||||
- cwd (string, optional): absolute working directory fallback for the agent process
|
||||
- promptTemplate (string, optional): prompt template rendered by AgentCompany before execution
|
||||
- model (string, optional): informational only, defaults to kimi-for-coding
|
||||
- command (string, optional): defaults to "kimi"
|
||||
- extraArgs (string[], optional): additional CLI args
|
||||
- env (object, optional): KEY=VALUE environment variables
|
||||
|
||||
Operational fields:
|
||||
- timeoutSec (number, optional): run timeout in seconds
|
||||
- graceSec (number, optional): graceful termination period in seconds
|
||||
|
||||
Notes:
|
||||
- Kimi integration currently runs in generic CLI mode.
|
||||
- Session resume, model discovery, graceful cancel handshakes, structured transcript, and cost attribution are not guaranteed.
|
||||
- AgentCompany must surface these capability downgrades explicitly in the desktop UI.
|
||||
`;
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { AdapterExecutionContext, AdapterExecutionResult } from "@agentcompany/adapter-utils";
|
||||
import {
|
||||
asNumber,
|
||||
asString,
|
||||
asStringArray,
|
||||
ensureAbsoluteDirectory,
|
||||
ensureCommandResolvable,
|
||||
ensurePathInEnv,
|
||||
runChildProcess,
|
||||
} from "@agentcompany/adapter-utils/server-utils";
|
||||
import { DEFAULT_KIMI_LOCAL_MODEL } from "../index.js";
|
||||
|
||||
function normalizeEnv(input: unknown): Record<string, string> {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) return {};
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||
const { runId, config, context, onLog } = ctx;
|
||||
const command = asString(config.command, "kimi");
|
||||
const cwd = asString(config.cwd, process.cwd());
|
||||
const model = asString(config.model, DEFAULT_KIMI_LOCAL_MODEL);
|
||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||
const graceSec = asNumber(config.graceSec, 20);
|
||||
const extraArgs = asStringArray(config.extraArgs);
|
||||
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
|
||||
const prompt = typeof context.taskTitle === "string" && context.taskTitle.length > 0
|
||||
? [
|
||||
context.taskTitle,
|
||||
typeof context.taskDescription === "string" && context.taskDescription.length > 0
|
||||
? context.taskDescription
|
||||
: "",
|
||||
].filter(Boolean).join("\n\n")
|
||||
: [
|
||||
`You are agent ${ctx.agent.name}.`,
|
||||
"Respond with a concise execution summary when complete.",
|
||||
].join("\n\n");
|
||||
|
||||
const env = normalizeEnv(ensurePathInEnv(normalizeEnv(config.env)));
|
||||
await ensureCommandResolvable(command, cwd, env);
|
||||
|
||||
const result = await runChildProcess(runId, command, extraArgs, {
|
||||
cwd,
|
||||
env,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
stdin: prompt,
|
||||
onLog,
|
||||
});
|
||||
|
||||
const summary =
|
||||
result.stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.slice(-8)
|
||||
.join("\n") || null;
|
||||
|
||||
return {
|
||||
exitCode: result.exitCode,
|
||||
signal: result.signal,
|
||||
timedOut: result.timedOut,
|
||||
summary,
|
||||
model,
|
||||
billingType: "unknown",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { execute } from "./execute.js";
|
||||
export { testEnvironment } from "./test.js";
|
||||
@@ -0,0 +1,62 @@
|
||||
import type {
|
||||
AdapterEnvironmentTestContext,
|
||||
AdapterEnvironmentTestResult,
|
||||
} from "@agentcompany/adapter-utils";
|
||||
import {
|
||||
asString,
|
||||
ensureCommandResolvable,
|
||||
ensurePathInEnv,
|
||||
} from "@agentcompany/adapter-utils/server-utils";
|
||||
|
||||
function normalizeEnv(input: unknown): Record<string, string> {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) return {};
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
export async function testEnvironment(
|
||||
ctx: AdapterEnvironmentTestContext,
|
||||
): Promise<AdapterEnvironmentTestResult> {
|
||||
const command = asString(ctx.config.command, "kimi");
|
||||
const cwd = asString(ctx.config.cwd, process.cwd());
|
||||
const env = normalizeEnv(ensurePathInEnv(normalizeEnv(ctx.config.env)));
|
||||
|
||||
try {
|
||||
await ensureCommandResolvable(command, cwd, env);
|
||||
return {
|
||||
adapterType: "kimi_local",
|
||||
status: "warn",
|
||||
checks: [
|
||||
{
|
||||
code: "kimi_command_detected",
|
||||
level: "info",
|
||||
message: `Detected Kimi Code command "${command}".`,
|
||||
},
|
||||
{
|
||||
code: "kimi_runtime_generic_mode",
|
||||
level: "warn",
|
||||
message:
|
||||
"Kimi integration is running in generic CLI mode. Session resume, model discovery, and structured transcript are not guaranteed.",
|
||||
},
|
||||
],
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
adapterType: "kimi_local",
|
||||
status: "fail",
|
||||
checks: [
|
||||
{
|
||||
code: "kimi_command_missing",
|
||||
level: "error",
|
||||
message: error instanceof Error ? error.message : "Kimi command is not available.",
|
||||
hint: "Install Kimi Code with `curl -L code.kimi.com/install.sh | bash` and ensure the CLI is available on PATH.",
|
||||
},
|
||||
],
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { CreateConfigValues } from "@agentcompany/adapter-utils";
|
||||
import { DEFAULT_KIMI_LOCAL_MODEL } from "../index.js";
|
||||
|
||||
function parseCommaArgs(value: string): string[] {
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseEnvVars(text: string): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const eq = trimmed.indexOf("=");
|
||||
if (eq <= 0) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
const value = trimmed.slice(eq + 1);
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||
env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function parseEnvBindings(bindings: unknown): Record<string, unknown> {
|
||||
if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {};
|
||||
const env: Record<string, unknown> = {};
|
||||
for (const [key, raw] of Object.entries(bindings)) {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||
if (typeof raw === "string") {
|
||||
env[key] = { type: "plain", value: raw };
|
||||
continue;
|
||||
}
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
|
||||
const rec = raw as Record<string, unknown>;
|
||||
if (rec.type === "plain" && typeof rec.value === "string") {
|
||||
env[key] = { type: "plain", value: rec.value };
|
||||
continue;
|
||||
}
|
||||
if (rec.type === "secret_ref" && typeof rec.secretId === "string") {
|
||||
env[key] = {
|
||||
type: "secret_ref",
|
||||
secretId: rec.secretId,
|
||||
...(typeof rec.version === "number" || rec.version === "latest"
|
||||
? { version: rec.version }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
export function buildKimiLocalConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||
const config: Record<string, unknown> = {};
|
||||
if (v.cwd) config.cwd = v.cwd;
|
||||
if (v.promptTemplate) config.promptTemplate = v.promptTemplate;
|
||||
config.model = v.model || DEFAULT_KIMI_LOCAL_MODEL;
|
||||
config.timeoutSec = 0;
|
||||
config.graceSec = 20;
|
||||
const env = parseEnvBindings(v.envBindings);
|
||||
const legacy = parseEnvVars(v.envVars);
|
||||
for (const [key, value] of Object.entries(legacy)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(env, key)) {
|
||||
env[key] = { type: "plain", value };
|
||||
}
|
||||
}
|
||||
if (Object.keys(env).length > 0) config.env = env;
|
||||
if (v.command) config.command = v.command;
|
||||
if (v.extraArgs) config.extraArgs = parseCommaArgs(v.extraArgs);
|
||||
return config;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { buildKimiLocalConfig } from "./build-config.js";
|
||||
export { parseKimiStdoutLine } from "./parse-stdout.js";
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { TranscriptEntry } from "@agentcompany/adapter-utils";
|
||||
|
||||
export function parseKimiStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||
const text = line.trim();
|
||||
if (!text) return [];
|
||||
return [{ kind: "stdout", ts, text: line }];
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseKimiStdoutLine } from "../src/ui/parse-stdout.js";
|
||||
|
||||
describe("parseKimiStdoutLine", () => {
|
||||
it("returns a stdout entry for non-empty lines", () => {
|
||||
const entries = parseKimiStdoutLine("hello", "2026-03-17T00:00:00.000Z");
|
||||
expect(entries).toEqual([
|
||||
{ kind: "stdout", ts: "2026-03-17T00:00:00.000Z", text: "hello" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops blank lines", () => {
|
||||
expect(parseKimiStdoutLine(" ", "2026-03-17T00:00:00.000Z")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -159,7 +159,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
env.AGENT_COMPANY_API_KEY = authToken;
|
||||
}
|
||||
const runtimeEnv = Object.fromEntries(
|
||||
Object.entries(ensurePathInEnv({ ...process.env, ...env })).filter(
|
||||
Object.entries(ensurePathInEnv(env)).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
),
|
||||
);
|
||||
|
||||
@@ -106,7 +106,7 @@ export async function discoverOpenCodeModels(input: {
|
||||
const command = resolveOpenCodeCommand(input.command);
|
||||
const cwd = asString(input.cwd, process.cwd());
|
||||
const env = normalizeEnv(input.env);
|
||||
const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env }));
|
||||
const runtimeEnv = normalizeEnv(ensurePathInEnv(env));
|
||||
|
||||
const result = await runChildProcess(
|
||||
`opencode-models-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
|
||||
@@ -90,7 +90,7 @@ export async function testEnvironment(
|
||||
});
|
||||
}
|
||||
|
||||
const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env }));
|
||||
const runtimeEnv = normalizeEnv(ensurePathInEnv(env));
|
||||
|
||||
const cwdInvalid = checks.some((check) => check.code === "opencode_cwd_invalid");
|
||||
if (cwdInvalid) {
|
||||
@@ -191,9 +191,13 @@ export async function testEnvironment(
|
||||
}
|
||||
}
|
||||
|
||||
const modelUnavailable = checks.some((check) => check.code === "opencode_hello_probe_model_unavailable");
|
||||
if (!configuredModel && !modelUnavailable) {
|
||||
// No model configured – skip model requirement if no model-related checks exist
|
||||
if (!configuredModel) {
|
||||
checks.push({
|
||||
code: "opencode_model_missing",
|
||||
level: "warn",
|
||||
message: "OpenCode requires a configured provider/model ID before runs can start.",
|
||||
hint: "Run `opencode models`, choose a provider/model, and save it in the connector settings.",
|
||||
});
|
||||
} else if (configuredModel && canRunProbe) {
|
||||
try {
|
||||
await ensureOpenCodeModelConfiguredAndAvailable({
|
||||
|
||||
@@ -272,7 +272,7 @@ export async function runChildProcess(
|
||||
const onLogError = opts.onLogError ?? ((err, id, msg) => console.warn({ err, runId: id }, msg));
|
||||
|
||||
return new Promise<RunProcessResult>((resolve, reject) => {
|
||||
const rawMerged: NodeJS.ProcessEnv = { ...process.env, ...opts.env };
|
||||
const rawMerged: NodeJS.ProcessEnv = { ...opts.env };
|
||||
|
||||
// Strip Claude Code nesting-guard env vars so spawned `claude` processes
|
||||
// don't refuse to start with "cannot be launched inside another session".
|
||||
|
||||
Generated
+19
-8
@@ -17,6 +17,9 @@ importers:
|
||||
'@agentcompany/adapter-gemini-local':
|
||||
specifier: workspace:*
|
||||
version: link:packages/adapter-gemini-local
|
||||
'@agentcompany/adapter-kimi-local':
|
||||
specifier: workspace:*
|
||||
version: link:packages/adapter-kimi-local
|
||||
'@agentcompany/adapter-opencode-local':
|
||||
specifier: workspace:*
|
||||
version: link:packages/adapter-opencode-local
|
||||
@@ -26,9 +29,6 @@ importers:
|
||||
'@fontsource-variable/jetbrains-mono':
|
||||
specifier: 5.2.8
|
||||
version: 5.2.8
|
||||
'@fontsource-variable/outfit':
|
||||
specifier: 5.2.8
|
||||
version: 5.2.8
|
||||
'@phosphor-icons/react':
|
||||
specifier: 2.1.10
|
||||
version: 2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@@ -163,6 +163,22 @@ importers:
|
||||
specifier: ^5.7.3
|
||||
version: 5.9.3
|
||||
|
||||
packages/adapter-kimi-local:
|
||||
dependencies:
|
||||
'@agentcompany/adapter-utils':
|
||||
specifier: workspace:*
|
||||
version: link:../adapter-utils
|
||||
picocolors:
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^24.6.0
|
||||
version: 24.12.0
|
||||
typescript:
|
||||
specifier: ^5.7.3
|
||||
version: 5.9.3
|
||||
|
||||
packages/adapter-opencode-local:
|
||||
dependencies:
|
||||
'@agentcompany/adapter-utils':
|
||||
@@ -683,9 +699,6 @@ packages:
|
||||
'@fontsource-variable/jetbrains-mono@5.2.8':
|
||||
resolution: {integrity: sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==}
|
||||
|
||||
'@fontsource-variable/outfit@5.2.8':
|
||||
resolution: {integrity: sha512-4oUDCZx/Tcz6HZP423w/niqEH31Gks5IsqHV2ZZz1qKHaVIZdj2f0/S1IK2n8jl6Xo0o3N+3RjNHlV9R73ozQA==}
|
||||
|
||||
'@humanfs/core@0.19.1':
|
||||
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
||||
engines: {node: '>=18.18.0'}
|
||||
@@ -3343,8 +3356,6 @@ snapshots:
|
||||
|
||||
'@fontsource-variable/jetbrains-mono@5.2.8': {}
|
||||
|
||||
'@fontsource-variable/outfit@5.2.8': {}
|
||||
|
||||
'@humanfs/core@0.19.1': {}
|
||||
|
||||
'@humanfs/node@0.16.7':
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const packageJsonPath = join(__dirname, "..", "package.json");
|
||||
const releaseDir = join(__dirname, "..", "build", "release");
|
||||
const buildDir = join(__dirname, "..", "build");
|
||||
const releaseDir = join(buildDir, "release");
|
||||
const runtimeConfigPath = join(buildDir, "electron-builder.runtime.json");
|
||||
|
||||
const extraArgs = process.argv.slice(2);
|
||||
const env = { ...process.env };
|
||||
const hasMacSigningMaterial = Boolean(
|
||||
env.CSC_LINK?.trim() ||
|
||||
env.CSC_NAME?.trim() ||
|
||||
env.APPLE_API_KEY?.trim(),
|
||||
);
|
||||
|
||||
function bumpPatchVersion(currentVersion) {
|
||||
const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(currentVersion);
|
||||
@@ -19,21 +26,53 @@ function bumpPatchVersion(currentVersion) {
|
||||
return `${major}.${minor}.${Number.parseInt(patch, 10) + 1}`;
|
||||
}
|
||||
|
||||
async function prepareBuildVersion() {
|
||||
async function resolveBuildVersion() {
|
||||
const raw = await readFile(packageJsonPath, "utf8");
|
||||
const pkg = JSON.parse(raw);
|
||||
const nextVersion = bumpPatchVersion(pkg.version);
|
||||
pkg.version = nextVersion;
|
||||
await writeFile(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf8");
|
||||
return nextVersion;
|
||||
}
|
||||
|
||||
async function writeRuntimeConfig(buildVersion) {
|
||||
const runtimeConfig = {
|
||||
extends: join(__dirname, "..", "electron-builder.yml"),
|
||||
directories: {
|
||||
output: releaseDir,
|
||||
},
|
||||
extraMetadata: {
|
||||
version: buildVersion,
|
||||
},
|
||||
};
|
||||
if (process.platform === "darwin" && !hasMacSigningMaterial && env.AGENTCOMPANY_ENABLE_LOCAL_DMG !== "true") {
|
||||
runtimeConfig.mac = {
|
||||
target: ["zip"],
|
||||
hardenedRuntime: false,
|
||||
};
|
||||
} else if (process.platform === "darwin") {
|
||||
runtimeConfig.mac = {
|
||||
hardenedRuntime: true,
|
||||
};
|
||||
}
|
||||
await writeFile(runtimeConfigPath, `${JSON.stringify(runtimeConfig, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
if (env.CSC_IDENTITY_AUTO_DISCOVERY === undefined) {
|
||||
env.CSC_IDENTITY_AUTO_DISCOVERY = "false";
|
||||
}
|
||||
|
||||
const allowedPublishModes = new Set(["always", "never", "onTag", "onTagOrDraft"]);
|
||||
const explicitPublishArg = extraArgs.some((arg) => arg === "--publish" || arg.startsWith("--publish="));
|
||||
const explicitPlatformArg = extraArgs.some((arg) =>
|
||||
arg === "--mac" ||
|
||||
arg === "--win" ||
|
||||
arg === "--linux" ||
|
||||
arg === "-m" ||
|
||||
arg === "-w" ||
|
||||
arg === "-l" ||
|
||||
arg.startsWith("--mac=") ||
|
||||
arg.startsWith("--win=") ||
|
||||
arg.startsWith("--linux="),
|
||||
);
|
||||
const requestedPublishMode = env.AGENTCOMPANY_ELECTRON_PUBLISH?.trim();
|
||||
let publishMode = "never";
|
||||
|
||||
@@ -48,17 +87,22 @@ if (requestedPublishMode) {
|
||||
}
|
||||
}
|
||||
|
||||
const builderArgs = ["exec", "electron-builder", "--config", "electron-builder.yml"];
|
||||
const builderArgs = ["exec", "electron-builder", "--config", runtimeConfigPath];
|
||||
if (!explicitPublishArg) {
|
||||
builderArgs.push("--publish", publishMode);
|
||||
}
|
||||
if (process.platform === "darwin" && !hasMacSigningMaterial && env.AGENTCOMPANY_ENABLE_LOCAL_DMG !== "true" && !explicitPlatformArg) {
|
||||
builderArgs.push("--mac", "zip");
|
||||
}
|
||||
builderArgs.push(...extraArgs);
|
||||
|
||||
await rm(releaseDir, { recursive: true, force: true });
|
||||
const buildVersion = env.AGENTCOMPANY_BUILD_VERSION?.trim() || await prepareBuildVersion();
|
||||
await rm(buildDir, { recursive: true, force: true });
|
||||
await mkdir(buildDir, { recursive: true });
|
||||
const buildVersion = env.AGENTCOMPANY_BUILD_VERSION?.trim() || await resolveBuildVersion();
|
||||
await writeRuntimeConfig(buildVersion);
|
||||
env.AGENTCOMPANY_BUILD_VERSION = buildVersion;
|
||||
env.npm_package_version = buildVersion;
|
||||
console.log(`[build-desktop] Cleaned build output and bumped package version to ${buildVersion}.`);
|
||||
console.log(`[build-desktop] Cleaned ${buildDir} and staged non-mutating build version ${buildVersion}.`);
|
||||
|
||||
const child = spawn("pnpm", builderArgs, {
|
||||
stdio: "inherit",
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const patchScriptPath = join(__dirname, "patch-electron-name.sh");
|
||||
|
||||
if (process.platform !== "darwin") {
|
||||
console.log("[postinstall] Skipping macOS Electron plist patch on non-darwin host.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!existsSync(patchScriptPath)) {
|
||||
console.log("[postinstall] No Electron name patch script found.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const result = spawnSync("bash", [patchScriptPath], {
|
||||
cwd: dirname(__dirname),
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.warn(`[postinstall] Electron name patch failed: ${result.error.message}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if ((result.status ?? 0) !== 0) {
|
||||
console.warn(`[postinstall] Electron name patch exited with status ${result.status}. Continuing install.`);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
+35
-3
@@ -1,6 +1,8 @@
|
||||
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const SIGNING_KEY = randomBytes(32);
|
||||
let signingKey: Buffer | null = null;
|
||||
const TOKEN_TTL_MS = 48 * 60 * 60 * 1000; // 48 hours
|
||||
|
||||
interface TokenPayload {
|
||||
@@ -12,20 +14,22 @@ interface TokenPayload {
|
||||
}
|
||||
|
||||
function sign(payload: TokenPayload): string {
|
||||
const key = getSigningKey();
|
||||
const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
|
||||
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||
const signature = createHmac("sha256", SIGNING_KEY)
|
||||
const signature = createHmac("sha256", key)
|
||||
.update(`${header}.${body}`)
|
||||
.digest("base64url");
|
||||
return `${header}.${body}.${signature}`;
|
||||
}
|
||||
|
||||
function verify(token: string): TokenPayload | null {
|
||||
const key = getSigningKey();
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
const [header, body, signature] = parts as [string, string, string];
|
||||
|
||||
const expectedSignature = createHmac("sha256", SIGNING_KEY)
|
||||
const expectedSignature = createHmac("sha256", key)
|
||||
.update(`${header}.${body}`)
|
||||
.digest("base64url");
|
||||
|
||||
@@ -44,6 +48,34 @@ function verify(token: string): TokenPayload | null {
|
||||
}
|
||||
}
|
||||
|
||||
function getSigningKey() {
|
||||
if (!signingKey) {
|
||||
throw new Error("Agent token signing key was used before initialization.");
|
||||
}
|
||||
return signingKey;
|
||||
}
|
||||
|
||||
export function initializeAgentTokenSigningKey(profileDir: string) {
|
||||
const keyPath = join(profileDir, "agent-api-signing.key");
|
||||
mkdirSync(profileDir, { recursive: true });
|
||||
try {
|
||||
const nextKey = randomBytes(32);
|
||||
writeFileSync(keyPath, nextKey, { flag: "wx", mode: 0o600 });
|
||||
signingKey = nextKey;
|
||||
return;
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code !== "EEXIST") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const persistedKey = readFileSync(keyPath);
|
||||
if (persistedKey.length !== 32) {
|
||||
throw new Error(`Invalid agent API signing key length at ${keyPath}. Expected 32 bytes, received ${persistedKey.length}.`);
|
||||
}
|
||||
signingKey = persistedKey;
|
||||
}
|
||||
|
||||
export function createAgentToken(agentId: string, companyId: string, runId: string): string {
|
||||
const now = Date.now();
|
||||
return sign({ agentId, companyId, runId, iat: now, exp: now + TOKEN_TTL_MS });
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { AppDatabase } from "./database";
|
||||
import type { TaskRecord } from "@shared/types";
|
||||
|
||||
function safeGetAgent(db: AppDatabase, agentId: string) {
|
||||
try {
|
||||
return db.getAgent(agentId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function managesAgent(db: AppDatabase, managerId: string, targetAgentId: string): boolean {
|
||||
if (managerId === targetAgentId) {
|
||||
return true;
|
||||
}
|
||||
return db.getChainOfCommand(targetAgentId).some((agent) => agent.id === managerId);
|
||||
}
|
||||
|
||||
export function canManageOrganization(db: AppDatabase, companyId: string, agentId: string): boolean {
|
||||
const agent = safeGetAgent(db, agentId);
|
||||
if (!agent || agent.companyId !== companyId) {
|
||||
return false;
|
||||
}
|
||||
if (!agent.reportsTo) {
|
||||
return true;
|
||||
}
|
||||
return db.getDirectReports(agentId, companyId).length > 0 || agent.department === "hr";
|
||||
}
|
||||
|
||||
export function canAssignTask(db: AppDatabase, companyId: string, actorAgentId: string, targetAgentId: string | null): boolean {
|
||||
if (!targetAgentId || targetAgentId === actorAgentId) {
|
||||
return true;
|
||||
}
|
||||
if (!db.belongsToCompany("agents", targetAgentId, companyId)) {
|
||||
return false;
|
||||
}
|
||||
if (canManageOrganization(db, companyId, actorAgentId)) {
|
||||
return true;
|
||||
}
|
||||
return managesAgent(db, actorAgentId, targetAgentId);
|
||||
}
|
||||
|
||||
export function canUpdateTask(db: AppDatabase, companyId: string, actorAgentId: string, task: TaskRecord): boolean {
|
||||
if (task.companyId !== companyId) {
|
||||
return false;
|
||||
}
|
||||
if (task.assigneeAgentId === actorAgentId) {
|
||||
return true;
|
||||
}
|
||||
if (!task.assigneeAgentId) {
|
||||
return canManageOrganization(db, companyId, actorAgentId);
|
||||
}
|
||||
if (canManageOrganization(db, companyId, actorAgentId)) {
|
||||
return true;
|
||||
}
|
||||
return managesAgent(db, actorAgentId, task.assigneeAgentId);
|
||||
}
|
||||
|
||||
export function canManageGoal(db: AppDatabase, companyId: string, actorAgentId: string, ownerAgentId: string | null): boolean {
|
||||
if (canManageOrganization(db, companyId, actorAgentId)) {
|
||||
return true;
|
||||
}
|
||||
return Boolean(ownerAgentId && ownerAgentId === actorAgentId);
|
||||
}
|
||||
|
||||
export function canManageProject(db: AppDatabase, companyId: string, actorAgentId: string, leadAgentId: string | null): boolean {
|
||||
if (canManageOrganization(db, companyId, actorAgentId)) {
|
||||
return true;
|
||||
}
|
||||
return Boolean(leadAgentId && leadAgentId === actorAgentId);
|
||||
}
|
||||
+104
-54
@@ -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 { validateAgentToken, type TokenPayload } from "./agent-auth";
|
||||
import { canAssignTask, canManageGoal, canManageOrganization, canManageProject, canUpdateTask } from "./agent-permissions";
|
||||
import type { AppDatabase } from "./database";
|
||||
import { agentInputSchema, approvalInputSchema, goalInputSchema, projectInputSchema, taskInputSchema } from "@shared/contracts";
|
||||
|
||||
@@ -58,12 +59,20 @@ export class AgentApiServer {
|
||||
private onDomainChanged: () => void;
|
||||
private onWakeAgent: (agentId: string, companyId: string, trigger: string) => string | null;
|
||||
private callbacks: ApiServerEventCallbacks;
|
||||
private allowedOrigins: Set<string>;
|
||||
private onBrowserAction: ((actionId: string, companyId: string, socialAccountId: string, actionType: string, payload: Record<string, unknown>) => void) | null = null;
|
||||
|
||||
constructor(db: AppDatabase, onDomainChanged: () => void, onWakeAgent: (agentId: string, companyId: string, trigger: string) => string | null, callbacks?: Partial<ApiServerEventCallbacks>) {
|
||||
constructor(
|
||||
db: AppDatabase,
|
||||
onDomainChanged: () => void,
|
||||
onWakeAgent: (agentId: string, companyId: string, trigger: string) => string | null,
|
||||
callbacks?: Partial<ApiServerEventCallbacks>,
|
||||
allowedOrigins: Iterable<string> = ["app://-"],
|
||||
) {
|
||||
this.db = db;
|
||||
this.onDomainChanged = onDomainChanged;
|
||||
this.onWakeAgent = onWakeAgent;
|
||||
this.allowedOrigins = new Set(Array.from(allowedOrigins).filter(Boolean));
|
||||
this.callbacks = {
|
||||
onDomainChanged,
|
||||
onWakeAgent,
|
||||
@@ -115,11 +124,7 @@ export class AgentApiServer {
|
||||
private async handleRequest(req: IncomingMessage, res: ServerResponse) {
|
||||
const origin = req.headers.origin;
|
||||
if (origin) {
|
||||
const allowedOrigin =
|
||||
origin === "app://-" ||
|
||||
origin.startsWith("http://127.0.0.1:") ||
|
||||
origin.startsWith("http://localhost:");
|
||||
if (!allowedOrigin) {
|
||||
if (!this.allowedOrigins.has(origin)) {
|
||||
this.json(res, 403, { error: "Origin not allowed" });
|
||||
return;
|
||||
}
|
||||
@@ -150,11 +155,24 @@ export class AgentApiServer {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.db.belongsToCompany("runs", token.runId, token.companyId)) {
|
||||
const runHeader = req.headers["x-agent-company-run-id"];
|
||||
const claimedRunId = Array.isArray(runHeader) ? runHeader[0] : runHeader;
|
||||
if (typeof claimedRunId !== "string" || claimedRunId.trim() !== token.runId) {
|
||||
this.json(res, 401, { error: "Missing or mismatched X-Agent-Company-Run-Id header" });
|
||||
return;
|
||||
}
|
||||
|
||||
const runState = this.db.getRunAuthState(token.runId);
|
||||
if (!runState || runState.companyId !== token.companyId || runState.agentId !== token.agentId) {
|
||||
this.json(res, 403, { error: "Token references an unknown or mismatched run" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!["queued", "running"].includes(runState.status)) {
|
||||
this.json(res, 401, { error: "Run token is no longer active" });
|
||||
return;
|
||||
}
|
||||
|
||||
for (const route of this.routes) {
|
||||
if (route.method !== method) continue;
|
||||
const match = pathname.match(route.pattern);
|
||||
@@ -294,17 +312,19 @@ export class AgentApiServer {
|
||||
this.json(res, 404, { error: "Task not found" });
|
||||
return;
|
||||
}
|
||||
if (task.assigneeAgentId && !canUpdateTask(this.db, token.companyId, token.agentId, task)) {
|
||||
this.json(res, 403, { error: "Current agent is not allowed to checkout this task" });
|
||||
return;
|
||||
}
|
||||
const previousStatus = task.status;
|
||||
const success = this.db.checkoutTask(params.issueId!, agentId);
|
||||
if (!success) {
|
||||
this.json(res, 409, { error: "Task cannot be checked out — already assigned or not in valid status" });
|
||||
return;
|
||||
}
|
||||
this.onDomainChanged();
|
||||
// Checkout changes status from todo/backlog to in_progress
|
||||
const checkedOutTask = (() => { try { return this.db.getTask(params.issueId!); } catch { return null; } })();
|
||||
if (checkedOutTask) {
|
||||
this.callbacks.onTaskStatusChanged(checkedOutTask.id, checkedOutTask.companyId, "todo", "in_progress");
|
||||
}
|
||||
// Checkout changes status from todo/backlog to in_progress — use actual previous status
|
||||
this.callbacks.onTaskStatusChanged(task.id, task.companyId, previousStatus, "in_progress");
|
||||
this.json(res, 200, { ok: true });
|
||||
});
|
||||
|
||||
@@ -322,8 +342,16 @@ export class AgentApiServer {
|
||||
this.json(res, 404, { error: "Task not found" });
|
||||
return;
|
||||
}
|
||||
if (!canUpdateTask(this.db, token.companyId, token.agentId, task)) {
|
||||
this.json(res, 403, { error: "Current agent is not allowed to update this task" });
|
||||
return;
|
||||
}
|
||||
|
||||
const newAssignee = update.assigneeAgentId !== undefined ? update.assigneeAgentId : task.assigneeAgentId;
|
||||
if (!canAssignTask(this.db, token.companyId, token.agentId, newAssignee ?? null)) {
|
||||
this.json(res, 403, { error: "Current agent is not allowed to assign this task to the selected agent" });
|
||||
return;
|
||||
}
|
||||
const input: Record<string, unknown> = {
|
||||
id: task.id,
|
||||
companyId: task.companyId,
|
||||
@@ -450,6 +478,10 @@ export class AgentApiServer {
|
||||
])) {
|
||||
return;
|
||||
}
|
||||
if (!canAssignTask(this.db, companyId, token.agentId, input.assigneeAgentId ?? null)) {
|
||||
this.json(res, 403, { error: "Current agent is not allowed to assign this task to the selected agent" });
|
||||
return;
|
||||
}
|
||||
|
||||
let id: string;
|
||||
try {
|
||||
@@ -521,6 +553,10 @@ export class AgentApiServer {
|
||||
])) {
|
||||
return;
|
||||
}
|
||||
if (!canManageProject(this.db, companyId, token.agentId, input.leadAgentId ?? null)) {
|
||||
this.json(res, 403, { error: "Current agent is not allowed to create or assign this project" });
|
||||
return;
|
||||
}
|
||||
|
||||
const id = this.db.saveProject({
|
||||
companyId,
|
||||
@@ -572,6 +608,11 @@ export class AgentApiServer {
|
||||
this.json(res, 404, { error: "Project not found" });
|
||||
return;
|
||||
}
|
||||
const nextLeadAgentId = parsed.data.leadAgentId ?? existing.leadAgentId ?? null;
|
||||
if (!canManageProject(this.db, token.companyId, token.agentId, nextLeadAgentId)) {
|
||||
this.json(res, 403, { error: "Current agent is not allowed to update this project" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.db.saveProject({
|
||||
@@ -580,7 +621,7 @@ export class AgentApiServer {
|
||||
name: parsed.data.name ?? existing.name,
|
||||
description: parsed.data.description ?? existing.description,
|
||||
status: parsed.data.status ?? existing.status,
|
||||
leadAgentId: parsed.data.leadAgentId ?? existing.leadAgentId ?? null,
|
||||
leadAgentId: nextLeadAgentId,
|
||||
goalId: parsed.data.goalId !== undefined ? parsed.data.goalId : (existing.goalId ?? null),
|
||||
targetDate: parsed.data.targetDate !== undefined ? parsed.data.targetDate : (existing.targetDate ?? null),
|
||||
});
|
||||
@@ -612,6 +653,10 @@ export class AgentApiServer {
|
||||
])) {
|
||||
return;
|
||||
}
|
||||
if (!canManageGoal(this.db, companyId, token.agentId, input.ownerAgentId ?? null)) {
|
||||
this.json(res, 403, { error: "Current agent is not allowed to create this goal" });
|
||||
return;
|
||||
}
|
||||
|
||||
const id = this.db.saveGoal(input);
|
||||
this.db.addActivity({
|
||||
@@ -651,6 +696,11 @@ export class AgentApiServer {
|
||||
this.json(res, 404, { error: "Goal not found" });
|
||||
return;
|
||||
}
|
||||
const nextOwnerAgentId = parsed.data.ownerAgentId ?? existing.ownerAgentId ?? null;
|
||||
if (!canManageGoal(this.db, token.companyId, token.agentId, nextOwnerAgentId)) {
|
||||
this.json(res, 403, { error: "Current agent is not allowed to update this goal" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.db.saveGoal({
|
||||
@@ -659,7 +709,7 @@ export class AgentApiServer {
|
||||
title: parsed.data.title ?? existing.title,
|
||||
description: parsed.data.description ?? existing.description,
|
||||
status: parsed.data.status ?? existing.status,
|
||||
ownerAgentId: parsed.data.ownerAgentId ?? existing.ownerAgentId ?? null,
|
||||
ownerAgentId: nextOwnerAgentId,
|
||||
});
|
||||
this.onDomainChanged();
|
||||
this.json(res, 200, { ok: true });
|
||||
@@ -674,6 +724,10 @@ export class AgentApiServer {
|
||||
this.json(res, 403, { error: "Company mismatch" });
|
||||
return;
|
||||
}
|
||||
if (!canManageOrganization(this.db, companyId, token.agentId)) {
|
||||
this.json(res, 403, { error: "Current agent is not allowed to request hires" });
|
||||
return;
|
||||
}
|
||||
const parsed = agentInputSchema.safeParse({
|
||||
companyId,
|
||||
status: "pending_approval",
|
||||
@@ -748,6 +802,24 @@ export class AgentApiServer {
|
||||
entityId: agentId,
|
||||
detail: input.name,
|
||||
});
|
||||
|
||||
// Auto-approve hire if company policy allows it
|
||||
const snapshot = this.db.listSnapshot();
|
||||
const company = snapshot.companies.find((c) => c.id === companyId);
|
||||
if (company?.autoApproveHires) {
|
||||
this.db.decideApproval({ companyId, approvalId, state: "approved", decisionNote: "Auto-approved by company policy" });
|
||||
this.callbacks.onApprovalResolved(approvalId, companyId, "approved");
|
||||
this.db.addActivity({
|
||||
companyId,
|
||||
actor: "board",
|
||||
action: "approval.approved",
|
||||
entityType: "approval",
|
||||
entityId: approvalId,
|
||||
detail: `Auto-approved hire: ${input.name}`,
|
||||
});
|
||||
log.info(`[auto-approve] Auto-approved hire of ${input.name} via agent API`);
|
||||
}
|
||||
|
||||
this.onDomainChanged();
|
||||
this.json(res, 201, { agentId, approvalId });
|
||||
});
|
||||
@@ -912,46 +984,8 @@ export class AgentApiServer {
|
||||
this.json(res, 200, approval);
|
||||
});
|
||||
|
||||
this.addRoute("PATCH", "/api/approvals/:approvalId", (_req, res, params, token, body) => {
|
||||
const approvalId = params.approvalId!;
|
||||
|
||||
const decisionSchema = z.object({
|
||||
state: z.enum(["approved", "rejected", "revision_requested"]),
|
||||
decisionNote: z.string().max(400).default(""),
|
||||
});
|
||||
|
||||
const parsed = decisionSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
this.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() });
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify the approval exists and belongs to this company
|
||||
const snapshot = this.db.listSnapshot();
|
||||
const approval = snapshot.approvals.find(a => a.id === approvalId && a.companyId === token.companyId);
|
||||
if (!approval) {
|
||||
this.json(res, 404, { error: "Approval not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (approval.state !== "pending" && approval.state !== "revision_requested") {
|
||||
this.json(res, 409, { error: "Approval already decided" });
|
||||
return;
|
||||
}
|
||||
|
||||
this.db.decideApproval({
|
||||
companyId: token.companyId,
|
||||
approvalId,
|
||||
state: parsed.data.state,
|
||||
decisionNote: parsed.data.decisionNote,
|
||||
});
|
||||
|
||||
this.onDomainChanged();
|
||||
|
||||
// Fire the full approval resolved event chain (automation rules, hire activation, etc.)
|
||||
this.callbacks.onApprovalResolved(approvalId, token.companyId, parsed.data.state);
|
||||
|
||||
this.json(res, 200, { ok: true, state: parsed.data.state });
|
||||
this.addRoute("PATCH", "/api/approvals/:approvalId", (_req, res) => {
|
||||
this.json(res, 403, { error: "Approvals must be decided from the desktop board, not from an agent run token" });
|
||||
});
|
||||
|
||||
this.addRoute("GET", "/api/companies/:companyId/agents", (_req, res, params, token) => {
|
||||
@@ -1556,6 +1590,22 @@ export class AgentApiServer {
|
||||
}
|
||||
});
|
||||
|
||||
this.addRoute("GET", "/api/agents/:agentId/metrics", (_req, res, params, token) => {
|
||||
const snapshot = this.db.listSnapshot();
|
||||
const agent = snapshot.agents.find((entry) => entry.id === params.agentId && entry.companyId === token.companyId);
|
||||
if (!agent) {
|
||||
this.json(res, 404, { error: "Agent not found" });
|
||||
return;
|
||||
}
|
||||
const companyMetrics = this.db.getCompanyMetrics(token.companyId, 30);
|
||||
const agentMetric = companyMetrics.agentMetrics.find((metric) => metric.agentId === params.agentId);
|
||||
if (!agentMetric) {
|
||||
this.json(res, 404, { error: "Metrics not found" });
|
||||
return;
|
||||
}
|
||||
this.json(res, 200, agentMetric);
|
||||
});
|
||||
|
||||
this.addRoute("GET", "/api/agents/me/metrics", (_req, res, _params, token) => {
|
||||
const companyMetrics = this.db.getCompanyMetrics(token.companyId, 30);
|
||||
const agentMetric = companyMetrics.agentMetrics.find(m => m.agentId === token.agentId);
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { app } from "electron";
|
||||
|
||||
interface AppLifecycleDependencies {
|
||||
getMainWindow: () => Electron.BrowserWindow | null;
|
||||
ensureWindow: () => Promise<Electron.BrowserWindow | null>;
|
||||
shouldQuit: () => boolean;
|
||||
onBeforeQuit: () => void;
|
||||
}
|
||||
|
||||
export function createAppLifecycleService({
|
||||
getMainWindow,
|
||||
ensureWindow,
|
||||
shouldQuit: _shouldQuit,
|
||||
onBeforeQuit,
|
||||
}: AppLifecycleDependencies) {
|
||||
function register(hasSingleInstanceLock: boolean) {
|
||||
app.on("activate", async () => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
if (!mainWindow.isVisible()) {
|
||||
mainWindow.show();
|
||||
}
|
||||
mainWindow.focus();
|
||||
return;
|
||||
}
|
||||
await ensureWindow();
|
||||
});
|
||||
|
||||
app.on("second-instance", () => {
|
||||
if (!hasSingleInstanceLock) {
|
||||
return;
|
||||
}
|
||||
const mainWindow = getMainWindow();
|
||||
if (!mainWindow) {
|
||||
return;
|
||||
}
|
||||
if (!mainWindow.isVisible()) {
|
||||
mainWindow.show();
|
||||
}
|
||||
if (mainWindow.isMinimized()) {
|
||||
mainWindow.restore();
|
||||
}
|
||||
mainWindow.focus();
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
// On macOS, keep the app running when all windows are closed (standard behavior).
|
||||
// On Windows and Linux, quit the app.
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on("before-quit", () => {
|
||||
onBeforeQuit();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
register,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,208 @@
|
||||
import { app } from "electron";
|
||||
import log from "electron-log/main.js";
|
||||
import type { AgentApiServer } from "./api-server";
|
||||
import type { AppDatabase } from "./database";
|
||||
import type { BrowserManager } from "./browser-manager";
|
||||
import { prepareHostEnvironment } from "./host-environment-service";
|
||||
import { createServiceGraph } from "./service-graph";
|
||||
import { createIpcSupport } from "./ipc-support";
|
||||
import type { DesktopEvent } from "@shared/types";
|
||||
|
||||
interface BootstrapApplicationDependencies {
|
||||
hasSingleInstanceLock: boolean;
|
||||
isDev: boolean;
|
||||
devServerOrigin: string | null;
|
||||
profileDir: string;
|
||||
appDatabaseFactory: (profileDir: string) => AppDatabase;
|
||||
initSigningKey: (profileDir: string) => void;
|
||||
windowStatePath: string;
|
||||
preloadPath: string;
|
||||
rendererDist: string;
|
||||
iconPath: string;
|
||||
devServerUrl?: string;
|
||||
workerPath: string;
|
||||
createApiServer: (db: AppDatabase, callbacks: {
|
||||
onDomainChanged: () => void;
|
||||
onWakeAgent: (agentId: string, companyId: string, trigger: string) => string | null;
|
||||
onTaskCreated: (taskId: string, companyId: string) => void;
|
||||
onTaskStatusChanged: (taskId: string, companyId: string, prev: string, next: string) => void;
|
||||
onCommentPosted: (taskId: string, companyId: string, authorAgentId: string | null) => void;
|
||||
onApprovalResolved: (approvalId: string, companyId: string, decision: string) => void;
|
||||
onDocumentCreated: (companyId: string, authorAgentId: string | null, projectId: string | null) => void;
|
||||
onAutoAssignTask: (taskId: string, companyId: string) => string | null;
|
||||
}, allowedOrigins: string[]) => AgentApiServer;
|
||||
createRunToken: (agentId: string, companyId: string, runId: string) => string;
|
||||
seedDefaultAutomationRules: (companyId: string) => void;
|
||||
isQuittingRef: { current: boolean };
|
||||
onRuntimeReady?: (state: {
|
||||
db: AppDatabase;
|
||||
apiServer: AgentApiServer;
|
||||
serviceGraph: ReturnType<typeof createServiceGraph>;
|
||||
ipcSupport: ReturnType<typeof createIpcSupport>;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export async function bootstrapApplication({
|
||||
hasSingleInstanceLock,
|
||||
isDev,
|
||||
devServerOrigin,
|
||||
profileDir,
|
||||
appDatabaseFactory,
|
||||
initSigningKey,
|
||||
windowStatePath,
|
||||
preloadPath,
|
||||
rendererDist,
|
||||
iconPath,
|
||||
devServerUrl,
|
||||
workerPath,
|
||||
createApiServer,
|
||||
createRunToken,
|
||||
seedDefaultAutomationRules,
|
||||
isQuittingRef,
|
||||
onRuntimeReady,
|
||||
}: BootstrapApplicationDependencies) {
|
||||
await app.whenReady();
|
||||
log.initialize();
|
||||
await prepareHostEnvironment({ iconPath, logger: log });
|
||||
|
||||
const db = appDatabaseFactory(profileDir);
|
||||
db.init();
|
||||
initSigningKey(db.profileDir);
|
||||
|
||||
const apiServerRef = { current: null as AgentApiServer | null };
|
||||
const browserManagerRef = { current: null as BrowserManager | null };
|
||||
let ipcSupport: ReturnType<typeof createIpcSupport> | null = null;
|
||||
const notify = (title: string, body: string) => {
|
||||
if (!ipcSupport) {
|
||||
throw new Error("IPC support is not initialized.");
|
||||
}
|
||||
ipcSupport.notify(title, body);
|
||||
};
|
||||
const emitEvent = (event: DesktopEvent) => {
|
||||
ipcSupport?.emitEvent(event);
|
||||
};
|
||||
const publishDomainChanged = () => {
|
||||
ipcSupport?.publishDomainChanged();
|
||||
};
|
||||
|
||||
const serviceGraph = createServiceGraph({
|
||||
db,
|
||||
apiServerRef,
|
||||
browserManagerRef,
|
||||
isQuittingRef,
|
||||
preloadPath,
|
||||
rendererDist,
|
||||
iconPath,
|
||||
isDev,
|
||||
devServerUrl,
|
||||
windowStatePath,
|
||||
workerPath,
|
||||
logger: log,
|
||||
emitEvent,
|
||||
publishDomainChanged,
|
||||
notify,
|
||||
createRunToken,
|
||||
seedDefaultAutomationRules,
|
||||
});
|
||||
ipcSupport = createIpcSupport({
|
||||
isDev,
|
||||
devServerOrigin,
|
||||
logger: log,
|
||||
getMainWindow: () => serviceGraph.getDesktopShellService()?.getMainWindow() ?? null,
|
||||
getDb: () => ({
|
||||
hasCompany: (companyId: string) => db.hasCompany(companyId),
|
||||
belongsToCompany: (table: string, id: string, companyId: string) => db.belongsToCompany(table as any, id, companyId),
|
||||
}),
|
||||
});
|
||||
serviceGraph.init();
|
||||
|
||||
const wakeAgentIfPossible = (agentId: string | null | undefined, companyId: string, trigger: string) => {
|
||||
const orchestration = serviceGraph.getOrchestrationService();
|
||||
if (!orchestration) throw new Error("Orchestration service is not initialized.");
|
||||
return orchestration.wakeAgentIfPossible(agentId, companyId, trigger);
|
||||
};
|
||||
const handleTaskCreated = (taskId: string, companyId: string) => {
|
||||
const automation = serviceGraph.getAutomationService();
|
||||
if (!automation) throw new Error("Automation service is not initialized.");
|
||||
automation.handleTaskCreated(taskId, companyId);
|
||||
};
|
||||
const handleTaskStatusChange = (taskId: string, companyId: string, prev: string, next: string) => {
|
||||
const automation = serviceGraph.getAutomationService();
|
||||
if (!automation) throw new Error("Automation service is not initialized.");
|
||||
automation.handleTaskStatusChange(taskId, companyId, prev, next);
|
||||
};
|
||||
const handleApprovalResolved = (approvalId: string, companyId: string, decision: string) => {
|
||||
const automation = serviceGraph.getAutomationService();
|
||||
if (!automation) throw new Error("Automation service is not initialized.");
|
||||
automation.handleApprovalResolved(approvalId, companyId, decision);
|
||||
};
|
||||
const dispatchAutomation = (trigger: string, ctx: Record<string, unknown>) => {
|
||||
const automation = serviceGraph.getAutomationService();
|
||||
if (!automation) throw new Error("Automation service is not initialized.");
|
||||
automation.dispatchAutomation(trigger, ctx as any);
|
||||
};
|
||||
|
||||
const apiServer = createApiServer(db, {
|
||||
onDomainChanged: publishDomainChanged,
|
||||
onWakeAgent: (agentId, companyId, trigger) => wakeAgentIfPossible(agentId, companyId, trigger),
|
||||
onTaskCreated: handleTaskCreated,
|
||||
onTaskStatusChanged: handleTaskStatusChange,
|
||||
onCommentPosted: (taskId, companyId, authorAgentId) => {
|
||||
dispatchAutomation("comment_posted", { companyId, taskId, agentId: authorAgentId ?? undefined });
|
||||
},
|
||||
onApprovalResolved: handleApprovalResolved,
|
||||
onDocumentCreated: (companyId, authorAgentId, projectId) => {
|
||||
dispatchAutomation("document_created", { companyId, agentId: authorAgentId ?? undefined, projectId: projectId ?? undefined });
|
||||
},
|
||||
onAutoAssignTask: (taskId, companyId) => db.autoAssignTask(taskId, companyId),
|
||||
}, [isDev && devServerUrl ? new URL(devServerUrl).origin : null, "app://-"].filter((origin): origin is string => Boolean(origin)));
|
||||
|
||||
apiServerRef.current = apiServer;
|
||||
await apiServer.start();
|
||||
onRuntimeReady?.({
|
||||
db,
|
||||
apiServer,
|
||||
serviceGraph,
|
||||
ipcSupport,
|
||||
});
|
||||
|
||||
if (apiServer && serviceGraph.getBrowserActionService()) {
|
||||
apiServer.setBrowserActionHandler(async (actionId, companyId) => {
|
||||
const browserActions = serviceGraph.getBrowserActionService();
|
||||
if (!browserActions) throw new Error("Browser action service is not initialized");
|
||||
await browserActions.dispatchBrowserAction(actionId, companyId);
|
||||
});
|
||||
}
|
||||
|
||||
// Show the window immediately so the user sees the app launch without delay.
|
||||
await serviceGraph.getDesktopShellService()?.ensureWindow();
|
||||
serviceGraph.getAppLifecycleService()?.register(hasSingleInstanceLock);
|
||||
|
||||
// Run connector health checks AFTER the window is visible.
|
||||
// This ensures connectors have a known status before any agent run is attempted,
|
||||
// but doesn't block the startup experience.
|
||||
const connectorHealth = serviceGraph.getConnectorHealthService();
|
||||
if (connectorHealth) {
|
||||
const connectors = db.listSnapshot().connectors;
|
||||
const healthCheckResults = await Promise.allSettled(
|
||||
connectors.map((connector) => connectorHealth.checkConnector(connector.id)),
|
||||
);
|
||||
for (let i = 0; i < healthCheckResults.length; i++) {
|
||||
const result = healthCheckResults[i]!;
|
||||
if (result.status === "rejected") {
|
||||
log.warn(`[bootstrap] Connector health check failed for ${connectors[i]?.id}: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`);
|
||||
}
|
||||
}
|
||||
log.info(`[bootstrap] Connector health checks completed (${healthCheckResults.filter((r) => r.status === "fulfilled").length}/${connectors.length} succeeded)`);
|
||||
}
|
||||
|
||||
serviceGraph.getOrchestrationService()?.startHeartbeatScheduler();
|
||||
serviceGraph.getOrchestrationService()?.resumeQueuedRuns();
|
||||
|
||||
return {
|
||||
db,
|
||||
apiServer,
|
||||
serviceGraph,
|
||||
ipcSupport,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { join } from "node:path";
|
||||
import type { AppDatabase } from "./database";
|
||||
import { BrowserManager } from "./browser-manager";
|
||||
|
||||
interface BrowserActionDependencies {
|
||||
db: AppDatabase;
|
||||
browserManagerRef: { current: BrowserManager | null };
|
||||
publishDomainChanged: () => void;
|
||||
}
|
||||
|
||||
export function createBrowserActionService({
|
||||
db,
|
||||
browserManagerRef,
|
||||
publishDomainChanged,
|
||||
}: BrowserActionDependencies) {
|
||||
function requireBrowserManager() {
|
||||
if (!browserManagerRef.current) {
|
||||
throw new Error("Browser manager is not initialized.");
|
||||
}
|
||||
return browserManagerRef.current;
|
||||
}
|
||||
|
||||
async function dispatchBrowserAction(actionId: string, companyId: string) {
|
||||
const action = db.getBrowserAction(actionId, companyId);
|
||||
if (!action) {
|
||||
throw new Error(`Browser action not found: ${actionId}`);
|
||||
}
|
||||
|
||||
const account = db.getSocialAccount(action.socialAccountId, companyId);
|
||||
if (!account) {
|
||||
db.updateBrowserAction(actionId, {
|
||||
status: "failed",
|
||||
errorMessage: "Social account no longer exists.",
|
||||
finishedAt: new Date().toISOString(),
|
||||
});
|
||||
publishDomainChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
let payload: Record<string, unknown>;
|
||||
try {
|
||||
payload = JSON.parse(action.payloadJson) as Record<string, unknown>;
|
||||
} catch {
|
||||
db.updateBrowserAction(actionId, {
|
||||
status: "failed",
|
||||
errorMessage: "Browser action payload is not valid JSON.",
|
||||
finishedAt: new Date().toISOString(),
|
||||
});
|
||||
publishDomainChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
db.updateBrowserAction(actionId, {
|
||||
status: "running",
|
||||
startedAt: new Date().toISOString(),
|
||||
errorMessage: null,
|
||||
});
|
||||
publishDomainChanged();
|
||||
|
||||
try {
|
||||
const result = await requireBrowserManager().executeAction({
|
||||
id: actionId,
|
||||
socialAccountId: account.id,
|
||||
platform: account.platform,
|
||||
actionType: action.actionType,
|
||||
payload,
|
||||
});
|
||||
|
||||
db.updateBrowserAction(actionId, {
|
||||
status: result.cancelled ? "cancelled" : result.success ? "succeeded" : "failed",
|
||||
resultSummary: result.summary,
|
||||
screenshotPath: result.screenshotPath,
|
||||
errorMessage: result.error,
|
||||
finishedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
db.updateBrowserAction(actionId, {
|
||||
status: "failed",
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
finishedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
publishDomainChanged();
|
||||
}
|
||||
|
||||
async function clearSocialAccountData(socialAccountId: string, screenshotPaths: string[] = []) {
|
||||
if (!browserManagerRef.current) {
|
||||
return;
|
||||
}
|
||||
await browserManagerRef.current.clearSocialAccountData(socialAccountId, screenshotPaths);
|
||||
}
|
||||
|
||||
function cancelAction(actionId: string) {
|
||||
return browserManagerRef.current?.cancelAction(actionId) ?? false;
|
||||
}
|
||||
|
||||
async function launchLoginBrowser(socialAccountId: string, platform: string, loginUrl?: string) {
|
||||
return requireBrowserManager().launchLoginBrowser(socialAccountId, platform, loginUrl);
|
||||
}
|
||||
|
||||
function resetBrowserManager() {
|
||||
browserManagerRef.current?.shutdown();
|
||||
browserManagerRef.current = new BrowserManager(join(db.artifactsDir, "browser"));
|
||||
}
|
||||
|
||||
return {
|
||||
dispatchBrowserAction,
|
||||
clearSocialAccountData,
|
||||
cancelAction,
|
||||
launchLoginBrowser,
|
||||
resetBrowserManager,
|
||||
};
|
||||
}
|
||||
+145
-5
@@ -1,6 +1,7 @@
|
||||
import { BrowserWindow, session, type NativeImage } from "electron";
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { join, resolve, sep } from "node:path";
|
||||
import log from "electron-log/main.js";
|
||||
|
||||
export interface BrowserAction {
|
||||
@@ -13,6 +14,7 @@ export interface BrowserAction {
|
||||
|
||||
export interface BrowserActionResult {
|
||||
success: boolean;
|
||||
cancelled: boolean;
|
||||
summary: string;
|
||||
screenshotPath: string | null;
|
||||
error: string | null;
|
||||
@@ -104,6 +106,8 @@ export class BrowserManager {
|
||||
private processing = new Set<string>();
|
||||
private loginWindows = new Map<string, BrowserWindow>();
|
||||
private initializedSessions = new Set<string>();
|
||||
private activeActionWindows = new Map<string, BrowserWindow>();
|
||||
private cancelledActionIds = new Set<string>();
|
||||
|
||||
constructor(profileDir: string) {
|
||||
this.screenshotDir = join(profileDir, "browser-screenshots");
|
||||
@@ -121,6 +125,36 @@ export class BrowserManager {
|
||||
return socialSession;
|
||||
}
|
||||
|
||||
async clearSocialAccountData(socialAccountId: string, screenshotPaths: string[] = []) {
|
||||
const existing = this.loginWindows.get(socialAccountId);
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
existing.close();
|
||||
}
|
||||
this.loginWindows.delete(socialAccountId);
|
||||
this.queues.delete(socialAccountId);
|
||||
this.processing.delete(socialAccountId);
|
||||
|
||||
const ses = this.getSession(socialAccountId);
|
||||
try {
|
||||
await ses.clearStorageData();
|
||||
await ses.clearCache();
|
||||
await ses.clearAuthCache();
|
||||
} catch (error) {
|
||||
log.warn(`[browser] Failed to fully clear session for ${socialAccountId}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
screenshotPaths
|
||||
.filter((entry): entry is string => typeof entry === "string" && entry.length > 0)
|
||||
.filter((screenshotPath) => {
|
||||
const resolvedPath = resolve(screenshotPath);
|
||||
const resolvedRoot = resolve(this.screenshotDir);
|
||||
return resolvedPath === resolvedRoot || resolvedPath.startsWith(`${resolvedRoot}${sep}`);
|
||||
})
|
||||
.map((screenshotPath) => rm(screenshotPath, { force: true }).catch(() => undefined)),
|
||||
);
|
||||
}
|
||||
|
||||
async launchLoginBrowser(
|
||||
socialAccountId: string,
|
||||
platform: string,
|
||||
@@ -129,12 +163,13 @@ export class BrowserManager {
|
||||
const existing = this.loginWindows.get(socialAccountId);
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
existing.focus();
|
||||
return { success: true, summary: "Login window already open", screenshotPath: null, error: null };
|
||||
return { success: true, cancelled: false, summary: "Login window already open", screenshotPath: null, error: null };
|
||||
}
|
||||
|
||||
const rules = this.getPlatformRules(platform);
|
||||
const url = loginUrl || rules.loginUrl;
|
||||
const ses = this.getSession(socialAccountId);
|
||||
this.assertAllowedUrl(platform, url, "login");
|
||||
|
||||
log.info(`[browser] Opening login window for ${platform} → ${url}`);
|
||||
|
||||
@@ -151,6 +186,7 @@ export class BrowserManager {
|
||||
});
|
||||
|
||||
this.loginWindows.set(socialAccountId, win);
|
||||
this.attachWindowSecurity(win, platform);
|
||||
|
||||
win.setMenuBarVisibility(false);
|
||||
|
||||
@@ -167,6 +203,7 @@ export class BrowserManager {
|
||||
log.info(`[browser] Login window closed for ${platform} — verified=${verified}`);
|
||||
resolve({
|
||||
success: verified,
|
||||
cancelled: false,
|
||||
summary: verified
|
||||
? `Authenticated session detected for ${platform}`
|
||||
: `No authenticated ${platform} session detected`,
|
||||
@@ -187,6 +224,36 @@ export class BrowserManager {
|
||||
});
|
||||
}
|
||||
|
||||
cancelAction(actionId: string): boolean {
|
||||
this.cancelledActionIds.add(actionId);
|
||||
const activeWindow = this.activeActionWindows.get(actionId);
|
||||
if (activeWindow && !activeWindow.isDestroyed()) {
|
||||
activeWindow.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const [socialAccountId, queue] of this.queues.entries()) {
|
||||
const index = queue.findIndex((item) => item.action.id === actionId);
|
||||
if (index < 0) {
|
||||
continue;
|
||||
}
|
||||
const [item] = queue.splice(index, 1);
|
||||
item?.resolve({
|
||||
success: false,
|
||||
cancelled: true,
|
||||
summary: "Action cancelled before execution.",
|
||||
screenshotPath: null,
|
||||
error: "Action cancelled.",
|
||||
});
|
||||
if (queue.length === 0) {
|
||||
this.queues.delete(socialAccountId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async processQueue(socialAccountId: string) {
|
||||
if (this.processing.has(socialAccountId)) return;
|
||||
const queue = this.queues.get(socialAccountId);
|
||||
@@ -225,28 +292,39 @@ export class BrowserManager {
|
||||
sandbox: true,
|
||||
},
|
||||
});
|
||||
this.activeActionWindows.set(action.id, win);
|
||||
this.attachWindowSecurity(win, platform);
|
||||
win.setMenuBarVisibility(false);
|
||||
|
||||
try {
|
||||
let summary = "Action completed";
|
||||
const assertNotCancelled = () => {
|
||||
if (this.cancelledActionIds.has(action.id)) {
|
||||
throw new Error("BROWSER_ACTION_CANCELLED");
|
||||
}
|
||||
};
|
||||
|
||||
switch (action.actionType) {
|
||||
case "navigate": {
|
||||
assertNotCancelled();
|
||||
const url = String(payload.url || "https://google.com");
|
||||
this.assertAllowedUrl(platform, url, "navigate");
|
||||
await win.loadURL(url);
|
||||
await this.waitForLoad(win);
|
||||
assertNotCancelled();
|
||||
const title = await win.webContents.executeJavaScript("document.title");
|
||||
summary = `Navigated to ${title}`;
|
||||
break;
|
||||
}
|
||||
|
||||
case "post": {
|
||||
assertNotCancelled();
|
||||
const content = String(payload.content || "");
|
||||
const postUrl = this.getComposeUrl(platform);
|
||||
await win.loadURL(postUrl);
|
||||
await this.waitForLoad(win);
|
||||
await this.delay(2000);
|
||||
assertNotCancelled();
|
||||
|
||||
if (platform === "twitter") {
|
||||
await win.webContents.executeJavaScript(`
|
||||
@@ -297,12 +375,14 @@ export class BrowserManager {
|
||||
}
|
||||
|
||||
case "reply": {
|
||||
assertNotCancelled();
|
||||
const targetUrl = String(payload.targetUrl || "");
|
||||
this.assertAllowedUrl(platform, targetUrl, "reply");
|
||||
const replyContent = String(payload.content || "");
|
||||
await win.loadURL(targetUrl);
|
||||
await this.waitForLoad(win);
|
||||
await this.delay(2000);
|
||||
assertNotCancelled();
|
||||
|
||||
await win.webContents.executeJavaScript(`
|
||||
(async () => {
|
||||
@@ -326,11 +406,13 @@ export class BrowserManager {
|
||||
}
|
||||
|
||||
case "like": {
|
||||
assertNotCancelled();
|
||||
const targetUrl = String(payload.targetUrl || "");
|
||||
this.assertAllowedUrl(platform, targetUrl, "like");
|
||||
await win.loadURL(targetUrl);
|
||||
await this.waitForLoad(win);
|
||||
await this.delay(2000);
|
||||
assertNotCancelled();
|
||||
await win.webContents.executeJavaScript(`
|
||||
(() => {
|
||||
const btn = document.querySelector('[data-testid="like"], button[aria-label*="Like"]');
|
||||
@@ -343,11 +425,13 @@ export class BrowserManager {
|
||||
}
|
||||
|
||||
case "follow": {
|
||||
assertNotCancelled();
|
||||
const targetUrl = String(payload.targetUrl || payload.profileUrl || "");
|
||||
this.assertAllowedUrl(platform, targetUrl, "follow");
|
||||
await win.loadURL(targetUrl);
|
||||
await this.waitForLoad(win);
|
||||
await this.delay(2000);
|
||||
assertNotCancelled();
|
||||
await win.webContents.executeJavaScript(`
|
||||
(() => {
|
||||
const btn = document.querySelector('[data-testid*="follow"], button[aria-label*="Follow"]');
|
||||
@@ -360,12 +444,14 @@ export class BrowserManager {
|
||||
}
|
||||
|
||||
case "browse_feed": {
|
||||
assertNotCancelled();
|
||||
const feedUrl = String(payload.url || this.getFeedUrl(platform));
|
||||
this.assertAllowedUrl(platform, feedUrl, "browse_feed");
|
||||
const maxPosts = Number(payload.maxPosts || 10);
|
||||
await win.loadURL(feedUrl);
|
||||
await this.waitForLoad(win);
|
||||
await this.delay(3000);
|
||||
assertNotCancelled();
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await win.webContents.executeJavaScript("window.scrollBy(0, window.innerHeight)");
|
||||
@@ -383,12 +469,14 @@ export class BrowserManager {
|
||||
}
|
||||
|
||||
case "search": {
|
||||
assertNotCancelled();
|
||||
const query = String(payload.query || "");
|
||||
const searchUrl = this.getSearchUrl(platform, query);
|
||||
this.assertAllowedUrl(platform, searchUrl, "search");
|
||||
await win.loadURL(searchUrl);
|
||||
await this.waitForLoad(win);
|
||||
await this.delay(3000);
|
||||
assertNotCancelled();
|
||||
|
||||
const results: string[] = await win.webContents.executeJavaScript(`
|
||||
(() => {
|
||||
@@ -401,6 +489,7 @@ export class BrowserManager {
|
||||
}
|
||||
|
||||
case "screenshot": {
|
||||
assertNotCancelled();
|
||||
const url = String(payload.url || "about:blank");
|
||||
if (url !== "about:blank") {
|
||||
this.assertAllowedUrl(platform, url, "screenshot");
|
||||
@@ -408,6 +497,7 @@ export class BrowserManager {
|
||||
await this.waitForLoad(win);
|
||||
await this.delay(2000);
|
||||
}
|
||||
assertNotCancelled();
|
||||
const title = await win.webContents.executeJavaScript("document.title");
|
||||
summary = `Screenshot captured: ${title}`;
|
||||
break;
|
||||
@@ -431,11 +521,13 @@ export class BrowserManager {
|
||||
|
||||
return {
|
||||
success: true,
|
||||
cancelled: false,
|
||||
summary,
|
||||
screenshotPath: existsSync(screenshotPath) ? screenshotPath : null,
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
const wasCancelled = this.cancelledActionIds.has(action.id) || (error instanceof Error && error.message === "BROWSER_ACTION_CANCELLED");
|
||||
try {
|
||||
const image: NativeImage = await win.webContents.capturePage();
|
||||
if (!image.isEmpty()) writeFileSync(screenshotPath, image.toPNG());
|
||||
@@ -443,14 +535,20 @@ export class BrowserManager {
|
||||
|
||||
if (!win.isDestroyed()) win.close();
|
||||
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
log.error(`[browser] Action ${action.actionType} failed: ${msg}`);
|
||||
const msg = wasCancelled ? "Action cancelled." : error instanceof Error ? error.message : String(error);
|
||||
if (!wasCancelled) {
|
||||
log.error(`[browser] Action ${action.actionType} failed: ${msg}`);
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
summary: "Action failed",
|
||||
cancelled: wasCancelled,
|
||||
summary: wasCancelled ? "Action cancelled" : "Action failed",
|
||||
screenshotPath: existsSync(screenshotPath) ? screenshotPath : null,
|
||||
error: msg,
|
||||
};
|
||||
} finally {
|
||||
this.activeActionWindows.delete(action.id);
|
||||
this.cancelledActionIds.delete(action.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,6 +574,43 @@ export class BrowserManager {
|
||||
return PLATFORM_RULES[platform] ?? PLATFORM_RULES.other;
|
||||
}
|
||||
|
||||
private isAllowedNavigation(platform: string, rawUrl: string) {
|
||||
try {
|
||||
this.assertAllowedUrl(platform, rawUrl, "navigate");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private attachWindowSecurity(win: BrowserWindow, platform: string) {
|
||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (!this.isAllowedNavigation(platform, url)) {
|
||||
return { action: "deny" };
|
||||
}
|
||||
setImmediate(() => {
|
||||
void win.loadURL(url).catch((error) => {
|
||||
log.warn(`[browser] Failed to open allowed popup target for ${platform}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
});
|
||||
return { action: "deny" };
|
||||
});
|
||||
win.webContents.on("will-navigate", (event, navigationUrl) => {
|
||||
if (this.isAllowedNavigation(platform, navigationUrl)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
log.warn(`[browser] Blocked navigation outside allowlist for ${platform}: ${navigationUrl}`);
|
||||
});
|
||||
win.webContents.on("will-redirect", (event, navigationUrl) => {
|
||||
if (this.isAllowedNavigation(platform, navigationUrl)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
log.warn(`[browser] Blocked redirect outside allowlist for ${platform}: ${navigationUrl}`);
|
||||
});
|
||||
}
|
||||
|
||||
private assertAllowedUrl(platform: string, rawUrl: string, actionType: string) {
|
||||
const allowedHosts = this.getPlatformRules(platform).allowedHosts;
|
||||
let url: URL;
|
||||
@@ -484,6 +619,9 @@ export class BrowserManager {
|
||||
} catch {
|
||||
throw new Error(`Invalid URL for ${actionType}: ${rawUrl}`);
|
||||
}
|
||||
if (url.protocol !== "https:") {
|
||||
throw new Error(`Navigation blocked for ${actionType}. Only https URLs are allowed.`);
|
||||
}
|
||||
const host = url.hostname.toLowerCase();
|
||||
const isAllowed = allowedHosts.some((allowedHost) => host === allowedHost || host.endsWith(`.${allowedHost}`));
|
||||
if (!isAllowed) {
|
||||
@@ -578,6 +716,8 @@ export class BrowserManager {
|
||||
if (!win.isDestroyed()) win.close();
|
||||
}
|
||||
this.loginWindows.clear();
|
||||
this.activeActionWindows.clear();
|
||||
this.cancelledActionIds.clear();
|
||||
this.queues.clear();
|
||||
this.processing.clear();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { homedir } from "node:os";
|
||||
import type { ConnectorId } from "@shared/types";
|
||||
|
||||
function quoteShell(value: string) {
|
||||
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function escapeAppleScript(value: string) {
|
||||
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
function connectorAuthSpec(connectorId: ConnectorId, command: string) {
|
||||
switch (connectorId) {
|
||||
case "codex_local":
|
||||
return {
|
||||
title: "Codex Login",
|
||||
shellCommand: `${quoteShell(command)} login`,
|
||||
intro: "AgentCompany is opening the Codex login flow.",
|
||||
};
|
||||
case "claude_local":
|
||||
return {
|
||||
title: "Claude Code Login",
|
||||
shellCommand: `${quoteShell(command)} auth login`,
|
||||
intro: "AgentCompany is opening the Claude Code login flow.",
|
||||
};
|
||||
case "opencode_local":
|
||||
return {
|
||||
title: "OpenCode Login",
|
||||
shellCommand: `${quoteShell(command)} auth login`,
|
||||
intro: "AgentCompany is opening the OpenCode provider login flow.",
|
||||
};
|
||||
case "gemini_local":
|
||||
return {
|
||||
title: "Gemini CLI Login",
|
||||
shellCommand: `${quoteShell(command)}`,
|
||||
intro: "AgentCompany is opening Gemini CLI. If authentication is required, use /auth in the Gemini session.",
|
||||
};
|
||||
case "kimi_local":
|
||||
return {
|
||||
title: "Kimi Code Login",
|
||||
shellCommand: `${quoteShell(command)}`,
|
||||
intro: "AgentCompany is opening Kimi Code. Complete the interactive login flow there if prompted.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function buildTerminalCommand(connectorId: ConnectorId, command: string, cwd: string) {
|
||||
const spec = connectorAuthSpec(connectorId, command);
|
||||
const shell = process.env.SHELL || "/bin/zsh";
|
||||
const script = [
|
||||
"clear",
|
||||
`echo ${quoteShell(spec.intro)}`,
|
||||
"echo",
|
||||
`cd ${quoteShell(cwd)}`,
|
||||
spec.shellCommand,
|
||||
"echo",
|
||||
`echo ${quoteShell("You can close this terminal after the login flow completes.")}`,
|
||||
`exec ${quoteShell(shell)} -l`,
|
||||
].join("; ");
|
||||
return { title: spec.title, script };
|
||||
}
|
||||
|
||||
export async function openConnectorAuthTerminal(connectorId: ConnectorId, command: string, cwd?: string) {
|
||||
const targetCwd = cwd || process.env.HOME || homedir() || process.cwd();
|
||||
const { script } = buildTerminalCommand(connectorId, command, targetCwd);
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
const appleScript = `tell application "Terminal" to do script "${escapeAppleScript(script)}"\ntell application "Terminal" to activate`;
|
||||
const child = spawn("osascript", ["-e", appleScript], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
});
|
||||
child.unref();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const child = spawn("cmd.exe", ["/c", "start", "", "cmd.exe", "/k", script], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
shell: false,
|
||||
});
|
||||
child.unref();
|
||||
return true;
|
||||
}
|
||||
|
||||
const linuxCandidates: Array<[string, string[]]> = [
|
||||
["x-terminal-emulator", ["-e", "sh", "-lc", script]],
|
||||
["gnome-terminal", ["--", "sh", "-lc", script]],
|
||||
["konsole", ["-e", "sh", "-lc", script]],
|
||||
["xfce4-terminal", ["-e", `sh -lc ${quoteShell(script)}`]],
|
||||
];
|
||||
|
||||
for (const [binary, args] of linuxCandidates) {
|
||||
const availability = spawnSync("sh", ["-lc", `command -v ${quoteShell(binary)}`], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
if ((availability.status ?? 1) !== 0) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const child = spawn(binary, args, {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
});
|
||||
child.unref();
|
||||
return true;
|
||||
} catch {
|
||||
// Try next terminal candidate.
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("No supported terminal application was found for connector login.");
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { homedir } from "node:os";
|
||||
import type { AppDatabase } from "./database";
|
||||
import { getConnectorDefinition } from "./connectors";
|
||||
import { buildSanitizedRuntimeEnv } from "./runtime-env";
|
||||
import type { ConnectorId } from "@shared/types";
|
||||
|
||||
interface ConnectorHealthDependencies {
|
||||
db: AppDatabase;
|
||||
logger: {
|
||||
info: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
};
|
||||
publishDomainChanged: () => void;
|
||||
}
|
||||
|
||||
export function createConnectorHealthService({
|
||||
db,
|
||||
logger: _logger,
|
||||
publishDomainChanged,
|
||||
}: ConnectorHealthDependencies) {
|
||||
function randomFallbackCompanyId() {
|
||||
const snapshot = db.listSnapshot();
|
||||
return snapshot.companies[0]?.id ?? "00000000-0000-0000-0000-000000000000";
|
||||
}
|
||||
|
||||
function resolveProbeCwd() {
|
||||
const snapshot = db.listSnapshot();
|
||||
const currentCompanyId = db.getCurrentCompanyId();
|
||||
const preferredWorkspace = snapshot.workspaces.find((workspace) => workspace.companyId === currentCompanyId)
|
||||
?? snapshot.workspaces[0]
|
||||
?? null;
|
||||
return preferredWorkspace?.localPath ?? process.env.HOME ?? homedir() ?? process.cwd();
|
||||
}
|
||||
|
||||
function parseVersion(command: string): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false;
|
||||
const child = spawn(command, ["--version"], {
|
||||
env: process.env,
|
||||
shell: false,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let buffer = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
buffer += String(chunk);
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
buffer += String(chunk);
|
||||
});
|
||||
child.on("error", () => {
|
||||
if (!resolved) { resolved = true; resolve(null); }
|
||||
});
|
||||
child.on("close", () => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
const versionLine = buffer.split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? null;
|
||||
resolve(versionLine);
|
||||
}
|
||||
});
|
||||
// Kill the child after 8 seconds to avoid blocking startup.
|
||||
setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
child.kill("SIGKILL");
|
||||
resolve(null);
|
||||
}
|
||||
}, 8_000);
|
||||
});
|
||||
}
|
||||
|
||||
async function checkConnector(id: ConnectorId) {
|
||||
const connector = db.getConnector(id);
|
||||
const definition = getConnectorDefinition(id);
|
||||
const companyId = db.getCurrentCompanyId() ?? randomFallbackCompanyId();
|
||||
const probeCwd = resolveProbeCwd();
|
||||
const runtimeOverrides = db.resolveSecretEnv(connector.envBindingText, companyId);
|
||||
|
||||
db.updateConnectorHealth({
|
||||
id,
|
||||
status: "detected",
|
||||
authState: connector.authState,
|
||||
version: connector.version,
|
||||
lastError: null,
|
||||
});
|
||||
publishDomainChanged();
|
||||
|
||||
try {
|
||||
const testPromise = definition.module.testEnvironment({
|
||||
companyId,
|
||||
adapterType: id,
|
||||
config: {
|
||||
command: connector.command,
|
||||
model: connector.model,
|
||||
cwd: probeCwd,
|
||||
env: buildSanitizedRuntimeEnv(process.env, runtimeOverrides),
|
||||
approvalMode: "default",
|
||||
sandbox: true,
|
||||
},
|
||||
});
|
||||
const result = await Promise.race([
|
||||
testPromise,
|
||||
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("Connector health check timed out after 45 seconds")), 45_000)),
|
||||
]);
|
||||
|
||||
const authRequired = result.checks.some((check) =>
|
||||
/auth_required|login_required/i.test(check.code) ||
|
||||
/login is required|authentication is not ready|auth(?:entication)? required/i.test(check.message) ||
|
||||
/run .*login|use \/auth|configure .*api key/i.test(check.hint ?? ""),
|
||||
);
|
||||
let authState: "ok" | "required" | "error" = "ok";
|
||||
if (authRequired) {
|
||||
authState = "required";
|
||||
} else if (result.status === "fail") {
|
||||
authState = "error";
|
||||
}
|
||||
const version = await parseVersion(connector.command);
|
||||
|
||||
db.updateConnectorHealth({
|
||||
id,
|
||||
status:
|
||||
authRequired
|
||||
? "auth_required"
|
||||
: result.status === "pass"
|
||||
? "ready"
|
||||
: result.status === "warn"
|
||||
? "degraded"
|
||||
: "error",
|
||||
authState,
|
||||
version,
|
||||
lastError:
|
||||
result.status === "fail" || result.status === "warn"
|
||||
? result.checks.map((check) => check.message).join(" | ")
|
||||
: null,
|
||||
});
|
||||
db.addActivity({
|
||||
companyId,
|
||||
actor: "system",
|
||||
action: "connector.tested",
|
||||
entityType: "connector",
|
||||
entityId: id,
|
||||
detail: result.status,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
_logger.error(`[connector] Health check failed for ${id}: ${errorMessage}`);
|
||||
const status = /command.*not found|not executable/i.test(errorMessage)
|
||||
? "not_installed"
|
||||
: /timed out/i.test(errorMessage)
|
||||
? "degraded"
|
||||
: "error";
|
||||
db.updateConnectorHealth({
|
||||
id,
|
||||
status,
|
||||
authState: "error",
|
||||
version: null,
|
||||
lastError: errorMessage,
|
||||
});
|
||||
}
|
||||
publishDomainChanged();
|
||||
}
|
||||
|
||||
return {
|
||||
checkConnector,
|
||||
randomFallbackCompanyId,
|
||||
};
|
||||
}
|
||||
+30
-124
@@ -1,11 +1,4 @@
|
||||
import type {
|
||||
AdapterExecutionContext,
|
||||
AdapterExecutionResult,
|
||||
AdapterEnvironmentTestContext,
|
||||
AdapterEnvironmentTestResult,
|
||||
ServerAdapterModule,
|
||||
} from "@agentcompany/adapter-utils";
|
||||
import { asNumber, asString, ensureAbsoluteDirectory, ensureCommandResolvable, ensurePathInEnv, runChildProcess } from "@agentcompany/adapter-utils/server-utils";
|
||||
import type { ServerAdapterModule } from "@agentcompany/adapter-utils";
|
||||
import { agentConfigurationDoc as claudeDoc, models as claudeModels } from "@agentcompany/adapter-claude-local";
|
||||
import { execute as claudeExecute, sessionCodec as claudeSessionCodec, testEnvironment as claudeTestEnvironment } from "@agentcompany/adapter-claude-local/server";
|
||||
import {
|
||||
@@ -16,15 +9,23 @@ import {
|
||||
import { execute as codexExecute, sessionCodec as codexSessionCodec, testEnvironment as codexTestEnvironment } from "@agentcompany/adapter-codex-local/server";
|
||||
import { agentConfigurationDoc as geminiDoc, DEFAULT_GEMINI_LOCAL_MODEL, models as geminiModels } from "@agentcompany/adapter-gemini-local";
|
||||
import { execute as geminiExecute, sessionCodec as geminiSessionCodec, testEnvironment as geminiTestEnvironment } from "@agentcompany/adapter-gemini-local/server";
|
||||
import { agentConfigurationDoc as kimiDoc, DEFAULT_KIMI_LOCAL_MODEL, models as kimiModels } from "@agentcompany/adapter-kimi-local";
|
||||
import { execute as kimiExecute, testEnvironment as kimiTestEnvironment } from "@agentcompany/adapter-kimi-local/server";
|
||||
import { agentConfigurationDoc as opencodeDoc } from "@agentcompany/adapter-opencode-local";
|
||||
import { execute as opencodeExecute, listOpenCodeModels, sessionCodec as opencodeSessionCodec, testEnvironment as opencodeTestEnvironment } from "@agentcompany/adapter-opencode-local/server";
|
||||
import type { CapabilityMatrix, ConnectorId, ConnectorRecord } from "@shared/types";
|
||||
|
||||
function normalizeEnv(input: NodeJS.ProcessEnv): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(input).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
||||
);
|
||||
}
|
||||
const RESERVED_ENV_BINDING_KEYS = new Set([
|
||||
"PATH",
|
||||
"HOME",
|
||||
"USER",
|
||||
"SHELL",
|
||||
"NODE_OPTIONS",
|
||||
"ELECTRON_RUN_AS_NODE",
|
||||
"DYLD_INSERT_LIBRARIES",
|
||||
"LD_PRELOAD",
|
||||
"PWD",
|
||||
]);
|
||||
|
||||
interface ConnectorDefinition {
|
||||
id: ConnectorId;
|
||||
@@ -39,98 +40,6 @@ interface ConnectorDefinition {
|
||||
listModels?: () => Promise<Array<{ id: string; label: string }>>;
|
||||
}
|
||||
|
||||
const kimiModule: ServerAdapterModule = {
|
||||
type: "kimi_local",
|
||||
async testEnvironment(ctx: AdapterEnvironmentTestContext): Promise<AdapterEnvironmentTestResult> {
|
||||
const command = asString(ctx.config.command, "kimi");
|
||||
const cwd = asString(ctx.config.cwd, process.cwd());
|
||||
const env = normalizeEnv(ensurePathInEnv({ ...(ctx.config.env as Record<string, string> ?? process.env) }));
|
||||
try {
|
||||
await ensureCommandResolvable(command, cwd, env);
|
||||
return {
|
||||
adapterType: "kimi_local",
|
||||
status: "warn",
|
||||
checks: [
|
||||
{
|
||||
code: "kimi_command_detected",
|
||||
level: "info",
|
||||
message: `Detected Kimi Code command "${command}".`,
|
||||
},
|
||||
{
|
||||
code: "kimi_runtime_generic_mode",
|
||||
level: "warn",
|
||||
message:
|
||||
"Kimi integration is running in generic CLI mode. Session resume, model discovery, and structured transcript are not guaranteed.",
|
||||
},
|
||||
],
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
adapterType: "kimi_local",
|
||||
status: "fail",
|
||||
checks: [
|
||||
{
|
||||
code: "kimi_command_missing",
|
||||
level: "error",
|
||||
message: error instanceof Error ? error.message : "Kimi command is not available.",
|
||||
hint: "Install Kimi Code and ensure the CLI is available on PATH.",
|
||||
},
|
||||
],
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
},
|
||||
async execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||
const command = asString(ctx.config.command, "kimi");
|
||||
const cwd = asString(ctx.config.cwd, process.cwd());
|
||||
const model = asString(ctx.config.model, "kimi-for-coding");
|
||||
const timeoutSec = asNumber(ctx.config.timeoutSec, 0);
|
||||
const graceSec = asNumber(ctx.config.graceSec, 20);
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
|
||||
const prompt = typeof ctx.context.taskTitle === "string" && ctx.context.taskTitle.length > 0
|
||||
? [
|
||||
ctx.context.taskTitle,
|
||||
typeof ctx.context.taskDescription === "string" && ctx.context.taskDescription.length > 0
|
||||
? ctx.context.taskDescription
|
||||
: "",
|
||||
].filter(Boolean).join("\n\n")
|
||||
: [
|
||||
`You are agent ${ctx.agent.name}.`,
|
||||
"Respond with a concise execution summary when complete.",
|
||||
].join("\n\n");
|
||||
|
||||
const env = normalizeEnv(ensurePathInEnv({ ...(ctx.config.env as Record<string, string> ?? process.env) }));
|
||||
await ensureCommandResolvable(command, cwd, env);
|
||||
const result = await runChildProcess(ctx.runId, command, [], {
|
||||
cwd,
|
||||
env,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
stdin: prompt,
|
||||
onLog: ctx.onLog,
|
||||
});
|
||||
|
||||
const summary =
|
||||
result.stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.slice(-8)
|
||||
.join("\n") || null;
|
||||
|
||||
return {
|
||||
exitCode: result.exitCode,
|
||||
signal: result.signal,
|
||||
timedOut: result.timedOut,
|
||||
summary,
|
||||
model,
|
||||
billingType: "unknown",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const CONNECTORS: ConnectorDefinition[] = [
|
||||
{
|
||||
id: "codex_local",
|
||||
@@ -234,7 +143,7 @@ const CONNECTORS: ConnectorDefinition[] = [
|
||||
label: "Kimi Code",
|
||||
description: "Official Kimi Code CLI surfaced through a generic execution contract with explicit capability downgrade.",
|
||||
defaultCommand: "kimi",
|
||||
defaultModel: "kimi-for-coding",
|
||||
defaultModel: DEFAULT_KIMI_LOCAL_MODEL,
|
||||
notes: "MVP support is explicit generic mode until richer runtime contract evidence is implemented.",
|
||||
capabilityMatrix: {
|
||||
sessionResume: false,
|
||||
@@ -243,23 +152,14 @@ const CONNECTORS: ConnectorDefinition[] = [
|
||||
structuredTranscript: false,
|
||||
costAttribution: false,
|
||||
},
|
||||
configurationDoc: [
|
||||
"# kimi_local agent configuration",
|
||||
"",
|
||||
"Core fields:",
|
||||
"- command (string, optional): defaults to \"kimi\"",
|
||||
"- cwd (string, optional): working directory for the Kimi CLI process",
|
||||
"- model (string, optional): default kimi-for-coding",
|
||||
"- timeoutSec (number, optional)",
|
||||
"- graceSec (number, optional)",
|
||||
"",
|
||||
"Notes:",
|
||||
"- This connector currently runs Kimi in generic CLI mode.",
|
||||
"- Session resume, model discovery, structured transcript, and cost attribution are declared unsupported.",
|
||||
"- The UI must surface these limitations explicitly.",
|
||||
].join("\n"),
|
||||
module: kimiModule,
|
||||
listModels: async () => [{ id: "kimi-for-coding", label: "kimi-for-coding" }],
|
||||
configurationDoc: kimiDoc,
|
||||
module: {
|
||||
type: "kimi_local",
|
||||
execute: kimiExecute,
|
||||
testEnvironment: kimiTestEnvironment,
|
||||
models: kimiModels,
|
||||
},
|
||||
listModels: async () => kimiModels,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -285,6 +185,12 @@ export function parseEnvBindingText(text: string): Record<string, string> {
|
||||
return bindings;
|
||||
}
|
||||
|
||||
export function listInvalidEnvBindingKeys(text: string): string[] {
|
||||
return Object.keys(parseEnvBindingText(text)).filter((key) => {
|
||||
return RESERVED_ENV_BINDING_KEYS.has(key) || key.startsWith("AGENT_COMPANY_");
|
||||
});
|
||||
}
|
||||
|
||||
export function buildDefaultConnectorRows(existing: Array<Partial<ConnectorRecord>> = []): ConnectorRecord[] {
|
||||
const byId = new Map(existing.map((record) => [record.id, record]));
|
||||
return CONNECTORS.map((definition) => {
|
||||
@@ -294,7 +200,7 @@ export function buildDefaultConnectorRows(existing: Array<Partial<ConnectorRecor
|
||||
label: definition.label,
|
||||
description: definition.description,
|
||||
status: existingRecord?.status ?? "not_installed",
|
||||
command: definition.defaultCommand,
|
||||
command: existingRecord?.command ?? definition.defaultCommand,
|
||||
version: existingRecord?.version ?? null,
|
||||
authState: existingRecord?.authState ?? "unknown",
|
||||
lastCheckedAt: existingRecord?.lastCheckedAt ?? null,
|
||||
|
||||
+193
-20
@@ -1,9 +1,9 @@
|
||||
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, realpathSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { appendFile, copyFile, cp, rm } from "node:fs/promises";
|
||||
import { join, resolve, sep } from "node:path";
|
||||
import { extname, join, resolve, sep } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { DatabaseSync, backup as backupDatabase, type SQLInputValue } from "node:sqlite";
|
||||
import { getConnectorDefinition, buildDefaultConnectorRows, parseEnvBindingText } from "./connectors";
|
||||
import { getConnectorDefinition, buildDefaultConnectorRows, listInvalidEnvBindingKeys, parseEnvBindingText } from "./connectors";
|
||||
import { SecretVault } from "./vault";
|
||||
import type {
|
||||
ActivityRecord,
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
InboxItem,
|
||||
ProfileSnapshot,
|
||||
ProjectRecord,
|
||||
RunLogChunk,
|
||||
RunRecord,
|
||||
RunStatus,
|
||||
SecretRecord,
|
||||
@@ -34,6 +35,7 @@ import type {
|
||||
|
||||
type SqliteDatabase = DatabaseSync;
|
||||
type SqliteRow = Record<string, any>;
|
||||
const BOARD_READER_ID = "__board__";
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
@@ -66,6 +68,14 @@ function isWithinOrEqual(targetPath: string, rootPath: string) {
|
||||
return resolvedTarget === resolvedRoot || resolvedTarget.startsWith(`${resolvedRoot}${sep}`);
|
||||
}
|
||||
|
||||
function safeRealpath(targetPath: string) {
|
||||
try {
|
||||
return realpathSync(targetPath);
|
||||
} catch {
|
||||
return resolve(targetPath);
|
||||
}
|
||||
}
|
||||
|
||||
function taskPriorityOrderSql(columnName: string) {
|
||||
return `case ${columnName}
|
||||
when 'critical' then 4
|
||||
@@ -387,17 +397,31 @@ export class AppDatabase {
|
||||
}
|
||||
|
||||
const managedRoots = [this.profileDir, this.logsDir, this.artifactsDir, this.backupsDir];
|
||||
if (managedRoots.some((rootPath) => isWithinOrEqual(targetPath, rootPath))) {
|
||||
const sensitiveProfileExtensions = new Set([".sqlite", ".db", ".key"]);
|
||||
const forbiddenExecutableExtensions = new Set([".app", ".exe", ".bat", ".cmd", ".sh", ".command", ".desktop", ".msi", ".com", ".scr"]);
|
||||
const resolvedTarget = safeRealpath(targetPath);
|
||||
const targetStats = lstatSync(targetPath);
|
||||
const targetExtension = extname(resolvedTarget).toLowerCase();
|
||||
if (
|
||||
managedRoots.some((rootPath) => isWithinOrEqual(resolvedTarget, rootPath)) &&
|
||||
sensitiveProfileExtensions.has(targetExtension)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (!targetStats.isDirectory() && forbiddenExecutableExtensions.has(targetExtension)) {
|
||||
return false;
|
||||
}
|
||||
if (managedRoots.some((rootPath) => isWithinOrEqual(resolvedTarget, safeRealpath(rootPath)))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const workspaces = this.readAll<{ localPath: string }>("select local_path as localPath from workspaces");
|
||||
if (workspaces.some((workspace) => isWithinOrEqual(targetPath, workspace.localPath))) {
|
||||
if (workspaces.some((workspace) => isWithinOrEqual(resolvedTarget, safeRealpath(workspace.localPath)))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const runLogs = this.readAll<{ logPath: string | null }>("select log_path as logPath from runs where log_path is not null");
|
||||
return runLogs.some((run) => run.logPath ? resolve(run.logPath) === resolve(targetPath) : false);
|
||||
return runLogs.some((run) => run.logPath ? safeRealpath(run.logPath) === resolvedTarget : false);
|
||||
}
|
||||
|
||||
updateSettings(input: { theme?: "system" | "light" | "dark"; updatesEnabled?: boolean; logRetentionDays?: number }) {
|
||||
@@ -445,6 +469,15 @@ export class AppDatabase {
|
||||
listSnapshot(): ProfileSnapshot {
|
||||
const connectors = this.readConnectors();
|
||||
const theme = (this.getSetting("theme") as "system" | "light" | "dark" | null) ?? "system";
|
||||
const boardReadReceiptMap = new Map(
|
||||
this.readAll<{ messageId: string; readAt: string }>(
|
||||
`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,
|
||||
).map((row) => [row.messageId, row.readAt]),
|
||||
);
|
||||
return {
|
||||
currentCompanyId: this.getCurrentCompanyId(),
|
||||
profilePath: this.profileDir,
|
||||
@@ -554,7 +587,7 @@ export class AppDatabase {
|
||||
).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,
|
||||
subject: r.subject, body: r.body, priority: r.priority, readAt: r.read_at,
|
||||
subject: r.subject, body: r.body, priority: r.priority, readAt: boardReadReceiptMap.get(r.id) ?? null,
|
||||
parentMessageId: r.parent_message_id, attachmentsJson: r.attachments_json,
|
||||
createdAt: r.created_at,
|
||||
})),
|
||||
@@ -926,9 +959,13 @@ export class AppDatabase {
|
||||
}
|
||||
|
||||
resolveSecretEnv(bindingText: string, companyId: string): Record<string, string> {
|
||||
const invalidKeys = new Set(listInvalidEnvBindingKeys(bindingText));
|
||||
const bindings = parseEnvBindingText(bindingText);
|
||||
const result: Record<string, string> = {};
|
||||
for (const [envKey, secretName] of Object.entries(bindings)) {
|
||||
if (invalidKeys.has(envKey)) {
|
||||
continue;
|
||||
}
|
||||
const row = this.db.prepare("select backend, ciphertext from secrets where company_id = ? and name = ?").get(companyId, secretName) as
|
||||
| { backend: "safe_storage" | "vault"; ciphertext: Uint8Array }
|
||||
| undefined;
|
||||
@@ -1006,6 +1043,13 @@ export class AppDatabase {
|
||||
return row ? { ...row, isPrimary: toBoolean(row.isPrimary) } : null;
|
||||
}
|
||||
|
||||
getRunAuthState(runId: string): { id: string; companyId: string; agentId: string; status: RunRecord["status"] } | null {
|
||||
const row = this.db
|
||||
.prepare("select id, company_id as companyId, agent_id as agentId, status from runs where id = ?")
|
||||
.get(runId) as { id: string; companyId: string; agentId: string; status: RunRecord["status"] } | undefined;
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
createRun(input: {
|
||||
companyId: string;
|
||||
taskId: string;
|
||||
@@ -1044,14 +1088,69 @@ export class AppDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
readRunLogChunk(runId: string, offset = 0, limit = 65_536): RunLogChunk {
|
||||
const run = this.db.prepare("select log_path as logPath from runs where id = ?").get(runId) as { logPath: string | null } | undefined;
|
||||
if (!run?.logPath || !existsSync(run.logPath)) {
|
||||
return {
|
||||
content: "",
|
||||
offset: 0,
|
||||
nextOffset: 0,
|
||||
totalBytes: 0,
|
||||
eof: true,
|
||||
};
|
||||
}
|
||||
|
||||
const totalBytes = statSync(run.logPath).size;
|
||||
const safeOffset = Math.max(0, Math.min(offset, totalBytes));
|
||||
const safeLimit = Math.max(1_024, Math.min(limit, 262_144));
|
||||
const bytesToRead = Math.max(0, Math.min(safeLimit, totalBytes - safeOffset));
|
||||
|
||||
if (bytesToRead === 0) {
|
||||
return {
|
||||
content: "",
|
||||
offset: safeOffset,
|
||||
nextOffset: safeOffset,
|
||||
totalBytes,
|
||||
eof: true,
|
||||
};
|
||||
}
|
||||
|
||||
const fileDescriptor = openSync(run.logPath, "r");
|
||||
try {
|
||||
const buffer = Buffer.allocUnsafe(bytesToRead);
|
||||
const bytesRead = readSync(fileDescriptor, buffer, 0, bytesToRead, safeOffset);
|
||||
const nextOffset = safeOffset + bytesRead;
|
||||
return {
|
||||
content: buffer.subarray(0, bytesRead).toString("utf8"),
|
||||
offset: safeOffset,
|
||||
nextOffset,
|
||||
totalBytes,
|
||||
eof: nextOffset >= totalBytes,
|
||||
};
|
||||
} finally {
|
||||
closeSync(fileDescriptor);
|
||||
}
|
||||
}
|
||||
|
||||
markRunStatus(runId: string, status: RunRecord["status"], message: string) {
|
||||
const ts = nowIso();
|
||||
const startedAt = status === "running" ? ts : null;
|
||||
this.db
|
||||
.prepare(
|
||||
"update runs set status = ?, summary = case when ? != '' then ? else summary end, started_at = coalesce(started_at, ?), updated_at = ? where id = ?",
|
||||
)
|
||||
.run(status, message, message, startedAt, ts, runId);
|
||||
this.transaction(() => {
|
||||
this.db
|
||||
.prepare(
|
||||
"update runs set status = ?, summary = case when ? != '' then ? else summary end, started_at = coalesce(started_at, ?), updated_at = ? where id = ?",
|
||||
)
|
||||
.run(status, message, message, startedAt, ts, runId);
|
||||
|
||||
if (status === "running") {
|
||||
const run = this.db.prepare("select agent_id from runs where id = ?").get(runId) as { agent_id: string } | undefined;
|
||||
if (run) {
|
||||
this.db
|
||||
.prepare("update agents set status = 'running', updated_at = ? where id = ? and status in ('idle', 'active')")
|
||||
.run(ts, run.agent_id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
finishRun(input: {
|
||||
@@ -1125,6 +1224,11 @@ export class AppDatabase {
|
||||
this.db
|
||||
.prepare("update agents set status = 'paused', updated_at = ? where id = ?")
|
||||
.run(ts, run.agentId);
|
||||
} else {
|
||||
// Reset agent status from "running" back to "idle" unless paused/terminated/pending_approval
|
||||
this.db
|
||||
.prepare("update agents set status = 'idle', updated_at = ? where id = ? and status = 'running'")
|
||||
.run(ts, run.agentId);
|
||||
}
|
||||
});
|
||||
return { budgetHardStopped };
|
||||
@@ -1192,7 +1296,7 @@ export class AppDatabase {
|
||||
this.db.prepare("delete from agents where company_id = ?").run(companyId);
|
||||
this.db.prepare("delete from secrets where company_id = ?").run(companyId);
|
||||
this.db.prepare("delete from workspaces where company_id = ?").run(companyId);
|
||||
for (const table of ["automation_rules", "automation_log", "agent_messages", "workflow_pipelines", "meetings", "documents", "knowledge_base", "sprints"]) {
|
||||
for (const table of ["automation_rules", "automation_log", "agent_messages", "workflow_pipelines", "meetings", "documents", "knowledge_base", "sprints", "browser_actions", "social_accounts"]) {
|
||||
try {
|
||||
this.db.prepare(`delete from ${table} where company_id = ?`).run(companyId);
|
||||
} catch (err) {
|
||||
@@ -1326,7 +1430,10 @@ export class AppDatabase {
|
||||
}
|
||||
|
||||
deleteSocialAccount(id: string, companyId: string) {
|
||||
this.db.prepare("DELETE FROM social_accounts WHERE id = ? AND company_id = ?").run(id, companyId);
|
||||
this.transaction(() => {
|
||||
this.db.prepare("DELETE FROM social_accounts WHERE id = ? AND company_id = ?").run(id, companyId);
|
||||
this.db.prepare("DELETE FROM browser_actions WHERE social_account_id = ? AND company_id = ?").run(id, companyId);
|
||||
});
|
||||
}
|
||||
|
||||
listSocialAccounts(companyId: string): SocialAccountRecord[] {
|
||||
@@ -1502,7 +1609,18 @@ export class AppDatabase {
|
||||
const failedRuns = (this.db.prepare("select count(*) as c from runs where company_id = ? and status in ('failed', 'timed_out', 'interrupted')").get(companyId) as { c: number })?.c ?? 0;
|
||||
const todoTasks = (this.db.prepare("select count(*) as c from tasks where company_id = ? and status in ('todo', 'in_progress', 'in_review')").get(companyId) as { c: number })?.c ?? 0;
|
||||
const budgetWarnings = (this.db.prepare("select count(*) as c from agents where company_id = ? and budget_monthly_usd > 0 and (spent_monthly_usd * 1.0 / budget_monthly_usd) >= 0.8").get(companyId) as { c: number })?.c ?? 0;
|
||||
const unreadMessages = (this.db.prepare("select count(*) as c from agent_messages where company_id = ? and read_at is null and (channel = 'company' or channel = 'incident' or priority = 'urgent')").get(companyId) as { c: number })?.c ?? 0;
|
||||
const unreadMessages = (
|
||||
this.db.prepare(
|
||||
`select count(*) as c
|
||||
from agent_messages messages
|
||||
left join agent_message_reads reads
|
||||
on reads.message_id = messages.id
|
||||
and reads.agent_id = ?
|
||||
where messages.company_id = ?
|
||||
and reads.read_at is null
|
||||
and (messages.channel = 'company' or messages.channel = 'incident' or messages.priority = 'urgent')`,
|
||||
).get(BOARD_READER_ID, companyId) as { c: number }
|
||||
)?.c ?? 0;
|
||||
return { pendingApprovals, activeRuns, failedRuns, todoTasks, budgetWarnings, unreadMessages };
|
||||
}
|
||||
|
||||
@@ -1674,6 +1792,7 @@ export class AppDatabase {
|
||||
cp(this.logsDir, join(targetDir, "logs"), { recursive: true, force: true }),
|
||||
cp(this.artifactsDir, join(targetDir, "artifacts"), { recursive: true, force: true }),
|
||||
copyIfExists(join(this.profileDir, "vault.key"), join(targetDir, "vault.key")),
|
||||
copyIfExists(join(this.profileDir, "agent-api-signing.key"), join(targetDir, "agent-api-signing.key")),
|
||||
copyIfExists(`${this.dbPath}-wal`, join(targetDir, "profile.sqlite-wal")),
|
||||
copyIfExists(`${this.dbPath}-shm`, join(targetDir, "profile.sqlite-shm")),
|
||||
];
|
||||
@@ -1778,10 +1897,11 @@ export class AppDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
private purgeExpiredPendingWakes() {
|
||||
purgeExpiredPendingWakes() {
|
||||
// Remove pending wakes older than 2 hours — if agent hasn't woken by then, the wake is stale
|
||||
const cutoff = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
|
||||
this.db.prepare("delete from pending_wakes where created_at < ?").run(cutoff);
|
||||
const result = this.db.prepare("delete from pending_wakes where created_at < ?").run(cutoff);
|
||||
return Number(result.changes);
|
||||
}
|
||||
|
||||
private interruptStaleRuns() {
|
||||
@@ -1789,10 +1909,14 @@ export class AppDatabase {
|
||||
this.transaction(() => {
|
||||
this.db
|
||||
.prepare(
|
||||
"update runs set status = 'interrupted', error_message = 'AgentCompany restarted before the run could report completion.', finished_at = ?, updated_at = ? where status in ('queued', 'running')",
|
||||
"update runs set status = 'interrupted', error_message = 'AgentCompany restarted before the run could report completion.', finished_at = ?, updated_at = ? where status = 'running'",
|
||||
)
|
||||
.run(ts, ts);
|
||||
this.db.prepare("update tasks set active_run_id = null where active_run_id is not null").run();
|
||||
this.db.prepare("update tasks set active_run_id = null where active_run_id in (select id from runs where status = 'interrupted')").run();
|
||||
// Reset any agents stuck in "running" status back to "idle"
|
||||
this.db
|
||||
.prepare("update agents set status = 'idle', updated_at = ? where status = 'running'")
|
||||
.run(ts);
|
||||
this.releaseAllWorkspaceLocks();
|
||||
});
|
||||
}
|
||||
@@ -2032,6 +2156,19 @@ export class AppDatabase {
|
||||
return Number(result.changes);
|
||||
}
|
||||
|
||||
listQueuedRuns() {
|
||||
return 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
|
||||
where status = 'queued'
|
||||
order by created_at asc`,
|
||||
).map((record) => ({ ...record, unattributedCost: toBoolean(record.unattributedCost) })) as RunRecord[];
|
||||
}
|
||||
|
||||
isWorkspaceLocked(workspaceId: string): boolean {
|
||||
const row = this.db
|
||||
.prepare("select lock_holder_run_id from workspaces where id = ?")
|
||||
@@ -2162,6 +2299,15 @@ export class AppDatabase {
|
||||
this.db.prepare("delete from automation_rules where id = ? and company_id = ?").run(id, companyId);
|
||||
}
|
||||
|
||||
listAutomationRuleNames(companyId: string): Set<string> {
|
||||
return new Set(
|
||||
this.readAll<{ name: string }>(
|
||||
"select name from automation_rules where company_id = ?",
|
||||
companyId,
|
||||
).map((row) => row.name),
|
||||
);
|
||||
}
|
||||
|
||||
toggleAutomationRule(id: string, companyId: string, status: "active" | "paused") {
|
||||
this.db.prepare("update automation_rules set status = ?, updated_at = ? where id = ? and company_id = ?").run(status, nowIso(), id, companyId);
|
||||
}
|
||||
@@ -2213,6 +2359,22 @@ export class AppDatabase {
|
||||
sql += " order by created_at asc";
|
||||
|
||||
let messages = this.readAll<AgentMessageRecord>(sql, ...params);
|
||||
if (!opts?.agentId) {
|
||||
const boardReadReceiptMap = new Map(
|
||||
this.readAll<{ messageId: string; readAt: string }>(
|
||||
`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 = ? and messages.company_id = ?`,
|
||||
BOARD_READER_ID,
|
||||
companyId,
|
||||
).map((row) => [row.messageId, row.readAt]),
|
||||
);
|
||||
messages = messages.map((message) => ({
|
||||
...message,
|
||||
readAt: boardReadReceiptMap.get(message.id) ?? null,
|
||||
}));
|
||||
}
|
||||
if (opts?.agentId) {
|
||||
let department: string | null = null;
|
||||
try {
|
||||
@@ -2286,7 +2448,11 @@ export class AppDatabase {
|
||||
}
|
||||
|
||||
markMessageRead(messageId: string, companyId: string) {
|
||||
this.db.prepare("update agent_messages set read_at = ? where id = ? and company_id = ?").run(nowIso(), messageId, companyId);
|
||||
this.markAgentMessageRead(messageId, companyId, BOARD_READER_ID);
|
||||
}
|
||||
|
||||
markMessageReadForReader(messageId: string, companyId: string, readerAgentId?: string | null) {
|
||||
this.markAgentMessageRead(messageId, companyId, readerAgentId ?? BOARD_READER_ID);
|
||||
}
|
||||
|
||||
markAgentMessageRead(messageId: string, companyId: string, agentId: string) {
|
||||
@@ -2856,6 +3022,13 @@ export class AppDatabase {
|
||||
await cp(join(sourceDir, "artifacts"), this.artifactsDir, { recursive: true, force: true });
|
||||
}
|
||||
await copyIfExists(join(sourceDir, "vault.key"), join(this.profileDir, "vault.key"));
|
||||
await copyIfExists(join(sourceDir, "agent-api-signing.key"), join(this.profileDir, "agent-api-signing.key"));
|
||||
if (existsSync(join(this.profileDir, "vault.key"))) {
|
||||
chmodSync(join(this.profileDir, "vault.key"), 0o600);
|
||||
}
|
||||
if (existsSync(join(this.profileDir, "agent-api-signing.key"))) {
|
||||
chmodSync(join(this.profileDir, "agent-api-signing.key"), 0o600);
|
||||
}
|
||||
|
||||
this.db = this.openConnection();
|
||||
this.init();
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
export const DEFAULT_AUTOMATION_RULES: ReadonlyArray<{
|
||||
name: string;
|
||||
description: string;
|
||||
trigger: string;
|
||||
action: string;
|
||||
conditionsJson?: string;
|
||||
actionConfigJson?: string;
|
||||
priority?: number;
|
||||
}> = [
|
||||
{
|
||||
name: "Auto-assign new tasks",
|
||||
description: "Automatically assign new unassigned tasks to the best-fit agent based on capabilities and workload.",
|
||||
trigger: "task_created",
|
||||
action: "assign_task",
|
||||
priority: 90,
|
||||
},
|
||||
{
|
||||
name: "Escalate blocked tasks",
|
||||
description: "When a task is blocked, escalate to the assignee's manager.",
|
||||
trigger: "task_blocked",
|
||||
action: "escalate_to_manager",
|
||||
actionConfigJson: JSON.stringify({ body: "A task assigned to your report is blocked and needs your attention." }),
|
||||
priority: 85,
|
||||
},
|
||||
{
|
||||
name: "Escalate completed work to manager",
|
||||
description: "When a run completes successfully, escalate to the assignee's manager so they review the work.",
|
||||
trigger: "run_completed",
|
||||
action: "escalate_to_manager",
|
||||
actionConfigJson: JSON.stringify({ body: "A task run has completed successfully. Please review the work and approve or send back for changes." }),
|
||||
priority: 80,
|
||||
},
|
||||
{
|
||||
name: "Reassign on run failure",
|
||||
description: "When a run fails, attempt to reassign the task to another capable agent.",
|
||||
trigger: "run_failed",
|
||||
action: "reassign_task",
|
||||
priority: 75,
|
||||
},
|
||||
{
|
||||
name: "Budget alert at 80%",
|
||||
description: "Send an alert when an agent reaches 80% of their monthly budget.",
|
||||
trigger: "budget_threshold",
|
||||
action: "send_message",
|
||||
actionConfigJson: JSON.stringify({ subject: "Budget warning", body: "Agent is approaching budget limit (80% utilized).", priority: "urgent" }),
|
||||
priority: 70,
|
||||
},
|
||||
{
|
||||
name: "Notify department on task completion",
|
||||
description: "When a run completes successfully, send a status update to the agent's department channel.",
|
||||
trigger: "run_completed",
|
||||
action: "send_message",
|
||||
actionConfigJson: JSON.stringify({ channel: "department", subject: "Task completed", body: "A task has been completed successfully. Check the task details for more information.", priority: "low" }),
|
||||
priority: 40,
|
||||
},
|
||||
{
|
||||
name: "Wake new hire on approval",
|
||||
description: "When a new agent hire is approved, immediately wake the new agent to start onboarding.",
|
||||
trigger: "hire_approved",
|
||||
action: "trigger_heartbeat",
|
||||
actionConfigJson: JSON.stringify({}),
|
||||
priority: 90,
|
||||
},
|
||||
{
|
||||
name: "Auto-run assigned tasks",
|
||||
description: "When a task transitions to 'todo' status, automatically wake the assigned agent to start working on it.",
|
||||
trigger: "task_status_changed",
|
||||
conditionsJson: JSON.stringify({ status: "todo" }),
|
||||
action: "run_task",
|
||||
actionConfigJson: JSON.stringify({}),
|
||||
priority: 65,
|
||||
},
|
||||
{
|
||||
name: "Notify company on sprint start",
|
||||
description: "When a sprint starts, notify the entire company.",
|
||||
trigger: "sprint_started",
|
||||
action: "send_message",
|
||||
actionConfigJson: JSON.stringify({ channel: "company", subject: "Sprint started", body: "A new sprint has begun. Check your assigned tasks and start execution.", priority: "normal" }),
|
||||
priority: 50,
|
||||
},
|
||||
{
|
||||
name: "Schedule retrospective on sprint end",
|
||||
description: "When a sprint ends, schedule a retrospective meeting.",
|
||||
trigger: "sprint_ended",
|
||||
action: "schedule_meeting",
|
||||
actionConfigJson: JSON.stringify({ meetingType: "retrospective", title: "Sprint Retrospective", durationMinutes: 45 }),
|
||||
priority: 55,
|
||||
},
|
||||
{
|
||||
name: "Create status report on schedule",
|
||||
description: "Periodically create a status report document for the company.",
|
||||
trigger: "schedule",
|
||||
action: "create_document",
|
||||
actionConfigJson: JSON.stringify({ documentType: "status_report", title: "Automated Status Report" }),
|
||||
priority: 30,
|
||||
},
|
||||
{
|
||||
name: "Auto-assign work to idle agents",
|
||||
description: "When an agent has been idle for 10+ minutes, automatically assign available unassigned tasks from the backlog.",
|
||||
trigger: "agent_idle",
|
||||
action: "assign_task",
|
||||
priority: 60,
|
||||
},
|
||||
{
|
||||
name: "Wake idle agents to check for work",
|
||||
description: "When an agent has no tasks and is idle, wake them so they can check for available work and coordinate with their team.",
|
||||
trigger: "agent_idle",
|
||||
action: "trigger_heartbeat",
|
||||
priority: 45,
|
||||
},
|
||||
{
|
||||
name: "Wake manager when task moves to review",
|
||||
description: "When a task status changes to in_review, wake the assignee's manager to review the work.",
|
||||
trigger: "task_status_changed",
|
||||
conditionsJson: JSON.stringify({ status: "in_review" }),
|
||||
action: "escalate_to_manager",
|
||||
actionConfigJson: JSON.stringify({ body: "A task from your report has been submitted for review. Please review the work and either approve (mark as done) or send back with feedback." }),
|
||||
priority: 82,
|
||||
},
|
||||
{
|
||||
name: "Wake assigned agent on task feedback",
|
||||
description: "When a reviewed task is sent back to in_progress with feedback, wake the assigned agent to address the feedback.",
|
||||
trigger: "task_status_changed",
|
||||
conditionsJson: JSON.stringify({ status: "in_progress", previousStatus: "in_review" }),
|
||||
action: "trigger_heartbeat",
|
||||
actionConfigJson: JSON.stringify({}),
|
||||
priority: 78,
|
||||
},
|
||||
];
|
||||
|
||||
export function seedDefaultAutomationRules(
|
||||
companyId: string,
|
||||
deps: {
|
||||
listAutomationRuleNames: (companyId: string) => Set<string>;
|
||||
saveAutomationRule: (input: {
|
||||
companyId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
trigger: string;
|
||||
conditionsJson?: string;
|
||||
action: string;
|
||||
actionConfigJson?: string;
|
||||
priority?: number;
|
||||
status?: string;
|
||||
}) => string;
|
||||
},
|
||||
) {
|
||||
const existingRuleNames = deps.listAutomationRuleNames(companyId);
|
||||
for (const rule of DEFAULT_AUTOMATION_RULES) {
|
||||
if (existingRuleNames.has(rule.name)) {
|
||||
continue;
|
||||
}
|
||||
deps.saveAutomationRule({
|
||||
companyId,
|
||||
name: rule.name,
|
||||
description: rule.description,
|
||||
trigger: rule.trigger,
|
||||
conditionsJson: rule.conditionsJson ?? "{}",
|
||||
action: rule.action,
|
||||
actionConfigJson: rule.actionConfigJson ?? "{}",
|
||||
priority: rule.priority ?? 50,
|
||||
status: "active",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { resolve, sep } from "node:path";
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
dialog,
|
||||
Menu,
|
||||
nativeImage,
|
||||
net,
|
||||
protocol,
|
||||
session,
|
||||
shell,
|
||||
Tray,
|
||||
} from "electron";
|
||||
import { attachWindowStatePersistence, loadWindowState } from "./window-state";
|
||||
|
||||
interface DesktopShellDependencies {
|
||||
preloadPath: string;
|
||||
rendererDist: string;
|
||||
iconPath: string;
|
||||
isDev: boolean;
|
||||
devServerUrl?: string;
|
||||
windowStatePath: string;
|
||||
shouldQuit: () => boolean;
|
||||
onRequestQuit: () => void;
|
||||
onCheckForUpdates: () => Promise<void>;
|
||||
onBackupProfile: () => Promise<void>;
|
||||
onShortcut: (action: "toggle-command-palette" | "toggle-console" | "refresh") => void;
|
||||
notify: (title: string, body: string) => void;
|
||||
}
|
||||
|
||||
function isSocialBrowserSession(contents: Electron.WebContents) {
|
||||
return contents.session !== session.defaultSession;
|
||||
}
|
||||
|
||||
function isWithinDirectory(rootPath: string, targetPath: string) {
|
||||
const normalizedRoot = resolve(rootPath);
|
||||
const normalizedTarget = resolve(targetPath);
|
||||
return normalizedTarget === normalizedRoot || normalizedTarget.startsWith(`${normalizedRoot}${sep}`);
|
||||
}
|
||||
|
||||
export function createDesktopShellService({
|
||||
preloadPath,
|
||||
rendererDist,
|
||||
iconPath,
|
||||
isDev,
|
||||
devServerUrl,
|
||||
windowStatePath,
|
||||
shouldQuit,
|
||||
onRequestQuit,
|
||||
onCheckForUpdates,
|
||||
onBackupProfile,
|
||||
onShortcut,
|
||||
notify,
|
||||
}: DesktopShellDependencies) {
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let appTray: Tray | null = null;
|
||||
let backgroundCloseNoticeShown = false;
|
||||
let windowCreationPromise: Promise<BrowserWindow | null> | null = null;
|
||||
|
||||
function getMainWindow() {
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
function installSecurityGuards() {
|
||||
session.defaultSession.setPermissionCheckHandler(() => false);
|
||||
session.defaultSession.setPermissionRequestHandler((_webContents, _permission, callback) => {
|
||||
callback(false);
|
||||
});
|
||||
app.on("web-contents-created", (_event, contents) => {
|
||||
contents.setWindowOpenHandler(() => ({ action: "deny" }));
|
||||
contents.on("will-attach-webview", (event) => {
|
||||
event.preventDefault();
|
||||
});
|
||||
contents.on("will-navigate", (event) => {
|
||||
if (isSocialBrowserSession(contents)) return;
|
||||
event.preventDefault();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function registerProtocol() {
|
||||
protocol.handle("app", (request) => {
|
||||
const url = new URL(request.url);
|
||||
const relativePath = decodeURIComponent(url.pathname === "/" ? "index.html" : url.pathname.replace(/^\/+/, ""));
|
||||
const targetPath = resolve(rendererDist, relativePath);
|
||||
if (!isWithinDirectory(rendererDist, targetPath) || !existsSync(targetPath)) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
return net.fetch(pathToFileURL(targetPath).toString());
|
||||
});
|
||||
}
|
||||
|
||||
async function createWindow() {
|
||||
const windowIcon = nativeImage.createFromPath(iconPath);
|
||||
const savedWindowState = loadWindowState(windowStatePath);
|
||||
mainWindow = new BrowserWindow({
|
||||
title: "AgentCompany",
|
||||
icon: windowIcon.isEmpty() ? undefined : windowIcon,
|
||||
width: savedWindowState.bounds.width,
|
||||
height: savedWindowState.bounds.height,
|
||||
x: savedWindowState.bounds.x,
|
||||
y: savedWindowState.bounds.y,
|
||||
minWidth: 1180,
|
||||
minHeight: 760,
|
||||
backgroundColor: "#13110f",
|
||||
titleBarStyle: process.platform === "darwin" ? "hiddenInset" : "default",
|
||||
webPreferences: {
|
||||
preload: preloadPath,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
webviewTag: false,
|
||||
devTools: isDev,
|
||||
webSecurity: true,
|
||||
allowRunningInsecureContent: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (isDev && devServerUrl) {
|
||||
await mainWindow.loadURL(devServerUrl);
|
||||
} else {
|
||||
await mainWindow.loadURL("app://-/index.html");
|
||||
}
|
||||
attachWindowStatePersistence(mainWindow, windowStatePath);
|
||||
if (savedWindowState.maximized) {
|
||||
mainWindow.maximize();
|
||||
}
|
||||
mainWindow.on("close", (event) => {
|
||||
if (process.platform === "darwin" || shouldQuit()) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
mainWindow?.hide();
|
||||
if (!backgroundCloseNoticeShown) {
|
||||
backgroundCloseNoticeShown = true;
|
||||
notify("AgentCompany is still running", "Automation continues in the background until you quit the app.");
|
||||
}
|
||||
});
|
||||
mainWindow.on("closed", () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureWindow() {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
if (!windowCreationPromise) {
|
||||
windowCreationPromise = createWindow()
|
||||
.then(() => mainWindow)
|
||||
.finally(() => {
|
||||
windowCreationPromise = null;
|
||||
});
|
||||
}
|
||||
return windowCreationPromise;
|
||||
}
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
function buildMenu() {
|
||||
const macAppMenu: Electron.MenuItemConstructorOptions = {
|
||||
label: app.name,
|
||||
submenu: [
|
||||
{ role: "about" },
|
||||
{ type: "separator" },
|
||||
{ role: "services" },
|
||||
{ type: "separator" },
|
||||
{ role: "hide" },
|
||||
{ role: "hideOthers" },
|
||||
{ role: "unhide" },
|
||||
{ type: "separator" },
|
||||
{ role: "quit" },
|
||||
],
|
||||
};
|
||||
const template: Electron.MenuItemConstructorOptions[] = [
|
||||
...(process.platform === "darwin" ? [macAppMenu] : []),
|
||||
{
|
||||
label: "File",
|
||||
submenu: [
|
||||
{
|
||||
label: "Open Workspace Folder",
|
||||
accelerator: "CmdOrCtrl+O",
|
||||
click: async () => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ["openDirectory"],
|
||||
});
|
||||
if (result.canceled || result.filePaths.length === 0) return;
|
||||
shell.openPath(result.filePaths[0] ?? "");
|
||||
},
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Backup Profile",
|
||||
accelerator: "CmdOrCtrl+Shift+B",
|
||||
click: async () => {
|
||||
await onBackupProfile();
|
||||
},
|
||||
},
|
||||
...(process.platform === "darwin" ? [] : [{ type: "separator" as const }, { role: "quit" as const }]),
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Edit",
|
||||
submenu: [
|
||||
{ role: "undo" },
|
||||
{ role: "redo" },
|
||||
{ type: "separator" },
|
||||
{ role: "cut" },
|
||||
{ role: "copy" },
|
||||
{ role: "paste" },
|
||||
{ role: "selectAll" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "View",
|
||||
submenu: [
|
||||
{
|
||||
label: "Command Palette",
|
||||
accelerator: "CmdOrCtrl+K",
|
||||
click: () => onShortcut("toggle-command-palette"),
|
||||
},
|
||||
{
|
||||
label: "Toggle Console",
|
||||
accelerator: "CmdOrCtrl+`",
|
||||
click: () => onShortcut("toggle-console"),
|
||||
},
|
||||
{
|
||||
label: "Refresh Data",
|
||||
accelerator: "CmdOrCtrl+R",
|
||||
click: () => onShortcut("refresh"),
|
||||
},
|
||||
{ type: "separator" },
|
||||
{ role: "toggleDevTools", visible: isDev },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Run",
|
||||
submenu: [
|
||||
{
|
||||
label: "Check For Updates",
|
||||
click: async () => {
|
||||
await onCheckForUpdates();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
|
||||
}
|
||||
|
||||
function createTray() {
|
||||
if (process.platform === "darwin" || appTray) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trayIcon = nativeImage.createFromPath(iconPath);
|
||||
appTray = new Tray(trayIcon.isEmpty() ? nativeImage.createEmpty() : trayIcon);
|
||||
appTray.setToolTip("AgentCompany");
|
||||
appTray.setContextMenu(
|
||||
Menu.buildFromTemplate([
|
||||
{
|
||||
label: "Show AgentCompany",
|
||||
click: () => {
|
||||
void ensureWindow().then((win) => {
|
||||
if (win) {
|
||||
win.show();
|
||||
win.focus();
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Hide AgentCompany",
|
||||
click: () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.hide();
|
||||
}
|
||||
},
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Quit",
|
||||
click: () => {
|
||||
onRequestQuit();
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
appTray.on("click", () => {
|
||||
void ensureWindow().then((win) => {
|
||||
if (!win) return;
|
||||
if (win.isVisible()) {
|
||||
win.hide();
|
||||
return;
|
||||
}
|
||||
win.show();
|
||||
win.focus();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
appTray?.destroy();
|
||||
appTray = null;
|
||||
}
|
||||
|
||||
function initialize() {
|
||||
installSecurityGuards();
|
||||
registerProtocol();
|
||||
buildMenu();
|
||||
createTray();
|
||||
}
|
||||
|
||||
return {
|
||||
initialize,
|
||||
installSecurityGuards,
|
||||
registerProtocol,
|
||||
buildMenu,
|
||||
createTray,
|
||||
createWindow,
|
||||
ensureWindow,
|
||||
getMainWindow,
|
||||
destroy,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { delimiter as pathDelimiter } from "node:path";
|
||||
import { app, nativeImage } from "electron";
|
||||
|
||||
interface HostEnvironmentDependencies {
|
||||
iconPath: string;
|
||||
logger: {
|
||||
info: (...args: unknown[]) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export async function prepareHostEnvironment({
|
||||
iconPath,
|
||||
logger,
|
||||
}: HostEnvironmentDependencies) {
|
||||
if (process.platform !== "win32" && process.env.AGENTCOMPANY_ENABLE_SHELL_PATH_PROBE === "true") {
|
||||
try {
|
||||
const userShell = process.env.SHELL || "/bin/zsh";
|
||||
const shellPath = await new Promise<string>((resolve) => {
|
||||
const child = spawn(userShell, ["-ilc", "echo -n $PATH"], {
|
||||
env: { ...process.env },
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
timeout: 5000,
|
||||
});
|
||||
let output = "";
|
||||
child.stdout?.on("data", (chunk: Buffer) => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
child.on("close", () => resolve(output.trim()));
|
||||
child.on("error", () => resolve(""));
|
||||
});
|
||||
if (shellPath && shellPath.length > 0) {
|
||||
const current = new Set((process.env.PATH ?? "").split(pathDelimiter).filter(Boolean));
|
||||
const shellParts = shellPath.split(pathDelimiter).filter(Boolean);
|
||||
const toAdd = shellParts.filter((entry) => !current.has(entry));
|
||||
if (toAdd.length > 0) {
|
||||
process.env.PATH = [process.env.PATH, ...toAdd].join(pathDelimiter);
|
||||
logger.info(`[path] Added ${toAdd.length} shell PATH entries.`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal environment enrichment.
|
||||
}
|
||||
} else if (process.platform !== "win32") {
|
||||
logger.info("[path] Skipping interactive shell PATH probe; using deterministic PATH setup only.");
|
||||
}
|
||||
|
||||
const appIcon = nativeImage.createFromPath(iconPath);
|
||||
if (!appIcon.isEmpty()) {
|
||||
if (process.platform === "darwin" && app.dock) {
|
||||
app.dock.setIcon(appIcon);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
app.setAppUserModelId("com.agentcompany.desktop");
|
||||
}
|
||||
}
|
||||
+19
-3555
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
IPC_CHANNELS,
|
||||
agentMessageInputSchema,
|
||||
autoAssignSchema,
|
||||
markMessageReadSchema,
|
||||
messageQuerySchema,
|
||||
} from "@shared/contracts";
|
||||
import { fail, ok, type RegisterDomainHandlersDependencies } from "./ipc-domain-common";
|
||||
|
||||
export function registerAgentCoordinationHandlers({
|
||||
registerHandle,
|
||||
db,
|
||||
ensureCompanyExists,
|
||||
findInvalidReference,
|
||||
publishDomainChanged,
|
||||
emitEvent,
|
||||
wakeAgentIfPossible,
|
||||
}: Pick<
|
||||
RegisterDomainHandlersDependencies,
|
||||
| "registerHandle"
|
||||
| "db"
|
||||
| "ensureCompanyExists"
|
||||
| "findInvalidReference"
|
||||
| "publishDomainChanged"
|
||||
| "emitEvent"
|
||||
| "wakeAgentIfPossible"
|
||||
>) {
|
||||
registerHandle(IPC_CHANNELS.sendAgentMessage, async (_event, payload) => {
|
||||
const parsed = agentMessageInputSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (parsed.channel === "direct" && !parsed.toAgentId) {
|
||||
return fail("DIRECT_MESSAGE_RECIPIENT_REQUIRED", "Direct messages require a recipient agent.");
|
||||
}
|
||||
const invalidReference = findInvalidReference(parsed.companyId, [
|
||||
{ id: parsed.fromAgentId, table: "agents", label: "message sender" },
|
||||
{ id: parsed.toAgentId, table: "agents", label: "message recipient" },
|
||||
{ id: parsed.channel === "project" ? parsed.channelTargetId : null, table: "projects", label: "project channel" },
|
||||
]);
|
||||
if (invalidReference) {
|
||||
return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`);
|
||||
}
|
||||
const id = db.sendAgentMessage(parsed);
|
||||
const message = db.getAgentMessage(id, parsed.companyId);
|
||||
if (message) {
|
||||
emitEvent({ type: "new-message", message });
|
||||
}
|
||||
publishDomainChanged();
|
||||
if (parsed.toAgentId) {
|
||||
wakeAgentIfPossible(parsed.toAgentId, parsed.companyId, "message");
|
||||
}
|
||||
if (parsed.channel === "department" && parsed.channelTargetId) {
|
||||
const departmentAgents = db.getIdleAgentsForDepartment(parsed.companyId, parsed.channelTargetId);
|
||||
for (const agent of departmentAgents.filter((entry: { id: string }) => entry.id !== parsed.fromAgentId)) {
|
||||
wakeAgentIfPossible(agent.id, parsed.companyId, "message");
|
||||
}
|
||||
}
|
||||
if (parsed.channel === "company") {
|
||||
const ceo = db.listSnapshot().agents.find((agent: { companyId: string; reportsTo: string | null; status: string }) =>
|
||||
agent.companyId === parsed.companyId && !agent.reportsTo && agent.status !== "terminated",
|
||||
);
|
||||
if (ceo && ceo.id !== parsed.fromAgentId) {
|
||||
wakeAgentIfPossible(ceo.id, parsed.companyId, "message");
|
||||
}
|
||||
}
|
||||
return ok(id);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.listAgentMessages, async (_event, payload) => {
|
||||
const parsed = messageQuerySchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
const invalidReference = findInvalidReference(parsed.companyId, [
|
||||
{ id: parsed.agentId, table: "agents", label: "message reader" },
|
||||
{ id: parsed.channel === "project" ? parsed.channelTargetId : null, table: "projects", label: "project channel" },
|
||||
]);
|
||||
if (invalidReference) {
|
||||
return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`);
|
||||
}
|
||||
return ok(db.listAgentMessages(parsed.companyId, {
|
||||
agentId: parsed.agentId,
|
||||
channel: parsed.channel,
|
||||
channelTargetId: parsed.channelTargetId,
|
||||
unreadOnly: parsed.unreadOnly,
|
||||
limit: parsed.limit,
|
||||
}));
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.markMessageRead, async (_event, payload) => {
|
||||
const parsed = markMessageReadSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.getAgentMessage(parsed.messageId, parsed.companyId)) {
|
||||
return fail("MESSAGE_NOT_FOUND", "Message not found in the current company.");
|
||||
}
|
||||
if (parsed.readerAgentId && !db.belongsToCompany("agents", parsed.readerAgentId, parsed.companyId)) {
|
||||
return fail("INVALID_COMPANY_REFERENCE", "The selected message reader does not belong to the current company.");
|
||||
}
|
||||
db.markMessageReadForReader(parsed.messageId, parsed.companyId, parsed.readerAgentId ?? null);
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.autoAssignTask, async (_event, payload) => {
|
||||
const parsed = autoAssignSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
const agentId = db.autoAssignTask(parsed.taskId, parsed.companyId);
|
||||
if (!agentId) {
|
||||
return fail("NO_AVAILABLE_AGENT", "No available agent could be found for this task.");
|
||||
}
|
||||
publishDomainChanged();
|
||||
return ok(agentId);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
IPC_CHANNELS,
|
||||
automationRuleInputSchema,
|
||||
deleteEntitySchema,
|
||||
runWorkflowSchema,
|
||||
toggleAutomationRuleSchema,
|
||||
workflowInputSchema,
|
||||
} from "@shared/contracts";
|
||||
import { fail, ok, type RegisterDomainHandlersDependencies } from "./ipc-domain-common";
|
||||
|
||||
export function registerAutomationOperationsHandlers({
|
||||
registerHandle,
|
||||
db,
|
||||
ensureCompanyExists,
|
||||
publishDomainChanged,
|
||||
runWorkflowPipeline,
|
||||
}: Pick<
|
||||
RegisterDomainHandlersDependencies,
|
||||
| "registerHandle"
|
||||
| "db"
|
||||
| "ensureCompanyExists"
|
||||
| "publishDomainChanged"
|
||||
| "runWorkflowPipeline"
|
||||
>) {
|
||||
const companyIdSchema = z.string().uuid();
|
||||
|
||||
registerHandle(IPC_CHANNELS.saveAutomationRule, async (_event, payload) => {
|
||||
const parsed = automationRuleInputSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
const id = db.saveAutomationRule(parsed);
|
||||
publishDomainChanged();
|
||||
return ok(id);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.deleteAutomationRule, async (_event, payload) => {
|
||||
const parsed = deleteEntitySchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.belongsToCompany("automation_rules", parsed.id, parsed.companyId)) {
|
||||
return fail("ENTITY_NOT_FOUND", "Automation rule not found in the current company.");
|
||||
}
|
||||
db.deleteAutomationRule(parsed.id, parsed.companyId);
|
||||
publishDomainChanged();
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.toggleAutomationRule, async (_event, payload) => {
|
||||
const parsed = toggleAutomationRuleSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.belongsToCompany("automation_rules", parsed.id, parsed.companyId)) {
|
||||
return fail("ENTITY_NOT_FOUND", "Automation rule not found in the current company.");
|
||||
}
|
||||
db.toggleAutomationRule(parsed.id, parsed.companyId, parsed.status);
|
||||
publishDomainChanged();
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.getAutomationLog, async (_event, companyId) => {
|
||||
const parsed = companyIdSchema.parse(companyId);
|
||||
const companyError = ensureCompanyExists(parsed);
|
||||
if (companyError) return companyError;
|
||||
return ok(db.getAutomationLog(parsed));
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.saveWorkflow, async (_event, payload) => {
|
||||
const parsed = workflowInputSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
const id = db.saveWorkflow(parsed);
|
||||
publishDomainChanged();
|
||||
return ok(id);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.deleteWorkflow, async (_event, payload) => {
|
||||
const parsed = deleteEntitySchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.belongsToCompany("workflow_pipelines", parsed.id, parsed.companyId)) {
|
||||
return fail("ENTITY_NOT_FOUND", "Workflow not found in the current company.");
|
||||
}
|
||||
db.deleteWorkflow(parsed.id, parsed.companyId);
|
||||
publishDomainChanged();
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.runWorkflow, async (_event, payload) => {
|
||||
const parsed = runWorkflowSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.belongsToCompany("workflow_pipelines", parsed.id, parsed.companyId)) {
|
||||
return fail("ENTITY_NOT_FOUND", "Workflow not found in the current company.");
|
||||
}
|
||||
await runWorkflowPipeline(parsed.id, parsed.companyId);
|
||||
publishDomainChanged();
|
||||
return ok(true);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
IPC_CHANNELS,
|
||||
browserActionQuerySchema,
|
||||
cancelBrowserActionSchema,
|
||||
deleteEntitySchema,
|
||||
socialAccountInputSchema,
|
||||
triggerBrowserLoginSchema,
|
||||
} from "@shared/contracts";
|
||||
import { fail, ok, type RegisterDomainHandlersDependencies } from "./ipc-domain-common";
|
||||
|
||||
export function registerBrowserHandlers({
|
||||
registerHandle,
|
||||
db,
|
||||
logger,
|
||||
ensureCompanyExists,
|
||||
publishDomainChanged,
|
||||
getBrowserActions,
|
||||
}: Pick<
|
||||
RegisterDomainHandlersDependencies,
|
||||
| "registerHandle"
|
||||
| "db"
|
||||
| "logger"
|
||||
| "ensureCompanyExists"
|
||||
| "publishDomainChanged"
|
||||
| "getBrowserActions"
|
||||
>) {
|
||||
registerHandle(IPC_CHANNELS.saveSocialAccount, (_event, payload) => {
|
||||
const input = socialAccountInputSchema.parse(payload);
|
||||
const err = ensureCompanyExists(input.companyId);
|
||||
if (err) return err;
|
||||
const id = db.saveSocialAccount(input);
|
||||
publishDomainChanged();
|
||||
return ok(id);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.deleteSocialAccount, async (_event, payload) => {
|
||||
const input = deleteEntitySchema.parse(payload);
|
||||
const err = ensureCompanyExists(input.companyId);
|
||||
if (err) return err;
|
||||
const browserActions = db.listBrowserActions(input.companyId, { socialAccountId: input.id, limit: 500 });
|
||||
await getBrowserActions()?.clearSocialAccountData(
|
||||
input.id,
|
||||
browserActions.flatMap((action: { screenshotPath: string | null }) => (action.screenshotPath ? [action.screenshotPath] : [])),
|
||||
);
|
||||
db.deleteSocialAccount(input.id, input.companyId);
|
||||
publishDomainChanged();
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.listBrowserActions, (_event, payload) => {
|
||||
const input = browserActionQuerySchema.parse(payload);
|
||||
const err = ensureCompanyExists(input.companyId);
|
||||
if (err) return err;
|
||||
return ok(db.listBrowserActions(input.companyId, {
|
||||
socialAccountId: input.socialAccountId,
|
||||
status: input.status,
|
||||
limit: input.limit,
|
||||
}));
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.cancelBrowserAction, (_event, payload) => {
|
||||
const input = cancelBrowserActionSchema.parse(payload);
|
||||
const action = db.getBrowserAction(input.actionId, input.companyId);
|
||||
if (!action) {
|
||||
return fail("BROWSER_ACTION_NOT_FOUND", "Browser action not found.");
|
||||
}
|
||||
getBrowserActions()?.cancelAction(input.actionId);
|
||||
db.updateBrowserAction(input.actionId, {
|
||||
status: "cancelled",
|
||||
resultSummary: "Browser action cancelled from the desktop control surface.",
|
||||
errorMessage: "Action cancelled.",
|
||||
finishedAt: new Date().toISOString(),
|
||||
});
|
||||
publishDomainChanged();
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.triggerBrowserLogin, async (_event, payload) => {
|
||||
const input = triggerBrowserLoginSchema.parse(payload);
|
||||
const err = ensureCompanyExists(input.companyId);
|
||||
if (err) return err;
|
||||
const browserActions = getBrowserActions();
|
||||
if (!browserActions) {
|
||||
return fail("BROWSER_NOT_READY", "Browser manager not initialized");
|
||||
}
|
||||
|
||||
const account = db.getSocialAccount(input.socialAccountId, input.companyId);
|
||||
if (!account) {
|
||||
return fail("ACCOUNT_NOT_FOUND", "Social account not found");
|
||||
}
|
||||
|
||||
browserActions.launchLoginBrowser(input.socialAccountId, account.platform).then((result) => {
|
||||
if (result.success) {
|
||||
const latestAccount = db.getSocialAccount(input.socialAccountId, account.companyId);
|
||||
if (!latestAccount) {
|
||||
return;
|
||||
}
|
||||
db.saveSocialAccount({
|
||||
id: latestAccount.id,
|
||||
companyId: latestAccount.companyId,
|
||||
platform: latestAccount.platform,
|
||||
accountName: latestAccount.accountName,
|
||||
displayName: latestAccount.displayName,
|
||||
profileUrl: latestAccount.profileUrl,
|
||||
credentialSecretId: latestAccount.credentialSecretId,
|
||||
status: "logged_in",
|
||||
requireApproval: latestAccount.requireApproval,
|
||||
});
|
||||
publishDomainChanged();
|
||||
}
|
||||
}).catch((error) => {
|
||||
logger.error(`[browser] Login failed: ${error}`);
|
||||
});
|
||||
|
||||
return ok(true);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import {
|
||||
IPC_CHANNELS,
|
||||
deleteEntitySchema,
|
||||
documentInputSchema,
|
||||
knowledgeEntryInputSchema,
|
||||
meetingInputSchema,
|
||||
sprintInputSchema,
|
||||
} from "@shared/contracts";
|
||||
import { fail, ok, type RegisterDomainHandlersDependencies } from "./ipc-domain-common";
|
||||
|
||||
const COMPANY_SCOPED_REFERENCE_TABLES = new Set([
|
||||
"agents",
|
||||
"goals",
|
||||
"projects",
|
||||
"tasks",
|
||||
"approvals",
|
||||
"runs",
|
||||
"secrets",
|
||||
"meetings",
|
||||
"documents",
|
||||
"knowledge_base",
|
||||
"sprints",
|
||||
"automation_rules",
|
||||
"workflow_pipelines",
|
||||
]);
|
||||
|
||||
export function registerContentOperationsHandlers({
|
||||
registerHandle,
|
||||
db,
|
||||
ensureCompanyExists,
|
||||
findInvalidReference,
|
||||
publishDomainChanged,
|
||||
wakeAgentIfPossible,
|
||||
dispatchAutomation,
|
||||
}: Pick<
|
||||
RegisterDomainHandlersDependencies,
|
||||
| "registerHandle"
|
||||
| "db"
|
||||
| "ensureCompanyExists"
|
||||
| "findInvalidReference"
|
||||
| "publishDomainChanged"
|
||||
| "wakeAgentIfPossible"
|
||||
| "dispatchAutomation"
|
||||
>) {
|
||||
registerHandle(IPC_CHANNELS.saveMeeting, async (_event, payload) => {
|
||||
const parsed = meetingInputSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
let participantAgentIds: string[] = [];
|
||||
try {
|
||||
const parsedParticipantIds = JSON.parse(parsed.participantAgentIds);
|
||||
participantAgentIds = Array.isArray(parsedParticipantIds)
|
||||
? parsedParticipantIds.filter((value): value is string => typeof value === "string")
|
||||
: [];
|
||||
} catch {
|
||||
return fail("INVALID_PARTICIPANTS", "Meeting participants must be valid JSON.");
|
||||
}
|
||||
const invalidReference = findInvalidReference(parsed.companyId, [
|
||||
{ id: parsed.organizerAgentId, table: "agents", label: "meeting organizer" },
|
||||
...participantAgentIds.map((agentId) => ({ id: agentId, table: "agents", label: "meeting participant" })),
|
||||
]);
|
||||
if (invalidReference) {
|
||||
return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`);
|
||||
}
|
||||
const id = db.saveMeeting(parsed);
|
||||
publishDomainChanged();
|
||||
return ok(id);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.deleteMeeting, async (_event, payload) => {
|
||||
const parsed = deleteEntitySchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.belongsToCompany("meetings", parsed.id, parsed.companyId)) {
|
||||
return fail("ENTITY_NOT_FOUND", "Meeting not found in the current company.");
|
||||
}
|
||||
db.deleteMeeting(parsed.id, parsed.companyId);
|
||||
publishDomainChanged();
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.saveDocument, async (_event, payload) => {
|
||||
const parsed = documentInputSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
const invalidReference = findInvalidReference(parsed.companyId, [
|
||||
{ id: parsed.authorAgentId, table: "agents", label: "document author" },
|
||||
{ id: parsed.reviewerAgentId, table: "agents", label: "document reviewer" },
|
||||
{ id: parsed.projectId, table: "projects", label: "document project" },
|
||||
{ id: parsed.goalId, table: "goals", label: "document goal" },
|
||||
{ id: parsed.parentDocId, table: "documents", label: "parent document" },
|
||||
]);
|
||||
if (invalidReference) {
|
||||
return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`);
|
||||
}
|
||||
const isNew = !parsed.id;
|
||||
const id = db.saveDocument(parsed);
|
||||
if (isNew) {
|
||||
dispatchAutomation("document_created", {
|
||||
companyId: parsed.companyId,
|
||||
agentId: parsed.authorAgentId ?? undefined,
|
||||
projectId: parsed.projectId ?? undefined,
|
||||
});
|
||||
if (parsed.authorAgentId && parsed.reviewerAgentId) {
|
||||
wakeAgentIfPossible(parsed.reviewerAgentId, parsed.companyId, "assignment");
|
||||
}
|
||||
}
|
||||
publishDomainChanged();
|
||||
return ok(id);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.deleteDocument, async (_event, payload) => {
|
||||
const parsed = deleteEntitySchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.belongsToCompany("documents", parsed.id, parsed.companyId)) {
|
||||
return fail("ENTITY_NOT_FOUND", "Document not found in the current company.");
|
||||
}
|
||||
db.deleteDocument(parsed.id, parsed.companyId);
|
||||
publishDomainChanged();
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.saveKnowledgeEntry, async (_event, payload) => {
|
||||
const parsed = knowledgeEntryInputSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
const knowledgeReferences = [
|
||||
{ id: parsed.authorAgentId, table: "agents", label: "knowledge author" },
|
||||
...(parsed.referencedEntityId && parsed.referencedEntityType
|
||||
&& COMPANY_SCOPED_REFERENCE_TABLES.has(parsed.referencedEntityType)
|
||||
? [{ id: parsed.referencedEntityId, table: parsed.referencedEntityType, label: "referenced entity" }]
|
||||
: []),
|
||||
];
|
||||
const invalidReference = findInvalidReference(parsed.companyId, [
|
||||
...knowledgeReferences,
|
||||
]);
|
||||
if (invalidReference) {
|
||||
return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`);
|
||||
}
|
||||
const id = db.saveKnowledgeEntry(parsed);
|
||||
publishDomainChanged();
|
||||
return ok(id);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.deleteKnowledgeEntry, async (_event, payload) => {
|
||||
const parsed = deleteEntitySchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.belongsToCompany("knowledge_base", parsed.id, parsed.companyId)) {
|
||||
return fail("ENTITY_NOT_FOUND", "Knowledge entry not found in the current company.");
|
||||
}
|
||||
db.deleteKnowledgeEntry(parsed.id, parsed.companyId);
|
||||
publishDomainChanged();
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.saveSprint, async (_event, payload) => {
|
||||
const parsed = sprintInputSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
let previousSprintStatus: string | null = null;
|
||||
if (parsed.id) {
|
||||
try {
|
||||
previousSprintStatus = db.listSnapshot().sprints.find((s: { id: string }) => s.id === parsed.id)?.status ?? null;
|
||||
} catch {
|
||||
previousSprintStatus = null;
|
||||
}
|
||||
}
|
||||
const id = db.saveSprint(parsed);
|
||||
if (parsed.status === "active" && previousSprintStatus !== "active") {
|
||||
dispatchAutomation("sprint_started", { companyId: parsed.companyId });
|
||||
}
|
||||
if (parsed.status === "completed" && previousSprintStatus !== "completed") {
|
||||
dispatchAutomation("sprint_ended", { companyId: parsed.companyId });
|
||||
}
|
||||
publishDomainChanged();
|
||||
return ok(id);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.deleteSprint, async (_event, payload) => {
|
||||
const parsed = deleteEntitySchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.belongsToCompany("sprints", parsed.id, parsed.companyId)) {
|
||||
return fail("ENTITY_NOT_FOUND", "Sprint not found in the current company.");
|
||||
}
|
||||
db.deleteSprint(parsed.id, parsed.companyId);
|
||||
publishDomainChanged();
|
||||
return ok(true);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
IPC_CHANNELS,
|
||||
companyInputSchema,
|
||||
connectorInputSchema,
|
||||
deleteEntitySchema,
|
||||
secretInputSchema,
|
||||
} from "@shared/contracts";
|
||||
import { fail, ok, type RegisterCoreHandlersDependencies } from "./ipc-core-common";
|
||||
|
||||
export function registerCoreAdminHandlers({
|
||||
registerHandle,
|
||||
db,
|
||||
ensureCompanyExists,
|
||||
publishDomainChanged,
|
||||
checkConnector,
|
||||
openConnectorAuthTerminal,
|
||||
}: Pick<
|
||||
RegisterCoreHandlersDependencies,
|
||||
"registerHandle" | "db" | "ensureCompanyExists" | "publishDomainChanged" | "checkConnector" | "openConnectorAuthTerminal"
|
||||
>) {
|
||||
registerHandle(IPC_CHANNELS.saveCompany, async (_event, payload) => {
|
||||
const parsed = companyInputSchema.parse(payload);
|
||||
const id = db.saveCompany(parsed);
|
||||
db.addActivity({
|
||||
companyId: id,
|
||||
actor: "board",
|
||||
action: parsed.id ? "company.updated" : "company.created",
|
||||
entityType: "company",
|
||||
entityId: id,
|
||||
detail: parsed.name,
|
||||
});
|
||||
publishDomainChanged();
|
||||
return ok(id);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.setCurrentCompany, async (_event, companyId) => {
|
||||
if (typeof companyId !== "string" || !companyId) {
|
||||
return fail("INVALID_COMPANY_ID", "A valid company id is required.");
|
||||
}
|
||||
if (!db.hasCompany(companyId)) {
|
||||
return fail("COMPANY_NOT_FOUND", "The selected company does not exist.");
|
||||
}
|
||||
db.setCurrentCompany(companyId);
|
||||
publishDomainChanged();
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.saveConnector, async (_event, payload) => {
|
||||
const parsed = connectorInputSchema.parse(payload);
|
||||
const existingConnector = db.getConnector(parsed.id);
|
||||
if (parsed.command !== existingConnector.command) {
|
||||
return fail("CONNECTOR_COMMAND_LOCKED", `Connector command is managed by AgentCompany and must remain "${existingConnector.command}".`);
|
||||
}
|
||||
db.saveConnector({
|
||||
...parsed,
|
||||
command: existingConnector.command,
|
||||
model: parsed.model ?? null,
|
||||
});
|
||||
db.addActivity({
|
||||
companyId: db.getCurrentCompanyId() ?? "00000000-0000-0000-0000-000000000000",
|
||||
actor: "board",
|
||||
action: "connector.updated",
|
||||
entityType: "connector",
|
||||
entityId: parsed.id,
|
||||
detail: existingConnector.command,
|
||||
});
|
||||
publishDomainChanged();
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.testConnector, async (_event, id) => {
|
||||
const parsed = connectorInputSchema.shape.id.parse(id);
|
||||
await checkConnector(parsed);
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.openConnectorAuthTerminal, async (_event, id) => {
|
||||
const parsed = connectorInputSchema.shape.id.parse(id);
|
||||
await openConnectorAuthTerminal(parsed);
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.saveSecret, async (_event, payload) => {
|
||||
const parsed = secretInputSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
const id = db.saveSecret(parsed);
|
||||
db.addActivity({
|
||||
companyId: parsed.companyId,
|
||||
actor: "board",
|
||||
action: parsed.id ? "secret.updated" : "secret.created",
|
||||
entityType: "secret",
|
||||
entityId: id,
|
||||
detail: parsed.name,
|
||||
});
|
||||
publishDomainChanged();
|
||||
return ok(id);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.deleteSecret, async (_event, payload) => {
|
||||
const parsed = deleteEntitySchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.belongsToCompany("secrets", parsed.id, parsed.companyId)) {
|
||||
return fail("SECRET_NOT_FOUND", "Secret not found in the current company.");
|
||||
}
|
||||
db.deleteSecret(parsed.id, parsed.companyId);
|
||||
db.addActivity({
|
||||
companyId: parsed.companyId,
|
||||
actor: "board",
|
||||
action: "secret.deleted",
|
||||
entityType: "secret",
|
||||
entityId: parsed.id,
|
||||
detail: "",
|
||||
});
|
||||
publishDomainChanged();
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.deleteCompany, async (_event, companyId) => {
|
||||
if (typeof companyId !== "string" || companyId.length === 0) {
|
||||
return fail("INVALID_INPUT", "Company ID is required.");
|
||||
}
|
||||
if (!db.hasCompany(companyId)) {
|
||||
return fail("COMPANY_NOT_FOUND", "Company not found.");
|
||||
}
|
||||
db.deleteCompany(companyId);
|
||||
publishDomainChanged();
|
||||
return ok(true);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { IpcMainInvokeEvent } from "electron";
|
||||
import type { DesktopResult } from "@shared/contracts";
|
||||
import type { AppDatabase } from "./database";
|
||||
|
||||
export type CompanyScopedTable = "workspaces" | "agents" | "goals" | "projects" | "tasks" | "approvals" | "runs" | "secrets";
|
||||
|
||||
export interface RegisterCoreHandlersDependencies {
|
||||
registerHandle: (
|
||||
channel: string,
|
||||
handler: (event: IpcMainInvokeEvent, ...args: unknown[]) => Promise<DesktopResult<unknown>> | DesktopResult<unknown>,
|
||||
) => void;
|
||||
db: AppDatabase;
|
||||
ensureCompanyExists: (companyId: string) => DesktopResult<never> | null;
|
||||
findInvalidReference: (
|
||||
companyId: string,
|
||||
refs: Array<{ id: string | null | undefined; table: CompanyScopedTable; label: string }>,
|
||||
) => { id: string | null | undefined; table: CompanyScopedTable; label: string } | null;
|
||||
publishDomainChanged: () => void;
|
||||
emitEvent: (event: any) => void;
|
||||
wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null;
|
||||
handleTaskCreated: (taskId: string, companyId: string) => void;
|
||||
handleTaskStatusChange: (taskId: string, companyId: string, previousStatus: string, newStatus: string) => void;
|
||||
handleApprovalResolved: (approvalId: string, companyId: string, decision: string) => void;
|
||||
dispatchAutomation: (trigger: string, ctx: { companyId: string; [key: string]: unknown }) => void;
|
||||
queueTaskRun: (taskId: string, trigger: string, actor: string) => DesktopResult<string>;
|
||||
checkConnector: (id: any) => Promise<void>;
|
||||
openConnectorAuthTerminal: (id: any) => Promise<boolean>;
|
||||
checkForUpdates: () => Promise<boolean>;
|
||||
getWorkerProcess: () => { postMessage: (message: unknown) => void } | null;
|
||||
resetAfterRestore: () => void;
|
||||
getApiPort: () => number;
|
||||
listConnectorModels: (connectorId: string) => Promise<Array<{ id: string; label: string }>>;
|
||||
}
|
||||
|
||||
export function ok<T>(data: T): DesktopResult<T> {
|
||||
return { ok: true, data };
|
||||
}
|
||||
|
||||
export function fail(code: string, message: string): DesktopResult<never> {
|
||||
return { ok: false, error: { code, message } };
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { RegisterCoreHandlersDependencies } from "./ipc-core-common";
|
||||
import { registerCoreAdminHandlers } from "./ipc-core-admin-handlers";
|
||||
import { registerCoreOperationHandlers } from "./ipc-core-operations-handlers";
|
||||
import { registerCoreReadHandlers } from "./ipc-core-read-handlers";
|
||||
import { registerCoreWorkGraphHandlers } from "./ipc-core-work-graph-handlers";
|
||||
|
||||
export function registerCoreHandlers(dependencies: RegisterCoreHandlersDependencies) {
|
||||
registerCoreReadHandlers(dependencies);
|
||||
registerCoreAdminHandlers(dependencies);
|
||||
registerCoreWorkGraphHandlers(dependencies);
|
||||
registerCoreOperationHandlers(dependencies);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import {
|
||||
IPC_CHANNELS,
|
||||
approvalDecisionSchema,
|
||||
approvalInputSchema,
|
||||
cancelRunSchema,
|
||||
commentInputSchema,
|
||||
heartbeatSettingsSchema,
|
||||
listCommentsSchema,
|
||||
runLogChunkSchema,
|
||||
runLogSchema,
|
||||
startRunSchema,
|
||||
triggerHeartbeatSchema,
|
||||
} from "@shared/contracts";
|
||||
import { fail, ok, type RegisterCoreHandlersDependencies } from "./ipc-core-common";
|
||||
|
||||
export function registerCoreOperationHandlers({
|
||||
registerHandle,
|
||||
db,
|
||||
ensureCompanyExists,
|
||||
publishDomainChanged,
|
||||
emitEvent,
|
||||
wakeAgentIfPossible,
|
||||
handleApprovalResolved,
|
||||
dispatchAutomation,
|
||||
queueTaskRun,
|
||||
getWorkerProcess,
|
||||
}: Pick<
|
||||
RegisterCoreHandlersDependencies,
|
||||
| "registerHandle"
|
||||
| "db"
|
||||
| "ensureCompanyExists"
|
||||
| "publishDomainChanged"
|
||||
| "emitEvent"
|
||||
| "wakeAgentIfPossible"
|
||||
| "handleApprovalResolved"
|
||||
| "dispatchAutomation"
|
||||
| "queueTaskRun"
|
||||
| "getWorkerProcess"
|
||||
>) {
|
||||
registerHandle(IPC_CHANNELS.requestApproval, async (_event, payload) => {
|
||||
const parsed = approvalInputSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
const invalidTask = parsed.relatedTaskId && !db.belongsToCompany("tasks", parsed.relatedTaskId, parsed.companyId);
|
||||
const invalidAgent = parsed.requestedByAgentId && !db.belongsToCompany("agents", parsed.requestedByAgentId, parsed.companyId);
|
||||
if (invalidTask || invalidAgent) {
|
||||
return fail("INVALID_COMPANY_REFERENCE", "The approval references data outside the current company.");
|
||||
}
|
||||
const id = db.requestApproval(parsed);
|
||||
db.addActivity({
|
||||
companyId: parsed.companyId,
|
||||
actor: parsed.requestedByAgentId ?? "board",
|
||||
action: "approval.created",
|
||||
entityType: "approval",
|
||||
entityId: id,
|
||||
detail: parsed.payloadSummary,
|
||||
});
|
||||
return ok(id);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.decideApproval, async (_event, payload) => {
|
||||
const parsed = approvalDecisionSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.belongsToCompany("approvals", parsed.approvalId, parsed.companyId)) {
|
||||
return fail("APPROVAL_NOT_FOUND", "Approval not found in the current company.");
|
||||
}
|
||||
const approval = db.getApprovalRecord(parsed.approvalId);
|
||||
if (!approval || approval.companyId !== parsed.companyId) {
|
||||
return fail("APPROVAL_NOT_FOUND", "Approval not found in the current company.");
|
||||
}
|
||||
db.decideApproval(parsed);
|
||||
db.addActivity({
|
||||
companyId: approval.companyId,
|
||||
actor: "board",
|
||||
action: `approval.${parsed.state}`,
|
||||
entityType: "approval",
|
||||
entityId: approval.id,
|
||||
detail: parsed.decisionNote,
|
||||
});
|
||||
handleApprovalResolved(parsed.approvalId, approval.companyId, parsed.state);
|
||||
publishDomainChanged();
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.startTaskRun, async (_event, payload) => {
|
||||
const parsed = startRunSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.belongsToCompany("tasks", parsed.taskId, parsed.companyId)) {
|
||||
return fail("TASK_NOT_FOUND", "Task not found in the current company.");
|
||||
}
|
||||
return queueTaskRun(parsed.taskId, "manual", "board");
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.cancelRun, (_event, payload) => {
|
||||
const parsed = cancelRunSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.belongsToCompany("runs", parsed.runId, parsed.companyId)) {
|
||||
return fail("RUN_NOT_FOUND", "Run not found in the current company.");
|
||||
}
|
||||
getWorkerProcess()?.postMessage({ type: "cancel-run", runId: parsed.runId });
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.getRunLog, (_event, payload) => {
|
||||
const parsed = runLogSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.belongsToCompany("runs", parsed.runId, parsed.companyId)) {
|
||||
return fail("RUN_NOT_FOUND", "Run not found in the current company.");
|
||||
}
|
||||
return ok(db.readRunLog(parsed.runId));
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.getRunLogChunk, (_event, payload) => {
|
||||
const parsed = runLogChunkSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.belongsToCompany("runs", parsed.runId, parsed.companyId)) {
|
||||
return fail("RUN_NOT_FOUND", "Run not found in the current company.");
|
||||
}
|
||||
return ok(db.readRunLogChunk(parsed.runId, parsed.offset, parsed.limit));
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.addComment, async (_event, payload) => {
|
||||
const parsed = commentInputSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
const comment = db.addComment({
|
||||
companyId: parsed.companyId,
|
||||
taskId: parsed.taskId,
|
||||
authorAgentId: parsed.authorAgentId ?? null,
|
||||
authorName: parsed.authorName,
|
||||
body: parsed.body,
|
||||
});
|
||||
db.addActivity({
|
||||
companyId: parsed.companyId,
|
||||
actor: parsed.authorAgentId ?? "board",
|
||||
action: "comment.created",
|
||||
entityType: "task",
|
||||
entityId: parsed.taskId,
|
||||
detail: parsed.body.slice(0, 100),
|
||||
});
|
||||
emitEvent({ type: "agent-message", taskId: parsed.taskId, comment });
|
||||
publishDomainChanged();
|
||||
try {
|
||||
const task = db.getTask(parsed.taskId);
|
||||
if (task.assigneeAgentId) {
|
||||
wakeAgentIfPossible(task.assigneeAgentId, task.companyId, "comment");
|
||||
}
|
||||
dispatchAutomation("comment_posted", {
|
||||
companyId: parsed.companyId,
|
||||
taskId: parsed.taskId,
|
||||
agentId: parsed.authorAgentId ?? undefined,
|
||||
});
|
||||
} catch {
|
||||
// Task may have been deleted between comment creation and wake evaluation.
|
||||
}
|
||||
return ok(comment.id);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.listComments, async (_event, payload) => {
|
||||
const parsed = listCommentsSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.belongsToCompany("tasks", parsed.taskId, parsed.companyId)) {
|
||||
return fail("TASK_NOT_FOUND", "Task not found in the current company.");
|
||||
}
|
||||
return ok(db.listComments(parsed.taskId));
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.setHeartbeat, async (_event, payload) => {
|
||||
const parsed = heartbeatSettingsSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.belongsToCompany("agents", parsed.agentId, parsed.companyId)) {
|
||||
return fail("AGENT_NOT_FOUND", "Agent not found in the current company.");
|
||||
}
|
||||
db.setHeartbeat(parsed.agentId, parsed.enabled, parsed.intervalSec);
|
||||
db.addActivity({
|
||||
companyId: parsed.companyId,
|
||||
actor: "board",
|
||||
action: parsed.enabled ? "heartbeat.enabled" : "heartbeat.disabled",
|
||||
entityType: "agent",
|
||||
entityId: parsed.agentId,
|
||||
detail: `Interval: ${parsed.intervalSec}s`,
|
||||
});
|
||||
emitEvent({ type: "heartbeat-status", agentId: parsed.agentId, enabled: parsed.enabled, nextWakeAt: null });
|
||||
publishDomainChanged();
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.triggerHeartbeat, async (_event, payload) => {
|
||||
const parsed = triggerHeartbeatSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.belongsToCompany("agents", parsed.agentId, parsed.companyId)) {
|
||||
return fail("AGENT_NOT_FOUND", "Agent not found in the current company.");
|
||||
}
|
||||
const runId = wakeAgentIfPossible(parsed.agentId, parsed.companyId, parsed.trigger);
|
||||
if (!runId) {
|
||||
return fail("HEARTBEAT_SKIPPED", "Agent cannot be woken right now (check connector status, budget, or active runs).");
|
||||
}
|
||||
return ok(runId);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { dialog, shell } from "electron";
|
||||
import { join } from "node:path";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
IPC_CHANNELS,
|
||||
connectorInputSchema,
|
||||
metricsQuerySchema,
|
||||
settingsSchema,
|
||||
} from "@shared/contracts";
|
||||
import { fail, ok, type RegisterCoreHandlersDependencies } from "./ipc-core-common";
|
||||
|
||||
export function registerCoreReadHandlers({
|
||||
registerHandle,
|
||||
db,
|
||||
ensureCompanyExists,
|
||||
publishDomainChanged,
|
||||
checkForUpdates,
|
||||
resetAfterRestore,
|
||||
getApiPort,
|
||||
listConnectorModels,
|
||||
}: Pick<
|
||||
RegisterCoreHandlersDependencies,
|
||||
| "registerHandle"
|
||||
| "db"
|
||||
| "ensureCompanyExists"
|
||||
| "publishDomainChanged"
|
||||
| "checkForUpdates"
|
||||
| "resetAfterRestore"
|
||||
| "getApiPort"
|
||||
| "listConnectorModels"
|
||||
>) {
|
||||
registerHandle(IPC_CHANNELS.loadSnapshot, () => ok(db.listRendererSnapshot()));
|
||||
|
||||
registerHandle(IPC_CHANNELS.pickDirectory, async () => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ["openDirectory", "createDirectory"],
|
||||
});
|
||||
return ok(result.canceled ? null : (result.filePaths[0] ?? null));
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.openPath, async (_event, targetPath) => {
|
||||
if (typeof targetPath !== "string" || targetPath.length === 0) {
|
||||
return fail("INVALID_PATH", "A valid path is required.");
|
||||
}
|
||||
if (!db.canOpenPath(targetPath)) {
|
||||
return fail("OPEN_PATH_FORBIDDEN", "The requested path is outside the managed desktop workspace.");
|
||||
}
|
||||
const error = await shell.openPath(targetPath);
|
||||
return error ? fail("OPEN_PATH_FAILED", error) : ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.backupProfile, async () => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ["openDirectory", "createDirectory"],
|
||||
});
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return ok(null);
|
||||
}
|
||||
const backupPath = join(result.filePaths[0]!, `agentcompany-backup-${Date.now()}`);
|
||||
await db.exportBackup(backupPath);
|
||||
return ok(backupPath);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.restoreProfile, async () => {
|
||||
const hasActiveRuns = db.listSnapshot().runs.some((run: { status: string }) => run.status === "queued" || run.status === "running");
|
||||
if (hasActiveRuns) {
|
||||
return fail("RESTORE_BLOCKED", "Stop all active runs before restoring a profile backup.");
|
||||
}
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ["openDirectory"],
|
||||
});
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return ok(false);
|
||||
}
|
||||
await db.restoreFromBackup(result.filePaths[0]!);
|
||||
resetAfterRestore();
|
||||
publishDomainChanged();
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.updateSettings, async (_event, payload) => {
|
||||
const parsed = settingsSchema.parse(payload);
|
||||
db.updateSettings(parsed);
|
||||
publishDomainChanged();
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.checkForUpdates, async () => {
|
||||
await checkForUpdates();
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.getSidebarBadges, async (_event, companyId) => {
|
||||
if (typeof companyId !== "string" || !companyId) {
|
||||
return fail("INVALID_COMPANY_ID", "A valid company id is required.");
|
||||
}
|
||||
return ok(db.getSidebarBadges(companyId));
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.getApiPort, () => ok(getApiPort()));
|
||||
|
||||
registerHandle(IPC_CHANNELS.listConnectorModels, async (_event, connectorId) => {
|
||||
try {
|
||||
const parsed = connectorInputSchema.shape.id.parse(connectorId);
|
||||
return ok(await listConnectorModels(parsed));
|
||||
} catch {
|
||||
return ok([]);
|
||||
}
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.getInbox, (_event, payload) => {
|
||||
const companyId = z.string().uuid().parse(payload);
|
||||
const err = ensureCompanyExists(companyId);
|
||||
if (err) return err;
|
||||
return ok(db.getInbox(companyId));
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.getCompanyMetrics, (_event, payload) => {
|
||||
const input = metricsQuerySchema.parse(payload);
|
||||
const err = ensureCompanyExists(input.companyId);
|
||||
if (err) return err;
|
||||
return ok(db.getCompanyMetrics(input.companyId, input.days));
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.getAgentMetrics, (_event, payload) => {
|
||||
const input = metricsQuerySchema.parse(payload);
|
||||
const err = ensureCompanyExists(input.companyId);
|
||||
if (err) return err;
|
||||
if (!input.agentId) {
|
||||
return fail("MISSING_AGENT_ID", "agentId is required");
|
||||
}
|
||||
const metrics = db.getCompanyMetrics(input.companyId, input.days);
|
||||
const agentMetric = metrics.agentMetrics.find((metric: { agentId: string }) => metric.agentId === input.agentId);
|
||||
if (!agentMetric) {
|
||||
return fail("AGENT_NOT_FOUND", "Agent metrics not found");
|
||||
}
|
||||
return ok(agentMetric);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.getStandupReport, (_event, payload) => {
|
||||
const companyId = z.string().uuid().parse(payload);
|
||||
const err = ensureCompanyExists(companyId);
|
||||
if (err) return err;
|
||||
return ok(db.getStandupReport(companyId));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import {
|
||||
IPC_CHANNELS,
|
||||
agentInputSchema,
|
||||
deleteEntitySchema,
|
||||
goalInputSchema,
|
||||
projectInputSchema,
|
||||
taskInputSchema,
|
||||
workspaceInputSchema,
|
||||
} from "@shared/contracts";
|
||||
import { fail, ok, type RegisterCoreHandlersDependencies } from "./ipc-core-common";
|
||||
|
||||
export function registerCoreWorkGraphHandlers({
|
||||
registerHandle,
|
||||
db,
|
||||
ensureCompanyExists,
|
||||
findInvalidReference,
|
||||
publishDomainChanged,
|
||||
wakeAgentIfPossible,
|
||||
handleTaskCreated,
|
||||
handleTaskStatusChange,
|
||||
}: Pick<
|
||||
RegisterCoreHandlersDependencies,
|
||||
| "registerHandle"
|
||||
| "db"
|
||||
| "ensureCompanyExists"
|
||||
| "findInvalidReference"
|
||||
| "publishDomainChanged"
|
||||
| "wakeAgentIfPossible"
|
||||
| "handleTaskCreated"
|
||||
| "handleTaskStatusChange"
|
||||
>) {
|
||||
registerHandle(IPC_CHANNELS.saveWorkspace, async (_event, payload) => {
|
||||
const parsed = workspaceInputSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
const invalidReference = findInvalidReference(parsed.companyId, [
|
||||
{ id: parsed.projectId, table: "projects", label: "project" },
|
||||
]);
|
||||
if (invalidReference) {
|
||||
return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`);
|
||||
}
|
||||
const id = db.saveWorkspace(parsed);
|
||||
db.addActivity({
|
||||
companyId: parsed.companyId,
|
||||
actor: "board",
|
||||
action: parsed.id ? "workspace.updated" : "workspace.created",
|
||||
entityType: "workspace",
|
||||
entityId: id,
|
||||
detail: parsed.localPath,
|
||||
});
|
||||
publishDomainChanged();
|
||||
return ok(id);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.saveAgent, async (_event, payload) => {
|
||||
const parsed = agentInputSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
const invalidReference = findInvalidReference(parsed.companyId, [
|
||||
{ id: parsed.reportsTo, table: "agents", label: "manager" },
|
||||
{ id: parsed.workspaceId, table: "workspaces", label: "workspace" },
|
||||
]);
|
||||
if (invalidReference) {
|
||||
return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`);
|
||||
}
|
||||
let id: string;
|
||||
try {
|
||||
id = db.saveAgent({
|
||||
id: parsed.id ?? null,
|
||||
companyId: parsed.companyId,
|
||||
name: parsed.name,
|
||||
role: parsed.role,
|
||||
title: parsed.title,
|
||||
department: parsed.department ?? null,
|
||||
status: parsed.status,
|
||||
reportsTo: parsed.reportsTo ?? null,
|
||||
connectorId: parsed.connectorId,
|
||||
workspaceId: parsed.workspaceId ?? null,
|
||||
model: parsed.model ?? null,
|
||||
metadataJson: parsed.metadataJson,
|
||||
capabilities: parsed.capabilities,
|
||||
budgetMonthlyUsd: parsed.budgetMonthlyUsd,
|
||||
});
|
||||
} catch (error) {
|
||||
return fail("INVALID_AGENT_HIERARCHY", error instanceof Error ? error.message : "Agent could not be saved.");
|
||||
}
|
||||
db.addActivity({
|
||||
companyId: parsed.companyId,
|
||||
actor: "board",
|
||||
action: parsed.id ? "agent.updated" : "agent.created",
|
||||
entityType: "agent",
|
||||
entityId: id,
|
||||
detail: parsed.name,
|
||||
});
|
||||
publishDomainChanged();
|
||||
return ok(id);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.saveGoal, async (_event, payload) => {
|
||||
const parsed = goalInputSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
const invalidReference = findInvalidReference(parsed.companyId, [
|
||||
{ id: parsed.parentId, table: "goals", label: "parent goal" },
|
||||
{ id: parsed.ownerAgentId, table: "agents", label: "owner agent" },
|
||||
]);
|
||||
if (invalidReference) {
|
||||
return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`);
|
||||
}
|
||||
const id = db.saveGoal(parsed);
|
||||
db.addActivity({
|
||||
companyId: parsed.companyId,
|
||||
actor: "board",
|
||||
action: parsed.id ? "goal.updated" : "goal.created",
|
||||
entityType: "goal",
|
||||
entityId: id,
|
||||
detail: parsed.title,
|
||||
});
|
||||
publishDomainChanged();
|
||||
if (parsed.status === "active" && parsed.ownerAgentId) {
|
||||
wakeAgentIfPossible(parsed.ownerAgentId, parsed.companyId, "goal_activated");
|
||||
}
|
||||
return ok(id);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.saveProject, async (_event, payload) => {
|
||||
const parsed = projectInputSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
const invalidReference = findInvalidReference(parsed.companyId, [
|
||||
{ id: parsed.goalId, table: "goals", label: "goal" },
|
||||
{ id: parsed.leadAgentId, table: "agents", label: "lead agent" },
|
||||
]);
|
||||
if (invalidReference) {
|
||||
return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`);
|
||||
}
|
||||
const id = db.saveProject(parsed);
|
||||
db.addActivity({
|
||||
companyId: parsed.companyId,
|
||||
actor: "board",
|
||||
action: parsed.id ? "project.updated" : "project.created",
|
||||
entityType: "project",
|
||||
entityId: id,
|
||||
detail: parsed.name,
|
||||
});
|
||||
if (parsed.leadAgentId) {
|
||||
wakeAgentIfPossible(parsed.leadAgentId, parsed.companyId, "assignment");
|
||||
}
|
||||
publishDomainChanged();
|
||||
return ok(id);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.saveTask, async (_event, payload) => {
|
||||
const parsed = taskInputSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
const invalidReference = findInvalidReference(parsed.companyId, [
|
||||
{ id: parsed.projectId, table: "projects", label: "project" },
|
||||
{ id: parsed.goalId, table: "goals", label: "goal" },
|
||||
{ id: parsed.parentId, table: "tasks", label: "parent task" },
|
||||
{ id: parsed.assigneeAgentId, table: "agents", label: "assignee agent" },
|
||||
{ id: parsed.workspaceId, table: "workspaces", label: "workspace" },
|
||||
]);
|
||||
if (invalidReference) {
|
||||
return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`);
|
||||
}
|
||||
const isNew = !parsed.id;
|
||||
let previousStatus: string | null = null;
|
||||
if (!isNew) {
|
||||
try {
|
||||
previousStatus = db.getTask(parsed.id!).status;
|
||||
} catch {
|
||||
previousStatus = null;
|
||||
}
|
||||
}
|
||||
let id: string;
|
||||
try {
|
||||
id = db.saveTask(parsed);
|
||||
} catch (error) {
|
||||
return fail("INVALID_TASK_STATE", error instanceof Error ? error.message : "Task could not be saved.");
|
||||
}
|
||||
db.addActivity({
|
||||
companyId: parsed.companyId,
|
||||
actor: "board",
|
||||
action: isNew ? "task.created" : "task.updated",
|
||||
entityType: "task",
|
||||
entityId: id,
|
||||
detail: parsed.title,
|
||||
});
|
||||
if (isNew) {
|
||||
handleTaskCreated(id, parsed.companyId);
|
||||
} else if (previousStatus && previousStatus !== parsed.status) {
|
||||
handleTaskStatusChange(id, parsed.companyId, previousStatus, parsed.status);
|
||||
}
|
||||
publishDomainChanged();
|
||||
return ok(id);
|
||||
});
|
||||
|
||||
const deleteHandlers = [
|
||||
{ channel: IPC_CHANNELS.deleteAgent, table: "agents", entity: "agent", deleteFn: (id: string, companyId: string) => db.deleteAgent(id, companyId) },
|
||||
{ channel: IPC_CHANNELS.deleteTask, table: "tasks", entity: "task", deleteFn: (id: string, companyId: string) => db.deleteTask(id, companyId) },
|
||||
{ channel: IPC_CHANNELS.deleteGoal, table: "goals", entity: "goal", deleteFn: (id: string, companyId: string) => db.deleteGoal(id, companyId) },
|
||||
{ channel: IPC_CHANNELS.deleteProject, table: "projects", entity: "project", deleteFn: (id: string, companyId: string) => db.deleteProject(id, companyId) },
|
||||
{ channel: IPC_CHANNELS.deleteWorkspace, table: "workspaces", entity: "workspace", deleteFn: (id: string, companyId: string) => db.deleteWorkspace(id, companyId) },
|
||||
{ channel: IPC_CHANNELS.deleteApproval, table: "approvals", entity: "approval", deleteFn: (id: string, companyId: string) => db.deleteApproval(id, companyId) },
|
||||
] as const;
|
||||
|
||||
for (const { channel, table, entity, deleteFn } of deleteHandlers) {
|
||||
registerHandle(channel, async (_event, payload) => {
|
||||
const parsed = deleteEntitySchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.belongsToCompany(table, parsed.id, parsed.companyId)) {
|
||||
return fail(`${entity.toUpperCase()}_NOT_FOUND`, `${entity.charAt(0).toUpperCase() + entity.slice(1)} not found in the current company.`);
|
||||
}
|
||||
deleteFn(parsed.id, parsed.companyId);
|
||||
db.addActivity({
|
||||
companyId: parsed.companyId,
|
||||
actor: "board",
|
||||
action: `${entity}.deleted`,
|
||||
entityType: entity,
|
||||
entityId: parsed.id,
|
||||
detail: "",
|
||||
});
|
||||
publishDomainChanged();
|
||||
return ok(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { IpcMainInvokeEvent } from "electron";
|
||||
import { z } from "zod";
|
||||
import type { DesktopResult } from "@shared/contracts";
|
||||
import type { bootstrapOnboardingSchema } from "@shared/contracts";
|
||||
import type { AppDatabase } from "./database";
|
||||
|
||||
export interface BrowserActionControl {
|
||||
clearSocialAccountData: (socialAccountId: string, screenshotPaths?: string[]) => Promise<void>;
|
||||
cancelAction: (actionId: string) => boolean;
|
||||
launchLoginBrowser: (socialAccountId: string, platform: string, loginUrl?: string) => Promise<{
|
||||
success: boolean;
|
||||
cancelled: boolean;
|
||||
summary: string;
|
||||
screenshotPath: string | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface RegisterDomainHandlersDependencies {
|
||||
registerHandle: (
|
||||
channel: string,
|
||||
handler: (event: IpcMainInvokeEvent, ...args: unknown[]) => Promise<DesktopResult<unknown>> | DesktopResult<unknown>,
|
||||
) => void;
|
||||
db: AppDatabase;
|
||||
logger: {
|
||||
error: (...args: unknown[]) => void;
|
||||
};
|
||||
ensureCompanyExists: (companyId: string) => DesktopResult<never> | null;
|
||||
findInvalidReference: (
|
||||
companyId: string,
|
||||
refs: Array<{ id: string | null | undefined; table: string; label: string }>,
|
||||
) => { id: string | null | undefined; table: string; label: string } | null;
|
||||
publishDomainChanged: () => void;
|
||||
emitEvent: (event: any) => void;
|
||||
wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null;
|
||||
dispatchAutomation: (trigger: string, ctx: { companyId: string; [key: string]: unknown }) => void;
|
||||
runWorkflowPipeline: (workflowId: string, companyId: string) => Promise<void>;
|
||||
bootstrapOnboarding: (input: z.infer<typeof bootstrapOnboardingSchema>) => unknown;
|
||||
getBrowserActions: () => BrowserActionControl | null;
|
||||
randomFallbackCompanyId: () => string;
|
||||
}
|
||||
|
||||
export function ok<T>(data: T): DesktopResult<T> {
|
||||
return { ok: true, data };
|
||||
}
|
||||
|
||||
export function fail(code: string, message: string): DesktopResult<never> {
|
||||
return { ok: false, error: { code, message } };
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { RegisterDomainHandlersDependencies } from "./ipc-domain-common";
|
||||
import { registerAgentCoordinationHandlers } from "./ipc-agent-coordination-handlers";
|
||||
import { registerAutomationOperationsHandlers } from "./ipc-automation-operations-handlers";
|
||||
import { registerBrowserHandlers } from "./ipc-browser-handlers";
|
||||
import { registerContentOperationsHandlers } from "./ipc-content-operations-handlers";
|
||||
import { registerOnboardingHandlers } from "./ipc-onboarding-handlers";
|
||||
|
||||
export function registerDomainHandlers(dependencies: RegisterDomainHandlersDependencies) {
|
||||
registerOnboardingHandlers(dependencies);
|
||||
registerBrowserHandlers(dependencies);
|
||||
registerContentOperationsHandlers(dependencies);
|
||||
registerAutomationOperationsHandlers(dependencies);
|
||||
registerAgentCoordinationHandlers(dependencies);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
IPC_CHANNELS,
|
||||
bootstrapOnboardingSchema,
|
||||
} from "@shared/contracts";
|
||||
import { fail, ok, type RegisterDomainHandlersDependencies } from "./ipc-domain-common";
|
||||
|
||||
export function registerOnboardingHandlers({
|
||||
registerHandle,
|
||||
bootstrapOnboarding,
|
||||
}: Pick<
|
||||
RegisterDomainHandlersDependencies,
|
||||
"registerHandle" | "bootstrapOnboarding"
|
||||
>) {
|
||||
registerHandle(IPC_CHANNELS.bootstrapOnboarding, (_event, payload) => {
|
||||
const input = bootstrapOnboardingSchema.parse(payload);
|
||||
try {
|
||||
return ok(bootstrapOnboarding(input));
|
||||
} catch (error) {
|
||||
return fail("ONBOARDING_BOOTSTRAP_FAILED", error instanceof Error ? error.message : "Unable to bootstrap onboarding.");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { IpcMainInvokeEvent, BrowserWindow } from "electron";
|
||||
import { ipcMain, Notification } from "electron";
|
||||
import { ZodError } from "zod";
|
||||
import type { DesktopEvent } from "@shared/types";
|
||||
import type { DesktopResult } from "@shared/contracts";
|
||||
|
||||
interface IpcSupportDependencies {
|
||||
isDev: boolean;
|
||||
devServerOrigin: string | null;
|
||||
logger: {
|
||||
error: (...args: unknown[]) => void;
|
||||
};
|
||||
getMainWindow: () => BrowserWindow | null;
|
||||
getDb: () => {
|
||||
hasCompany: (companyId: string) => boolean;
|
||||
belongsToCompany: (table: string, id: string, companyId: string) => boolean;
|
||||
};
|
||||
}
|
||||
|
||||
function isTrustedRendererUrl(url: string, isDev: boolean, devServerOrigin: string | null) {
|
||||
if (!url) {
|
||||
return false;
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (isDev) {
|
||||
return devServerOrigin ? parsed.origin === devServerOrigin : false;
|
||||
}
|
||||
return parsed.protocol === "app:" && parsed.hostname === "-";
|
||||
}
|
||||
|
||||
export function createIpcSupport({
|
||||
isDev,
|
||||
devServerOrigin,
|
||||
logger,
|
||||
getMainWindow,
|
||||
getDb,
|
||||
}: IpcSupportDependencies) {
|
||||
function sanitizeHandlerError(error: unknown) {
|
||||
if (error instanceof ZodError) {
|
||||
return {
|
||||
code: "IPC_INVALID_PAYLOAD",
|
||||
message: "The desktop request payload is invalid.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
code: "IPC_HANDLER_FAILED",
|
||||
message: "The desktop operation failed. Review the application logs for details.",
|
||||
};
|
||||
}
|
||||
|
||||
function fail(code: string, message: string): DesktopResult<never> {
|
||||
return { ok: false, error: { code, message } };
|
||||
}
|
||||
|
||||
function registerHandle(
|
||||
channel: string,
|
||||
handler: (event: IpcMainInvokeEvent, ...args: unknown[]) => Promise<DesktopResult<unknown>> | DesktopResult<unknown>,
|
||||
) {
|
||||
ipcMain.handle(channel, async (event, ...args) => {
|
||||
const senderUrl = event.senderFrame?.url ?? event.sender.getURL();
|
||||
if (!isTrustedRendererUrl(senderUrl, isDev, devServerOrigin)) {
|
||||
logger.error(`[ipc:${channel}] blocked untrusted sender ${senderUrl || "<empty>"}`);
|
||||
return fail("IPC_FORBIDDEN", "IPC sender is not allowed.");
|
||||
}
|
||||
try {
|
||||
return await handler(event, ...args);
|
||||
} catch (error) {
|
||||
const rawMessage = error instanceof Error ? error.message : String(error);
|
||||
const sanitized = sanitizeHandlerError(error);
|
||||
logger.error(`[ipc:${channel}] ${rawMessage}`);
|
||||
return fail(sanitized.code, sanitized.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function emitEvent(event: DesktopEvent) {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send("agent-company:event", event);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureCompanyExists(companyId: string) {
|
||||
return getDb().hasCompany(companyId)
|
||||
? null
|
||||
: fail("COMPANY_NOT_FOUND", "The selected company no longer exists.");
|
||||
}
|
||||
|
||||
function findInvalidReference(
|
||||
companyId: string,
|
||||
refs: Array<{ id: string | null | undefined; table: string; label: string }>,
|
||||
) {
|
||||
return refs.find((ref) => ref.id && !getDb().belongsToCompany(ref.table, ref.id, companyId)) ?? null;
|
||||
}
|
||||
|
||||
function publishDomainChanged() {
|
||||
emitEvent({ type: "domain-changed" });
|
||||
}
|
||||
|
||||
function notify(title: string, body: string) {
|
||||
if (Notification.isSupported()) {
|
||||
new Notification({ title, body }).show();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fail,
|
||||
registerHandle,
|
||||
emitEvent,
|
||||
ensureCompanyExists,
|
||||
findInvalidReference,
|
||||
publishDomainChanged,
|
||||
notify,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { delimiter as PATH_DELIMITER } from "node:path";
|
||||
import { app, protocol } from "electron";
|
||||
|
||||
function listExtraPathEntries() {
|
||||
return (
|
||||
process.platform === "win32"
|
||||
? [
|
||||
`${process.env.LOCALAPPDATA ?? ""}\\Programs`,
|
||||
`${process.env.APPDATA ?? ""}\\npm`,
|
||||
`${process.env.USERPROFILE ?? ""}\\.cargo\\bin`,
|
||||
`${process.env.USERPROFILE ?? ""}\\.local\\bin`,
|
||||
`${process.env.USERPROFILE ?? ""}\\.bun\\bin`,
|
||||
]
|
||||
: [
|
||||
"/usr/local/bin",
|
||||
"/opt/homebrew/bin",
|
||||
"/opt/homebrew/sbin",
|
||||
`${process.env.HOME}/.local/bin`,
|
||||
`${process.env.HOME}/.cargo/bin`,
|
||||
`${process.env.HOME}/.nvm/current/bin`,
|
||||
`${process.env.HOME}/.volta/bin`,
|
||||
`${process.env.HOME}/.bun/bin`,
|
||||
"/usr/local/sbin",
|
||||
"/snap/bin",
|
||||
]
|
||||
).filter((entry) => entry && !entry.includes("undefined"));
|
||||
}
|
||||
|
||||
function prependCommonCliPaths() {
|
||||
const currentPath = process.env.PATH ?? "";
|
||||
const existing = new Set(currentPath.split(PATH_DELIMITER).filter(Boolean));
|
||||
const toAdd = listExtraPathEntries().filter((entry) => !existing.has(entry));
|
||||
if (toAdd.length === 0) {
|
||||
return;
|
||||
}
|
||||
process.env.PATH = [...toAdd, currentPath].join(PATH_DELIMITER);
|
||||
}
|
||||
|
||||
function registerPrivilegedSchemes() {
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: "app",
|
||||
privileges: {
|
||||
secure: true,
|
||||
standard: true,
|
||||
supportFetchAPI: true,
|
||||
stream: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
export function configureMainProcess(appName: string) {
|
||||
app.name = appName;
|
||||
app.enableSandbox();
|
||||
prependCommonCliPaths();
|
||||
registerPrivilegedSchemes();
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import log from "electron-log/main.js";
|
||||
import type { AgentApiServer } from "./api-server";
|
||||
import { initializeAgentTokenSigningKey } from "./agent-auth";
|
||||
import { bootstrapApplication } from "./bootstrap-application";
|
||||
import { openConnectorAuthTerminal as openConnectorAuthTerminalImpl } from "./connector-auth-service";
|
||||
import { getConnectorDefinition } from "./connectors";
|
||||
import type { AppDatabase } from "./database";
|
||||
import { seedDefaultAutomationRules as seedDefaultAutomationRulesImpl } from "./default-automation-rules";
|
||||
import { registerCoreHandlers } from "./ipc-core-handlers";
|
||||
import { registerDomainHandlers } from "./ipc-domain-handlers";
|
||||
|
||||
type BootstrapResult = Awaited<ReturnType<typeof bootstrapApplication>>;
|
||||
|
||||
interface MainRuntimeState {
|
||||
db: AppDatabase | null;
|
||||
apiServer: AgentApiServer | null;
|
||||
serviceGraph: BootstrapResult["serviceGraph"] | null;
|
||||
ipcSupport: BootstrapResult["ipcSupport"] | null;
|
||||
}
|
||||
|
||||
export function createMainRuntimeSupport(state: MainRuntimeState) {
|
||||
function requireDb() {
|
||||
if (!state.db) {
|
||||
throw new Error("Database is not initialized.");
|
||||
}
|
||||
return state.db;
|
||||
}
|
||||
|
||||
function requireServiceGraph() {
|
||||
if (!state.serviceGraph) {
|
||||
throw new Error("Service graph is not initialized.");
|
||||
}
|
||||
return state.serviceGraph;
|
||||
}
|
||||
|
||||
function requireIpcSupport() {
|
||||
if (!state.ipcSupport) {
|
||||
throw new Error("IPC support is not initialized.");
|
||||
}
|
||||
return state.ipcSupport;
|
||||
}
|
||||
|
||||
function requireAutomationService() {
|
||||
const service = requireServiceGraph().getAutomationService();
|
||||
if (!service) {
|
||||
throw new Error("Automation service is not initialized.");
|
||||
}
|
||||
return service;
|
||||
}
|
||||
|
||||
function requireBrowserActionService() {
|
||||
const service = requireServiceGraph().getBrowserActionService();
|
||||
if (!service) {
|
||||
throw new Error("Browser action service is not initialized.");
|
||||
}
|
||||
return service;
|
||||
}
|
||||
|
||||
function requireConnectorHealthService() {
|
||||
const service = requireServiceGraph().getConnectorHealthService();
|
||||
if (!service) {
|
||||
throw new Error("Connector health service is not initialized.");
|
||||
}
|
||||
return service;
|
||||
}
|
||||
|
||||
function requireOnboardingService() {
|
||||
const service = requireServiceGraph().getOnboardingService();
|
||||
if (!service) {
|
||||
throw new Error("Onboarding service is not initialized.");
|
||||
}
|
||||
return service;
|
||||
}
|
||||
|
||||
function requireOrchestrationService() {
|
||||
const service = requireServiceGraph().getOrchestrationService();
|
||||
if (!service) {
|
||||
throw new Error("Orchestration service is not initialized.");
|
||||
}
|
||||
return service;
|
||||
}
|
||||
|
||||
function requireUpdaterService() {
|
||||
const service = requireServiceGraph().getUpdaterService();
|
||||
if (!service) {
|
||||
throw new Error("Updater service is not initialized.");
|
||||
}
|
||||
return service;
|
||||
}
|
||||
|
||||
function emitEvent(event: Parameters<BootstrapResult["ipcSupport"]["emitEvent"]>[0]) {
|
||||
requireIpcSupport().emitEvent(event);
|
||||
}
|
||||
|
||||
function publishDomainChanged() {
|
||||
requireIpcSupport().publishDomainChanged();
|
||||
}
|
||||
|
||||
function ensureCompanyExists(companyId: string) {
|
||||
return requireIpcSupport().ensureCompanyExists(companyId);
|
||||
}
|
||||
|
||||
function findInvalidReference<T extends { id: string | null | undefined; table: string; label: string }>(
|
||||
companyId: string,
|
||||
refs: T[],
|
||||
) {
|
||||
return requireIpcSupport().findInvalidReference(companyId, refs) as T | null;
|
||||
}
|
||||
|
||||
function seedDefaultAutomationRules(companyId: string) {
|
||||
const db = requireDb();
|
||||
seedDefaultAutomationRulesImpl(companyId, {
|
||||
listAutomationRuleNames: (targetCompanyId) => db.listAutomationRuleNames(targetCompanyId),
|
||||
saveAutomationRule: (input) => db.saveAutomationRule(input),
|
||||
});
|
||||
}
|
||||
|
||||
function registerHandlers() {
|
||||
registerCoreHandlers({
|
||||
registerHandle: requireIpcSupport().registerHandle,
|
||||
db: requireDb(),
|
||||
ensureCompanyExists,
|
||||
findInvalidReference,
|
||||
publishDomainChanged,
|
||||
emitEvent,
|
||||
wakeAgentIfPossible: (agentId, companyId, trigger) =>
|
||||
requireOrchestrationService().wakeAgentIfPossible(agentId, companyId, trigger),
|
||||
handleTaskCreated: (taskId, companyId) => requireAutomationService().handleTaskCreated(taskId, companyId),
|
||||
handleTaskStatusChange: (taskId, companyId, previousStatus, newStatus) =>
|
||||
requireAutomationService().handleTaskStatusChange(taskId, companyId, previousStatus, newStatus),
|
||||
handleApprovalResolved: (approvalId, companyId, decision) =>
|
||||
requireAutomationService().handleApprovalResolved(approvalId, companyId, decision),
|
||||
dispatchAutomation: (trigger, ctx) => requireAutomationService().dispatchAutomation(trigger, ctx),
|
||||
queueTaskRun: (taskId, trigger, actor) =>
|
||||
requireOrchestrationService().queueTaskRun(taskId, trigger, actor),
|
||||
checkConnector: async (id) => requireConnectorHealthService().checkConnector(id),
|
||||
openConnectorAuthTerminal: async (id) => {
|
||||
const connector = requireDb().getConnector(id);
|
||||
return openConnectorAuthTerminalImpl(id, connector.command);
|
||||
},
|
||||
checkForUpdates: () => requireUpdaterService().checkForUpdates(),
|
||||
getWorkerProcess: () => requireServiceGraph().getWorkerService()?.getWorker() ?? null,
|
||||
resetAfterRestore: () => {
|
||||
initializeAgentTokenSigningKey(requireDb().profileDir);
|
||||
requireBrowserActionService().resetBrowserManager();
|
||||
},
|
||||
getApiPort: () => state.apiServer?.getPort() ?? 0,
|
||||
listConnectorModels: async (connectorId) => {
|
||||
const definition = getConnectorDefinition(connectorId as Parameters<typeof getConnectorDefinition>[0]);
|
||||
return definition.listModels ? definition.listModels() : [];
|
||||
},
|
||||
});
|
||||
|
||||
registerDomainHandlers({
|
||||
registerHandle: requireIpcSupport().registerHandle,
|
||||
db: requireDb(),
|
||||
logger: log,
|
||||
ensureCompanyExists,
|
||||
findInvalidReference,
|
||||
publishDomainChanged,
|
||||
emitEvent,
|
||||
wakeAgentIfPossible: (agentId, companyId, trigger) =>
|
||||
requireOrchestrationService().wakeAgentIfPossible(agentId, companyId, trigger),
|
||||
dispatchAutomation: (trigger, ctx) => requireAutomationService().dispatchAutomation(trigger, ctx),
|
||||
runWorkflowPipeline: (workflowId, companyId) =>
|
||||
requireAutomationService().runWorkflowPipeline(workflowId, companyId),
|
||||
bootstrapOnboarding: (input) => requireOnboardingService().bootstrapOnboarding(input),
|
||||
getBrowserActions: () => requireServiceGraph().getBrowserActionService(),
|
||||
randomFallbackCompanyId: () => requireConnectorHealthService().randomFallbackCompanyId(),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
publishDomainChanged,
|
||||
seedDefaultAutomationRules,
|
||||
registerHandlers,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { AgentApiServer } from "./api-server";
|
||||
import { createAgentToken, initializeAgentTokenSigningKey } from "./agent-auth";
|
||||
import { bootstrapApplication } from "./bootstrap-application";
|
||||
import { AppDatabase } from "./database";
|
||||
import { createMainRuntimeSupport } from "./main-runtime-support";
|
||||
|
||||
interface CreateMainRuntimeDependencies {
|
||||
hasSingleInstanceLock: boolean;
|
||||
isDev: boolean;
|
||||
devServerOrigin: string | null;
|
||||
profileDir: string;
|
||||
windowStatePath: string;
|
||||
preloadPath: string;
|
||||
rendererDist: string;
|
||||
iconPath: string;
|
||||
workerPath: string;
|
||||
devServerUrl?: string;
|
||||
}
|
||||
|
||||
export function createMainRuntime({
|
||||
hasSingleInstanceLock,
|
||||
isDev,
|
||||
devServerOrigin,
|
||||
profileDir,
|
||||
windowStatePath,
|
||||
preloadPath,
|
||||
rendererDist,
|
||||
iconPath,
|
||||
workerPath,
|
||||
devServerUrl,
|
||||
}: CreateMainRuntimeDependencies) {
|
||||
const state: {
|
||||
db: AppDatabase | null;
|
||||
apiServer: AgentApiServer | null;
|
||||
serviceGraph: Awaited<ReturnType<typeof bootstrapApplication>>["serviceGraph"] | null;
|
||||
ipcSupport: Awaited<ReturnType<typeof bootstrapApplication>>["ipcSupport"] | null;
|
||||
isQuitting: boolean;
|
||||
} = {
|
||||
db: null,
|
||||
apiServer: null,
|
||||
serviceGraph: null,
|
||||
ipcSupport: null,
|
||||
isQuitting: false,
|
||||
};
|
||||
const support = createMainRuntimeSupport(state);
|
||||
|
||||
async function bootstrap() {
|
||||
await bootstrapApplication({
|
||||
hasSingleInstanceLock,
|
||||
isDev,
|
||||
devServerOrigin,
|
||||
profileDir,
|
||||
appDatabaseFactory: (targetProfileDir) => new AppDatabase(targetProfileDir),
|
||||
initSigningKey: initializeAgentTokenSigningKey,
|
||||
windowStatePath,
|
||||
preloadPath,
|
||||
rendererDist,
|
||||
iconPath,
|
||||
devServerUrl,
|
||||
workerPath,
|
||||
createApiServer: (db, callbacks, allowedOrigins) =>
|
||||
new AgentApiServer(
|
||||
db,
|
||||
support.publishDomainChanged,
|
||||
(agentId, companyId, trigger) => callbacks.onWakeAgent(agentId, companyId, trigger),
|
||||
callbacks,
|
||||
allowedOrigins,
|
||||
),
|
||||
createRunToken: createAgentToken,
|
||||
seedDefaultAutomationRules: support.seedDefaultAutomationRules,
|
||||
isQuittingRef: {
|
||||
get current() {
|
||||
return state.isQuitting;
|
||||
},
|
||||
set current(value: boolean) {
|
||||
state.isQuitting = value;
|
||||
},
|
||||
},
|
||||
onRuntimeReady: ({ db, apiServer, serviceGraph, ipcSupport }) => {
|
||||
state.db = db;
|
||||
state.apiServer = apiServer;
|
||||
state.serviceGraph = serviceGraph;
|
||||
state.ipcSupport = ipcSupport;
|
||||
// Register IPC handlers immediately so the renderer can call loadSnapshot()
|
||||
// as soon as the window appears — before connector health checks finish.
|
||||
support.registerHandlers();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
bootstrap,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import type { AppDatabase } from "./database";
|
||||
import type { ConnectorId } from "@shared/types";
|
||||
|
||||
interface OnboardingBootstrapSummary {
|
||||
companyId: string;
|
||||
companyName: string;
|
||||
workspaceId: string | null;
|
||||
leadAgentId: string | null;
|
||||
projectId: string | null;
|
||||
taskId: string | null;
|
||||
runId: string | null;
|
||||
}
|
||||
|
||||
interface BootstrapInput {
|
||||
companyName: string;
|
||||
companyDescription: string;
|
||||
workspacePath: string;
|
||||
autoApproveHires: boolean;
|
||||
goalTitle: string;
|
||||
goalDescription: string;
|
||||
connectorId?: ConnectorId;
|
||||
}
|
||||
|
||||
interface OnboardingDependencies {
|
||||
db: AppDatabase;
|
||||
seedDefaultAutomationRules: (companyId: string) => void;
|
||||
queueTaskRun: (taskId: string, trigger: string, actor: string) => {
|
||||
ok: boolean;
|
||||
data?: string;
|
||||
error?: { code: string; message: string };
|
||||
};
|
||||
publishDomainChanged: () => void;
|
||||
}
|
||||
|
||||
const DEFERRABLE_RUN_ERRORS = new Set([
|
||||
"WORKER_UNAVAILABLE",
|
||||
"CONNECTOR_NOT_READY",
|
||||
"TASK_ALREADY_RUNNING",
|
||||
"AGENT_ALREADY_RUNNING",
|
||||
]);
|
||||
|
||||
function resolveFallbackConnector(
|
||||
readyConnectors: Array<{ id: ConnectorId }>,
|
||||
preferredConnectorId?: ConnectorId,
|
||||
) {
|
||||
return preferredConnectorId ?? readyConnectors[0]!.id;
|
||||
}
|
||||
|
||||
export function createOnboardingService({
|
||||
db,
|
||||
seedDefaultAutomationRules,
|
||||
queueTaskRun,
|
||||
publishDomainChanged,
|
||||
}: OnboardingDependencies) {
|
||||
function updateAgentWorkspaceBinding(agentId: string, workspaceId: string | null) {
|
||||
if (!workspaceId) return;
|
||||
const agent = db.getAgent(agentId);
|
||||
if (agent.workspaceId === workspaceId) return;
|
||||
db.saveAgent({
|
||||
id: agent.id,
|
||||
companyId: agent.companyId,
|
||||
name: agent.name,
|
||||
role: agent.role,
|
||||
title: agent.title,
|
||||
department: agent.department,
|
||||
status: agent.status,
|
||||
reportsTo: agent.reportsTo,
|
||||
connectorId: agent.connectorId,
|
||||
workspaceId,
|
||||
model: agent.model,
|
||||
metadataJson: agent.metadataJson,
|
||||
capabilities: agent.capabilities,
|
||||
budgetMonthlyUsd: agent.budgetMonthlyUsd,
|
||||
});
|
||||
}
|
||||
|
||||
function createDefaultWorkspace(companyId: string, workspacePath: string) {
|
||||
const trimmedPath = workspacePath.trim();
|
||||
if (!trimmedPath) return null;
|
||||
return db.saveWorkspace({
|
||||
companyId,
|
||||
name: "Default Workspace",
|
||||
localPath: trimmedPath,
|
||||
repoUrl: "",
|
||||
repoRef: "",
|
||||
isPrimary: true,
|
||||
});
|
||||
}
|
||||
|
||||
function createInitialExecutionLoop(companyId: string, workspaceId: string | null): OnboardingBootstrapSummary {
|
||||
const snapshot = db.listSnapshot();
|
||||
const company = snapshot.companies.find((entry) => entry.id === companyId) ?? null;
|
||||
if (!company) {
|
||||
throw new Error("Company no longer exists during onboarding bootstrap.");
|
||||
}
|
||||
|
||||
const leadAgent = snapshot.agents.find((agent) => agent.companyId === companyId && !agent.reportsTo) ?? null;
|
||||
if (!leadAgent) {
|
||||
throw new Error("Unable to find the lead agent for the company bootstrap.");
|
||||
}
|
||||
|
||||
updateAgentWorkspaceBinding(leadAgent.id, workspaceId);
|
||||
|
||||
const activeGoal =
|
||||
snapshot.goals.find((goal) => goal.companyId === companyId && goal.status === "active")
|
||||
?? snapshot.goals.find((goal) => goal.companyId === companyId)
|
||||
?? null;
|
||||
|
||||
const kickoffProjectName = activeGoal ? `Execution plan: ${activeGoal.title}` : "Initial operating plan";
|
||||
const kickoffProjectDescription = activeGoal
|
||||
? `Turn the founding goal "${activeGoal.title}" into an execution plan, delegation map, and the first wave of real work.`
|
||||
: "Establish the first operating plan, assign work, and start the company execution loop.";
|
||||
|
||||
const projectId = db.saveProject({
|
||||
companyId,
|
||||
goalId: activeGoal?.id ?? null,
|
||||
name: kickoffProjectName,
|
||||
description: kickoffProjectDescription,
|
||||
leadAgentId: leadAgent.id,
|
||||
targetDate: null,
|
||||
status: "in_progress",
|
||||
});
|
||||
|
||||
const kickoffTaskTitle = activeGoal ? `Operationalize goal: ${activeGoal.title}` : "Establish the first operating cadence";
|
||||
const kickoffTaskDescription = activeGoal
|
||||
? [
|
||||
`Read the founding goal "${activeGoal.title}" and convert it into a real operating plan.`,
|
||||
"Create the first execution structure inside AgentCompany: projects, tasks, delegation, approvals, and coordination messages as needed.",
|
||||
"Use the bound workspace as the default execution boundary for the first round of work.",
|
||||
].join("\n\n")
|
||||
: [
|
||||
"Establish the first operating plan for the company.",
|
||||
"Review the current company state, create the first real tasks, delegate initial work, and report progress through tasks, approvals, and messages.",
|
||||
].join("\n\n");
|
||||
|
||||
const taskId = db.saveTask({
|
||||
companyId,
|
||||
projectId,
|
||||
goalId: activeGoal?.id ?? null,
|
||||
parentId: null,
|
||||
title: kickoffTaskTitle,
|
||||
description: kickoffTaskDescription,
|
||||
assigneeAgentId: leadAgent.id,
|
||||
workspaceId: workspaceId ?? leadAgent.workspaceId ?? null,
|
||||
priority: "high",
|
||||
status: "todo",
|
||||
});
|
||||
|
||||
const startRunResult = queueTaskRun(taskId, "assignment", "system");
|
||||
if (!startRunResult.ok && !DEFERRABLE_RUN_ERRORS.has(startRunResult.error?.code ?? "")) {
|
||||
throw new Error(startRunResult.error?.message ?? "Failed to queue the initial execution loop.");
|
||||
}
|
||||
|
||||
return {
|
||||
companyId,
|
||||
companyName: company.name,
|
||||
workspaceId,
|
||||
leadAgentId: leadAgent.id,
|
||||
projectId,
|
||||
taskId,
|
||||
runId: startRunResult.ok ? startRunResult.data ?? null : null,
|
||||
};
|
||||
}
|
||||
|
||||
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 companyId = db.saveCompany({
|
||||
name: input.companyName.trim(),
|
||||
description: input.companyDescription.trim(),
|
||||
status: "active",
|
||||
autoApproveHires: input.autoApproveHires,
|
||||
});
|
||||
const workspaceId = createDefaultWorkspace(companyId, input.workspacePath);
|
||||
|
||||
const goalTitle = input.goalTitle.trim();
|
||||
if (goalTitle) {
|
||||
db.saveGoal({
|
||||
companyId,
|
||||
title: goalTitle,
|
||||
description: input.goalDescription.trim() || `Achieve: ${goalTitle}`,
|
||||
status: "active",
|
||||
parentId: null,
|
||||
ownerAgentId: null,
|
||||
});
|
||||
}
|
||||
|
||||
const ceoId = db.saveAgent({
|
||||
companyId,
|
||||
name: "CEO",
|
||||
role: "CEO",
|
||||
title: "Chief Executive Officer",
|
||||
reportsTo: null,
|
||||
connectorId: selectedConnectorId,
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities:
|
||||
"Executive leadership, strategic planning, team building, goal decomposition, delegation, and organizational management. Responsible for hiring the leadership team, setting strategy, and coordinating all company operations.",
|
||||
budgetMonthlyUsd: 250,
|
||||
status: "idle",
|
||||
});
|
||||
db.setHeartbeat(ceoId, true, 120);
|
||||
db.setCurrentCompany(companyId);
|
||||
seedDefaultAutomationRules(companyId);
|
||||
|
||||
const kickoff = createInitialExecutionLoop(companyId, workspaceId);
|
||||
|
||||
db.addActivity({
|
||||
companyId,
|
||||
actor: "board",
|
||||
action: "company.bootstrapped",
|
||||
entityType: "company",
|
||||
entityId: companyId,
|
||||
detail: `Bootstrapped from onboarding with connector ${selectedConnectorId}.`,
|
||||
});
|
||||
publishDomainChanged();
|
||||
return kickoff;
|
||||
}
|
||||
|
||||
return {
|
||||
bootstrapOnboarding,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,683 @@
|
||||
import { buildTaskPrompt } from "./prompt-builder";
|
||||
import type { AppDatabase } from "./database";
|
||||
import type { DesktopResult } from "@shared/contracts";
|
||||
|
||||
type WorkspaceLockMode = "exclusive" | "shared" | "none";
|
||||
|
||||
interface WorkspaceResolution {
|
||||
workspace: ReturnType<AppDatabase["getWorkspace"]>;
|
||||
lockMode: WorkspaceLockMode;
|
||||
source: "task" | "agent" | "project" | "company_fallback" | "none";
|
||||
}
|
||||
|
||||
interface AutomationDispatch {
|
||||
companyId: string;
|
||||
taskId?: string;
|
||||
agentId?: string;
|
||||
runId?: string;
|
||||
projectId?: string;
|
||||
department?: string;
|
||||
previousStatus?: string;
|
||||
newStatus?: string;
|
||||
costUsd?: number;
|
||||
}
|
||||
|
||||
interface EnqueueRunPayload {
|
||||
runId: string;
|
||||
companyId: string;
|
||||
taskId: string;
|
||||
taskTitle: string;
|
||||
taskDescription: string;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
connectorId: string;
|
||||
workspaceId: string | null;
|
||||
workspacePath: string | null;
|
||||
workspaceMode: WorkspaceLockMode;
|
||||
command: string;
|
||||
model: string | null;
|
||||
thinkingEffort: string | null;
|
||||
env: Record<string, string>;
|
||||
sessionParams: Record<string, unknown> | null;
|
||||
systemPrompt: string | null;
|
||||
trigger: string | null;
|
||||
}
|
||||
|
||||
interface OrchestrationDependencies {
|
||||
db: AppDatabase;
|
||||
logger: {
|
||||
info: (...args: unknown[]) => void;
|
||||
warn: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
};
|
||||
publishDomainChanged: () => void;
|
||||
notify: (title: string, body: string) => void;
|
||||
createRunToken: (agentId: string, companyId: string, runId: string) => string;
|
||||
getApiPort: () => number;
|
||||
dispatchAutomation: (trigger: string, ctx: AutomationDispatch) => void;
|
||||
hasWorkerReady: () => boolean;
|
||||
enqueueWorkerRun: (payload: EnqueueRunPayload) => void;
|
||||
}
|
||||
|
||||
export function createOrchestrationService({
|
||||
db,
|
||||
logger,
|
||||
publishDomainChanged,
|
||||
notify,
|
||||
createRunToken,
|
||||
getApiPort,
|
||||
dispatchAutomation,
|
||||
hasWorkerReady,
|
||||
enqueueWorkerRun,
|
||||
}: OrchestrationDependencies) {
|
||||
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
const scheduleLastFiredMap = new Map<string, number>();
|
||||
|
||||
function ok<T>(data: T): DesktopResult<T> {
|
||||
return { ok: true, data };
|
||||
}
|
||||
|
||||
function fail(code: string, message: string): DesktopResult<never> {
|
||||
return { ok: false, error: { code, message } };
|
||||
}
|
||||
|
||||
function hasActiveRun(filter: { taskId?: string; agentId?: string; companyId: string }) {
|
||||
return db.hasActiveRun(filter);
|
||||
}
|
||||
|
||||
function resolveWorkspace(
|
||||
task: { workspaceId: string | null; projectId?: string | null },
|
||||
agent: { workspaceId: string | null },
|
||||
companyId: string,
|
||||
): WorkspaceResolution {
|
||||
const taskWorkspace = db.getWorkspace(task.workspaceId);
|
||||
if (taskWorkspace) {
|
||||
return { workspace: taskWorkspace, lockMode: "exclusive", source: "task" };
|
||||
}
|
||||
|
||||
const agentWorkspace = db.getWorkspace(agent.workspaceId);
|
||||
if (agentWorkspace) {
|
||||
return { workspace: agentWorkspace, lockMode: "exclusive", source: "agent" };
|
||||
}
|
||||
|
||||
const projectWorkspace = db.getProjectPrimaryWorkspace(task.projectId ?? null, companyId);
|
||||
if (projectWorkspace) {
|
||||
return { workspace: projectWorkspace, lockMode: "exclusive", source: "project" };
|
||||
}
|
||||
|
||||
const fallbackWorkspace = db.getAnyCompanyWorkspace(companyId);
|
||||
if (fallbackWorkspace) {
|
||||
return { workspace: fallbackWorkspace, lockMode: "shared", source: "company_fallback" };
|
||||
}
|
||||
|
||||
return { workspace: null, lockMode: "none", source: "none" };
|
||||
}
|
||||
|
||||
function autoPauseBudgetExceededAgent(agent: { id: string; companyId: string; name: string; spentMonthlyUsd: number; budgetMonthlyUsd: number }) {
|
||||
if (!(agent.budgetMonthlyUsd > 0) || agent.spentMonthlyUsd < agent.budgetMonthlyUsd) {
|
||||
return false;
|
||||
}
|
||||
db.addActivity({
|
||||
companyId: agent.companyId,
|
||||
actor: "system",
|
||||
action: "agent.auto_paused",
|
||||
entityType: "agent",
|
||||
entityId: agent.id,
|
||||
detail: `Budget exhausted at ${agent.spentMonthlyUsd.toFixed(2)} / ${agent.budgetMonthlyUsd.toFixed(2)} USD.`,
|
||||
});
|
||||
notify("Agent paused for budget limit", agent.name);
|
||||
publishDomainChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
function ensureAgentHeartbeatEnabled(agentId: string) {
|
||||
const agent = db.getAgent(agentId);
|
||||
if (!agent.heartbeatEnabled) {
|
||||
db.setHeartbeat(agentId, true, agent.heartbeatIntervalSec || 120);
|
||||
}
|
||||
}
|
||||
|
||||
function getRunEligibilityFailure(
|
||||
agent: { status: string; budgetMonthlyUsd: number; spentMonthlyUsd: number; name: string; connectorId: string; id: string; companyId: string },
|
||||
connector: { id: string; status: string },
|
||||
) {
|
||||
if (["terminated", "paused", "pending_approval"].includes(agent.status)) {
|
||||
return "AGENT_NOT_RUNNABLE";
|
||||
}
|
||||
if (agent.budgetMonthlyUsd > 0 && agent.spentMonthlyUsd >= agent.budgetMonthlyUsd) {
|
||||
autoPauseBudgetExceededAgent(agent);
|
||||
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})`);
|
||||
return "CONNECTOR_NOT_READY";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function dispatchRunToWorker(params: {
|
||||
runId: string;
|
||||
companyId: string;
|
||||
agent: ReturnType<AppDatabase["getAgent"]>;
|
||||
task: ReturnType<AppDatabase["getTask"]>;
|
||||
connector: ReturnType<AppDatabase["getConnector"]>;
|
||||
workspace: ReturnType<AppDatabase["getWorkspace"]>;
|
||||
workspaceMode: WorkspaceLockMode;
|
||||
companyName: string;
|
||||
companyDescription: string;
|
||||
snapshot: ReturnType<AppDatabase["listSnapshot"]>;
|
||||
trigger: string;
|
||||
}) {
|
||||
const { runId, companyId, agent, task, connector, workspace, workspaceMode, companyName, companyDescription, snapshot, trigger } = params;
|
||||
if (!hasWorkerReady()) {
|
||||
throw new Error("Orchestration worker is not ready.");
|
||||
}
|
||||
|
||||
const env = db.resolveSecretEnv(connector.envBindingText, companyId);
|
||||
const apiPort = getApiPort();
|
||||
const apiUrl = apiPort ? `http://127.0.0.1:${apiPort}` : "";
|
||||
const apiKey = apiUrl ? createRunToken(agent.id, companyId, runId) : "";
|
||||
|
||||
const assignedTasks = db.listAgentTasks(agent.id, companyId, ["todo", "in_progress", "blocked"]);
|
||||
const reviewTasks = db.listReviewTasksForManager(agent.id, companyId);
|
||||
const relevantTaskIds = new Set([
|
||||
...assignedTasks.map((task) => task.id),
|
||||
...reviewTasks.map((task) => task.id),
|
||||
]);
|
||||
const allRelevantTasks = [...assignedTasks, ...reviewTasks];
|
||||
const recentComments = snapshot.comments
|
||||
.filter((comment) => relevantTaskIds.has(comment.taskId))
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
||||
.slice(0, 30)
|
||||
.map((comment) => ({
|
||||
taskId: comment.taskId,
|
||||
taskTitle: allRelevantTasks.find((task) => task.id === comment.taskId)?.title ?? comment.taskId,
|
||||
authorName: comment.authorName,
|
||||
body: comment.body,
|
||||
createdAt: comment.createdAt,
|
||||
}));
|
||||
|
||||
const systemPrompt = apiUrl ? buildTaskPrompt({
|
||||
agent,
|
||||
apiUrl,
|
||||
apiKey,
|
||||
runId,
|
||||
task,
|
||||
companyName,
|
||||
goals: snapshot.goals.filter((goal) => goal.companyId === companyId),
|
||||
projects: snapshot.projects.filter((project) => project.companyId === companyId),
|
||||
directReports: db.getDirectReports(agent.id, companyId),
|
||||
chainOfCommand: db.getChainOfCommand(agent.id),
|
||||
assignedTasks,
|
||||
allAgents: snapshot.agents.filter((entry) => entry.companyId === companyId),
|
||||
pendingApprovals: snapshot.approvals.filter(
|
||||
(approval) => approval.companyId === companyId &&
|
||||
approval.requestedByAgentId === agent.id &&
|
||||
(approval.state === "pending" || approval.state === "revision_requested"),
|
||||
),
|
||||
tasksAwaitingReview: db.listReviewTasksForManager(agent.id, companyId),
|
||||
wakeReason: trigger,
|
||||
allTasks: snapshot.tasks.filter((entry) => entry.companyId === companyId),
|
||||
recentComments,
|
||||
companyDescription,
|
||||
socialAccounts: db.listSocialAccounts(companyId).map((account) => ({
|
||||
id: account.id,
|
||||
platform: account.platform,
|
||||
accountName: account.accountName,
|
||||
status: account.status,
|
||||
requireApproval: account.requireApproval,
|
||||
})),
|
||||
recentMessages: db.listAgentMessages(companyId, { agentId: agent.id, limit: 15 }),
|
||||
upcomingMeetings: snapshot.meetings
|
||||
.filter((meeting) => meeting.companyId === companyId && (meeting.status === "scheduled" || meeting.status === "in_progress"))
|
||||
.filter((meeting) => {
|
||||
try {
|
||||
const ids: string[] = JSON.parse(meeting.participantAgentIds);
|
||||
return ids.includes(agent.id) || meeting.organizerAgentId === agent.id;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.slice(0, 5),
|
||||
relevantKnowledge: snapshot.knowledgeBase
|
||||
.filter((entry) => entry.companyId === companyId && (entry.importance === "critical" || entry.importance === "high"))
|
||||
.slice(0, 8),
|
||||
activeSprint: snapshot.sprints.find((sprint) => sprint.companyId === companyId && sprint.status === "active") ?? null,
|
||||
recentDocuments: snapshot.documents
|
||||
.filter((document) => document.companyId === companyId && (document.authorAgentId === agent.id || document.reviewerAgentId === agent.id || document.status === "in_review"))
|
||||
.slice(0, 5),
|
||||
}) : null;
|
||||
|
||||
if (apiUrl) {
|
||||
env.AGENT_COMPANY_API_URL = apiUrl;
|
||||
env.AGENT_COMPANY_API_KEY = apiKey;
|
||||
env.AGENT_COMPANY_AGENT_ID = agent.id;
|
||||
env.AGENT_COMPANY_COMPANY_ID = companyId;
|
||||
env.AGENT_COMPANY_RUN_ID = runId;
|
||||
env.AGENT_COMPANY_TASK_ID = task.id;
|
||||
env.AGENT_COMPANY_WAKE_REASON = trigger;
|
||||
}
|
||||
|
||||
const taskSession = db.getTaskSession(task.id);
|
||||
let thinkingEffort: string | null = null;
|
||||
try {
|
||||
const meta = JSON.parse(agent.metadataJson || "{}");
|
||||
if (typeof meta.thinkingEffort === "string" && meta.thinkingEffort) {
|
||||
thinkingEffort = meta.thinkingEffort;
|
||||
}
|
||||
} catch {
|
||||
thinkingEffort = null;
|
||||
}
|
||||
|
||||
enqueueWorkerRun({
|
||||
runId,
|
||||
companyId,
|
||||
taskId: task.id,
|
||||
taskTitle: task.title,
|
||||
taskDescription: task.description,
|
||||
agentId: agent.id,
|
||||
agentName: agent.name,
|
||||
connectorId: connector.id,
|
||||
workspaceId: workspaceMode === "exclusive" ? (workspace?.id ?? null) : null,
|
||||
workspacePath: workspace?.localPath ?? null,
|
||||
workspaceMode,
|
||||
command: connector.command,
|
||||
model: connector.model ?? agent.model,
|
||||
thinkingEffort,
|
||||
env,
|
||||
sessionParams: taskSession.sessionParams,
|
||||
systemPrompt,
|
||||
trigger,
|
||||
});
|
||||
}
|
||||
|
||||
function startAgentHeartbeatRun(agentId: string, companyId: string, trigger: string): string | null {
|
||||
if (!hasWorkerReady()) return null;
|
||||
const snapshot = db.listSnapshot();
|
||||
const company = snapshot.companies.find((entry) => entry.id === companyId) ?? null;
|
||||
if (!company || company.status !== "active") {
|
||||
logger.info(`[heartbeat] Skipping ${agentId} because company ${companyId} is not active.`);
|
||||
return null;
|
||||
}
|
||||
const agent = db.getAgent(agentId);
|
||||
if (agent.companyId !== companyId) {
|
||||
logger.warn(`[heartbeat] Refusing cross-company run for agent ${agentId}: ${agent.companyId} !== ${companyId}`);
|
||||
return null;
|
||||
}
|
||||
const connector = db.getConnector(agent.connectorId);
|
||||
if (getRunEligibilityFailure(agent, connector)) {
|
||||
return null;
|
||||
}
|
||||
if (hasActiveRun({ agentId: agent.id, companyId })) {
|
||||
logger.info(`[heartbeat] Skipping ${agent.name} because it already has an active run.`);
|
||||
return null;
|
||||
}
|
||||
if (db.isAgentPendingApproval(agentId)) {
|
||||
logger.info(`[heartbeat] Skipping ${agent.name} because agent is pending hire approval.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const assignedTasks = db.listAgentTasks(agentId, companyId, ["todo", "in_progress", "blocked"]);
|
||||
const isTimer = trigger === "timer";
|
||||
const candidateTasks = isTimer
|
||||
? assignedTasks.filter((entry) => !entry.title.startsWith("[Heartbeat]"))
|
||||
: assignedTasks;
|
||||
|
||||
let task = candidateTasks.find((entry) => !entry.activeRunId) ?? null;
|
||||
|
||||
if (!task) {
|
||||
const hasActiveAssignedRun = candidateTasks.some(
|
||||
(entry) => entry.activeRunId && hasActiveRun({ taskId: entry.id, companyId }),
|
||||
);
|
||||
if (hasActiveAssignedRun) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isCeo = !agent.reportsTo;
|
||||
const isManager = snapshot.agents.some((entry) => entry.reportsTo === agent.id && entry.companyId === companyId);
|
||||
const activeGoals = snapshot.goals.filter((goal) => goal.companyId === companyId && (goal.status === "active" || goal.status === "planned"));
|
||||
const companyTasks = snapshot.tasks.filter((entry) => entry.companyId === companyId && entry.status !== "done" && entry.status !== "cancelled");
|
||||
const companyNeedsBootstrap = activeGoals.length > 0 && companyTasks.length < activeGoals.length;
|
||||
|
||||
if (isTimer && !isCeo && !isManager && !companyNeedsBootstrap) {
|
||||
logger.info(`[heartbeat] Skipping ${agent.name} — no actionable tasks and trigger is timer.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const taskTitle = isCeo
|
||||
? `[Heartbeat] ${agent.name} — Strategic planning & task delegation`
|
||||
: isManager
|
||||
? `[Heartbeat] ${agent.name} — Review team & delegate work`
|
||||
: `[Heartbeat] ${agent.name} — Check for work`;
|
||||
const taskDescription = isCeo
|
||||
? "Review active company goals. Break each goal into projects and actionable tasks. Assign tasks to department leads and team members. Create tasks using the API. This is your primary responsibility as CEO — ensure every goal has a clear execution plan with assigned owners."
|
||||
: isManager
|
||||
? "Review your department's goals and your team's workload. Create tasks for your direct reports if they have capacity. Review any work submitted for your approval. Ensure your team is productive and unblocked."
|
||||
: `Automated heartbeat run for ${agent.name}. Review assigned tasks, check goals, and take action as needed.`;
|
||||
|
||||
const taskId = db.saveTask({
|
||||
companyId,
|
||||
title: taskTitle,
|
||||
description: taskDescription,
|
||||
assigneeAgentId: agentId,
|
||||
workspaceId: agent.workspaceId ?? undefined,
|
||||
status: "in_progress",
|
||||
priority: isCeo ? "high" : "medium",
|
||||
});
|
||||
task = db.getTask(taskId);
|
||||
logger.info(`[heartbeat] Created bootstrap task for ${agent.name}: ${taskTitle}`);
|
||||
}
|
||||
|
||||
if (task.activeRunId || hasActiveRun({ taskId: task.id, companyId })) {
|
||||
logger.info(`[heartbeat] Skipping ${agent.name} because task ${task.title} already has an active run.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const workspaceResolution = resolveWorkspace(task, agent, companyId);
|
||||
const workspace = workspaceResolution.workspace;
|
||||
logger.info(
|
||||
`[heartbeat] Workspace resolution for ${agent.name}: task.workspaceId=${task.workspaceId ?? "null"}, agent.workspaceId=${agent.workspaceId ?? "null"}, projectId=${task.projectId ?? "null"}, source=${workspaceResolution.source}, mode=${workspaceResolution.lockMode}, resolved=${workspace?.name ?? "NONE"}, localPath=${workspace?.localPath ?? "NONE"}`,
|
||||
);
|
||||
|
||||
const run = db.createRun({
|
||||
companyId,
|
||||
taskId: task.id,
|
||||
agentId: agent.id,
|
||||
workspaceId: workspaceResolution.lockMode === "exclusive" ? (workspace?.id ?? null) : null,
|
||||
connectorId: connector.id,
|
||||
});
|
||||
|
||||
dispatchRunToWorker({
|
||||
runId: run.id,
|
||||
companyId,
|
||||
agent,
|
||||
task,
|
||||
connector,
|
||||
workspace,
|
||||
workspaceMode: workspaceResolution.lockMode,
|
||||
companyName: company.name,
|
||||
companyDescription: company.description ?? "",
|
||||
snapshot,
|
||||
trigger,
|
||||
});
|
||||
|
||||
db.markHeartbeatAt(agentId);
|
||||
db.addActivity({
|
||||
companyId,
|
||||
actor: "system",
|
||||
action: "heartbeat.triggered",
|
||||
entityType: "agent",
|
||||
entityId: agent.id,
|
||||
detail: `${trigger} heartbeat for ${agent.name}`,
|
||||
});
|
||||
publishDomainChanged();
|
||||
return run.id;
|
||||
}
|
||||
|
||||
function wakeAgentIfPossible(agentId: string | null | undefined, companyId: string, trigger: string) {
|
||||
if (!agentId) return null;
|
||||
try {
|
||||
const agent = db.getAgent(agentId);
|
||||
if (agent.companyId !== companyId) {
|
||||
logger.warn(`[wake] Refusing cross-company wake for agent ${agentId}: ${agent.companyId} !== ${companyId}`);
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
ensureAgentHeartbeatEnabled(agentId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const agentBusy = hasActiveRun({ agentId, companyId });
|
||||
const runId = startAgentHeartbeatRun(agentId, companyId, trigger);
|
||||
if (!runId && agentBusy) {
|
||||
db.enqueuePendingWake(companyId, agentId, trigger);
|
||||
}
|
||||
return runId;
|
||||
}
|
||||
|
||||
function queueTaskRun(taskId: string, trigger: string, actor: string) {
|
||||
if (!hasWorkerReady()) {
|
||||
logger.warn(`[run] Worker not ready — cannot queue run for task ${taskId} (trigger: ${trigger})`);
|
||||
return fail("WORKER_UNAVAILABLE", "Orchestration worker is not ready.");
|
||||
}
|
||||
const task = db.getTask(taskId);
|
||||
const snapshot = db.listSnapshot();
|
||||
const company = snapshot.companies.find((entry) => entry.id === task.companyId) ?? null;
|
||||
if (!company || company.status !== "active") {
|
||||
return fail("COMPANY_NOT_ACTIVE", "Runs can only start while the company is active.");
|
||||
}
|
||||
if (task.activeRunId || hasActiveRun({ taskId: task.id, companyId: task.companyId })) {
|
||||
return fail("TASK_ALREADY_RUNNING", "This task already has an active run.");
|
||||
}
|
||||
if (!task.assigneeAgentId) return fail("TASK_UNASSIGNED", "Assign an agent before starting a run.");
|
||||
const agent = db.getAgent(task.assigneeAgentId);
|
||||
if (agent.companyId !== task.companyId) {
|
||||
return fail("AGENT_COMPANY_MISMATCH", "The assigned agent does not belong to the task company.");
|
||||
}
|
||||
const connector = db.getConnector(agent.connectorId);
|
||||
const eligibilityFailure = getRunEligibilityFailure(agent, connector);
|
||||
if (eligibilityFailure === "AGENT_NOT_RUNNABLE") {
|
||||
return fail("AGENT_NOT_RUNNABLE", "The assigned agent is paused, pending approval, or terminated.");
|
||||
}
|
||||
if (eligibilityFailure === "AGENT_BUDGET_EXHAUSTED") {
|
||||
return fail("AGENT_BUDGET_EXHAUSTED", "The assigned agent has exhausted its monthly budget.");
|
||||
}
|
||||
if (eligibilityFailure === "CONNECTOR_NOT_READY") {
|
||||
return fail("CONNECTOR_NOT_READY", "The assigned agent connector is not ready.");
|
||||
}
|
||||
if (hasActiveRun({ agentId: agent.id, companyId: task.companyId })) {
|
||||
return fail("AGENT_ALREADY_RUNNING", "The assigned agent already has an active run.");
|
||||
}
|
||||
if (db.isAgentPendingApproval(agent.id)) {
|
||||
return fail("AGENT_PENDING_APPROVAL", "This agent is pending hire approval and cannot run tasks.");
|
||||
}
|
||||
const blockingApproval = db.getBlockingApproval(task.companyId, agent.id, task.id);
|
||||
if (blockingApproval) {
|
||||
return fail(
|
||||
"APPROVAL_GATE_BLOCKED",
|
||||
`Run blocked by pending approval: ${blockingApproval.type} — "${blockingApproval.payloadSummary}". Approve or reject before starting.`,
|
||||
);
|
||||
}
|
||||
|
||||
const workspaceResolution = resolveWorkspace(task, agent, task.companyId);
|
||||
const workspace = workspaceResolution.workspace;
|
||||
if (workspace && workspace.companyId !== task.companyId) {
|
||||
return fail("WORKSPACE_COMPANY_MISMATCH", "The selected workspace does not belong to the task company.");
|
||||
}
|
||||
|
||||
const run = db.createRun({
|
||||
companyId: task.companyId,
|
||||
taskId: task.id,
|
||||
agentId: agent.id,
|
||||
workspaceId: workspaceResolution.lockMode === "exclusive" ? (workspace?.id ?? null) : null,
|
||||
connectorId: connector.id,
|
||||
});
|
||||
|
||||
dispatchRunToWorker({
|
||||
runId: run.id,
|
||||
companyId: task.companyId,
|
||||
agent,
|
||||
task,
|
||||
connector,
|
||||
workspace,
|
||||
workspaceMode: workspaceResolution.lockMode,
|
||||
companyName: company.name,
|
||||
companyDescription: company.description,
|
||||
snapshot,
|
||||
trigger,
|
||||
});
|
||||
db.addActivity({
|
||||
companyId: task.companyId,
|
||||
actor,
|
||||
action: "run.queued",
|
||||
entityType: "run",
|
||||
entityId: run.id,
|
||||
detail: task.title,
|
||||
});
|
||||
publishDomainChanged();
|
||||
return ok(run.id);
|
||||
}
|
||||
|
||||
function resumeQueuedRuns() {
|
||||
if (!hasWorkerReady()) {
|
||||
return;
|
||||
}
|
||||
const snapshot = db.listSnapshot();
|
||||
const companiesById = new Map(snapshot.companies.map((company) => [company.id, company]));
|
||||
|
||||
for (const run of db.listQueuedRuns()) {
|
||||
const company = companiesById.get(run.companyId);
|
||||
if (!company || company.status !== "active") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasBlockingSiblingRun = snapshot.runs.some((entry) =>
|
||||
entry.id !== run.id &&
|
||||
entry.companyId === run.companyId &&
|
||||
["queued", "running"].includes(entry.status) &&
|
||||
(entry.taskId === run.taskId || entry.agentId === run.agentId),
|
||||
);
|
||||
if (hasBlockingSiblingRun) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const task = db.getTask(run.taskId);
|
||||
const agent = db.getAgent(run.agentId);
|
||||
const connector = db.getConnector(run.connectorId);
|
||||
if (getRunEligibilityFailure(agent, connector)) {
|
||||
continue;
|
||||
}
|
||||
const workspaceResolution = resolveWorkspace(task, agent, run.companyId);
|
||||
dispatchRunToWorker({
|
||||
runId: run.id,
|
||||
companyId: run.companyId,
|
||||
agent,
|
||||
task,
|
||||
connector,
|
||||
workspace: workspaceResolution.workspace,
|
||||
workspaceMode: workspaceResolution.lockMode,
|
||||
companyName: company.name,
|
||||
companyDescription: company.description,
|
||||
snapshot,
|
||||
trigger: "recovery",
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn(`[run] Skipped queued run recovery for ${run.id}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startHeartbeatScheduler() {
|
||||
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
||||
|
||||
heartbeatTimer = setInterval(() => {
|
||||
try {
|
||||
if (!hasWorkerReady()) return;
|
||||
const snapshot = db.listSnapshot();
|
||||
const now = Date.now();
|
||||
|
||||
for (const company of snapshot.companies.filter((entry) => entry.status === "active")) {
|
||||
try {
|
||||
const eligibleAgents = db.listHeartbeatEligibleAgents(company.id);
|
||||
for (const agent of eligibleAgents) {
|
||||
if (hasActiveRun({ agentId: agent.id, companyId: company.id })) continue;
|
||||
|
||||
const lastHeartbeat = agent.lastHeartbeatAt ? new Date(agent.lastHeartbeatAt).getTime() : 0;
|
||||
const intervalMs = agent.heartbeatIntervalSec * 1000;
|
||||
if (now - lastHeartbeat < intervalMs) continue;
|
||||
|
||||
logger.info(`[heartbeat] Waking agent ${agent.name} (interval: ${agent.heartbeatIntervalSec}s)`);
|
||||
startAgentHeartbeatRun(agent.id, company.id, "timer");
|
||||
}
|
||||
|
||||
const companyGoals = snapshot.goals.filter((goal) => goal.companyId === company.id && (goal.status === "active" || goal.status === "planned"));
|
||||
const companyActiveTasks = snapshot.tasks.filter((task) => task.companyId === company.id && !["done", "cancelled"].includes(task.status) && !task.title.startsWith("[Heartbeat]"));
|
||||
if (companyGoals.length > 0 && companyActiveTasks.length === 0) {
|
||||
const ceo = snapshot.agents.find((agent) => agent.companyId === company.id && !agent.reportsTo && agent.status !== "terminated");
|
||||
const ceoLastWake = ceo?.lastHeartbeatAt ? new Date(ceo.lastHeartbeatAt).getTime() : 0;
|
||||
const bootstrapCooldown = 120_000;
|
||||
if (ceo && !hasActiveRun({ agentId: ceo.id, companyId: company.id }) && (now - ceoLastWake > bootstrapCooldown)) {
|
||||
logger.info(`[heartbeat] Company ${company.name} has ${companyGoals.length} goals but no tasks — bootstrapping CEO ${ceo.name}`);
|
||||
startAgentHeartbeatRun(ceo.id, company.id, "bootstrap");
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleKey = `schedule-${company.id}`;
|
||||
const lastScheduleFired = scheduleLastFiredMap.get(scheduleKey) ?? 0;
|
||||
if (now - lastScheduleFired >= 60_000) {
|
||||
dispatchAutomation("schedule", { companyId: company.id });
|
||||
scheduleLastFiredMap.set(scheduleKey, now);
|
||||
}
|
||||
|
||||
const companyAgents = snapshot.agents.filter((agent) => agent.companyId === company.id && agent.status !== "terminated" && agent.status !== "paused" && agent.status !== "pending_approval");
|
||||
for (const agent of companyAgents) {
|
||||
const hasAssigned = snapshot.tasks.some(
|
||||
(task) => task.assigneeAgentId === agent.id && task.companyId === company.id && ["todo", "in_progress", "blocked"].includes(task.status),
|
||||
);
|
||||
if (!hasAssigned) {
|
||||
const lastActive = agent.lastHeartbeatAt ? new Date(agent.lastHeartbeatAt).getTime() : 0;
|
||||
const idleThreshold = 20 * 60 * 1000;
|
||||
if (now - lastActive > idleThreshold) {
|
||||
dispatchAutomation("agent_idle", {
|
||||
companyId: company.id,
|
||||
agentId: agent.id,
|
||||
department: agent.department ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const currentMonth = new Date().toISOString().slice(0, 7);
|
||||
const lastResetMonth = db.getLastBudgetResetMonth(company.id) ?? "";
|
||||
if (currentMonth !== lastResetMonth) {
|
||||
db.resetMonthlyBudgets(company.id);
|
||||
db.setLastBudgetResetMonth(company.id, currentMonth);
|
||||
logger.info(`[heartbeat] Reset monthly budgets for company ${company.name} (${currentMonth})`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[heartbeat] Error processing company ${company.id}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const staleReleased = db.releaseStaleWorkspaceLocks();
|
||||
if (staleReleased > 0) {
|
||||
logger.info(`[heartbeat] Released ${staleReleased} stale workspace lock(s)`);
|
||||
publishDomainChanged();
|
||||
}
|
||||
const stalePendingWakes = db.purgeExpiredPendingWakes();
|
||||
if (stalePendingWakes > 0) {
|
||||
logger.info(`[heartbeat] Purged ${stalePendingWakes} stale pending wake(s)`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[heartbeat] Stale lock cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[heartbeat] Scheduler error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}, 15_000);
|
||||
}
|
||||
|
||||
function stopHeartbeatScheduler() {
|
||||
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
||||
heartbeatTimer = null;
|
||||
}
|
||||
|
||||
return {
|
||||
resolveWorkspace,
|
||||
autoPauseBudgetExceededAgent,
|
||||
wakeAgentIfPossible,
|
||||
dispatchRunToWorker,
|
||||
queueTaskRun,
|
||||
resumeQueuedRuns,
|
||||
startAgentHeartbeatRun,
|
||||
startHeartbeatScheduler,
|
||||
stopHeartbeatScheduler,
|
||||
};
|
||||
}
|
||||
@@ -1,34 +1,8 @@
|
||||
import { runningProcesses } from "@agentcompany/adapter-utils/server-utils";
|
||||
import { getConnectorDefinition } from "@main/connectors";
|
||||
import { buildSanitizedRuntimeEnv } from "@main/runtime-env";
|
||||
import type { ConnectorId, RunStatus } from "@shared/types";
|
||||
|
||||
const ENV_ALLOWLIST: ReadonlySet<string> = new Set([
|
||||
"PATH",
|
||||
"HOME", "USER", "SHELL", "LANG", "LC_ALL", "LC_CTYPE", "TERM", "TMPDIR",
|
||||
"DEVELOPER_DIR", "SDKROOT",
|
||||
"NODE_ENV", "NODE_OPTIONS",
|
||||
"GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL",
|
||||
"GIT_SSH_COMMAND", "GIT_ASKPASS", "GIT_TERMINAL_PROMPT",
|
||||
"XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "XDG_RUNTIME_DIR",
|
||||
"DISPLAY", "WAYLAND_DISPLAY", "DBUS_SESSION_BUS_ADDRESS",
|
||||
"APPDATA", "LOCALAPPDATA", "USERPROFILE", "SYSTEMROOT", "COMSPEC", "PROGRAMFILES",
|
||||
"AGENT_COMPANY_API_URL", "AGENT_COMPANY_API_KEY", "AGENT_COMPANY_RUN_ID",
|
||||
]);
|
||||
|
||||
function buildSanitizedEnv(
|
||||
hostEnv: Record<string, string>,
|
||||
secretBindings: Record<string, string>,
|
||||
): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const key of ENV_ALLOWLIST) {
|
||||
if (key in hostEnv) {
|
||||
result[key] = hostEnv[key];
|
||||
}
|
||||
}
|
||||
Object.assign(result, secretBindings);
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildRedactor(env: Record<string, string>): (text: string) => string {
|
||||
const sensitivePatterns: string[] = [];
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
@@ -114,6 +88,52 @@ function workspaceKey(job: RunJob) {
|
||||
return job.workspaceId ?? job.workspacePath ?? `task:${job.taskId}`;
|
||||
}
|
||||
|
||||
function buildConnectorExecutionConfig(job: RunJob, sanitizedEnv: Record<string, string>) {
|
||||
const baseConfig: Record<string, unknown> = {
|
||||
command: job.command,
|
||||
cwd: job.workspacePath ?? process.cwd(),
|
||||
model: job.model,
|
||||
env: sanitizedEnv,
|
||||
};
|
||||
|
||||
switch (job.connectorId) {
|
||||
case "codex_local":
|
||||
return {
|
||||
...baseConfig,
|
||||
modelReasoningEffort: job.thinkingEffort ?? "",
|
||||
dangerouslyBypassApprovalsAndSandbox: true,
|
||||
extraArgs: [
|
||||
"--skip-git-repo-check",
|
||||
"--sandbox",
|
||||
"danger-full-access",
|
||||
"--ask-for-approval",
|
||||
"never",
|
||||
],
|
||||
};
|
||||
case "claude_local":
|
||||
return {
|
||||
...baseConfig,
|
||||
effort: job.thinkingEffort ?? "",
|
||||
dangerouslySkipPermissions: true,
|
||||
};
|
||||
case "gemini_local":
|
||||
return {
|
||||
...baseConfig,
|
||||
yolo: true,
|
||||
approvalMode: "yolo",
|
||||
sandbox: false,
|
||||
};
|
||||
case "kimi_local":
|
||||
return {
|
||||
...baseConfig,
|
||||
extraArgs: ["--yolo"],
|
||||
};
|
||||
case "opencode_local":
|
||||
default:
|
||||
return baseConfig;
|
||||
}
|
||||
}
|
||||
|
||||
function enqueue(job: RunJob) {
|
||||
queue.push(job);
|
||||
post({
|
||||
@@ -151,7 +171,7 @@ async function startRun(job: RunJob) {
|
||||
message: `Running with ${definition.label}.`,
|
||||
});
|
||||
|
||||
const sanitizedEnv = buildSanitizedEnv(
|
||||
const sanitizedEnv = buildSanitizedRuntimeEnv(
|
||||
Object.fromEntries(
|
||||
Object.entries(process.env).filter((e): e is [string, string] => typeof e[1] === "string"),
|
||||
),
|
||||
@@ -178,17 +198,7 @@ async function startRun(job: RunJob) {
|
||||
typeof job.sessionParams?.sessionId === "string" ? job.sessionParams.sessionId : null,
|
||||
taskKey: job.taskId,
|
||||
},
|
||||
config: {
|
||||
command: job.command,
|
||||
cwd: job.workspacePath ?? process.cwd(),
|
||||
model: job.model,
|
||||
env: sanitizedEnv,
|
||||
effort: job.connectorId === "claude_local" ? (job.thinkingEffort ?? "") : "",
|
||||
modelReasoningEffort: job.connectorId === "codex_local" ? (job.thinkingEffort ?? "") : "",
|
||||
dangerouslySkipPermissions: true,
|
||||
dangerouslyBypassApprovalsAndSandbox: true,
|
||||
extraArgs: job.connectorId === "codex_local" ? ["--skip-git-repo-check"] : [],
|
||||
},
|
||||
config: buildConnectorExecutionConfig(job, sanitizedEnv),
|
||||
context: {
|
||||
taskId: job.taskId,
|
||||
taskTitle: job.systemPrompt ?? job.taskTitle,
|
||||
|
||||
@@ -204,15 +204,28 @@ ${ctx.assignedTasks.map((t) => `- [${t.id}] ${t.title} (status: ${t.status}, pri
|
||||
}
|
||||
|
||||
if (ctx.tasksAwaitingReview.length > 0) {
|
||||
sections.push(`# Tasks Awaiting Your Review
|
||||
${ctx.tasksAwaitingReview.map((t) => {
|
||||
const assigneeName = ctx.allAgents.find((a) => a.id === t.assigneeAgentId)?.name ?? "Unknown";
|
||||
return `- [${t.id}] ${t.title} (by ${assigneeName}, priority: ${t.priority})`;
|
||||
}).join("\n")}
|
||||
const reviewDetails = ctx.tasksAwaitingReview.map((t) => {
|
||||
const assigneeName = ctx.allAgents.find((a) => a.id === t.assigneeAgentId)?.name ?? "Unknown";
|
||||
const desc = t.description ? `\n Description: ${t.description.slice(0, 300)}${t.description.length > 300 ? "..." : ""}` : "";
|
||||
const taskComments = ctx.recentComments
|
||||
.filter((c) => c.taskId === t.id)
|
||||
.slice(-3)
|
||||
.map((c) => ` - ${c.authorName}: "${c.body.slice(0, 150)}${c.body.length > 150 ? "..." : ""}"`)
|
||||
.join("\n");
|
||||
const commentSection = taskComments ? `\n Recent comments:\n${taskComments}` : "\n No comments yet.";
|
||||
return `- [${t.id}] ${t.title} (by ${assigneeName}, priority: ${t.priority})${desc}${commentSection}`;
|
||||
}).join("\n\n");
|
||||
|
||||
These tasks have been submitted for your review by your direct reports. Read their comments, then either:
|
||||
- Approve: PATCH /api/issues/{issueId} with status "done" and an approving comment
|
||||
- Request changes: PATCH /api/issues/{issueId} with status "in_progress" and feedback comment
|
||||
sections.push(`# Tasks Awaiting Your Review (${ctx.tasksAwaitingReview.length} pending)
|
||||
|
||||
${reviewDetails}
|
||||
|
||||
REVIEW WORKFLOW — This is your HIGHEST PRIORITY after resolving blockers:
|
||||
1. For EACH task above, first read ALL comments: GET /api/issues/{issueId}/comments
|
||||
2. Evaluate whether the work meets the task description and acceptance criteria
|
||||
3. To APPROVE: PATCH /api/issues/{issueId} with {"status": "done", "comment": "Approved. <your feedback>"}
|
||||
4. To REQUEST CHANGES: PATCH /api/issues/{issueId} with {"status": "in_progress", "comment": "Changes needed: <specific feedback>"}
|
||||
5. Do NOT ignore review requests — your team is blocked until you respond
|
||||
`);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
const ENV_ALLOWLIST: ReadonlySet<string> = new Set([
|
||||
"PATH",
|
||||
"HOME",
|
||||
"USER",
|
||||
"SHELL",
|
||||
"LANG",
|
||||
"LC_ALL",
|
||||
"LC_CTYPE",
|
||||
"TERM",
|
||||
"TMPDIR",
|
||||
"DEVELOPER_DIR",
|
||||
"SDKROOT",
|
||||
"NODE_ENV",
|
||||
"NODE_OPTIONS",
|
||||
"GIT_AUTHOR_NAME",
|
||||
"GIT_AUTHOR_EMAIL",
|
||||
"GIT_COMMITTER_NAME",
|
||||
"GIT_COMMITTER_EMAIL",
|
||||
"GIT_SSH_COMMAND",
|
||||
"GIT_ASKPASS",
|
||||
"GIT_TERMINAL_PROMPT",
|
||||
"XDG_CONFIG_HOME",
|
||||
"XDG_DATA_HOME",
|
||||
"XDG_CACHE_HOME",
|
||||
"XDG_RUNTIME_DIR",
|
||||
"DISPLAY",
|
||||
"WAYLAND_DISPLAY",
|
||||
"DBUS_SESSION_BUS_ADDRESS",
|
||||
"APPDATA",
|
||||
"LOCALAPPDATA",
|
||||
"USERPROFILE",
|
||||
"SYSTEMROOT",
|
||||
"COMSPEC",
|
||||
"PROGRAMFILES",
|
||||
"PROGRAMFILES(X86)",
|
||||
"PROGRAMDATA",
|
||||
"PATHEXT",
|
||||
"AGENT_COMPANY_AGENT_ID",
|
||||
"AGENT_COMPANY_COMPANY_ID",
|
||||
"AGENT_COMPANY_API_URL",
|
||||
"AGENT_COMPANY_API_KEY",
|
||||
"AGENT_COMPANY_RUN_ID",
|
||||
"AGENT_COMPANY_TASK_ID",
|
||||
"AGENT_COMPANY_WAKE_REASON",
|
||||
"AGENT_COMPANY_WAKE_COMMENT_ID",
|
||||
"AGENT_COMPANY_APPROVAL_ID",
|
||||
"AGENT_COMPANY_APPROVAL_STATUS",
|
||||
"AGENT_COMPANY_WORKSPACE_CWD",
|
||||
"AGENT_COMPANY_WORKSPACE_SOURCE",
|
||||
"AGENT_COMPANY_WORKSPACE_STRATEGY",
|
||||
"AGENT_COMPANY_WORKSPACE_ID",
|
||||
"AGENT_COMPANY_WORKSPACE_REPO_URL",
|
||||
"AGENT_COMPANY_WORKSPACE_REPO_REF",
|
||||
"AGENT_COMPANY_WORKSPACE_BRANCH",
|
||||
"AGENT_COMPANY_WORKSPACE_WORKTREE_PATH",
|
||||
"AGENT_COMPANY_WORKSPACES_JSON",
|
||||
"AGENT_COMPANY_RUNTIME_SERVICE_INTENTS_JSON",
|
||||
"AGENT_COMPANY_RUNTIME_SERVICES_JSON",
|
||||
"AGENT_COMPANY_RUNTIME_PRIMARY_URL",
|
||||
"AGENT_COMPANY_LINKED_ISSUE_IDS",
|
||||
]);
|
||||
|
||||
const FORBIDDEN_RUNTIME_OVERRIDE_KEYS: ReadonlySet<string> = new Set([
|
||||
"PATH",
|
||||
"HOME",
|
||||
"USER",
|
||||
"SHELL",
|
||||
"NODE_OPTIONS",
|
||||
"ELECTRON_RUN_AS_NODE",
|
||||
"DYLD_INSERT_LIBRARIES",
|
||||
"LD_PRELOAD",
|
||||
"PWD",
|
||||
]);
|
||||
|
||||
function normalizeStringEnv(input: NodeJS.ProcessEnv | Record<string, string>): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(input).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
||||
);
|
||||
}
|
||||
|
||||
export function stripForbiddenRuntimeOverrides(env: Record<string, string>): Record<string, string> {
|
||||
const sanitized: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (FORBIDDEN_RUNTIME_OVERRIDE_KEYS.has(key)) {
|
||||
continue;
|
||||
}
|
||||
sanitized[key] = value;
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export function buildSanitizedRuntimeEnv(
|
||||
hostEnv: NodeJS.ProcessEnv | Record<string, string>,
|
||||
runtimeOverrides: Record<string, string>,
|
||||
): Record<string, string> {
|
||||
const normalizedHostEnv = normalizeStringEnv(hostEnv);
|
||||
const sanitized: Record<string, string> = {};
|
||||
for (const key of ENV_ALLOWLIST) {
|
||||
if (key in normalizedHostEnv) {
|
||||
sanitized[key] = normalizedHostEnv[key]!;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...sanitized,
|
||||
...stripForbiddenRuntimeOverrides(runtimeOverrides),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import type { AgentApiServer } from "./api-server";
|
||||
import type { AppDatabase } from "./database";
|
||||
import { BrowserManager } from "./browser-manager";
|
||||
import { createAutomationService } from "./automation-service";
|
||||
import { createBrowserActionService } from "./browser-action-service";
|
||||
import { createConnectorHealthService } from "./connector-health-service";
|
||||
import { createDesktopShellService } from "./desktop-shell-service";
|
||||
import { createOnboardingService } from "./onboarding-service";
|
||||
import { createOrchestrationService } from "./orchestration-service";
|
||||
import { createUpdaterService } from "./updater-service";
|
||||
import { createWorkerService } from "./worker-service";
|
||||
import { createAppLifecycleService } from "./app-lifecycle-service";
|
||||
import type { DesktopEvent, RunRecord } from "@shared/types";
|
||||
import type { AutomationEventContext } from "./automation-service";
|
||||
|
||||
interface ServiceGraphDependencies {
|
||||
db: AppDatabase;
|
||||
apiServerRef: { current: AgentApiServer | null };
|
||||
browserManagerRef: { current: BrowserManager | null };
|
||||
isQuittingRef: { current: boolean };
|
||||
preloadPath: string;
|
||||
rendererDist: string;
|
||||
iconPath: string;
|
||||
isDev: boolean;
|
||||
devServerUrl?: string;
|
||||
windowStatePath: string;
|
||||
workerPath: string;
|
||||
logger: {
|
||||
info: (...args: unknown[]) => void;
|
||||
warn: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
};
|
||||
emitEvent: (event: DesktopEvent) => void;
|
||||
publishDomainChanged: () => void;
|
||||
notify: (title: string, body: string) => void;
|
||||
createRunToken: (agentId: string, companyId: string, runId: string) => string;
|
||||
seedDefaultAutomationRules: (companyId: string) => void;
|
||||
}
|
||||
|
||||
export function createServiceGraph({
|
||||
db,
|
||||
apiServerRef,
|
||||
browserManagerRef,
|
||||
isQuittingRef,
|
||||
preloadPath,
|
||||
rendererDist,
|
||||
iconPath,
|
||||
isDev,
|
||||
devServerUrl,
|
||||
windowStatePath,
|
||||
workerPath,
|
||||
logger,
|
||||
emitEvent,
|
||||
publishDomainChanged,
|
||||
notify,
|
||||
createRunToken,
|
||||
seedDefaultAutomationRules,
|
||||
}: ServiceGraphDependencies) {
|
||||
let automationService: ReturnType<typeof createAutomationService> | null = null;
|
||||
let browserActionService: ReturnType<typeof createBrowserActionService> | null = null;
|
||||
let connectorHealthService: ReturnType<typeof createConnectorHealthService> | null = null;
|
||||
let onboardingService: ReturnType<typeof createOnboardingService> | null = null;
|
||||
let desktopShellService: ReturnType<typeof createDesktopShellService> | null = null;
|
||||
let orchestrationService: ReturnType<typeof createOrchestrationService> | null = null;
|
||||
let workerService: ReturnType<typeof createWorkerService> | null = null;
|
||||
let updaterService: ReturnType<typeof createUpdaterService> | null = null;
|
||||
let appLifecycleService: ReturnType<typeof createAppLifecycleService> | null = null;
|
||||
|
||||
const wakeAgentIfPossible = (agentId: string | null | undefined, companyId: string, trigger: string) => {
|
||||
if (!orchestrationService) {
|
||||
throw new Error("Orchestration service is not initialized.");
|
||||
}
|
||||
return orchestrationService.wakeAgentIfPossible(agentId, companyId, trigger);
|
||||
};
|
||||
|
||||
const dispatchBrowserAction = async (actionId: string, companyId: string) => {
|
||||
if (!browserActionService) {
|
||||
throw new Error("Browser action service is not initialized");
|
||||
}
|
||||
return browserActionService.dispatchBrowserAction(actionId, companyId);
|
||||
};
|
||||
|
||||
const dispatchAutomation = (trigger: string, ctx: AutomationEventContext) => {
|
||||
if (!automationService) {
|
||||
throw new Error("Automation service is not initialized.");
|
||||
}
|
||||
automationService.dispatchAutomation(trigger, ctx);
|
||||
};
|
||||
|
||||
const processRunCompletion = (run: RunRecord, event: Parameters<ReturnType<typeof createAutomationService>["processRunCompletion"]>[1]) => {
|
||||
if (!automationService) {
|
||||
throw new Error("Automation service is not initialized.");
|
||||
}
|
||||
automationService.processRunCompletion(run, event);
|
||||
};
|
||||
|
||||
const queueTaskRun = (taskId: string, trigger: string, actor: string) => {
|
||||
if (!orchestrationService) {
|
||||
throw new Error("Orchestration service is not initialized.");
|
||||
}
|
||||
return orchestrationService.queueTaskRun(taskId, trigger, actor);
|
||||
};
|
||||
|
||||
const dispatchRunToWorker = (params: Parameters<ReturnType<typeof createOrchestrationService>["dispatchRunToWorker"]>[0]) => {
|
||||
if (!orchestrationService) {
|
||||
throw new Error("Orchestration service is not initialized.");
|
||||
}
|
||||
return orchestrationService.dispatchRunToWorker(params);
|
||||
};
|
||||
|
||||
function init() {
|
||||
desktopShellService = createDesktopShellService({
|
||||
preloadPath,
|
||||
rendererDist,
|
||||
iconPath,
|
||||
isDev,
|
||||
devServerUrl,
|
||||
windowStatePath,
|
||||
shouldQuit: () => isQuittingRef.current,
|
||||
onRequestQuit: () => {
|
||||
isQuittingRef.current = true;
|
||||
},
|
||||
onCheckForUpdates: async () => {
|
||||
if (!updaterService) throw new Error("Updater service is not initialized.");
|
||||
await updaterService.checkForUpdates();
|
||||
},
|
||||
onBackupProfile: async () => {
|
||||
const target = await import("electron").then(({ dialog }) => dialog.showOpenDialog({
|
||||
properties: ["openDirectory", "createDirectory"],
|
||||
}));
|
||||
if (target.canceled || target.filePaths.length === 0) return;
|
||||
await db.exportBackup(`${target.filePaths[0]!}/agentcompany-backup-${Date.now()}`);
|
||||
},
|
||||
onShortcut: (action) => emitEvent({ type: "shortcut", action }),
|
||||
notify,
|
||||
});
|
||||
desktopShellService.initialize();
|
||||
|
||||
connectorHealthService = createConnectorHealthService({
|
||||
db,
|
||||
logger,
|
||||
publishDomainChanged,
|
||||
});
|
||||
|
||||
workerService = createWorkerService({
|
||||
db,
|
||||
logger,
|
||||
workerPath,
|
||||
isQuitting: () => isQuittingRef.current,
|
||||
publishDomainChanged,
|
||||
emitEvent,
|
||||
wakeAgentIfPossible,
|
||||
dispatchRunToWorker,
|
||||
processRunCompletion: (run, event) => processRunCompletion(run, event),
|
||||
notify,
|
||||
});
|
||||
|
||||
orchestrationService = createOrchestrationService({
|
||||
db,
|
||||
logger,
|
||||
publishDomainChanged,
|
||||
notify,
|
||||
createRunToken,
|
||||
getApiPort: () => apiServerRef.current?.getPort() ?? 0,
|
||||
dispatchAutomation,
|
||||
hasWorkerReady: () => Boolean(workerService?.getWorker()),
|
||||
enqueueWorkerRun: (payload) => {
|
||||
workerService?.getWorker()?.postMessage({ type: "enqueue-run", payload });
|
||||
},
|
||||
});
|
||||
workerService.createWorker();
|
||||
|
||||
automationService = createAutomationService({
|
||||
db,
|
||||
logger,
|
||||
emitEvent,
|
||||
publishDomainChanged,
|
||||
notify,
|
||||
wakeAgentIfPossible,
|
||||
dispatchBrowserAction,
|
||||
});
|
||||
|
||||
onboardingService = createOnboardingService({
|
||||
db,
|
||||
seedDefaultAutomationRules,
|
||||
queueTaskRun,
|
||||
publishDomainChanged,
|
||||
});
|
||||
|
||||
browserActionService = createBrowserActionService({
|
||||
db,
|
||||
browserManagerRef,
|
||||
publishDomainChanged,
|
||||
});
|
||||
browserActionService.resetBrowserManager();
|
||||
|
||||
updaterService = createUpdaterService({
|
||||
db,
|
||||
logger,
|
||||
notify,
|
||||
});
|
||||
updaterService.attachListeners();
|
||||
|
||||
appLifecycleService = createAppLifecycleService({
|
||||
getMainWindow: () => desktopShellService?.getMainWindow() ?? null,
|
||||
ensureWindow: async () => desktopShellService?.ensureWindow() ?? null,
|
||||
shouldQuit: () => isQuittingRef.current,
|
||||
onBeforeQuit: () => {
|
||||
isQuittingRef.current = true;
|
||||
orchestrationService?.stopHeartbeatScheduler();
|
||||
apiServerRef.current?.stop();
|
||||
apiServerRef.current = null;
|
||||
browserManagerRef.current?.shutdown();
|
||||
browserManagerRef.current = null;
|
||||
desktopShellService?.destroy();
|
||||
desktopShellService = null;
|
||||
workerService?.destroy();
|
||||
workerService = null;
|
||||
browserActionService = null;
|
||||
automationService = null;
|
||||
connectorHealthService = null;
|
||||
onboardingService = null;
|
||||
orchestrationService = null;
|
||||
updaterService = null;
|
||||
appLifecycleService = null;
|
||||
db.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
init,
|
||||
getDesktopShellService: () => desktopShellService,
|
||||
getConnectorHealthService: () => connectorHealthService,
|
||||
getWorkerService: () => workerService,
|
||||
getBrowserActionService: () => browserActionService,
|
||||
getOrchestrationService: () => orchestrationService,
|
||||
getAutomationService: () => automationService,
|
||||
getOnboardingService: () => onboardingService,
|
||||
getUpdaterService: () => updaterService,
|
||||
getAppLifecycleService: () => appLifecycleService,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,61 @@
|
||||
import electronUpdater from "electron-updater";
|
||||
import type { AppDatabase } from "./database";
|
||||
|
||||
const { autoUpdater } = electronUpdater;
|
||||
|
||||
interface UpdaterServiceDependencies {
|
||||
db: AppDatabase;
|
||||
logger: {
|
||||
info: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
};
|
||||
notify: (title: string, body: string) => void;
|
||||
}
|
||||
|
||||
export function createUpdaterService({
|
||||
db,
|
||||
logger,
|
||||
notify,
|
||||
}: UpdaterServiceDependencies) {
|
||||
function attachListeners() {
|
||||
autoUpdater.on("checking-for-update", () => {
|
||||
logger.info("[updater] checking for updates");
|
||||
});
|
||||
autoUpdater.on("update-available", (info) => {
|
||||
notify("Update available", `AgentCompany ${info.version} is available to download.`);
|
||||
});
|
||||
autoUpdater.on("update-not-available", () => {
|
||||
notify("No updates found", "AgentCompany is already on the latest configured version.");
|
||||
});
|
||||
autoUpdater.on("error", (error) => {
|
||||
logger.error("[updater] error", error);
|
||||
notify("Update check failed", error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
}
|
||||
|
||||
async function checkForUpdates() {
|
||||
const snapshot = db.listSnapshot();
|
||||
if (!snapshot.updatesEnabled || process.env.AGENTCOMPANY_DISABLE_UPDATES === "true") {
|
||||
return false;
|
||||
}
|
||||
const updateUrl = process.env.AGENTCOMPANY_UPDATE_URL?.trim();
|
||||
if (!updateUrl || updateUrl.includes("example.invalid")) {
|
||||
logger.info("[updater] Skipping update check because no real update URL is configured.");
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
autoUpdater.logger = logger as any;
|
||||
autoUpdater.autoDownload = false;
|
||||
await autoUpdater.checkForUpdates();
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
attachListeners,
|
||||
checkForUpdates,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import type { BrowserWindow, Rectangle } from "electron";
|
||||
|
||||
interface StoredWindowState {
|
||||
bounds: Rectangle;
|
||||
maximized: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_BOUNDS: Rectangle = {
|
||||
width: 1480,
|
||||
height: 980,
|
||||
x: 120,
|
||||
y: 80,
|
||||
};
|
||||
|
||||
function safeParseState(filePath: string): StoredWindowState | null {
|
||||
try {
|
||||
const raw = readFileSync(filePath, "utf8");
|
||||
const parsed = JSON.parse(raw) as Partial<StoredWindowState>;
|
||||
if (!parsed.bounds || typeof parsed.bounds !== "object") return null;
|
||||
const { width, height, x, y } = parsed.bounds as Partial<Rectangle>;
|
||||
if (![width, height].every((value) => typeof value === "number" && Number.isFinite(value))) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
bounds: {
|
||||
width: width!,
|
||||
height: height!,
|
||||
x: typeof x === "number" && Number.isFinite(x) ? x : DEFAULT_BOUNDS.x,
|
||||
y: typeof y === "number" && Number.isFinite(y) ? y : DEFAULT_BOUNDS.y,
|
||||
},
|
||||
maximized: parsed.maximized === true,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeState(filePath: string, state: StoredWindowState) {
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
writeFileSync(filePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
export function loadWindowState(filePath: string) {
|
||||
return safeParseState(filePath) ?? {
|
||||
bounds: DEFAULT_BOUNDS,
|
||||
maximized: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function attachWindowStatePersistence(win: BrowserWindow, filePath: string) {
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const persist = () => {
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer);
|
||||
}
|
||||
saveTimer = setTimeout(() => {
|
||||
saveTimer = null;
|
||||
writeState(filePath, {
|
||||
bounds: win.getBounds(),
|
||||
maximized: win.isMaximized(),
|
||||
});
|
||||
}, 250);
|
||||
};
|
||||
|
||||
win.on("resize", persist);
|
||||
win.on("move", persist);
|
||||
win.on("maximize", persist);
|
||||
win.on("unmaximize", persist);
|
||||
win.on("close", () => {
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = null;
|
||||
}
|
||||
writeState(filePath, {
|
||||
bounds: win.getBounds(),
|
||||
maximized: win.isMaximized(),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { utilityProcess } from "electron";
|
||||
import { z } from "zod";
|
||||
import { getConnectorDefinition } from "./connectors";
|
||||
import type { AppDatabase } from "./database";
|
||||
import type { RunRecord } from "@shared/types";
|
||||
|
||||
const runStatusSchema = z.enum(["queued", "running", "succeeded", "failed", "cancelled", "timed_out", "interrupted"]);
|
||||
export const workerMessageSchema = z.union([
|
||||
z.object({
|
||||
type: z.literal("run-log"),
|
||||
runId: z.string().uuid(),
|
||||
stream: z.enum(["stdout", "stderr"]),
|
||||
chunk: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("run-status"),
|
||||
runId: z.string().uuid(),
|
||||
status: runStatusSchema,
|
||||
message: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("run-finished"),
|
||||
runId: z.string().uuid(),
|
||||
status: runStatusSchema,
|
||||
summary: z.string().nullable(),
|
||||
errorMessage: z.string().nullable(),
|
||||
exitCode: z.number().int().nullable(),
|
||||
signal: z.string().nullable(),
|
||||
model: z.string().nullable(),
|
||||
sessionParams: z.record(z.string(), z.unknown()).nullable(),
|
||||
sessionDisplayId: z.string().nullable(),
|
||||
costUsd: z.number().nullable(),
|
||||
unattributedCost: z.boolean(),
|
||||
}),
|
||||
]);
|
||||
type WorkerUiEvent = Extract<z.infer<typeof workerMessageSchema>, { type: "run-log" | "run-status" }>;
|
||||
|
||||
interface WorkerServiceDependencies {
|
||||
db: AppDatabase;
|
||||
logger: {
|
||||
info: (...args: unknown[]) => void;
|
||||
warn: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
};
|
||||
workerPath: string;
|
||||
isQuitting: () => boolean;
|
||||
publishDomainChanged: () => void;
|
||||
emitEvent: (event: WorkerUiEvent) => void;
|
||||
wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null;
|
||||
dispatchRunToWorker: (params: {
|
||||
runId: string;
|
||||
companyId: string;
|
||||
agent: ReturnType<AppDatabase["getAgent"]>;
|
||||
task: ReturnType<AppDatabase["getTask"]>;
|
||||
connector: ReturnType<AppDatabase["getConnector"]>;
|
||||
workspace: ReturnType<AppDatabase["getWorkspace"]>;
|
||||
workspaceMode: "exclusive" | "shared" | "none";
|
||||
companyName: string;
|
||||
companyDescription: string;
|
||||
snapshot: ReturnType<AppDatabase["listSnapshot"]>;
|
||||
trigger: string;
|
||||
}) => void;
|
||||
processRunCompletion: (run: RunRecord, event: z.infer<typeof workerMessageSchema> & { type: "run-finished" }) => void;
|
||||
notify: (title: string, body: string) => void;
|
||||
}
|
||||
|
||||
export function createWorkerService({
|
||||
db,
|
||||
logger,
|
||||
workerPath,
|
||||
isQuitting,
|
||||
publishDomainChanged,
|
||||
emitEvent,
|
||||
wakeAgentIfPossible,
|
||||
dispatchRunToWorker,
|
||||
processRunCompletion,
|
||||
notify,
|
||||
}: WorkerServiceDependencies) {
|
||||
let worker: ReturnType<typeof utilityProcess.fork> | null = null;
|
||||
|
||||
function getWorker() {
|
||||
return worker;
|
||||
}
|
||||
|
||||
function createWorker() {
|
||||
worker = utilityProcess.fork(workerPath, [], {
|
||||
stdio: "pipe",
|
||||
serviceName: "AgentCompany Orchestrator",
|
||||
});
|
||||
|
||||
worker.stdout?.on("data", (chunk) => logger.info(`[worker] ${String(chunk).trim()}`));
|
||||
worker.stderr?.on("data", (chunk) => logger.error(`[worker] ${String(chunk).trim()}`));
|
||||
|
||||
worker.on("exit", (code) => {
|
||||
if (isQuitting()) return;
|
||||
|
||||
logger.warn(`[worker] Worker exited with code ${code}, recovering...`);
|
||||
|
||||
const snapshot = db.listSnapshot();
|
||||
const affectedAgentIds = new Set<string>();
|
||||
const requeueCandidates: Array<{ id: string; taskId: string; agentId: string; companyId: string; workspaceId: string | null; connectorId: string }> = [];
|
||||
|
||||
for (const run of snapshot.runs) {
|
||||
if (run.status === "running") {
|
||||
db.finishRun({
|
||||
runId: run.id,
|
||||
status: "interrupted",
|
||||
summary: "Worker process crashed.",
|
||||
errorMessage: "Worker process exited unexpectedly.",
|
||||
exitCode: code,
|
||||
signal: null,
|
||||
model: null,
|
||||
sessionDisplayId: null,
|
||||
costUsd: null,
|
||||
unattributedCost: true,
|
||||
});
|
||||
if (run.workspaceId) {
|
||||
db.releaseWorkspaceLock(run.workspaceId, run.id);
|
||||
}
|
||||
affectedAgentIds.add(run.agentId);
|
||||
|
||||
const chain = db.getChainOfCommand(run.agentId);
|
||||
if (chain.length > 0) {
|
||||
wakeAgentIfPossible(chain[0].id, run.companyId, "report_failed");
|
||||
}
|
||||
} else if (run.status === "queued") {
|
||||
requeueCandidates.push({
|
||||
id: run.id,
|
||||
taskId: run.taskId,
|
||||
agentId: run.agentId,
|
||||
companyId: run.companyId,
|
||||
workspaceId: run.workspaceId,
|
||||
connectorId: run.connectorId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const agentId of affectedAgentIds) {
|
||||
const pendingWakes = db.consumePendingWakes(agentId);
|
||||
for (const wake of pendingWakes) {
|
||||
wakeAgentIfPossible(agentId, wake.companyId, wake.trigger);
|
||||
}
|
||||
}
|
||||
|
||||
publishDomainChanged();
|
||||
|
||||
createWorker();
|
||||
|
||||
if (requeueCandidates.length > 0 && worker) {
|
||||
logger.info(`[worker] Re-enqueuing ${requeueCandidates.length} queued run(s) after worker restart.`);
|
||||
const recoverySnapshot = db.listSnapshot();
|
||||
for (const candidate of requeueCandidates) {
|
||||
try {
|
||||
const task = db.getTask(candidate.taskId);
|
||||
const agent = db.getAgent(candidate.agentId);
|
||||
const connector = db.getConnector(agent.connectorId);
|
||||
const workspaceResolution = {
|
||||
taskWorkspace: db.getWorkspace(task.workspaceId),
|
||||
agentWorkspace: db.getWorkspace(agent.workspaceId),
|
||||
projectWorkspace: db.getProjectPrimaryWorkspace(task.projectId ?? null, candidate.companyId),
|
||||
};
|
||||
const workspace = workspaceResolution.taskWorkspace
|
||||
?? workspaceResolution.agentWorkspace
|
||||
?? workspaceResolution.projectWorkspace
|
||||
?? db.getAnyCompanyWorkspace(candidate.companyId);
|
||||
const workspaceMode = workspaceResolution.taskWorkspace || workspaceResolution.agentWorkspace || workspaceResolution.projectWorkspace
|
||||
? "exclusive"
|
||||
: workspace
|
||||
? "shared"
|
||||
: "none";
|
||||
const recoveryCompany = recoverySnapshot.companies.find((company) => company.id === candidate.companyId);
|
||||
|
||||
dispatchRunToWorker({
|
||||
runId: candidate.id,
|
||||
companyId: candidate.companyId,
|
||||
agent,
|
||||
task,
|
||||
connector,
|
||||
workspace,
|
||||
workspaceMode,
|
||||
companyName: recoveryCompany?.name ?? "Company",
|
||||
companyDescription: recoveryCompany?.description ?? "",
|
||||
snapshot: recoverySnapshot,
|
||||
trigger: "recovery",
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn(`[worker] Failed to re-enqueue run ${candidate.id}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
db.finishRun({
|
||||
runId: candidate.id,
|
||||
status: "interrupted",
|
||||
summary: "Could not re-enqueue after worker restart.",
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
model: null,
|
||||
sessionDisplayId: null,
|
||||
costUsd: null,
|
||||
unattributedCost: true,
|
||||
});
|
||||
if (candidate.workspaceId) {
|
||||
db.releaseWorkspaceLock(candidate.workspaceId, candidate.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
worker.on("message", async (message) => {
|
||||
let event: z.infer<typeof workerMessageSchema>;
|
||||
try {
|
||||
event = workerMessageSchema.parse(message);
|
||||
} catch (error) {
|
||||
logger.error("[worker] invalid message payload", error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "run-log") {
|
||||
await db.appendRunLog(event.runId, event.stream, event.chunk);
|
||||
emitEvent(event);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "run-status") {
|
||||
db.markRunStatus(event.runId, event.status, event.message);
|
||||
if (event.status === "running") {
|
||||
const run = db.listSnapshot().runs.find((entry) => entry.id === event.runId);
|
||||
if (run?.workspaceId && !db.claimWorkspaceLock(run.workspaceId, run.id)) {
|
||||
logger.error(`[workspace] Failed to claim lock for workspace ${run.workspaceId} when run ${run.id} entered running state. Cancelling run.`);
|
||||
worker?.postMessage({ type: "cancel-run", runId: run.id });
|
||||
}
|
||||
}
|
||||
emitEvent(event);
|
||||
publishDomainChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = db.listSnapshot();
|
||||
const run = snapshot.runs.find((entry) => entry.id === event.runId);
|
||||
if (!run) {
|
||||
return;
|
||||
}
|
||||
|
||||
const connector = getConnectorDefinition(run.connectorId);
|
||||
|
||||
let task: ReturnType<AppDatabase["getTask"]> | null = null;
|
||||
try {
|
||||
task = db.getTask(run.taskId);
|
||||
} catch {
|
||||
task = null;
|
||||
}
|
||||
|
||||
if (task) {
|
||||
if (event.sessionParams && connector.module.sessionCodec) {
|
||||
const serialized = connector.module.sessionCodec.serialize(event.sessionParams);
|
||||
db.updateTaskSession(task.id, serialized, connector.module.sessionCodec.getDisplayId?.(serialized ?? null) ?? event.sessionDisplayId ?? null);
|
||||
}
|
||||
if (!event.sessionParams) {
|
||||
db.updateTaskSession(task.id, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
const runOutcome = db.finishRun(event);
|
||||
if (run.workspaceId) {
|
||||
db.releaseWorkspaceLock(run.workspaceId, run.id);
|
||||
}
|
||||
db.addActivity({
|
||||
companyId: run.companyId,
|
||||
actor: run.agentId,
|
||||
action: `run.${event.status}`,
|
||||
entityType: "run",
|
||||
entityId: run.id,
|
||||
detail: event.summary ?? event.errorMessage ?? "",
|
||||
});
|
||||
publishDomainChanged();
|
||||
emitEvent({
|
||||
type: "run-status",
|
||||
runId: run.id,
|
||||
status: event.status,
|
||||
message: event.summary ?? event.errorMessage ?? event.status,
|
||||
});
|
||||
if ((event.status === "failed" || event.status === "timed_out" || event.status === "interrupted") && task) {
|
||||
notify("Run attention required", task.title);
|
||||
}
|
||||
|
||||
let updatedAgent: ReturnType<AppDatabase["getAgent"]> | null = null;
|
||||
try {
|
||||
updatedAgent = db.getAgent(run.agentId);
|
||||
} catch {
|
||||
updatedAgent = null;
|
||||
}
|
||||
|
||||
if (updatedAgent && runOutcome.budgetHardStopped) {
|
||||
db.addActivity({
|
||||
companyId: updatedAgent.companyId,
|
||||
actor: "system",
|
||||
action: "agent.auto_paused",
|
||||
entityType: "agent",
|
||||
entityId: updatedAgent.id,
|
||||
detail: `Budget exhausted at ${updatedAgent.spentMonthlyUsd.toFixed(2)} / ${updatedAgent.budgetMonthlyUsd.toFixed(2)} USD.`,
|
||||
});
|
||||
notify("Agent paused for budget limit", updatedAgent.name);
|
||||
publishDomainChanged();
|
||||
}
|
||||
|
||||
if (event.status === "failed" || event.status === "timed_out") {
|
||||
try {
|
||||
const chain = db.getChainOfCommand(run.agentId);
|
||||
if (chain.length > 0) {
|
||||
wakeAgentIfPossible(chain[0].id, run.companyId, "report_failed");
|
||||
}
|
||||
} catch {
|
||||
// Agent may have been deleted while resolving the escalation chain.
|
||||
}
|
||||
}
|
||||
|
||||
const pendingWakes = db.consumePendingWakes(run.agentId);
|
||||
if (pendingWakes.length > 0) {
|
||||
for (const wake of pendingWakes) {
|
||||
wakeAgentIfPossible(run.agentId, wake.companyId, wake.trigger);
|
||||
}
|
||||
}
|
||||
|
||||
processRunCompletion(run, event as z.infer<typeof workerMessageSchema> & { type: "run-finished" });
|
||||
});
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
worker?.kill();
|
||||
worker = null;
|
||||
}
|
||||
|
||||
return {
|
||||
createWorker,
|
||||
getWorker,
|
||||
destroy,
|
||||
};
|
||||
}
|
||||
@@ -24,6 +24,7 @@ const api: DesktopApi = {
|
||||
startTaskRun: (input) => ipcRenderer.invoke(IPC_CHANNELS.startTaskRun, input),
|
||||
cancelRun: (input) => ipcRenderer.invoke(IPC_CHANNELS.cancelRun, input),
|
||||
getRunLog: (input) => ipcRenderer.invoke(IPC_CHANNELS.getRunLog, input),
|
||||
getRunLogChunk: (input) => ipcRenderer.invoke(IPC_CHANNELS.getRunLogChunk, input),
|
||||
backupProfile: () => ipcRenderer.invoke(IPC_CHANNELS.backupProfile),
|
||||
restoreProfile: () => ipcRenderer.invoke(IPC_CHANNELS.restoreProfile),
|
||||
updateSettings: (input) => ipcRenderer.invoke(IPC_CHANNELS.updateSettings, input),
|
||||
@@ -38,6 +39,7 @@ const api: DesktopApi = {
|
||||
deleteSecret: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteSecret, input),
|
||||
getSidebarBadges: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.getSidebarBadges, companyId),
|
||||
listConnectorModels: (connectorId) => ipcRenderer.invoke(IPC_CHANNELS.listConnectorModels, connectorId),
|
||||
openConnectorAuthTerminal: (connectorId) => ipcRenderer.invoke(IPC_CHANNELS.openConnectorAuthTerminal, connectorId),
|
||||
addComment: (input) => ipcRenderer.invoke(IPC_CHANNELS.addComment, input),
|
||||
listComments: (input) => ipcRenderer.invoke(IPC_CHANNELS.listComments, input),
|
||||
setHeartbeat: (input) => ipcRenderer.invoke(IPC_CHANNELS.setHeartbeat, input),
|
||||
@@ -47,13 +49,12 @@ const api: DesktopApi = {
|
||||
getCompanyMetrics: (input) => ipcRenderer.invoke(IPC_CHANNELS.getCompanyMetrics, input),
|
||||
getAgentMetrics: (input) => ipcRenderer.invoke(IPC_CHANNELS.getAgentMetrics, input),
|
||||
getStandupReport: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.getStandupReport, companyId),
|
||||
applyCompanyTemplate: (input) => ipcRenderer.invoke(IPC_CHANNELS.applyCompanyTemplate, input),
|
||||
listCompanyTemplates: () => ipcRenderer.invoke(IPC_CHANNELS.listCompanyTemplates),
|
||||
bootstrapOnboarding: (input) => ipcRenderer.invoke(IPC_CHANNELS.bootstrapOnboarding, input),
|
||||
saveSocialAccount: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveSocialAccount, input),
|
||||
deleteSocialAccount: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteSocialAccount, input),
|
||||
listBrowserActions: (input) => ipcRenderer.invoke(IPC_CHANNELS.listBrowserActions, input),
|
||||
cancelBrowserAction: (input) => ipcRenderer.invoke(IPC_CHANNELS.cancelBrowserAction, input),
|
||||
triggerBrowserLogin: (id) => ipcRenderer.invoke(IPC_CHANNELS.triggerBrowserLogin, id),
|
||||
triggerBrowserLogin: (input) => ipcRenderer.invoke(IPC_CHANNELS.triggerBrowserLogin, input),
|
||||
saveMeeting: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveMeeting, input),
|
||||
deleteMeeting: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteMeeting, input),
|
||||
saveDocument: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveDocument, input),
|
||||
|
||||
+346
-93
@@ -34,7 +34,6 @@ import {
|
||||
SidebarSimple,
|
||||
TerminalWindow,
|
||||
WarningCircle,
|
||||
Wrench,
|
||||
X,
|
||||
} from "@phosphor-icons/react";
|
||||
import type {
|
||||
@@ -42,6 +41,7 @@ import type {
|
||||
CompanyMetrics,
|
||||
DesktopEvent,
|
||||
InboxItem,
|
||||
OnboardingBootstrapResult,
|
||||
ProfileSnapshot,
|
||||
RunRecord,
|
||||
SectionId,
|
||||
@@ -55,6 +55,7 @@ import { Inbox, StandupView, PerformanceView } from "./components/inbox";
|
||||
import { ConnectorEditor } from "./components/connectors";
|
||||
import { Dashboard, KanbanBoard } from "./components/dashboard";
|
||||
import { EntityWorkspace } from "./components/EntityWorkspace";
|
||||
import { RunCenter } from "./components/runs";
|
||||
import {
|
||||
ActivityList,
|
||||
ApprovalInspector,
|
||||
@@ -94,6 +95,11 @@ import { formatMoney, formatTime } from "./lib/formatters";
|
||||
|
||||
type NavItem = { id: SectionId; label: string; icon: typeof SquaresFour };
|
||||
type NavGroup = { label: string; items: NavItem[] };
|
||||
type PaletteAction = {
|
||||
key: string;
|
||||
section: SectionId;
|
||||
entityId?: string;
|
||||
};
|
||||
|
||||
const navGroups: NavGroup[] = [
|
||||
{
|
||||
@@ -103,6 +109,7 @@ const navGroups: NavGroup[] = [
|
||||
{ id: "inbox", label: "Inbox", icon: Bell },
|
||||
{ id: "standup", label: "Standup", icon: Heartbeat },
|
||||
{ id: "performance", label: "Performance", icon: ChartBar },
|
||||
{ id: "runs", label: "Runs", icon: TerminalWindow },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -180,6 +187,7 @@ export function App() {
|
||||
const [, setLogRevision] = useState(0);
|
||||
const [badges, setBadges] = useState<SidebarBadges | null>(null);
|
||||
const [commandPaletteSearch, setCommandPaletteSearch] = useState("");
|
||||
const [selectedPaletteIndex, setSelectedPaletteIndex] = useState(0);
|
||||
const [showOnboarding, setShowOnboarding] = useState(false);
|
||||
const [initialOnboardingLocked, setInitialOnboardingLocked] = useState(false);
|
||||
const [inboxItems, setInboxItems] = useState<InboxItem[]>([]);
|
||||
@@ -188,6 +196,23 @@ export function App() {
|
||||
const [socialAccounts, setSocialAccounts] = useState<SocialAccountRecord[]>([]);
|
||||
const [browserActions, setBrowserActions] = useState<BrowserActionRecord[]>([]);
|
||||
|
||||
const clearCompanyScopedUiState = useCallback(() => {
|
||||
setSelectedTaskId(null);
|
||||
setSelectedApprovalId(null);
|
||||
setSelectedRunId(null);
|
||||
setSelectedAgentId(null);
|
||||
setSelectedGoalId(null);
|
||||
setSelectedProjectId(null);
|
||||
setSelectedWorkspaceId(null);
|
||||
setSelectedCommunicationEntityId(null);
|
||||
setInboxItems([]);
|
||||
setCompanyMetrics(null);
|
||||
setStandupReport(null);
|
||||
setSocialAccounts([]);
|
||||
setBrowserActions([]);
|
||||
setBadges(null);
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const next = unwrap<ProfileSnapshot>(await window.agentCompany.loadSnapshot());
|
||||
@@ -200,6 +225,8 @@ export function App() {
|
||||
if (next.currentCompanyId) {
|
||||
const badgeResult = await window.agentCompany.getSidebarBadges(next.currentCompanyId);
|
||||
if (badgeResult.ok) setBadges(badgeResult.data);
|
||||
const inboxResult = await window.agentCompany.getInbox(next.currentCompanyId);
|
||||
if (inboxResult.ok) setInboxItems(inboxResult.data);
|
||||
setSocialAccounts(next.socialAccounts?.filter(a => a.companyId === next.currentCompanyId) ?? []);
|
||||
const browserActionResult = await window.agentCompany.listBrowserActions({ companyId: next.currentCompanyId, limit: 50 });
|
||||
if (browserActionResult.ok) {
|
||||
@@ -234,10 +261,19 @@ export function App() {
|
||||
void refreshRef.current();
|
||||
const unsubscribe = window.agentCompany.subscribe((event: DesktopEvent) => {
|
||||
if (event.type === "run-log") {
|
||||
logBufferRef.current = {
|
||||
...logBufferRef.current,
|
||||
[event.runId]: `${logBufferRef.current[event.runId] ?? ""}${event.chunk}`,
|
||||
};
|
||||
const MAX_LOG_BYTES_PER_RUN = 512_000;
|
||||
const MAX_TRACKED_RUNS = 50;
|
||||
const existing = logBufferRef.current[event.runId] ?? "";
|
||||
const updated = existing.length < MAX_LOG_BYTES_PER_RUN
|
||||
? existing + event.chunk
|
||||
: existing.slice(-MAX_LOG_BYTES_PER_RUN / 2) + event.chunk;
|
||||
logBufferRef.current[event.runId] = updated;
|
||||
const runIds = Object.keys(logBufferRef.current);
|
||||
if (runIds.length > MAX_TRACKED_RUNS) {
|
||||
for (const oldId of runIds.slice(0, runIds.length - MAX_TRACKED_RUNS)) {
|
||||
delete logBufferRef.current[oldId];
|
||||
}
|
||||
}
|
||||
setLogRevision((r) => r + 1);
|
||||
return;
|
||||
}
|
||||
@@ -257,19 +293,6 @@ export function App() {
|
||||
};
|
||||
}, [debouncedRefresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedRunId) return;
|
||||
void (async () => {
|
||||
const result = await window.agentCompany.getRunLog({ runId: selectedRunId });
|
||||
if (result.ok) {
|
||||
logBufferRef.current = { ...logBufferRef.current, [selectedRunId]: result.data };
|
||||
setLogRevision((r) => r + 1);
|
||||
return;
|
||||
}
|
||||
setError(result.error.message);
|
||||
})();
|
||||
}, [selectedRunId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!snapshot) return;
|
||||
const root = document.documentElement;
|
||||
@@ -280,41 +303,50 @@ export function App() {
|
||||
useEffect(() => {
|
||||
if (!snapshot?.currentCompanyId) return;
|
||||
const companyId = snapshot.currentCompanyId;
|
||||
let cancelled = false;
|
||||
|
||||
if (section === "inbox") {
|
||||
setInboxItems([]);
|
||||
}
|
||||
|
||||
if (section === "performance") {
|
||||
setCompanyMetrics(null);
|
||||
}
|
||||
|
||||
if (section === "standup") {
|
||||
setStandupReport(null);
|
||||
}
|
||||
|
||||
if (section === "social") {
|
||||
setSocialAccounts([]);
|
||||
}
|
||||
|
||||
if (section === "inbox") {
|
||||
void window.agentCompany.getInbox(companyId).then((result) => {
|
||||
if (result.ok) setInboxItems(result.data);
|
||||
if (!cancelled && result.ok) setInboxItems(result.data);
|
||||
});
|
||||
}
|
||||
|
||||
if (section === "performance") {
|
||||
void window.agentCompany.getCompanyMetrics({ companyId, days: 30 }).then((result) => {
|
||||
if (result.ok) setCompanyMetrics(result.data);
|
||||
if (!cancelled && result.ok) setCompanyMetrics(result.data);
|
||||
});
|
||||
}
|
||||
|
||||
if (section === "standup") {
|
||||
void window.agentCompany.getStandupReport(companyId).then((result) => {
|
||||
if (result.ok) setStandupReport(result.data);
|
||||
if (!cancelled && result.ok) setStandupReport(result.data);
|
||||
});
|
||||
}
|
||||
|
||||
if (section === "social") {
|
||||
setSocialAccounts(snapshot.socialAccounts?.filter(a => a.companyId === companyId) ?? []);
|
||||
setSocialAccounts(snapshot.socialAccounts?.filter((a) => a.companyId === companyId) ?? []);
|
||||
}
|
||||
}, [section, snapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!commandPaletteOpen) return;
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
setCommandPaletteOpen(false);
|
||||
setCommandPaletteSearch("");
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [commandPaletteOpen]);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [section, snapshot]);
|
||||
|
||||
const paletteTrapRef = useFocusTrap(commandPaletteOpen);
|
||||
|
||||
@@ -327,10 +359,36 @@ export function App() {
|
||||
const selectedTask = selectedTaskId ? (tasks.find((task) => task.id === selectedTaskId) ?? null) : null;
|
||||
const selectedConnector = snapshot?.connectors.find((connector) => connector.id === selectedConnectorId) ?? snapshot?.connectors[0] ?? null;
|
||||
const approvals = useMemo(() => snapshot?.approvals.filter((approval) => !currentCompany || approval.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
||||
const pendingApprovals = useMemo(() => approvals.filter((approval) => approval.state === "pending"), [approvals]);
|
||||
const selectedApproval = selectedApprovalId ? (approvals.find((approval) => approval.id === selectedApprovalId) ?? null) : null;
|
||||
const runs = useMemo(() => snapshot?.runs.filter((run) => !currentCompany || run.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
||||
const selectedRun = runs.find((run) => run.id === selectedRunId) ?? runs[0] ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedRunId && !runs.some((run) => run.id === selectedRunId)) {
|
||||
setSelectedRunId(runs[0]?.id ?? null);
|
||||
}
|
||||
}, [runs, selectedRunId]);
|
||||
|
||||
const activeRunId = selectedRun?.id ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeRunId) return;
|
||||
void (async () => {
|
||||
const activeRun = runs.find((run) => run.id === activeRunId);
|
||||
if (!activeRun) {
|
||||
return;
|
||||
}
|
||||
const result = await window.agentCompany.getRunLog({ companyId: activeRun.companyId, runId: activeRunId });
|
||||
if (result.ok) {
|
||||
logBufferRef.current = { ...logBufferRef.current, [activeRunId]: result.data };
|
||||
setLogRevision((r) => r + 1);
|
||||
return;
|
||||
}
|
||||
setError(result.error.message);
|
||||
})();
|
||||
}, [activeRunId, runs]);
|
||||
|
||||
const companyAgents = useMemo(() => snapshot?.agents.filter((a) => !currentCompany || a.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
||||
const selectedAgent = companyAgents.find((a) => a.id === selectedAgentId) ?? null;
|
||||
const companyGoals = useMemo(() => snapshot?.goals.filter((g) => !currentCompany || g.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
||||
@@ -341,6 +399,12 @@ export function App() {
|
||||
const activeInboxCount = inboxItems.length;
|
||||
const navBadges = useMemo<Partial<Record<SectionId, { count: number; tone: "default" | "warn" | "danger" }>>>(() => badges ? {
|
||||
inbox: activeInboxCount > 0 ? { count: activeInboxCount, tone: "warn" } : undefined,
|
||||
runs:
|
||||
badges.failedRuns > 0
|
||||
? { count: badges.failedRuns, tone: "danger" }
|
||||
: badges.activeRuns > 0
|
||||
? { count: badges.activeRuns, tone: "default" }
|
||||
: undefined,
|
||||
tasks: badges.todoTasks > 0 ? { count: badges.todoTasks, tone: "default" } : undefined,
|
||||
approvals: badges.pendingApprovals > 0 ? { count: badges.pendingApprovals, tone: "warn" } : undefined,
|
||||
costs: badges.budgetWarnings > 0 ? { count: badges.budgetWarnings, tone: "danger" } : undefined,
|
||||
@@ -363,24 +427,99 @@ export function App() {
|
||||
: navItems;
|
||||
const matchedAgents = paletteQuery ? companyAgents.filter((a) => a.name.toLowerCase().includes(paletteQuery) || a.role.toLowerCase().includes(paletteQuery)).slice(0, 5) : [];
|
||||
const matchedTasks = paletteQuery ? tasks.filter((t) => t.title.toLowerCase().includes(paletteQuery)).slice(0, 5) : [];
|
||||
const matchedRuns = paletteQuery ? runs.filter((run) => {
|
||||
const matchingTask = tasks.find((task) => task.id === run.taskId);
|
||||
return [
|
||||
run.id,
|
||||
run.summary ?? "",
|
||||
run.errorMessage ?? "",
|
||||
matchingTask?.title ?? "",
|
||||
run.connectorId,
|
||||
].some((value) => value.toLowerCase().includes(paletteQuery));
|
||||
}).slice(0, 5) : [];
|
||||
const matchedGoals = paletteQuery ? companyGoals.filter((g) => g.title.toLowerCase().includes(paletteQuery)).slice(0, 5) : [];
|
||||
const matchedProjects = paletteQuery ? companyProjects.filter((p) => p.name.toLowerCase().includes(paletteQuery)).slice(0, 5) : [];
|
||||
const hasEntityResults = matchedAgents.length > 0 || matchedTasks.length > 0 || matchedGoals.length > 0 || matchedProjects.length > 0;
|
||||
return { filteredNavItems, matchedAgents, matchedTasks, matchedGoals, matchedProjects, hasEntityResults };
|
||||
}, [paletteQuery, companyAgents, tasks, companyGoals, companyProjects]);
|
||||
const { filteredNavItems, matchedAgents, matchedTasks, matchedGoals, matchedProjects, hasEntityResults } = paletteResults;
|
||||
const hasEntityResults = matchedAgents.length > 0 || matchedTasks.length > 0 || matchedRuns.length > 0 || matchedGoals.length > 0 || matchedProjects.length > 0;
|
||||
return { filteredNavItems, matchedAgents, matchedTasks, matchedRuns, matchedGoals, matchedProjects, hasEntityResults };
|
||||
}, [paletteQuery, companyAgents, companyGoals, companyProjects, runs, tasks]);
|
||||
const { filteredNavItems, matchedAgents, matchedTasks, matchedRuns, matchedGoals, matchedProjects, hasEntityResults } = paletteResults;
|
||||
const paletteActions = useMemo<PaletteAction[]>(() => ([
|
||||
...filteredNavItems.map((item) => ({ key: `section:${item.id}`, section: item.id })),
|
||||
...matchedAgents.map((agent) => ({ key: `agent:${agent.id}`, section: "agents" as const, entityId: agent.id })),
|
||||
...matchedTasks.map((task) => ({ key: `task:${task.id}`, section: "tasks" as const, entityId: task.id })),
|
||||
...matchedRuns.map((run) => ({ key: `run:${run.id}`, section: "runs" as const, entityId: run.id })),
|
||||
...matchedGoals.map((goal) => ({ key: `goal:${goal.id}`, section: "goals" as const, entityId: goal.id })),
|
||||
...matchedProjects.map((project) => ({ key: `project:${project.id}`, section: "projects" as const, entityId: project.id })),
|
||||
]), [filteredNavItems, matchedAgents, matchedTasks, matchedRuns, matchedGoals, matchedProjects]);
|
||||
const paletteActionIndex = useMemo(() => new Map(paletteActions.map((action, index) => [action.key, index])), [paletteActions]);
|
||||
|
||||
const navigateTo = useCallback((targetSection: SectionId, entityId?: string) => {
|
||||
setSection(targetSection);
|
||||
if (entityId) {
|
||||
if (targetSection === "agents") setSelectedAgentId(entityId);
|
||||
else if (targetSection === "tasks") setSelectedTaskId(entityId);
|
||||
else if (targetSection === "connectors") setSelectedConnectorId(entityId);
|
||||
else if (targetSection === "runs") {
|
||||
setSelectedRunId(entityId);
|
||||
setConsoleOpen(true);
|
||||
}
|
||||
else if (targetSection === "tasks") {
|
||||
const matchingTask = tasks.find((task) => task.id === entityId) ?? null;
|
||||
if (matchingTask) {
|
||||
setSelectedTaskId(matchingTask.id);
|
||||
} else {
|
||||
const matchingRun = runs.find((run) => run.id === entityId) ?? null;
|
||||
if (matchingRun) {
|
||||
setSelectedRunId(matchingRun.id);
|
||||
setSelectedTaskId(matchingRun.taskId);
|
||||
setConsoleOpen(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (targetSection === "approvals") setSelectedApprovalId(entityId);
|
||||
else if (targetSection === "goals") setSelectedGoalId(entityId);
|
||||
else if (targetSection === "projects") setSelectedProjectId(entityId);
|
||||
else if (targetSection === "communication") setSelectedCommunicationEntityId(entityId);
|
||||
}
|
||||
}, []);
|
||||
}, [runs, tasks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!commandPaletteOpen) return;
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
setCommandPaletteOpen(false);
|
||||
setCommandPaletteSearch("");
|
||||
setSelectedPaletteIndex(0);
|
||||
return;
|
||||
}
|
||||
if (paletteActions.length === 0) return;
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setSelectedPaletteIndex((current) => (current + 1) % paletteActions.length);
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setSelectedPaletteIndex((current) => (current - 1 + paletteActions.length) % paletteActions.length);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
const selectedAction = paletteActions[Math.min(selectedPaletteIndex, paletteActions.length - 1)];
|
||||
if (!selectedAction) return;
|
||||
navigateTo(selectedAction.section, selectedAction.entityId);
|
||||
setCommandPaletteOpen(false);
|
||||
setCommandPaletteSearch("");
|
||||
setSelectedPaletteIndex(0);
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [commandPaletteOpen, navigateTo, paletteActions, selectedPaletteIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!commandPaletteOpen) return;
|
||||
setSelectedPaletteIndex(0);
|
||||
}, [commandPaletteOpen, commandPaletteSearch]);
|
||||
|
||||
const setCompany = useCallback(async (companyId: string) => {
|
||||
if (companyId === "__new__") {
|
||||
@@ -389,12 +528,13 @@ export function App() {
|
||||
}
|
||||
try {
|
||||
unwrap(await window.agentCompany.setCurrentCompany(companyId));
|
||||
clearCompanyScopedUiState();
|
||||
await refresh();
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}, [refresh]);
|
||||
}, [clearCompanyScopedUiState, refresh]);
|
||||
|
||||
const deleteCompany = useCallback(async (companyId: string) => {
|
||||
try {
|
||||
@@ -408,7 +548,11 @@ export function App() {
|
||||
}, [refresh]);
|
||||
|
||||
const startRun = useCallback(async (taskId: string) => {
|
||||
const result = await window.agentCompany.startTaskRun({ taskId });
|
||||
if (!currentCompany) {
|
||||
setError("Select a company before starting a run.");
|
||||
return;
|
||||
}
|
||||
const result = await window.agentCompany.startTaskRun({ companyId: currentCompany.id, taskId });
|
||||
if (!result.ok) {
|
||||
setError(result.error.message);
|
||||
return;
|
||||
@@ -416,29 +560,49 @@ export function App() {
|
||||
setSelectedRunId(result.data);
|
||||
setConsoleOpen(true);
|
||||
await refresh();
|
||||
}, [refresh]);
|
||||
}, [currentCompany, refresh]);
|
||||
|
||||
const cancelRun = useCallback(async (runId: string) => {
|
||||
try {
|
||||
unwrap(await window.agentCompany.cancelRun({ runId }));
|
||||
const run = runs.find((entry) => entry.id === runId);
|
||||
if (!run) {
|
||||
throw new Error("Selected run is no longer available.");
|
||||
}
|
||||
unwrap(await window.agentCompany.cancelRun({ companyId: run.companyId, runId }));
|
||||
await refresh();
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}, [refresh, runs]);
|
||||
|
||||
const handleOnboardingDone = useCallback(async (summary: OnboardingBootstrapResult) => {
|
||||
setShowOnboarding(false);
|
||||
setInitialOnboardingLocked(false);
|
||||
await refresh();
|
||||
setError(null);
|
||||
if (summary.runId) {
|
||||
setSection("runs");
|
||||
setSelectedRunId(summary.runId);
|
||||
setConsoleOpen(true);
|
||||
return;
|
||||
}
|
||||
if (summary.taskId) {
|
||||
setSection("tasks");
|
||||
setSelectedTaskId(summary.taskId);
|
||||
return;
|
||||
}
|
||||
if (summary.projectId) {
|
||||
setSection("projects");
|
||||
setSelectedProjectId(summary.projectId);
|
||||
return;
|
||||
}
|
||||
if (summary.leadAgentId) {
|
||||
setSection("agents");
|
||||
setSelectedAgentId(summary.leadAgentId);
|
||||
}
|
||||
}, [refresh]);
|
||||
|
||||
const quickConnectorCheck = useCallback(async () => {
|
||||
if (!selectedConnector) return;
|
||||
try {
|
||||
unwrap(await window.agentCompany.testConnector(selectedConnector.id));
|
||||
await refresh();
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}, [selectedConnector, refresh]);
|
||||
|
||||
if (!snapshot) {
|
||||
return (
|
||||
<div className="flex min-h-[100dvh] items-center justify-center text-[13px] text-[color:var(--muted)]">
|
||||
@@ -448,10 +612,10 @@ export function App() {
|
||||
}
|
||||
|
||||
if (snapshot.companies.length === 0 || showOnboarding || initialOnboardingLocked) {
|
||||
return <CompanyOnboarding connectors={snapshot.connectors} onDone={async () => {
|
||||
return <CompanyOnboarding connectors={snapshot.connectors} onDone={handleOnboardingDone} canCancel={snapshot.companies.length > 0 && !initialOnboardingLocked} onCancel={() => {
|
||||
setShowOnboarding(false);
|
||||
setInitialOnboardingLocked(false);
|
||||
await refresh();
|
||||
setError(null);
|
||||
}} />;
|
||||
}
|
||||
|
||||
@@ -688,6 +852,27 @@ export function App() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{section === "runs" ? (
|
||||
<RunCenter
|
||||
runs={runs}
|
||||
tasks={tasks}
|
||||
agents={companyAgents}
|
||||
workspaces={companyWorkspaces}
|
||||
selectedRunId={selectedRun?.id ?? null}
|
||||
onSelectRun={(runId: string) => {
|
||||
setSelectedRunId(runId);
|
||||
}}
|
||||
onOpenLogPath={async (path: string) => {
|
||||
unwrap(await window.agentCompany.openPath(path));
|
||||
}}
|
||||
onCancelRun={cancelRun}
|
||||
onOpenConsole={(runId: string) => {
|
||||
setSelectedRunId(runId);
|
||||
setConsoleOpen(true);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{section === "connectors" && selectedConnector ? (
|
||||
<div className="space-y-10">
|
||||
<Panel title="Connector registry">
|
||||
@@ -715,8 +900,6 @@ export function App() {
|
||||
</Panel>
|
||||
<Panel
|
||||
title={selectedConnector.label}
|
||||
|
||||
action={<ActionButton label="Run health check" onClick={() => void quickConnectorCheck()} icon={Wrench} tone="accent" />}
|
||||
>
|
||||
<ConnectorEditor connector={selectedConnector} onSaved={refresh} />
|
||||
</Panel>
|
||||
@@ -746,7 +929,7 @@ export function App() {
|
||||
workspaces={companyWorkspaces}
|
||||
agents={companyAgents}
|
||||
companyId={currentCompany!.id}
|
||||
onSaved={async () => { setSelectedAgentId(null); await refresh(); }}
|
||||
onSaved={refresh}
|
||||
onDeleted={async () => { setSelectedAgentId(null); await refresh(); }}
|
||||
/>
|
||||
) : null}
|
||||
@@ -861,6 +1044,8 @@ export function App() {
|
||||
projects={companyProjects}
|
||||
workspaces={companyWorkspaces}
|
||||
companyId={currentCompany!.id}
|
||||
comments={(snapshot.comments ?? []).filter((comment) => comment.taskId === selectedTask.id)}
|
||||
onRefresh={refresh}
|
||||
onRun={startRun}
|
||||
onCancelRun={cancelRun}
|
||||
onCreateApproval={async () => {
|
||||
@@ -880,6 +1065,11 @@ export function App() {
|
||||
setSelectedApprovalId(approvalId);
|
||||
await refresh();
|
||||
}}
|
||||
onOpenDiscussion={(taskId) => {
|
||||
setSelectedTaskId(null);
|
||||
setSection("communication");
|
||||
setSelectedCommunicationEntityId(taskId);
|
||||
}}
|
||||
onDeleted={async () => { setSelectedTaskId(null); await refresh(); }}
|
||||
/>
|
||||
) : null}
|
||||
@@ -893,7 +1083,7 @@ export function App() {
|
||||
<PendingReviewList tasks={tasks} agents={companyAgents} />
|
||||
</Panel>
|
||||
<Panel title="Pending approvals">
|
||||
<ApprovalList approvals={approvals} selectedApprovalId={selectedApproval?.id ?? null} onSelect={setSelectedApprovalId} />
|
||||
<ApprovalList approvals={pendingApprovals} selectedApprovalId={selectedApproval?.id ?? null} onSelect={setSelectedApprovalId} />
|
||||
</Panel>
|
||||
<Modal open={!!selectedApproval} title={selectedApproval?.payloadSummary ?? "Approval detail"} onClose={() => setSelectedApprovalId(null)}>
|
||||
{selectedApproval ? (
|
||||
@@ -943,33 +1133,60 @@ export function App() {
|
||||
agents={companyAgents.map(a => ({ id: a.id, name: a.name }))}
|
||||
companyId={snapshot.currentCompanyId}
|
||||
onSave={async (input) => {
|
||||
await window.agentCompany.saveSocialAccount({
|
||||
companyId: input.companyId,
|
||||
platform: input.platform,
|
||||
accountName: input.accountName,
|
||||
displayName: input.displayName,
|
||||
requireApproval: input.requireApproval,
|
||||
profileUrl: "",
|
||||
status: "login_required",
|
||||
metadataJson: "{}",
|
||||
});
|
||||
await refresh();
|
||||
try {
|
||||
unwrap(await window.agentCompany.saveSocialAccount({
|
||||
companyId: input.companyId,
|
||||
platform: input.platform,
|
||||
accountName: input.accountName,
|
||||
displayName: input.displayName,
|
||||
requireApproval: input.requireApproval,
|
||||
profileUrl: "",
|
||||
status: "login_required",
|
||||
metadataJson: "{}",
|
||||
}));
|
||||
await refresh();
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}}
|
||||
onDelete={async (id) => {
|
||||
if (!snapshot?.currentCompanyId) return;
|
||||
await window.agentCompany.deleteSocialAccount({ id, companyId: snapshot.currentCompanyId });
|
||||
await refresh();
|
||||
try {
|
||||
unwrap(await window.agentCompany.deleteSocialAccount({ id, companyId: snapshot.currentCompanyId }));
|
||||
await refresh();
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}}
|
||||
onLogin={async (accountId) => {
|
||||
await window.agentCompany.triggerBrowserLogin(accountId);
|
||||
await refresh();
|
||||
if (!snapshot?.currentCompanyId) return;
|
||||
try {
|
||||
unwrap(await window.agentCompany.triggerBrowserLogin({ companyId: snapshot.currentCompanyId, socialAccountId: accountId }));
|
||||
await refresh();
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}}
|
||||
onOpenPath={async (path) => {
|
||||
await window.agentCompany.openPath(path);
|
||||
try {
|
||||
unwrap(await window.agentCompany.openPath(path));
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}}
|
||||
onCancelAction={async (actionId) => {
|
||||
await window.agentCompany.cancelBrowserAction({ runId: actionId });
|
||||
await refresh();
|
||||
if (!snapshot?.currentCompanyId) return;
|
||||
try {
|
||||
unwrap(await window.agentCompany.cancelBrowserAction({ actionId, companyId: snapshot.currentCompanyId }));
|
||||
await refresh();
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}}
|
||||
onNavigate={(sectionName, entityId) => {
|
||||
setSection(sectionName);
|
||||
@@ -996,6 +1213,11 @@ export function App() {
|
||||
agents={companyAgents}
|
||||
companyId={currentCompany.id}
|
||||
selectedEntityId={selectedCommunicationEntityId}
|
||||
onEntityConsumed={() => setSelectedCommunicationEntityId(null)}
|
||||
onNavigateToTask={(taskId) => {
|
||||
setSection("tasks");
|
||||
setSelectedTaskId(taskId);
|
||||
}}
|
||||
onRefresh={refresh}
|
||||
/>
|
||||
) : null}
|
||||
@@ -1202,11 +1424,12 @@ export function App() {
|
||||
{filteredNavItems.map((item) => (
|
||||
<ListRow
|
||||
key={item.id}
|
||||
className="py-3"
|
||||
className={`py-3 ${paletteActionIndex.get(`section:${item.id}`) === selectedPaletteIndex ? "bg-[color:var(--panel-soft)]" : ""}`}
|
||||
onClick={() => {
|
||||
setSection(item.id);
|
||||
navigateTo(item.id);
|
||||
setCommandPaletteOpen(false);
|
||||
setCommandPaletteSearch("");
|
||||
setSelectedPaletteIndex(0);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -1231,12 +1454,12 @@ export function App() {
|
||||
{matchedAgents.map((agent) => (
|
||||
<ListRow
|
||||
key={agent.id}
|
||||
className="py-3"
|
||||
className={`py-3 ${paletteActionIndex.get(`agent:${agent.id}`) === selectedPaletteIndex ? "bg-[color:var(--panel-soft)]" : ""}`}
|
||||
onClick={() => {
|
||||
setSection("agents");
|
||||
setSelectedAgentId(agent.id);
|
||||
navigateTo("agents", agent.id);
|
||||
setCommandPaletteOpen(false);
|
||||
setCommandPaletteSearch("");
|
||||
setSelectedPaletteIndex(0);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -1260,12 +1483,12 @@ export function App() {
|
||||
{matchedTasks.map((task) => (
|
||||
<ListRow
|
||||
key={task.id}
|
||||
className="py-3"
|
||||
className={`py-3 ${paletteActionIndex.get(`task:${task.id}`) === selectedPaletteIndex ? "bg-[color:var(--panel-soft)]" : ""}`}
|
||||
onClick={() => {
|
||||
setSection("tasks");
|
||||
setSelectedTaskId(task.id);
|
||||
navigateTo("tasks", task.id);
|
||||
setCommandPaletteOpen(false);
|
||||
setCommandPaletteSearch("");
|
||||
setSelectedPaletteIndex(0);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -1281,6 +1504,36 @@ export function App() {
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{matchedRuns.length > 0 ? (
|
||||
<>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Runs</div>
|
||||
<ListFrame>
|
||||
{matchedRuns.map((run) => (
|
||||
<ListRow
|
||||
key={run.id}
|
||||
className={`py-3 ${paletteActionIndex.get(`run:${run.id}`) === selectedPaletteIndex ? "bg-[color:var(--panel-soft)]" : ""}`}
|
||||
onClick={() => {
|
||||
navigateTo("runs", run.id);
|
||||
setCommandPaletteOpen(false);
|
||||
setCommandPaletteSearch("");
|
||||
setSelectedPaletteIndex(0);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex items-center gap-3 text-[color:var(--text)]">
|
||||
<TerminalWindow size={16} />
|
||||
<span className="truncate text-[13px] tracking-[0.005em]">
|
||||
{run.summary || runs.find((entry) => entry.id === run.id)?.id.slice(0, 8) || run.id.slice(0, 8)}
|
||||
</span>
|
||||
</div>
|
||||
<StatusPill status={run.status} />
|
||||
</div>
|
||||
</ListRow>
|
||||
))}
|
||||
</ListFrame>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{matchedGoals.length > 0 ? (
|
||||
<>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Goals</div>
|
||||
@@ -1288,12 +1541,12 @@ export function App() {
|
||||
{matchedGoals.map((goal) => (
|
||||
<ListRow
|
||||
key={goal.id}
|
||||
className="py-3"
|
||||
className={`py-3 ${paletteActionIndex.get(`goal:${goal.id}`) === selectedPaletteIndex ? "bg-[color:var(--panel-soft)]" : ""}`}
|
||||
onClick={() => {
|
||||
setSection("goals");
|
||||
setSelectedGoalId(goal.id);
|
||||
navigateTo("goals", goal.id);
|
||||
setCommandPaletteOpen(false);
|
||||
setCommandPaletteSearch("");
|
||||
setSelectedPaletteIndex(0);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -1316,12 +1569,12 @@ export function App() {
|
||||
{matchedProjects.map((project) => (
|
||||
<ListRow
|
||||
key={project.id}
|
||||
className="py-3"
|
||||
className={`py-3 ${paletteActionIndex.get(`project:${project.id}`) === selectedPaletteIndex ? "bg-[color:var(--panel-soft)]" : ""}`}
|
||||
onClick={() => {
|
||||
setSection("projects");
|
||||
setSelectedProjectId(project.id);
|
||||
navigateTo("projects", project.id);
|
||||
setCommandPaletteOpen(false);
|
||||
setCommandPaletteSearch("");
|
||||
setSelectedPaletteIndex(0);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -31,7 +31,8 @@ import type {
|
||||
} from "@shared/types"
|
||||
import { getAgentInitials, formatMessageTime } from "../lib/formatters"
|
||||
import { DEPARTMENT_COLORS } from "@shared/constants"
|
||||
import { Modal, Select, Input, TextArea } from "./ui"
|
||||
import { unwrap } from "../lib/desktop"
|
||||
import { InlineNotice, Modal, Select, Input, TextArea } from "./ui"
|
||||
|
||||
const TERRACOTTA = "#d97745"
|
||||
const TERRACOTTA_SOFT = "#d9774518"
|
||||
@@ -181,18 +182,32 @@ function deriveTaskDiscussionChannels(
|
||||
tasks: TaskRecord[],
|
||||
): TaskDiscussionDescriptor[] {
|
||||
const taskMap = new Map(tasks.map((task) => [task.id, task]))
|
||||
const discussedTaskIds = new Set(comments.map((comment) => comment.taskId))
|
||||
return Array.from(discussedTaskIds)
|
||||
const activeTaskIds = tasks
|
||||
.filter((task) => ["todo", "in_progress", "in_review", "blocked"].includes(task.status))
|
||||
.map((task) => task.id)
|
||||
const discussionTaskIds = new Set([...comments.map((comment) => comment.taskId), ...activeTaskIds])
|
||||
|
||||
return Array.from(discussionTaskIds)
|
||||
.map((taskId) => {
|
||||
const task = taskMap.get(taskId)
|
||||
return {
|
||||
key: `task-${taskId}`,
|
||||
kind: "task" as const,
|
||||
taskId,
|
||||
label: task?.title ?? `Task ${taskId.slice(0, 8)}`,
|
||||
label: task
|
||||
? `${task.title}${task.status === "blocked" ? " · blocked" : task.status === "in_review" ? " · review" : ""}`
|
||||
: `Task ${taskId.slice(0, 8)}`,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
.sort((left, right) => {
|
||||
const leftTask = taskMap.get(left.taskId)
|
||||
const rightTask = taskMap.get(right.taskId)
|
||||
const priorityWeight: Record<string, number> = { critical: 4, high: 3, medium: 2, low: 1 }
|
||||
const leftWeight = leftTask ? priorityWeight[leftTask.priority] ?? 0 : 0
|
||||
const rightWeight = rightTask ? priorityWeight[rightTask.priority] ?? 0 : 0
|
||||
if (leftWeight !== rightWeight) return rightWeight - leftWeight
|
||||
return left.label.localeCompare(right.label)
|
||||
})
|
||||
}
|
||||
|
||||
function getChannelMessages(
|
||||
@@ -509,19 +524,23 @@ function TaskDiscussionComposer({
|
||||
}) {
|
||||
const [body, setBody] = useState("")
|
||||
const [sending, setSending] = useState(false)
|
||||
const [sendError, setSendError] = useState<string | null>(null)
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!body.trim()) return
|
||||
setSending(true)
|
||||
setSendError(null)
|
||||
try {
|
||||
await window.agentCompany.addComment({
|
||||
unwrap(await window.agentCompany.addComment({
|
||||
companyId,
|
||||
taskId,
|
||||
authorName: "Board",
|
||||
body: body.trim(),
|
||||
})
|
||||
}))
|
||||
setBody("")
|
||||
onSent()
|
||||
} catch (error) {
|
||||
setSendError(error instanceof Error ? error.message : "Failed to send task discussion reply.")
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
@@ -529,6 +548,7 @@ function TaskDiscussionComposer({
|
||||
|
||||
return (
|
||||
<div className="border-t border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3">
|
||||
{sendError ? <div className="mb-3"><InlineNotice message={sendError} /></div> : null}
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
value={body}
|
||||
@@ -609,6 +629,7 @@ function MessageComposer({
|
||||
const [body, setBody] = useState("")
|
||||
const [subject, setSubject] = useState("")
|
||||
const [sending, setSending] = useState(false)
|
||||
const [sendError, setSendError] = useState<string | null>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -620,8 +641,9 @@ function MessageComposer({
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!body.trim() || !fromAgentId) return
|
||||
setSending(true)
|
||||
setSendError(null)
|
||||
try {
|
||||
await window.agentCompany.sendAgentMessage({
|
||||
unwrap(await window.agentCompany.sendAgentMessage({
|
||||
companyId,
|
||||
fromAgentId,
|
||||
toAgentId: replyTo ? replyTo.fromAgentId : null,
|
||||
@@ -632,12 +654,12 @@ function MessageComposer({
|
||||
priority: "normal",
|
||||
parentMessageId: replyTo?.id ?? null,
|
||||
attachmentsJson: "[]",
|
||||
})
|
||||
}))
|
||||
setBody("")
|
||||
setSubject("")
|
||||
onSent()
|
||||
} catch (err) {
|
||||
void err
|
||||
} catch (error) {
|
||||
setSendError(error instanceof Error ? error.message : "Failed to send message.")
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
@@ -657,6 +679,7 @@ function MessageComposer({
|
||||
|
||||
return (
|
||||
<div className="border-t border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3">
|
||||
{sendError ? <div className="mb-3"><InlineNotice message={sendError} /></div> : null}
|
||||
<AnimatePresence>
|
||||
{replyTo ? (
|
||||
<motion.div
|
||||
@@ -758,6 +781,7 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
|
||||
const [body, setBody] = useState("")
|
||||
const [priority, setPriority] = useState<MessagePriority>("normal")
|
||||
const [sending, setSending] = useState(false)
|
||||
const [sendError, setSendError] = useState<string | null>(null)
|
||||
|
||||
const departmentOptions = useMemo(() => {
|
||||
const depts = new Set<string>()
|
||||
@@ -768,10 +792,11 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
|
||||
}, [agents])
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!body.trim() || !fromAgentId || !subject.trim()) return
|
||||
if (!body.trim() || !fromAgentId || !subject.trim() || (channel === "direct" && !toAgentId)) return
|
||||
setSending(true)
|
||||
setSendError(null)
|
||||
try {
|
||||
await window.agentCompany.sendAgentMessage({
|
||||
unwrap(await window.agentCompany.sendAgentMessage({
|
||||
companyId,
|
||||
fromAgentId,
|
||||
toAgentId: channel === "direct" && toAgentId ? toAgentId : null,
|
||||
@@ -782,14 +807,14 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
|
||||
priority,
|
||||
parentMessageId: null,
|
||||
attachmentsJson: "[]",
|
||||
})
|
||||
}))
|
||||
setSubject("")
|
||||
setBody("")
|
||||
setPriority("normal")
|
||||
onSent()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
void err
|
||||
} catch (error) {
|
||||
setSendError(error instanceof Error ? error.message : "Failed to send message.")
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
@@ -798,6 +823,7 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
|
||||
return (
|
||||
<Modal open={open} title="Compose Message" onClose={onClose} width="560px">
|
||||
<div className="space-y-4">
|
||||
{sendError ? <InlineNotice message={sendError} /> : null}
|
||||
<Select
|
||||
label="From Agent"
|
||||
value={fromAgentId}
|
||||
@@ -827,7 +853,7 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
|
||||
value={toAgentId}
|
||||
onChange={setToAgentId}
|
||||
options={[
|
||||
{ value: "", label: "Broadcast to all" },
|
||||
{ value: "", label: "Select a recipient..." },
|
||||
...agents
|
||||
.filter((a) => a.id !== fromAgentId)
|
||||
.map((a) => ({
|
||||
@@ -895,7 +921,7 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSend()}
|
||||
disabled={!body.trim() || !subject.trim() || !fromAgentId || sending}
|
||||
disabled={!body.trim() || !subject.trim() || !fromAgentId || (channel === "direct" && !toAgentId) || sending}
|
||||
className="inline-flex items-center gap-2 rounded-[8px] px-4 py-2 text-[12px] font-semibold text-white transition hover:brightness-90 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none"
|
||||
style={{ backgroundColor: TERRACOTTA }}
|
||||
>
|
||||
@@ -1060,6 +1086,8 @@ interface CommunicationHubProps {
|
||||
agents: AgentRecord[]
|
||||
companyId: string
|
||||
selectedEntityId?: string | null
|
||||
onEntityConsumed?: () => void
|
||||
onNavigateToTask: (taskId: string) => void
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
@@ -1070,12 +1098,16 @@ export function CommunicationHub({
|
||||
agents,
|
||||
companyId,
|
||||
selectedEntityId,
|
||||
onEntityConsumed,
|
||||
onNavigateToTask,
|
||||
onRefresh,
|
||||
}: CommunicationHubProps) {
|
||||
const [selectedChannelKey, setSelectedChannelKey] = useState<string | null>(null)
|
||||
const [showSidebar, setShowSidebar] = useState(true)
|
||||
const [showCompose, setShowCompose] = useState(false)
|
||||
const [replyTo, setReplyTo] = useState<AgentMessageRecord | null>(null)
|
||||
const [optimisticReadIds, setOptimisticReadIds] = useState<Set<string>>(new Set())
|
||||
const pendingReadIdsRef = useRef<Set<string>>(new Set())
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
search: "",
|
||||
channelType: "all",
|
||||
@@ -1085,39 +1117,65 @@ export function CommunicationHub({
|
||||
|
||||
const messageEndRef = useRef<HTMLDivElement>(null)
|
||||
const agentMap = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents])
|
||||
const effectiveMessages = useMemo(
|
||||
() =>
|
||||
messages.map((message) =>
|
||||
optimisticReadIds.has(message.id)
|
||||
? { ...message, readAt: message.readAt ?? new Date().toISOString() }
|
||||
: message,
|
||||
),
|
||||
[messages, optimisticReadIds],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setOptimisticReadIds((current) => {
|
||||
if (current.size === 0) return current
|
||||
const next = new Set(Array.from(current).filter((messageId) => effectiveMessages.some((message) => message.id === messageId)))
|
||||
return next.size === current.size ? current : next
|
||||
})
|
||||
}, [effectiveMessages])
|
||||
|
||||
// Derive channels from messages
|
||||
const messageChannels = useMemo(() => deriveChannels(messages, agents), [messages, agents])
|
||||
const messageChannels = useMemo(() => deriveChannels(effectiveMessages, agents), [effectiveMessages, agents])
|
||||
const taskDiscussionChannels = useMemo(() => deriveTaskDiscussionChannels(comments, tasks), [comments, tasks])
|
||||
const channels = useMemo(() => [...messageChannels, ...taskDiscussionChannels], [messageChannels, taskDiscussionChannels])
|
||||
|
||||
// Auto-select first channel
|
||||
// Auto-select first valid channel
|
||||
useEffect(() => {
|
||||
if (!selectedChannelKey && channels.length > 0) {
|
||||
if (channels.length === 0) {
|
||||
if (selectedChannelKey) {
|
||||
setSelectedChannelKey(null)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!selectedChannelKey || !channels.some((channel) => channel.key === selectedChannelKey)) {
|
||||
setSelectedChannelKey(channels[0].key)
|
||||
}
|
||||
}, [channels, selectedChannelKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedEntityId) return
|
||||
const matchingMessage = messages.find((message) => message.id === selectedEntityId)
|
||||
const matchingMessage = effectiveMessages.find((message) => message.id === selectedEntityId)
|
||||
if (matchingMessage) {
|
||||
const matchingChannel = messageChannels.find((channel) => getChannelMessages(messages, channel).some((message) => message.id === selectedEntityId))
|
||||
const matchingChannel = messageChannels.find((channel) => getChannelMessages(effectiveMessages, channel).some((message) => message.id === selectedEntityId))
|
||||
if (matchingChannel) {
|
||||
setSelectedChannelKey(matchingChannel.key)
|
||||
onEntityConsumed?.()
|
||||
}
|
||||
return
|
||||
}
|
||||
const matchingComment = comments.find((comment) => comment.id === selectedEntityId)
|
||||
if (matchingComment) {
|
||||
setSelectedChannelKey(`task-${matchingComment.taskId}`)
|
||||
onEntityConsumed?.()
|
||||
return
|
||||
}
|
||||
const matchingTaskDiscussion = taskDiscussionChannels.find((discussion) => discussion.taskId === selectedEntityId)
|
||||
if (matchingTaskDiscussion) {
|
||||
setSelectedChannelKey(matchingTaskDiscussion.key)
|
||||
onEntityConsumed?.()
|
||||
}
|
||||
}, [selectedEntityId, messages, comments, messageChannels, taskDiscussionChannels])
|
||||
}, [selectedEntityId, effectiveMessages, comments, messageChannels, onEntityConsumed, taskDiscussionChannels])
|
||||
|
||||
const activeChannel = useMemo(
|
||||
() => channels.find((c) => c.key === selectedChannelKey) ?? null,
|
||||
@@ -1127,10 +1185,10 @@ export function CommunicationHub({
|
||||
// Messages for the selected channel
|
||||
const channelMessages = useMemo(() => {
|
||||
if (!activeChannel || activeChannel.kind !== "message") return []
|
||||
return getChannelMessages(messages, activeChannel).sort(
|
||||
return getChannelMessages(effectiveMessages, activeChannel).sort(
|
||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||||
)
|
||||
}, [messages, activeChannel])
|
||||
}, [effectiveMessages, activeChannel])
|
||||
|
||||
const taskDiscussionComments = useMemo(() => {
|
||||
if (!activeChannel || activeChannel.kind !== "task") return []
|
||||
@@ -1173,6 +1231,58 @@ export function CommunicationHub({
|
||||
return result
|
||||
}, [channelMessages, filters, agentMap])
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeChannel || activeChannel.kind !== "message") return
|
||||
const unreadMessageIds = filteredMessages
|
||||
.filter((message) => !message.readAt && !pendingReadIdsRef.current.has(message.id))
|
||||
.map((message) => message.id)
|
||||
if (unreadMessageIds.length === 0) return
|
||||
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
for (const messageId of unreadMessageIds) {
|
||||
pendingReadIdsRef.current.add(messageId)
|
||||
}
|
||||
setOptimisticReadIds((current) => {
|
||||
const next = new Set(current)
|
||||
for (const messageId of unreadMessageIds) {
|
||||
next.add(messageId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
const results = await Promise.all(
|
||||
unreadMessageIds.map(async (messageId) => {
|
||||
try {
|
||||
unwrap(await window.agentCompany.markMessageRead({ messageId, companyId }))
|
||||
return { messageId, ok: true as const }
|
||||
} catch {
|
||||
return { messageId, ok: false as const }
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const failedIds = results.filter((result) => !result.ok).map((result) => result.messageId)
|
||||
for (const messageId of unreadMessageIds) {
|
||||
pendingReadIdsRef.current.delete(messageId)
|
||||
}
|
||||
|
||||
if (!cancelled && failedIds.length > 0) {
|
||||
setOptimisticReadIds((current) => {
|
||||
const next = new Set(current)
|
||||
for (const messageId of failedIds) {
|
||||
next.delete(messageId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [activeChannel, companyId, filteredMessages])
|
||||
|
||||
// Build thread structure: top-level messages + their replies
|
||||
const threadedMessages = useMemo(() => {
|
||||
const topLevel = filteredMessages.filter((m) => !m.parentMessageId)
|
||||
@@ -1195,22 +1305,23 @@ export function CommunicationHub({
|
||||
counts.set(ch.key, { unread: 0, hasUrgent: false })
|
||||
continue
|
||||
}
|
||||
const msgs = getChannelMessages(messages, ch)
|
||||
const unread = msgs.filter((m) => !m.readAt).length
|
||||
const hasUrgent = msgs.some((m) => !m.readAt && m.priority === "urgent")
|
||||
const visibleMessages = getChannelMessages(effectiveMessages, ch)
|
||||
const unread = visibleMessages.filter((m) => !m.readAt).length
|
||||
const hasUrgent = visibleMessages.some((m) => !m.readAt && m.priority === "urgent")
|
||||
counts.set(ch.key, { unread, hasUrgent })
|
||||
}
|
||||
return counts
|
||||
}, [channels, messages])
|
||||
}, [channels, effectiveMessages])
|
||||
|
||||
const totalUnread = useMemo(() => {
|
||||
return messages.filter((m) => !m.readAt).length
|
||||
}, [messages])
|
||||
return effectiveMessages.filter((m) => !m.readAt).length
|
||||
}, [effectiveMessages])
|
||||
|
||||
// Scroll to bottom when channel changes
|
||||
// Scroll to bottom when channel changes or new messages arrive
|
||||
const messageCount = useMemo(() => effectiveMessages.length, [effectiveMessages])
|
||||
useEffect(() => {
|
||||
messageEndRef.current?.scrollIntoView({ behavior: "auto" })
|
||||
}, [selectedChannelKey])
|
||||
}, [selectedChannelKey, messageCount])
|
||||
|
||||
// Group channels by type for sidebar sections
|
||||
const channelGroups = useMemo(() => {
|
||||
@@ -1405,6 +1516,15 @@ export function CommunicationHub({
|
||||
: `${taskDiscussionComments.length} comment${taskDiscussionComments.length !== 1 ? "s" : ""}${activeTask ? ` · ${activeTask.status.replaceAll("_", " ")}` : ""}`}
|
||||
</div>
|
||||
</div>
|
||||
{activeChannel.kind === "task" && activeTask ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigateToTask(activeTask.id)}
|
||||
className="rounded-[6px] border border-[color:var(--line)] px-2.5 py-1 text-[11px] font-medium text-[color:var(--muted-strong)] transition hover:bg-[color:var(--panel-soft)] hover:text-[color:var(--text)]"
|
||||
>
|
||||
Open task
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-[13px] text-[color:var(--muted)]">
|
||||
|
||||
@@ -1,21 +1,176 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { CheckCircle } from "@phosphor-icons/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { CheckCircle, SignIn } from "@phosphor-icons/react";
|
||||
import type { ConnectorRecord } from "@shared/types";
|
||||
import { ActionButton, Input, StatusPill, TextArea } from "./ui";
|
||||
import { unwrap } from "../lib/desktop";
|
||||
import { ActionButton, InlineNotice, Input, Select, StatusPill, TextArea } from "./ui";
|
||||
|
||||
function getAuthActionCopy(connector: ConnectorRecord) {
|
||||
switch (connector.id) {
|
||||
case "codex_local":
|
||||
return {
|
||||
title: "Codex login required",
|
||||
message: "Codex is installed, but the CLI needs an authenticated session before AgentCompany can dispatch runs.",
|
||||
buttonLabel: "Open Terminal To Sign In",
|
||||
};
|
||||
case "claude_local":
|
||||
return {
|
||||
title: "Claude Code login required",
|
||||
message: "Claude Code is installed, but the local CLI is not authenticated for this desktop runtime.",
|
||||
buttonLabel: "Open Terminal To Sign In",
|
||||
};
|
||||
case "opencode_local":
|
||||
return {
|
||||
title: "OpenCode provider login required",
|
||||
message: "OpenCode is installed, but the provider login must be completed before runs can start.",
|
||||
buttonLabel: "Open Terminal To Sign In",
|
||||
};
|
||||
case "gemini_local":
|
||||
return {
|
||||
title: "Gemini authentication required",
|
||||
message: "Gemini CLI is available, but the runtime still needs an authenticated session. AgentCompany will open Gemini in a terminal and you can complete its interactive auth flow there.",
|
||||
buttonLabel: "Open Terminal",
|
||||
};
|
||||
case "kimi_local":
|
||||
return {
|
||||
title: "Kimi interactive login required",
|
||||
message: "Kimi Code is available in generic mode. AgentCompany can open the CLI in a terminal so you can complete the interactive login flow there if prompted.",
|
||||
buttonLabel: "Open Terminal",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function ConnectorEditor({ connector, onSaved }: { connector: ConnectorRecord; onSaved: () => Promise<void> }) {
|
||||
const [model, setModel] = useState(connector.model ?? "");
|
||||
const [envBindingText, setEnvBindingText] = useState(connector.envBindingText);
|
||||
const [notes, setNotes] = useState(connector.notes);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [openingAuthTerminal, setOpeningAuthTerminal] = useState(false);
|
||||
const [autoChecking, setAutoChecking] = useState(false);
|
||||
const [modelOptions, setModelOptions] = useState<Array<{ id: string; label: string }>>([]);
|
||||
const [loadingModelOptions, setLoadingModelOptions] = useState(false);
|
||||
const [modelOptionsError, setModelOptionsError] = useState<string | null>(null);
|
||||
const authPollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const authActionCopy = getAuthActionCopy(connector);
|
||||
const needsLogin = connector.authState === "required" || connector.status === "auth_required";
|
||||
const requiresExplicitModel = connector.id === "opencode_local";
|
||||
const shouldLoadModelOptions = connector.capabilityMatrix.modelDiscovery || connector.id === "opencode_local";
|
||||
|
||||
useEffect(() => {
|
||||
setModel(connector.model ?? "");
|
||||
setEnvBindingText(connector.envBindingText);
|
||||
setNotes(connector.notes);
|
||||
setSubmitError(null);
|
||||
}, [connector]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (authPollTimeoutRef.current) {
|
||||
clearTimeout(authPollTimeoutRef.current);
|
||||
authPollTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!shouldLoadModelOptions) {
|
||||
setModelOptions([]);
|
||||
setModelOptionsError(null);
|
||||
setLoadingModelOptions(false);
|
||||
return;
|
||||
}
|
||||
setLoadingModelOptions(true);
|
||||
setModelOptionsError(null);
|
||||
void window.agentCompany.listConnectorModels(connector.id).then((result) => {
|
||||
if (cancelled) return;
|
||||
if (!result.ok) {
|
||||
setModelOptions([]);
|
||||
setModelOptionsError(result.error.message);
|
||||
setLoadingModelOptions(false);
|
||||
return;
|
||||
}
|
||||
setModelOptions(result.data);
|
||||
setLoadingModelOptions(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [connector.id, shouldLoadModelOptions]);
|
||||
|
||||
const modelSelectOptions = useMemo(() => {
|
||||
const options = [{ value: "", label: requiresExplicitModel ? "Please select a model..." : "Use connector default" }];
|
||||
const hasCurrentModel = modelOptions.some((option) => option.id === model);
|
||||
if (model && !hasCurrentModel) {
|
||||
options.push({ value: model, label: `${model} (saved)` });
|
||||
}
|
||||
options.push(...modelOptions.map((option) => ({ value: option.id, label: option.label || option.id })));
|
||||
return options;
|
||||
}, [model, modelOptions, requiresExplicitModel]);
|
||||
|
||||
const runHealthCheck = useCallback(async (quiet = false) => {
|
||||
setAutoChecking(true);
|
||||
try {
|
||||
unwrap(await window.agentCompany.testConnector(connector.id));
|
||||
await onSaved();
|
||||
if (!quiet) {
|
||||
setSubmitError(null);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!quiet) {
|
||||
setSubmitError(error instanceof Error ? error.message : "Automatic connector health check failed.");
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
setAutoChecking(false);
|
||||
}
|
||||
}, [connector.id, onSaved]);
|
||||
|
||||
const scheduleAuthPolling = useCallback((remainingAttempts: number) => {
|
||||
if (authPollTimeoutRef.current) {
|
||||
clearTimeout(authPollTimeoutRef.current);
|
||||
authPollTimeoutRef.current = null;
|
||||
}
|
||||
if (remainingAttempts <= 0) {
|
||||
return;
|
||||
}
|
||||
authPollTimeoutRef.current = setTimeout(() => {
|
||||
void (async () => {
|
||||
const ok = await runHealthCheck(true);
|
||||
if (!ok) {
|
||||
scheduleAuthPolling(remainingAttempts - 1);
|
||||
}
|
||||
})();
|
||||
}, 5000);
|
||||
}, [runHealthCheck]);
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
{submitError ? <InlineNotice message={submitError} /> : null}
|
||||
{needsLogin ? (
|
||||
<div className="rounded-[10px] border border-[color:var(--warn)] bg-[color:var(--warn-soft)] px-4 py-4 text-[12px] text-[color:var(--warn)]">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.08em]">{authActionCopy.title}</div>
|
||||
<div className="mt-1.5 leading-relaxed">{authActionCopy.message}</div>
|
||||
<div className="mt-3">
|
||||
<ActionButton
|
||||
label={authActionCopy.buttonLabel}
|
||||
icon={SignIn}
|
||||
onClick={() => void (async () => {
|
||||
setOpeningAuthTerminal(true);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
unwrap(await window.agentCompany.openConnectorAuthTerminal(connector.id));
|
||||
scheduleAuthPolling(12);
|
||||
} catch (error) {
|
||||
setSubmitError(error instanceof Error ? error.message : "Could not open a terminal for connector login.");
|
||||
} finally {
|
||||
setOpeningAuthTerminal(false);
|
||||
}
|
||||
})()}
|
||||
loading={openingAuthTerminal}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-1.5 text-[12px] font-medium text-[color:var(--muted-strong)]">Status</div>
|
||||
@@ -41,29 +196,72 @@ export function ConnectorEditor({ connector, onSaved }: { connector: ConnectorRe
|
||||
AgentCompany pins connector execution to the audited binary for this runtime. Command overrides are not accepted from the renderer.
|
||||
</div>
|
||||
</div>
|
||||
<Input label="Model" value={model} onChange={setModel} placeholder="Leave empty to use connector default" />
|
||||
{shouldLoadModelOptions ? (
|
||||
<div className="grid gap-2">
|
||||
<Select
|
||||
label={requiresExplicitModel ? "Model (required)" : "Model"}
|
||||
value={model}
|
||||
onChange={setModel}
|
||||
options={modelSelectOptions}
|
||||
/>
|
||||
{loadingModelOptions ? (
|
||||
<div className="text-[12px] text-[color:var(--muted)]">Loading available models...</div>
|
||||
) : null}
|
||||
{modelOptionsError ? (
|
||||
<InlineNotice message={modelOptionsError} />
|
||||
) : null}
|
||||
{requiresExplicitModel && !model.trim() ? (
|
||||
<div className="rounded-[8px] border border-[color:var(--warn)] bg-[color:var(--warn-soft)] px-4 py-3 text-[12px] text-[color:var(--warn)]">
|
||||
OpenCode is detected, but it still needs an explicit provider/model selection before AgentCompany can mark it ready.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<Input label="Model" value={model} onChange={setModel} placeholder="Leave empty to use connector default" />
|
||||
)}
|
||||
<TextArea label="Secret bindings" value={envBindingText} onChange={setEnvBindingText} rows={4} placeholder={"OPENAI_API_KEY=my-vault-key\nANTHROPIC_API_KEY=my-anthropic-key"} />
|
||||
<TextArea label="Operational notes" value={notes} onChange={setNotes} rows={4} />
|
||||
<div className="grid gap-3 md:grid-cols-[auto_1fr]">
|
||||
<div className="rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-4 py-3 text-[12px] text-[color:var(--muted-strong)]">
|
||||
Connector health checks run automatically on app launch, after save, and after terminal login is opened.
|
||||
{autoChecking ? " Refreshing connector state..." : ""}
|
||||
</div>
|
||||
{connector.lastError ? (
|
||||
<div className="rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-4 py-3 text-[12px] leading-relaxed text-[color:var(--muted-strong)]">
|
||||
<div className="mb-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Latest health-check detail</div>
|
||||
<div>{connector.lastError}</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex gap-3">
|
||||
<ActionButton
|
||||
label="Save connector"
|
||||
tone="accent"
|
||||
onClick={() => void (async () => {
|
||||
await window.agentCompany.saveConnector({
|
||||
id: connector.id,
|
||||
command: connector.command,
|
||||
model: model || null,
|
||||
envBindingText,
|
||||
notes,
|
||||
});
|
||||
await onSaved();
|
||||
setSaving(true);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
unwrap(await window.agentCompany.saveConnector({
|
||||
id: connector.id,
|
||||
command: connector.command,
|
||||
model: model || null,
|
||||
envBindingText,
|
||||
notes,
|
||||
}));
|
||||
await onSaved();
|
||||
await runHealthCheck(true);
|
||||
} catch (error) {
|
||||
setSubmitError(error instanceof Error ? error.message : "Connector could not be saved.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
})()}
|
||||
icon={CheckCircle}
|
||||
loading={saving}
|
||||
disabled={requiresExplicitModel && !model.trim()}
|
||||
/>
|
||||
<div className="rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-4 py-4 text-[12px] leading-relaxed text-[color:var(--muted)]">
|
||||
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Configuration doc</div>
|
||||
<pre className="mono whitespace-pre-wrap">{connector.configurationDoc}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-4 py-4 text-[12px] leading-relaxed text-[color:var(--muted)]">
|
||||
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Configuration doc</div>
|
||||
<pre className="mono whitespace-pre-wrap">{connector.configurationDoc}</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -819,7 +819,7 @@ export function Dashboard(props: DashboardProps) {
|
||||
<button
|
||||
key={connector.id}
|
||||
type="button"
|
||||
onClick={() => onNavigate("connectors")}
|
||||
onClick={() => onNavigate("connectors", connector.id)}
|
||||
className={`focus-ring flex items-center gap-2.5 rounded-[8px] border px-3 py-2.5 text-left transition duration-150 hover:bg-[color:var(--panel-soft)] ${
|
||||
isError ? "border-[color:var(--danger)] border-opacity-30" : "border-[color:var(--line)]"
|
||||
}`}
|
||||
@@ -870,7 +870,7 @@ export function Dashboard(props: DashboardProps) {
|
||||
<button
|
||||
key={run.id}
|
||||
type="button"
|
||||
onClick={() => onNavigate("tasks")}
|
||||
onClick={() => onNavigate("runs", run.id)}
|
||||
className="focus-ring flex w-full items-center gap-3 rounded-[8px] border border-[color:var(--danger)] border-opacity-20 bg-[color:var(--danger-soft)] px-4 py-3 text-left transition hover:border-opacity-40"
|
||||
>
|
||||
<XCircle size={18} className="shrink-0 text-[color:var(--danger)]" />
|
||||
@@ -890,7 +890,7 @@ export function Dashboard(props: DashboardProps) {
|
||||
<button
|
||||
key={message.id}
|
||||
type="button"
|
||||
onClick={() => onNavigate("communication")}
|
||||
onClick={() => onNavigate("communication", message.id)}
|
||||
className="focus-ring flex w-full items-center gap-3 rounded-[8px] border border-[color:var(--warn)] border-opacity-20 bg-[color:var(--warn-soft)] px-4 py-3 text-left transition hover:border-opacity-40"
|
||||
>
|
||||
<Users size={18} className="shrink-0 text-[color:var(--warn)]" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState, useMemo } from "react";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import {
|
||||
ArrowClockwise,
|
||||
ArrowSquareOut,
|
||||
@@ -28,6 +28,7 @@ import type {
|
||||
ActivityRecord,
|
||||
AgentRecord,
|
||||
ApprovalRecord,
|
||||
CommentRecord,
|
||||
DocumentRecord,
|
||||
GoalRecord,
|
||||
ProfileSnapshot,
|
||||
@@ -46,6 +47,21 @@ function describeActionError(error: unknown) {
|
||||
return error instanceof Error ? error.message : "The action could not be completed.";
|
||||
}
|
||||
|
||||
function parseApprovalPayloadJson(approval: ApprovalRecord): Record<string, unknown> | null {
|
||||
if (!approval.payloadJson) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(approval.payloadJson);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
const ENTITY_ICONS: Record<string, typeof Robot> = {
|
||||
agent: Robot,
|
||||
@@ -151,6 +167,9 @@ export function TaskInspector({
|
||||
onRun,
|
||||
onCancelRun,
|
||||
onCreateApproval,
|
||||
onOpenDiscussion,
|
||||
comments,
|
||||
onRefresh,
|
||||
onDeleted,
|
||||
}: {
|
||||
task: TaskRecord;
|
||||
@@ -164,6 +183,9 @@ export function TaskInspector({
|
||||
onRun: (taskId: string) => Promise<void>;
|
||||
onCancelRun: (runId: string) => Promise<void>;
|
||||
onCreateApproval: () => Promise<void>;
|
||||
onOpenDiscussion: (taskId: string) => void;
|
||||
comments: CommentRecord[];
|
||||
onRefresh: () => Promise<void>;
|
||||
onDeleted: () => Promise<void>;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
@@ -226,6 +248,7 @@ export function TaskInspector({
|
||||
projectId: editProject || null,
|
||||
goalId: editGoal || null,
|
||||
}));
|
||||
await onRefresh();
|
||||
setEditing(false);
|
||||
setActionNotice("Task changes saved.");
|
||||
} catch (error) {
|
||||
@@ -252,6 +275,7 @@ export function TaskInspector({
|
||||
projectId: task.projectId,
|
||||
goalId: task.goalId,
|
||||
}));
|
||||
await onRefresh();
|
||||
setActionNotice(`Task moved to ${status.replaceAll("_", " ")}.`);
|
||||
} catch (error) {
|
||||
setActionError(describeActionError(error));
|
||||
@@ -400,6 +424,7 @@ export function TaskInspector({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<ActionButton label="Discussion" onClick={() => onOpenDiscussion(task.id)} icon={ArrowRight} />
|
||||
<ActionButton label="Edit" onClick={startEdit} icon={Pencil} />
|
||||
<ActionButton label="Delete" onClick={() => setConfirmDelete(true)} icon={Trash} tone="danger" />
|
||||
</div>
|
||||
@@ -454,7 +479,13 @@ export function TaskInspector({
|
||||
})()}
|
||||
|
||||
{/* Communication */}
|
||||
<TaskComments taskId={task.id} companyId={task.companyId} agents={agents} />
|
||||
<TaskComments
|
||||
taskId={task.id}
|
||||
companyId={task.companyId}
|
||||
agents={agents}
|
||||
comments={comments}
|
||||
onRefresh={onRefresh}
|
||||
/>
|
||||
|
||||
{/* Run history — only if there are runs */}
|
||||
{runs.length > 0 && (
|
||||
@@ -509,35 +540,32 @@ function TaskComments({
|
||||
taskId,
|
||||
companyId,
|
||||
agents,
|
||||
comments,
|
||||
onRefresh,
|
||||
}: {
|
||||
taskId: string;
|
||||
companyId: string;
|
||||
agents: ProfileSnapshot["agents"];
|
||||
comments: CommentRecord[];
|
||||
onRefresh: () => Promise<void>;
|
||||
}) {
|
||||
const [comments, setComments] = useState<Array<{ id: string; authorAgentId: string | null; authorName: string; body: string; createdAt: string }>>([]);
|
||||
const [newComment, setNewComment] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const loadComments = useCallback(async () => {
|
||||
const result = await window.agentCompany.listComments({ taskId });
|
||||
if (result.ok) setComments(result.data);
|
||||
setLoading(false);
|
||||
}, [taskId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadComments();
|
||||
const unsubscribe = window.agentCompany.subscribe((event) => {
|
||||
if (event.type === "agent-message" && event.taskId === taskId) {
|
||||
setComments((prev) => [...prev, event.comment]);
|
||||
void onRefresh();
|
||||
}
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [taskId, loadComments]);
|
||||
}, [onRefresh, taskId]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!newComment.trim()) return;
|
||||
setSubmitError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
unwrap(await window.agentCompany.addComment({
|
||||
companyId,
|
||||
@@ -546,9 +574,11 @@ function TaskComments({
|
||||
body: newComment.trim(),
|
||||
}));
|
||||
setNewComment("");
|
||||
await loadComments();
|
||||
await onRefresh();
|
||||
} catch (error) {
|
||||
setSubmitError(describeActionError(error));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -559,9 +589,7 @@ function TaskComments({
|
||||
<span className="text-[13px] font-semibold text-[color:var(--text)]">Communication</span>
|
||||
<span className="text-[11px] text-[color:var(--muted)]">({comments.length})</span>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="text-[12px] text-[color:var(--muted)]">Loading...</div>
|
||||
) : comments.length === 0 ? (
|
||||
{comments.length === 0 ? (
|
||||
<div className="text-[12px] text-[color:var(--muted)]">No messages yet. Agents will post updates here during runs.</div>
|
||||
) : (
|
||||
<div className="max-h-[240px] space-y-2 overflow-y-auto rounded-lg border border-[color:var(--line)] p-3">
|
||||
@@ -592,7 +620,7 @@ function TaskComments({
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void handleSubmit(); } }}
|
||||
/>
|
||||
<ActionButton label="Send" tone="accent" onClick={() => void handleSubmit()} icon={ArrowRight} />
|
||||
<ActionButton label="Send" tone="accent" onClick={() => void handleSubmit()} icon={ArrowRight} loading={submitting} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -843,26 +871,36 @@ export function ApprovalInspector({
|
||||
|
||||
{/* Hire agent payload details */}
|
||||
{approval.type === "hire_agent" ? (() => {
|
||||
const proposedAgent = agents.find(
|
||||
(a) => a.status === "pending_approval" && a.reportsTo === approval.requestedByAgentId
|
||||
) ?? agents.find(
|
||||
(a) => a.name.toLowerCase().includes("pending") || a.status === "pending_approval"
|
||||
);
|
||||
if (!proposedAgent) return null;
|
||||
const payload = parseApprovalPayloadJson(approval);
|
||||
const payloadAgentId = typeof payload?.agentId === "string" ? payload.agentId : null;
|
||||
const requestedSnapshot =
|
||||
payload?.requestedConfigurationSnapshot &&
|
||||
typeof payload.requestedConfigurationSnapshot === "object" &&
|
||||
!Array.isArray(payload.requestedConfigurationSnapshot)
|
||||
? payload.requestedConfigurationSnapshot as Record<string, unknown>
|
||||
: null;
|
||||
const proposedAgent =
|
||||
(approval.relatedAgentId ? (agents.find((agent) => agent.id === approval.relatedAgentId) ?? null) : null) ??
|
||||
(payloadAgentId ? (agents.find((agent) => agent.id === payloadAgentId) ?? null) : null);
|
||||
const proposedManagerId =
|
||||
proposedAgent?.reportsTo ??
|
||||
(typeof requestedSnapshot?.reportsTo === "string" ? requestedSnapshot.reportsTo : null);
|
||||
const proposedManager = proposedManagerId
|
||||
? (agents.find((agent) => agent.id === proposedManagerId) ?? null)
|
||||
: null;
|
||||
|
||||
if (!proposedAgent && !requestedSnapshot) return null;
|
||||
return (
|
||||
<div className="rounded-[8px] border border-[color:var(--accent)] border-opacity-30 bg-[color:var(--accent-soft)] px-4 py-3">
|
||||
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.06em] text-[color:var(--accent)]">Proposed Hire</div>
|
||||
<div className="grid gap-1.5 text-[12px]">
|
||||
<div><span className="text-[color:var(--muted)]">Name:</span> <span className="font-medium text-[color:var(--text)]">{proposedAgent.name}</span></div>
|
||||
<div><span className="text-[color:var(--muted)]">Role:</span> <span className="text-[color:var(--text)]">{proposedAgent.role}</span></div>
|
||||
{proposedAgent.title ? <div><span className="text-[color:var(--muted)]">Title:</span> <span className="text-[color:var(--text)]">{proposedAgent.title}</span></div> : null}
|
||||
{proposedAgent.capabilities ? <div><span className="text-[color:var(--muted)]">Capabilities:</span> <span className="text-[color:var(--text)]">{proposedAgent.capabilities}</span></div> : null}
|
||||
<div><span className="text-[color:var(--muted)]">Connector:</span> <span className="mono text-[color:var(--text)]">{proposedAgent.connectorId}</span></div>
|
||||
<div><span className="text-[color:var(--muted)]">Budget:</span> <span className="mono text-[color:var(--text)]">{formatMoney(proposedAgent.budgetMonthlyUsd)}/mo</span></div>
|
||||
{proposedAgent.reportsTo ? (() => {
|
||||
const manager = agents.find((a) => a.id === proposedAgent.reportsTo);
|
||||
return manager ? <div><span className="text-[color:var(--muted)]">Reports to:</span> <span className="text-[color:var(--text)]">{manager.name}</span></div> : null;
|
||||
})() : null}
|
||||
<div><span className="text-[color:var(--muted)]">Name:</span> <span className="font-medium text-[color:var(--text)]">{proposedAgent?.name ?? approval.payloadSummary.replace(/^Hire\s+/i, "").split(/\s+as\s+/i)[0] ?? "Pending hire"}</span></div>
|
||||
<div><span className="text-[color:var(--muted)]">Role:</span> <span className="text-[color:var(--text)]">{proposedAgent?.role ?? (typeof requestedSnapshot?.role === "string" ? requestedSnapshot.role : "Unspecified")}</span></div>
|
||||
{(proposedAgent?.title || typeof requestedSnapshot?.title === "string") ? <div><span className="text-[color:var(--muted)]">Title:</span> <span className="text-[color:var(--text)]">{proposedAgent?.title || requestedSnapshot?.title as string}</span></div> : null}
|
||||
{(proposedAgent?.capabilities || typeof requestedSnapshot?.capabilities === "string") ? <div><span className="text-[color:var(--muted)]">Capabilities:</span> <span className="text-[color:var(--text)]">{proposedAgent?.capabilities || requestedSnapshot?.capabilities as string}</span></div> : null}
|
||||
<div><span className="text-[color:var(--muted)]">Connector:</span> <span className="mono text-[color:var(--text)]">{proposedAgent?.connectorId ?? (typeof requestedSnapshot?.connectorId === "string" ? requestedSnapshot.connectorId : "Unknown")}</span></div>
|
||||
<div><span className="text-[color:var(--muted)]">Budget:</span> <span className="mono text-[color:var(--text)]">{formatMoney(proposedAgent?.budgetMonthlyUsd ?? (typeof requestedSnapshot?.budgetMonthlyUsd === "number" ? requestedSnapshot.budgetMonthlyUsd : 0))}/mo</span></div>
|
||||
{proposedManager ? <div><span className="text-[color:var(--muted)]">Reports to:</span> <span className="text-[color:var(--text)]">{proposedManager.name}</span></div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1236,7 +1274,7 @@ export function WorkspaceInspector({
|
||||
{workspace.repoRef ? <span> · Ref: <span className="text-[color:var(--text)]">{workspace.repoRef}</span></span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<ActionButton label="Reveal in Finder" onClick={() => void onOpen(workspace.localPath)} icon={ArrowSquareOut} />
|
||||
<ActionButton label="Open folder" onClick={() => void onOpen(workspace.localPath)} icon={ArrowSquareOut} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1542,6 +1580,7 @@ export function SettingsPane({
|
||||
type="checkbox"
|
||||
checked={company.autoApproveHires}
|
||||
onChange={async (e) => {
|
||||
setActionError(null);
|
||||
try {
|
||||
unwrap(await window.agentCompany.saveCompany({
|
||||
id: company.id,
|
||||
@@ -1551,14 +1590,16 @@ export function SettingsPane({
|
||||
autoApproveHires: e.target.checked,
|
||||
}));
|
||||
await onSaved();
|
||||
} catch { /* ignore */ }
|
||||
} catch (error) {
|
||||
setActionError(describeActionError(error));
|
||||
}
|
||||
}}
|
||||
className="h-4 w-4 rounded accent-[color:var(--accent)]"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-[13px] font-medium text-[color:var(--text)]">Full Autonomy Mode</div>
|
||||
<div className="text-[13px] font-medium text-[color:var(--text)]">Auto-approve hire and strategy approvals</div>
|
||||
<div className="mt-0.5 text-[11px] text-[color:var(--muted)]">
|
||||
Auto-approve agent hires and CEO strategies. Agents will build the team and start operations without Board approval.
|
||||
Automatically approve agent hiring requests and CEO strategy approvals so the company can continue operating without waiting on the board for those specific approval types.
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
@@ -61,7 +61,7 @@ export function Inbox({ items, onNavigate }: InboxProps) {
|
||||
const handleItemClick = (item: InboxItem) => {
|
||||
const sectionMap: Record<string, SectionId> = {
|
||||
approval: "approvals",
|
||||
run: "tasks",
|
||||
run: "runs",
|
||||
agent: "agents",
|
||||
task: "tasks",
|
||||
message: "communication",
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import {
|
||||
ArrowClockwise,
|
||||
FolderOpen,
|
||||
Lightning,
|
||||
ListChecks,
|
||||
PlayCircle,
|
||||
WarningCircle,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { AgentRecord, RunRecord, TaskRecord, WorkspaceRecord } from "@shared/types";
|
||||
import { formatMoney, formatTime, timeAgo } from "../lib/formatters";
|
||||
import { ActionButton, EmptyState, Input, ListFrame, ListRow, Panel, Select, StatusPill } from "./ui";
|
||||
|
||||
const LOG_CHUNK_BYTES = 65_536;
|
||||
|
||||
interface RunCenterProps {
|
||||
runs: RunRecord[];
|
||||
tasks: TaskRecord[];
|
||||
agents: AgentRecord[];
|
||||
workspaces: WorkspaceRecord[];
|
||||
selectedRunId: string | null;
|
||||
onSelectRun: (runId: string) => void;
|
||||
onCancelRun: (runId: string) => Promise<void> | void;
|
||||
onOpenLogPath: (path: string) => Promise<void> | void;
|
||||
onOpenConsole: (runId: string) => void;
|
||||
}
|
||||
|
||||
interface RunLogState {
|
||||
content: string;
|
||||
offset: number;
|
||||
nextOffset: number;
|
||||
totalBytes: number;
|
||||
eof: boolean;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const initialLogState: RunLogState = {
|
||||
content: "",
|
||||
offset: 0,
|
||||
nextOffset: 0,
|
||||
totalBytes: 0,
|
||||
eof: true,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
function isActiveStatus(status: RunRecord["status"]) {
|
||||
return status === "queued" || status === "running";
|
||||
}
|
||||
|
||||
function isAttentionStatus(status: RunRecord["status"]) {
|
||||
return status === "failed" || status === "timed_out" || status === "interrupted";
|
||||
}
|
||||
|
||||
export function RunCenter({
|
||||
runs,
|
||||
tasks,
|
||||
agents,
|
||||
workspaces,
|
||||
selectedRunId,
|
||||
onSelectRun,
|
||||
onCancelRun,
|
||||
onOpenLogPath,
|
||||
onOpenConsole,
|
||||
}: RunCenterProps) {
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | RunRecord["status"]>("all");
|
||||
const [search, setSearch] = useState("");
|
||||
const [logState, setLogState] = useState<RunLogState>(initialLogState);
|
||||
const [logReloadKey, setLogReloadKey] = useState(0);
|
||||
|
||||
const agentMap = useMemo(() => new Map(agents.map((agent) => [agent.id, agent])), [agents]);
|
||||
const taskMap = useMemo(() => new Map(tasks.map((task) => [task.id, task])), [tasks]);
|
||||
const workspaceMap = useMemo(() => new Map(workspaces.map((workspace) => [workspace.id, workspace])), [workspaces]);
|
||||
|
||||
const selectedRun = useMemo(
|
||||
() => runs.find((run) => run.id === selectedRunId) ?? runs[0] ?? null,
|
||||
[runs, selectedRunId],
|
||||
);
|
||||
|
||||
const filteredRuns = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
return runs.filter((run) => {
|
||||
if (statusFilter !== "all" && run.status !== statusFilter) {
|
||||
return false;
|
||||
}
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
const task = taskMap.get(run.taskId);
|
||||
const agent = agentMap.get(run.agentId);
|
||||
const workspace = run.workspaceId ? workspaceMap.get(run.workspaceId) : null;
|
||||
return [
|
||||
task?.title ?? "",
|
||||
agent?.name ?? "",
|
||||
workspace?.name ?? "",
|
||||
run.summary ?? "",
|
||||
run.errorMessage ?? "",
|
||||
run.connectorId,
|
||||
run.id,
|
||||
].some((value) => value.toLowerCase().includes(query));
|
||||
});
|
||||
}, [agentMap, runs, search, statusFilter, taskMap, workspaceMap]);
|
||||
|
||||
const groupedCounts = useMemo(() => {
|
||||
let active = 0;
|
||||
let attention = 0;
|
||||
let completed = 0;
|
||||
|
||||
for (const run of runs) {
|
||||
if (isActiveStatus(run.status)) {
|
||||
active += 1;
|
||||
} else if (isAttentionStatus(run.status)) {
|
||||
attention += 1;
|
||||
} else {
|
||||
completed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { active, attention, completed };
|
||||
}, [runs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedRunId && !runs.some((run) => run.id === selectedRunId)) {
|
||||
const fallbackRun = runs[0];
|
||||
if (fallbackRun) {
|
||||
onSelectRun(fallbackRun.id);
|
||||
}
|
||||
}
|
||||
}, [onSelectRun, runs, selectedRunId]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadInitialChunk() {
|
||||
if (!selectedRun) {
|
||||
setLogState(initialLogState);
|
||||
return;
|
||||
}
|
||||
|
||||
setLogState((current) => ({ ...current, loading: true, error: null }));
|
||||
|
||||
const metaResult = await window.agentCompany.getRunLogChunk({
|
||||
companyId: selectedRun.companyId,
|
||||
runId: selectedRun.id,
|
||||
offset: 0,
|
||||
limit: 1024,
|
||||
});
|
||||
|
||||
if (!metaResult.ok) {
|
||||
if (!cancelled) {
|
||||
setLogState((current) => ({
|
||||
...current,
|
||||
loading: false,
|
||||
error: metaResult.error.message,
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const totalBytes = metaResult.data.totalBytes;
|
||||
const startOffset = Math.max(0, totalBytes - LOG_CHUNK_BYTES);
|
||||
const chunkResult =
|
||||
startOffset === 0 && totalBytes <= LOG_CHUNK_BYTES
|
||||
? metaResult
|
||||
: await window.agentCompany.getRunLogChunk({
|
||||
companyId: selectedRun.companyId,
|
||||
runId: selectedRun.id,
|
||||
offset: startOffset,
|
||||
limit: LOG_CHUNK_BYTES,
|
||||
});
|
||||
|
||||
if (!chunkResult.ok) {
|
||||
if (!cancelled) {
|
||||
setLogState((current) => ({
|
||||
...current,
|
||||
loading: false,
|
||||
error: chunkResult.error.message,
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
setLogState({
|
||||
...chunkResult.data,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void loadInitialChunk();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [logReloadKey, selectedRun]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedRun || !isActiveStatus(selectedRun.status)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const intervalId = window.setInterval(async () => {
|
||||
const latest = await window.agentCompany.getRunLogChunk({
|
||||
companyId: selectedRun.companyId,
|
||||
runId: selectedRun.id,
|
||||
offset: Math.max(0, logState.totalBytes - LOG_CHUNK_BYTES),
|
||||
limit: LOG_CHUNK_BYTES,
|
||||
});
|
||||
|
||||
if (!latest.ok || cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLogState({
|
||||
...latest.data,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
}, 2500);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [logState.totalBytes, selectedRun]);
|
||||
|
||||
async function loadOlderLogs() {
|
||||
if (!selectedRun || logState.offset <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextOffset = Math.max(0, logState.offset - LOG_CHUNK_BYTES);
|
||||
setLogState((current) => ({ ...current, loading: true, error: null }));
|
||||
|
||||
const result = await window.agentCompany.getRunLogChunk({
|
||||
companyId: selectedRun.companyId,
|
||||
runId: selectedRun.id,
|
||||
offset: nextOffset,
|
||||
limit: logState.offset - nextOffset,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
setLogState((current) => ({
|
||||
...current,
|
||||
loading: false,
|
||||
error: result.error.message,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
setLogState({
|
||||
...result.data,
|
||||
content: `${result.data.content}${logState.content}`,
|
||||
nextOffset: logState.nextOffset,
|
||||
eof: logState.eof,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (runs.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="No runs yet"
|
||||
detail="Start a task run from the task queue or onboarding handoff. This surface becomes the operator console for queue state, failures, and live log tails."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<RunSummaryCard
|
||||
label="Active"
|
||||
value={groupedCounts.active}
|
||||
detail="Queued or running right now"
|
||||
icon={PlayCircle}
|
||||
/>
|
||||
<RunSummaryCard
|
||||
label="Needs attention"
|
||||
value={groupedCounts.attention}
|
||||
detail="Failed, timed out, or interrupted"
|
||||
icon={WarningCircle}
|
||||
tone="danger"
|
||||
/>
|
||||
<RunSummaryCard
|
||||
label="Completed"
|
||||
value={groupedCounts.completed}
|
||||
detail="Finished without current intervention"
|
||||
icon={ListChecks}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 xl:grid-cols-[minmax(0,420px)_minmax(0,1fr)]">
|
||||
<div className="space-y-5">
|
||||
<Panel
|
||||
title="Run queue"
|
||||
action={(
|
||||
<Select
|
||||
compact
|
||||
value={statusFilter}
|
||||
onChange={(value) => setStatusFilter(value as "all" | RunRecord["status"])}
|
||||
options={[
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "queued", label: "Queued" },
|
||||
{ value: "running", label: "Running" },
|
||||
{ value: "succeeded", label: "Succeeded" },
|
||||
{ value: "failed", label: "Failed" },
|
||||
{ value: "timed_out", label: "Timed out" },
|
||||
{ value: "cancelled", label: "Cancelled" },
|
||||
{ value: "interrupted", label: "Interrupted" },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<div className="mb-4">
|
||||
<Input
|
||||
ariaLabel="Search runs"
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder="Search by task, agent, connector, workspace, or error"
|
||||
/>
|
||||
</div>
|
||||
<ListFrame>
|
||||
{filteredRuns.map((run) => {
|
||||
const task = taskMap.get(run.taskId);
|
||||
const agent = agentMap.get(run.agentId);
|
||||
const workspace = run.workspaceId ? workspaceMap.get(run.workspaceId) : null;
|
||||
const title = task?.title ?? (run.summary || `Run ${run.id.slice(0, 8)}`);
|
||||
const supportingText = run.errorMessage || run.summary || "No summary yet";
|
||||
return (
|
||||
<ListRow
|
||||
key={run.id}
|
||||
onClick={() => onSelectRun(run.id)}
|
||||
className={selectedRun?.id === run.id ? "bg-[color:var(--accent-soft)]" : "hover:bg-[color:var(--panel-soft)]"}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[13px] font-semibold text-[color:var(--text)]">{title}</div>
|
||||
<div className="mt-0.5 truncate text-[11px] text-[color:var(--muted)]">
|
||||
{agent?.name ?? "Unknown agent"} via {run.connectorId} {workspace ? `· ${workspace.name}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<StatusPill status={run.status} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 text-[11px] text-[color:var(--muted)]">
|
||||
<span className="truncate">{supportingText}</span>
|
||||
<span className="shrink-0">{timeAgo(run.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</ListRow>
|
||||
);
|
||||
})}
|
||||
</ListFrame>
|
||||
{filteredRuns.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No runs match this filter"
|
||||
detail="Widen the status filter or clear the search query to inspect the full run history."
|
||||
/>
|
||||
) : null}
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{selectedRun ? (
|
||||
<>
|
||||
<Panel
|
||||
title="Run detail"
|
||||
action={(
|
||||
<div className="flex items-center gap-2">
|
||||
<ActionButton
|
||||
label="Open console"
|
||||
icon={Lightning}
|
||||
onClick={() => onOpenConsole(selectedRun.id)}
|
||||
/>
|
||||
{isActiveStatus(selectedRun.status) ? (
|
||||
<ActionButton
|
||||
label="Cancel run"
|
||||
tone="danger"
|
||||
icon={WarningCircle}
|
||||
onClick={() => void onCancelRun(selectedRun.id)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<RunMeta label="Status" value={<StatusPill status={selectedRun.status} />} />
|
||||
<RunMeta label="Connector" value={selectedRun.connectorId} />
|
||||
<RunMeta label="Task" value={taskMap.get(selectedRun.taskId)?.title ?? selectedRun.taskId} />
|
||||
<RunMeta label="Agent" value={agentMap.get(selectedRun.agentId)?.name ?? selectedRun.agentId} />
|
||||
<RunMeta label="Workspace" value={selectedRun.workspaceId ? (workspaceMap.get(selectedRun.workspaceId)?.name ?? selectedRun.workspaceId) : "No exclusive workspace lock"} />
|
||||
<RunMeta label="Model" value={selectedRun.model ?? "Connector default"} />
|
||||
<RunMeta label="Started" value={formatTime(selectedRun.startedAt)} />
|
||||
<RunMeta label="Finished" value={formatTime(selectedRun.finishedAt)} />
|
||||
<RunMeta label="Cost" value={formatMoney(selectedRun.costUsd)} />
|
||||
<RunMeta label="Log bytes" value={logState.totalBytes.toLocaleString()} />
|
||||
</div>
|
||||
{selectedRun.errorMessage ? (
|
||||
<div className="mt-5 rounded-[10px] border border-[color:var(--danger)] bg-[color:var(--danger-soft)] px-4 py-3 text-[13px] text-[color:var(--danger)]">
|
||||
{selectedRun.errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
{selectedRun.logPath ? (
|
||||
<div className="mt-5">
|
||||
<ActionButton
|
||||
label="Open log file"
|
||||
icon={FolderOpen}
|
||||
onClick={() => void onOpenLogPath(selectedRun.logPath!)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</Panel>
|
||||
|
||||
<Panel
|
||||
title="Log tail"
|
||||
eyebrow="Incremental reader"
|
||||
action={(
|
||||
<div className="flex items-center gap-2">
|
||||
<ActionButton
|
||||
label="Reload tail"
|
||||
icon={ArrowClockwise}
|
||||
onClick={() => setLogReloadKey((current) => current + 1)}
|
||||
/>
|
||||
{logState.offset > 0 ? (
|
||||
<ActionButton
|
||||
label="Load older"
|
||||
icon={ListChecks}
|
||||
onClick={() => void loadOlderLogs()}
|
||||
disabled={logState.loading}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{logState.error ? (
|
||||
<div className="rounded-[10px] border border-[color:var(--danger)] bg-[color:var(--danger-soft)] px-4 py-3 text-[13px] text-[color:var(--danger)]">
|
||||
{logState.error}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mb-3 flex items-center justify-between text-[11px] text-[color:var(--muted)]">
|
||||
<span>
|
||||
Showing bytes {logState.offset.toLocaleString()} to {logState.nextOffset.toLocaleString()} of {logState.totalBytes.toLocaleString()}
|
||||
</span>
|
||||
<span>{logState.loading ? "Refreshing…" : logState.eof ? "At latest tail" : "Tail window loaded"}</span>
|
||||
</div>
|
||||
<div className="max-h-[420px] overflow-auto rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3">
|
||||
<pre className="mono whitespace-pre-wrap text-[12px] leading-relaxed text-[color:var(--muted-strong)]">
|
||||
{logState.content || "No log output captured yet."}
|
||||
</pre>
|
||||
</div>
|
||||
</Panel>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RunSummaryCard({
|
||||
label,
|
||||
value,
|
||||
detail,
|
||||
icon: Icon,
|
||||
tone = "default",
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
detail: string;
|
||||
icon: React.ElementType;
|
||||
tone?: "default" | "danger";
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-[12px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.06em] text-[color:var(--muted)]">{label}</div>
|
||||
<div className={`mt-1 text-[28px] font-semibold tracking-[-0.03em] ${tone === "danger" ? "text-[color:var(--danger)]" : "text-[color:var(--text)]"}`}>
|
||||
{value}
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] text-[color:var(--muted)]">{detail}</div>
|
||||
</div>
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-[10px] ${tone === "danger" ? "bg-[color:var(--danger-soft)] text-[color:var(--danger)]" : "bg-[color:var(--panel-soft)] text-[color:var(--muted-strong)]"}`}>
|
||||
<Icon size={18} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RunMeta({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-3.5 py-3">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">{label}</div>
|
||||
<div className="mt-1 text-[13px] text-[color:var(--text)]">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { type ElementType, useCallback, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
Browser,
|
||||
@@ -18,22 +18,30 @@ import {
|
||||
SignIn,
|
||||
Trash,
|
||||
UserPlus,
|
||||
FacebookLogo,
|
||||
GlobeHemisphereWest,
|
||||
InstagramLogo,
|
||||
LinkedinLogo,
|
||||
RedditLogo,
|
||||
TiktokLogo,
|
||||
XLogo,
|
||||
X,
|
||||
YoutubeLogo,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { BrowserActionRecord, SocialAccountRecord, SocialPlatform } from "@shared/types";
|
||||
import { timeAgo } from "../lib/formatters";
|
||||
import { ActionButton, Input } from "./ui";
|
||||
|
||||
const platformConfig: Record<SocialPlatform, { label: string; color: string; bgColor: string; icon: string }> = {
|
||||
twitter: { label: "Twitter / X", color: "#1DA1F2", bgColor: "#1DA1F210", icon: "𝕏" },
|
||||
linkedin: { label: "LinkedIn", color: "#0A66C2", bgColor: "#0A66C210", icon: "in" },
|
||||
instagram: { label: "Instagram", color: "#E4405F", bgColor: "#E4405F10", icon: "IG" },
|
||||
facebook: { label: "Facebook", color: "#1877F2", bgColor: "#1877F210", icon: "fb" },
|
||||
reddit: { label: "Reddit", color: "#FF4500", bgColor: "#FF450010", icon: "r/" },
|
||||
youtube: { label: "YouTube", color: "#FF0000", bgColor: "#FF000010", icon: "YT" },
|
||||
tiktok: { label: "TikTok", color: "#000000", bgColor: "#00000010", icon: "TT" },
|
||||
bluesky: { label: "Bluesky", color: "#0085FF", bgColor: "#0085FF10", icon: "🦋" },
|
||||
other: { label: "Other", color: "#6B7280", bgColor: "#6B728010", icon: "🌐" },
|
||||
const platformConfig: Record<SocialPlatform, { label: string; color: string; bgColor: string; icon: ElementType }> = {
|
||||
twitter: { label: "Twitter / X", color: "#1D9BF0", bgColor: "#1D9BF014", icon: XLogo },
|
||||
linkedin: { label: "LinkedIn", color: "#0A66C2", bgColor: "#0A66C214", icon: LinkedinLogo },
|
||||
instagram: { label: "Instagram", color: "#E4405F", bgColor: "#E4405F14", icon: InstagramLogo },
|
||||
facebook: { label: "Facebook", color: "#1877F2", bgColor: "#1877F214", icon: FacebookLogo },
|
||||
reddit: { label: "Reddit", color: "#FF4500", bgColor: "#FF450014", icon: RedditLogo },
|
||||
youtube: { label: "YouTube", color: "#FF0000", bgColor: "#FF000014", icon: YoutubeLogo },
|
||||
tiktok: { label: "TikTok", color: "#111315", bgColor: "#11131514", icon: TiktokLogo },
|
||||
bluesky: { label: "Bluesky", color: "#0085FF", bgColor: "#0085FF14", icon: GlobeHemisphereWest },
|
||||
other: { label: "Other", color: "#6B7280", bgColor: "#6B728014", icon: GlobeHemisphereWest },
|
||||
};
|
||||
|
||||
const actionTypeLabels: Record<string, { label: string; icon: React.ElementType }> = {
|
||||
@@ -58,41 +66,51 @@ function AccountCard({
|
||||
account: SocialAccountRecord;
|
||||
onLogin: () => void;
|
||||
onDelete: () => void;
|
||||
onClick: () => void;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const platform = platformConfig[account.platform] ?? platformConfig.other;
|
||||
const isLoggedIn = account.status === "logged_in" || account.status === "active";
|
||||
const PlatformIcon = platform.icon;
|
||||
const cardContent = (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-semibold text-[color:var(--text)]">{account.displayName || account.accountName}</span>
|
||||
<span
|
||||
className="rounded-full px-2 py-0.5 text-[9px] font-bold uppercase tracking-[0.1em]"
|
||||
style={{ backgroundColor: platform.bgColor, color: platform.color }}
|
||||
>
|
||||
{platform.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-[color:var(--muted)]">
|
||||
<span>@{account.accountName}</span>
|
||||
{account.requireApproval ? (
|
||||
<span className="flex items-center gap-1 text-[color:var(--warn)]">
|
||||
<ShieldCheck size={10} />
|
||||
Approval required
|
||||
</span>
|
||||
) : null}
|
||||
{account.lastUsedAt ? <span>Last used {timeAgo(account.lastUsedAt)}</span> : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="group flex items-center gap-3 rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3 transition hover:border-[color:var(--line-strong)] hover:shadow-sm">
|
||||
<div
|
||||
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-[10px] text-[14px] font-bold"
|
||||
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-[10px]"
|
||||
style={{ backgroundColor: platform.bgColor, color: platform.color }}
|
||||
>
|
||||
{platform.icon}
|
||||
<PlatformIcon size={18} weight="fill" />
|
||||
</div>
|
||||
|
||||
<button type="button" onClick={onClick} className="min-w-0 flex-1 text-left">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-semibold text-[color:var(--text)]">{account.displayName || account.accountName}</span>
|
||||
<span
|
||||
className="rounded-full px-2 py-0.5 text-[9px] font-bold uppercase tracking-[0.1em]"
|
||||
style={{ backgroundColor: platform.bgColor, color: platform.color }}
|
||||
>
|
||||
{platform.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-[color:var(--muted)]">
|
||||
<span>@{account.accountName}</span>
|
||||
{account.requireApproval ? (
|
||||
<span className="flex items-center gap-1 text-[color:var(--warn)]">
|
||||
<ShieldCheck size={10} />
|
||||
Approval required
|
||||
</span>
|
||||
) : null}
|
||||
{account.lastUsedAt ? <span>Last used {timeAgo(account.lastUsedAt)}</span> : null}
|
||||
</div>
|
||||
</button>
|
||||
{onClick ? (
|
||||
<button type="button" onClick={onClick} className="min-w-0 flex-1 text-left">
|
||||
{cardContent}
|
||||
</button>
|
||||
) : (
|
||||
<div className="min-w-0 flex-1 text-left">{cardContent}</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[10px] font-semibold ${
|
||||
@@ -185,7 +203,9 @@ function AddAccountDialog({
|
||||
: "border-[color:var(--line)] text-[color:var(--muted)] hover:border-[color:var(--line-strong)]"
|
||||
}`}
|
||||
>
|
||||
<span style={{ color: config.color }}>{config.icon}</span>
|
||||
<span style={{ color: config.color }}>
|
||||
<config.icon size={14} />
|
||||
</span>
|
||||
{config.label}
|
||||
</button>
|
||||
))}
|
||||
@@ -492,7 +512,6 @@ export function SocialAccountsPanel({
|
||||
account={account}
|
||||
onLogin={() => void onLogin(account.id)}
|
||||
onDelete={() => void onDelete(account.id)}
|
||||
onClick={() => {}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -322,8 +322,8 @@ export function ActionButton({
|
||||
export function StatusPill({ status }: { status: string }) {
|
||||
const positive = ["ready", "active", "running", "succeeded", "approved", "achieved", "done"].includes(status);
|
||||
const negative = ["error", "failed", "cancelled", "blocked", "rejected", "interrupted"].includes(status);
|
||||
const warning = ["auth_required", "degraded", "pending", "in_review", "todo", "queued"].includes(status);
|
||||
const checking = status === "detected" || status === "not_installed";
|
||||
const warning = ["auth_required", "degraded", "pending", "in_review", "todo", "queued", "not_installed"].includes(status);
|
||||
const checking = status === "detected";
|
||||
const toneClass = positive
|
||||
? "text-[color:var(--success)]"
|
||||
: negative
|
||||
|
||||
+51
-11
@@ -1,5 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import type { AgentMessageRecord, AgentMetrics, BrowserActionRecord, CommentRecord, CompanyMetrics, CompanyTemplate, DesktopEvent, InboxItem, ProfileSnapshot, StandupReport } from "./types";
|
||||
import type { AgentMessageRecord, AgentMetrics, BrowserActionRecord, CommentRecord, CompanyMetrics, DesktopEvent, InboxItem, OnboardingBootstrapResult, ProfileSnapshot, RunLogChunk, StandupReport } from "./types";
|
||||
|
||||
export const IPC_CHANNELS = {
|
||||
loadSnapshot: "agent-company:load-snapshot",
|
||||
@@ -20,6 +20,7 @@ export const IPC_CHANNELS = {
|
||||
startTaskRun: "agent-company:start-task-run",
|
||||
cancelRun: "agent-company:cancel-run",
|
||||
getRunLog: "agent-company:get-run-log",
|
||||
getRunLogChunk: "agent-company:get-run-log-chunk",
|
||||
backupProfile: "agent-company:backup-profile",
|
||||
restoreProfile: "agent-company:restore-profile",
|
||||
updateSettings: "agent-company:update-settings",
|
||||
@@ -34,6 +35,7 @@ export const IPC_CHANNELS = {
|
||||
deleteSecret: "agent-company:delete-secret",
|
||||
getSidebarBadges: "agent-company:get-sidebar-badges",
|
||||
listConnectorModels: "agent-company:list-connector-models",
|
||||
openConnectorAuthTerminal: "agent-company:open-connector-auth-terminal",
|
||||
addComment: "agent-company:add-comment",
|
||||
listComments: "agent-company:list-comments",
|
||||
setHeartbeat: "agent-company:set-heartbeat",
|
||||
@@ -44,8 +46,7 @@ export const IPC_CHANNELS = {
|
||||
getCompanyMetrics: "agent-company:get-company-metrics",
|
||||
getAgentMetrics: "agent-company:get-agent-metrics",
|
||||
getStandupReport: "agent-company:get-standup-report",
|
||||
applyCompanyTemplate: "agent-company:apply-company-template",
|
||||
listCompanyTemplates: "agent-company:list-company-templates",
|
||||
bootstrapOnboarding: "agent-company:bootstrap-onboarding",
|
||||
saveSocialAccount: "agent-company:save-social-account",
|
||||
deleteSocialAccount: "agent-company:delete-social-account",
|
||||
listBrowserActions: "agent-company:list-browser-actions",
|
||||
@@ -181,17 +182,32 @@ export const secretInputSchema = z.object({
|
||||
});
|
||||
|
||||
export const startRunSchema = z.object({
|
||||
companyId: z.string().uuid(),
|
||||
taskId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export const cancelRunSchema = z.object({
|
||||
companyId: z.string().uuid(),
|
||||
runId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export const runLogSchema = z.object({
|
||||
companyId: z.string().uuid(),
|
||||
runId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export const runLogChunkSchema = z.object({
|
||||
companyId: z.string().uuid(),
|
||||
runId: z.string().uuid(),
|
||||
offset: z.number().int().min(0).default(0),
|
||||
limit: z.number().int().min(1024).max(262_144).default(65_536),
|
||||
});
|
||||
|
||||
export const triggerBrowserLoginSchema = z.object({
|
||||
companyId: z.string().uuid(),
|
||||
socialAccountId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export const deleteEntitySchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
companyId: z.string().uuid(),
|
||||
@@ -222,6 +238,7 @@ export const commentInputSchema = z.object({
|
||||
});
|
||||
|
||||
export const listCommentsSchema = z.object({
|
||||
companyId: z.string().uuid(),
|
||||
taskId: z.string().uuid(),
|
||||
});
|
||||
|
||||
@@ -244,11 +261,13 @@ export const metricsQuerySchema = z.object({
|
||||
days: z.number().int().min(1).max(90).default(30),
|
||||
});
|
||||
|
||||
export const templateApplySchema = z.object({
|
||||
templateId: z.string(),
|
||||
export const bootstrapOnboardingSchema = z.object({
|
||||
companyName: z.string().min(2).max(80),
|
||||
companyDescription: z.string().max(500).default(""),
|
||||
workspaceId: z.string().uuid().optional(),
|
||||
workspacePath: z.string().default(""),
|
||||
autoApproveHires: z.boolean().default(true),
|
||||
goalTitle: z.string().default(""),
|
||||
goalDescription: z.string().default(""),
|
||||
connectorId: z.enum(["codex_local", "claude_local", "gemini_local", "opencode_local", "kimi_local"]).optional(),
|
||||
});
|
||||
|
||||
@@ -306,7 +325,21 @@ export const knowledgeEntryInputSchema = z.object({
|
||||
authorAgentId: optionalId,
|
||||
importance: z.enum(["critical", "high", "medium", "low"]).default("medium"),
|
||||
tagsJson: z.string().default("[]"),
|
||||
referencedEntityType: z.string().nullable().optional(),
|
||||
referencedEntityType: z.enum([
|
||||
"agents",
|
||||
"goals",
|
||||
"projects",
|
||||
"tasks",
|
||||
"approvals",
|
||||
"runs",
|
||||
"secrets",
|
||||
"meetings",
|
||||
"documents",
|
||||
"knowledge_base",
|
||||
"sprints",
|
||||
"automation_rules",
|
||||
"workflow_pipelines",
|
||||
]).nullable().optional(),
|
||||
referencedEntityId: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
@@ -330,6 +363,11 @@ export const browserActionQuerySchema = z.object({
|
||||
limit: z.number().int().min(1).max(100).default(50),
|
||||
});
|
||||
|
||||
export const cancelBrowserActionSchema = z.object({
|
||||
actionId: z.string().uuid(),
|
||||
companyId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export const automationRuleInputSchema = z.object({
|
||||
id: z.preprocess((v) => (v === "" ? null : v), z.string().uuid().nullable().optional()),
|
||||
companyId: z.string().uuid(),
|
||||
@@ -408,6 +446,7 @@ export const toggleAutomationRuleSchema = z.object({
|
||||
export const markMessageReadSchema = z.object({
|
||||
messageId: z.string().uuid(),
|
||||
companyId: z.string().uuid(),
|
||||
readerAgentId: optionalId,
|
||||
});
|
||||
|
||||
export const runWorkflowSchema = z.object({
|
||||
@@ -443,6 +482,7 @@ export type DesktopApi = {
|
||||
startTaskRun: (input: z.infer<typeof startRunSchema>) => Promise<DesktopResult<string>>;
|
||||
cancelRun: (input: z.infer<typeof cancelRunSchema>) => Promise<DesktopResult<boolean>>;
|
||||
getRunLog: (input: z.infer<typeof runLogSchema>) => Promise<DesktopResult<string>>;
|
||||
getRunLogChunk: (input: z.infer<typeof runLogChunkSchema>) => Promise<DesktopResult<RunLogChunk>>;
|
||||
backupProfile: () => Promise<DesktopResult<string | null>>;
|
||||
restoreProfile: () => Promise<DesktopResult<boolean>>;
|
||||
updateSettings: (input: z.infer<typeof settingsSchema>) => Promise<DesktopResult<boolean>>;
|
||||
@@ -457,6 +497,7 @@ export type DesktopApi = {
|
||||
deleteSecret: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
|
||||
getSidebarBadges: (companyId: string) => Promise<DesktopResult<SidebarBadges>>;
|
||||
listConnectorModels: (connectorId: string) => Promise<DesktopResult<Array<{ id: string; label: string }>>>;
|
||||
openConnectorAuthTerminal: (connectorId: z.infer<typeof connectorInputSchema.shape.id>) => Promise<DesktopResult<boolean>>;
|
||||
addComment: (input: z.infer<typeof commentInputSchema>) => Promise<DesktopResult<string>>;
|
||||
listComments: (input: z.infer<typeof listCommentsSchema>) => Promise<DesktopResult<CommentRecord[]>>;
|
||||
setHeartbeat: (input: z.infer<typeof heartbeatSettingsSchema>) => Promise<DesktopResult<boolean>>;
|
||||
@@ -466,13 +507,12 @@ export type DesktopApi = {
|
||||
getCompanyMetrics: (input: z.infer<typeof metricsQuerySchema>) => Promise<DesktopResult<CompanyMetrics>>;
|
||||
getAgentMetrics: (input: z.infer<typeof metricsQuerySchema>) => Promise<DesktopResult<AgentMetrics>>;
|
||||
getStandupReport: (companyId: string) => Promise<DesktopResult<StandupReport>>;
|
||||
applyCompanyTemplate: (input: z.infer<typeof templateApplySchema>) => Promise<DesktopResult<string>>;
|
||||
listCompanyTemplates: () => Promise<DesktopResult<CompanyTemplate[]>>;
|
||||
bootstrapOnboarding: (input: z.infer<typeof bootstrapOnboardingSchema>) => Promise<DesktopResult<OnboardingBootstrapResult>>;
|
||||
saveSocialAccount: (input: z.infer<typeof socialAccountInputSchema>) => Promise<DesktopResult<string>>;
|
||||
deleteSocialAccount: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
|
||||
listBrowserActions: (input: z.infer<typeof browserActionQuerySchema>) => Promise<DesktopResult<BrowserActionRecord[]>>;
|
||||
cancelBrowserAction: (input: z.infer<typeof cancelRunSchema>) => Promise<DesktopResult<boolean>>;
|
||||
triggerBrowserLogin: (socialAccountId: string) => Promise<DesktopResult<boolean>>;
|
||||
cancelBrowserAction: (input: z.infer<typeof cancelBrowserActionSchema>) => Promise<DesktopResult<boolean>>;
|
||||
triggerBrowserLogin: (input: z.infer<typeof triggerBrowserLoginSchema>) => Promise<DesktopResult<boolean>>;
|
||||
saveMeeting: (input: z.infer<typeof meetingInputSchema>) => Promise<DesktopResult<string>>;
|
||||
deleteMeeting: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
|
||||
saveDocument: (input: z.infer<typeof documentInputSchema>) => Promise<DesktopResult<string>>;
|
||||
|
||||
+32
-25
@@ -3,6 +3,7 @@ export type SectionId =
|
||||
| "inbox"
|
||||
| "standup"
|
||||
| "performance"
|
||||
| "runs"
|
||||
| "connectors"
|
||||
| "agents"
|
||||
| "orgchart"
|
||||
@@ -243,6 +244,14 @@ export interface RunRecord {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface RunLogChunk {
|
||||
content: string;
|
||||
offset: number;
|
||||
nextOffset: number;
|
||||
totalBytes: number;
|
||||
eof: boolean;
|
||||
}
|
||||
|
||||
export interface CostRecord {
|
||||
id: string;
|
||||
companyId: string;
|
||||
@@ -422,30 +431,14 @@ export interface CompanyMetrics {
|
||||
agentMetrics: AgentMetrics[];
|
||||
}
|
||||
|
||||
export interface CompanyTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
agents: Array<{
|
||||
name: string;
|
||||
role: string;
|
||||
title: string;
|
||||
department: DepartmentId;
|
||||
capabilities: string;
|
||||
reportsToIndex: number | null; // index in this array, null = CEO
|
||||
connectorPreference: ConnectorId;
|
||||
budgetMonthlyUsd: number;
|
||||
}>;
|
||||
goals: Array<{
|
||||
title: string;
|
||||
description: string;
|
||||
}>;
|
||||
departments?: Array<{
|
||||
id: DepartmentId;
|
||||
name: string;
|
||||
description: string;
|
||||
}>;
|
||||
export interface OnboardingBootstrapResult {
|
||||
companyId: string;
|
||||
companyName: string;
|
||||
workspaceId: string | null;
|
||||
leadAgentId: string | null;
|
||||
projectId: string | null;
|
||||
taskId: string | null;
|
||||
runId: string | null;
|
||||
}
|
||||
|
||||
export type SocialPlatform = "twitter" | "linkedin" | "instagram" | "facebook" | "reddit" | "youtube" | "tiktok" | "bluesky" | "other";
|
||||
@@ -582,7 +575,21 @@ export interface KnowledgeEntryRecord {
|
||||
authorAgentId: string | null;
|
||||
importance: "critical" | "high" | "medium" | "low";
|
||||
tagsJson: string;
|
||||
referencedEntityType: string | null;
|
||||
referencedEntityType:
|
||||
| "agents"
|
||||
| "goals"
|
||||
| "projects"
|
||||
| "tasks"
|
||||
| "approvals"
|
||||
| "runs"
|
||||
| "secrets"
|
||||
| "meetings"
|
||||
| "documents"
|
||||
| "knowledge_base"
|
||||
| "sprints"
|
||||
| "automation_rules"
|
||||
| "workflow_pipelines"
|
||||
| null;
|
||||
referencedEntityId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
@@ -27,25 +27,31 @@ test("first launch onboarding creates a company and opens the desktop shell", as
|
||||
try {
|
||||
const page = await app.firstWindow();
|
||||
await expect(page.getByText("AgentCompany Desktop")).toBeVisible();
|
||||
await page.getByRole("button", { name: /Custom Setup/i }).click();
|
||||
await page.getByLabel("Company name").fill("Orbit Foundry");
|
||||
await page.getByLabel("Workspace folder").fill(workspaceDir);
|
||||
await page.getByRole("button", { name: /^Continue$/i }).click();
|
||||
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 your company/i }).click();
|
||||
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: "Dashboard" })).toBeVisible();
|
||||
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 });
|
||||
await expect(showConsoleButton).toBeVisible();
|
||||
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();
|
||||
const hideConsoleButton = page.getByRole("button", { name: /hide console/i });
|
||||
await expect(hideConsoleButton).toBeVisible();
|
||||
await hideConsoleButton.click();
|
||||
await expect(page.getByText("Console", { exact: true })).toBeHidden();
|
||||
await expect(showConsoleButton).toBeVisible();
|
||||
} finally {
|
||||
await app.close();
|
||||
await rm(profileDir, { recursive: true, force: true });
|
||||
|
||||
@@ -2,12 +2,13 @@ import { execFileSync } from "node:child_process";
|
||||
|
||||
/**
|
||||
* Playwright globalSetup: build the Electron app from source before E2E tests.
|
||||
* Uses execFileSync (no shell) for safety and deterministic builds.
|
||||
* Uses execFileSync and enables shell mode on Windows so the pnpm shim resolves correctly.
|
||||
*/
|
||||
export default function globalSetup() {
|
||||
execFileSync("pnpm", ["electron-vite", "build"], {
|
||||
stdio: "inherit",
|
||||
cwd: process.cwd(),
|
||||
timeout: 120_000,
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
}
|
||||
+4
-1
@@ -1,7 +1,10 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
export function getElectronExecutablePath() {
|
||||
return resolve(process.cwd(), process.platform === "win32" ? "node_modules/.bin/electron.cmd" : "node_modules/.bin/electron");
|
||||
return require("electron");
|
||||
}
|
||||
|
||||
export function getMainEntryPath() {
|
||||
|
||||
@@ -1189,7 +1189,8 @@ describe("Agent Messages", () => {
|
||||
|
||||
db.markMessageRead(msgId, companyId);
|
||||
const messages = db.listAgentMessages(companyId);
|
||||
expect(messages[0].readAt).toBeTruthy();
|
||||
const updatedMessage = messages.find((message) => message.id === msgId);
|
||||
expect(updatedMessage?.readAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("tracks read state per agent without marking the sender unread", () => {
|
||||
|
||||
@@ -18,7 +18,7 @@ describe("parseEnvBindingText", () => {
|
||||
});
|
||||
|
||||
describe("buildDefaultConnectorRows", () => {
|
||||
it("keeps security-sensitive commands pinned to connector defaults", () => {
|
||||
it("preserves previously saved connector commands", () => {
|
||||
const [codex] = buildDefaultConnectorRows([
|
||||
{
|
||||
id: "codex_local",
|
||||
@@ -29,7 +29,7 @@ describe("buildDefaultConnectorRows", () => {
|
||||
]);
|
||||
|
||||
expect(codex.id).toBe("codex_local");
|
||||
expect(codex.command).toBe("codex");
|
||||
expect(codex.command).toBe("/usr/local/bin/codex");
|
||||
expect(codex.status).toBe("ready");
|
||||
expect(codex.notes).toBe("custom note");
|
||||
expect(codex.capabilityMatrix.structuredTranscript).toBe(true);
|
||||
|
||||
Reference in new issue
Block a user