Merge remote-tracking branch 'origin/main' into ashish921998/design-sync-pr
This commit is contained in:
commit
a4e8c7f360
|
|
@ -0,0 +1,34 @@
|
|||
# Exclude unnecessary directories and files from ESLint
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
**/node_modules/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
packages/*/dist/
|
||||
packages/*/dist-server/
|
||||
|
||||
# Test files
|
||||
coverage/
|
||||
*.coverage.js
|
||||
packages/*/coverage/
|
||||
|
||||
# Configuration files
|
||||
eslint.config.js
|
||||
.prettierrc
|
||||
.prettierignore
|
||||
tsconfig.json
|
||||
|
||||
# Web/Next.js specific (build artifacts)
|
||||
packages/web/.next/
|
||||
packages/web/next-env.d.ts
|
||||
|
||||
# Documentation
|
||||
docs/
|
||||
*.md
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
*.swp
|
||||
.cache
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
name: PR Diff Coverage
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: coverage-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
detect-changes:
|
||||
name: Detect changed packages
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
core: ${{ steps.filter.outputs.core }}
|
||||
cli: ${{ steps.filter.outputs.cli }}
|
||||
web: ${{ steps.filter.outputs.web }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
core:
|
||||
- 'packages/core/**'
|
||||
cli:
|
||||
- 'packages/cli/**'
|
||||
web:
|
||||
- 'packages/web/**'
|
||||
|
||||
diff-coverage:
|
||||
name: Diff Coverage (80% on changed code)
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.core == 'true' || needs.detect-changes.outputs.cli == 'true' || needs.detect-changes.outputs.web == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.x"
|
||||
|
||||
- name: Install diff-cover
|
||||
run: pip install diff-cover
|
||||
|
||||
- name: Install tmux
|
||||
if: needs.detect-changes.outputs.web == 'true'
|
||||
run: sudo apt-get update && sudo apt-get install -y tmux
|
||||
|
||||
- name: Start tmux server
|
||||
if: needs.detect-changes.outputs.web == 'true'
|
||||
run: tmux start-server
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm -r --filter '!@composio/ao-web' build
|
||||
|
||||
- name: Core tests with coverage
|
||||
if: needs.detect-changes.outputs.core == 'true'
|
||||
run: pnpm --filter @composio/ao-core exec vitest run --coverage
|
||||
|
||||
- name: CLI tests with coverage
|
||||
if: needs.detect-changes.outputs.cli == 'true'
|
||||
run: pnpm --filter @composio/ao-cli exec vitest run --coverage
|
||||
|
||||
- name: Web tests with coverage
|
||||
if: needs.detect-changes.outputs.web == 'true'
|
||||
run: pnpm --filter @composio/ao-web exec vitest run --coverage
|
||||
|
||||
- name: Prepare and merge lcov reports
|
||||
run: |
|
||||
mkdir -p combined-coverage
|
||||
touch combined-coverage/lcov.info
|
||||
|
||||
if [ -f packages/core/coverage/lcov.info ]; then
|
||||
sed 's|^SF:|SF:packages/core/|' packages/core/coverage/lcov.info >> combined-coverage/lcov.info
|
||||
fi
|
||||
if [ -f packages/cli/coverage/lcov.info ]; then
|
||||
sed 's|^SF:|SF:packages/cli/|' packages/cli/coverage/lcov.info >> combined-coverage/lcov.info
|
||||
fi
|
||||
if [ -f packages/web/coverage/lcov.info ]; then
|
||||
sed 's|^SF:|SF:packages/web/|' packages/web/coverage/lcov.info >> combined-coverage/lcov.info
|
||||
fi
|
||||
|
||||
- name: Check diff coverage (80% on changed lines)
|
||||
run: |
|
||||
diff-cover combined-coverage/lcov.info \
|
||||
--compare-branch=origin/${{ github.base_ref }} \
|
||||
--fail-under=80 \
|
||||
--diff-range-notation='...'
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
name: Deploy to VPS
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [CI]
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: deploy-vps
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: >-
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
github.event.workflow_run.head_branch == 'main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Verify SHA is current tip of main
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
DEPLOY_SHA="${{ github.event.workflow_run.head_sha }}"
|
||||
MAIN_SHA=$(gh api repos/${{ github.repository }}/git/ref/heads/main --jq '.object.sha')
|
||||
if [ "$DEPLOY_SHA" != "$MAIN_SHA" ]; then
|
||||
echo "Skipping: CI SHA $DEPLOY_SHA is not the current tip of main ($MAIN_SHA). Likely a stale rerun."
|
||||
exit 0
|
||||
fi
|
||||
echo "SHA verified: $DEPLOY_SHA is the current tip of main."
|
||||
|
||||
- name: Deploy via SSH
|
||||
uses: appleboy/ssh-action@v1.2.2
|
||||
with:
|
||||
host: ${{ secrets.VPS_HOST }}
|
||||
username: root
|
||||
key: ${{ secrets.VPS_SSH_KEY }}
|
||||
fingerprint: SHA256:mlWd4p7TXI+SnxXMr7THgQprWOk936qRQQdB4P2WsqA
|
||||
script: |
|
||||
set -e
|
||||
DEPLOY_SHA="${{ github.event.workflow_run.head_sha }}"
|
||||
cd /root/agent-orchestrator
|
||||
echo "==> Fetching latest changes..."
|
||||
git fetch origin main
|
||||
echo "==> Current: $(git rev-parse --short HEAD)"
|
||||
echo "==> Deploying CI-passed SHA: ${DEPLOY_SHA:0:7}"
|
||||
git checkout "$DEPLOY_SHA"
|
||||
echo "==> Updated: $(git rev-parse --short HEAD)"
|
||||
echo "==> Installing dependencies..."
|
||||
pnpm install --frozen-lockfile
|
||||
echo "==> Building..."
|
||||
pnpm build
|
||||
echo "==> Restarting services..."
|
||||
pm2 restart ao --update-env
|
||||
pm2 restart openclaw-gateway --update-env || true
|
||||
echo "==> Deploy complete: $(git rev-parse --short HEAD)"
|
||||
|
|
@ -64,3 +64,7 @@ CLAUDE.md
|
|||
AGENTS.md
|
||||
.claude/
|
||||
.opencode/
|
||||
|
||||
# OS-specific files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
|
|
|||
|
|
@ -182,6 +182,23 @@ pnpm --filter @composio/ao-runtime-myplugin build
|
|||
pnpm --filter @composio/ao-runtime-myplugin test
|
||||
```
|
||||
|
||||
### Publishing to the Marketplace Registry
|
||||
|
||||
To list your plugin in the AO marketplace so others can install it with `ao plugin install`, submit a PR that adds an entry to `packages/cli/src/assets/plugin-registry.json`.
|
||||
|
||||
Each entry requires:
|
||||
|
||||
- **`id`** — short kebab-case name (e.g. `tracker-jira`)
|
||||
- **`package`** — npm package name
|
||||
- **`slot`** — one of: `runtime`, `agent`, `workspace`, `tracker`, `scm`, `notifier`, `terminal`
|
||||
- **`description`** — one-line summary
|
||||
- **`source`** — always `"registry"`
|
||||
- **`latestVersion`** — semver string
|
||||
|
||||
Optionally include `setupAction` if post-install configuration is needed (e.g. `"openclaw-setup"`).
|
||||
|
||||
Your plugin package must satisfy the contract in [`docs/PLUGIN_SPEC.md`](docs/PLUGIN_SPEC.md) — export a `PluginModule` with a valid manifest and `create()` function. The package must be published to npm before your registry PR is merged so `ao plugin install` can fetch it.
|
||||
|
||||
---
|
||||
|
||||
## Code Conventions
|
||||
|
|
|
|||
12
README.md
12
README.md
|
|
@ -103,6 +103,7 @@ The orchestrator agent uses the [AO CLI](docs/CLI.md) internally to manage sessi
|
|||
|
||||
```yaml
|
||||
# agent-orchestrator.yaml
|
||||
# Runtime data is auto-derived under ~/.agent-orchestrator/{hash}-{projectId}/
|
||||
port: 3000
|
||||
|
||||
defaults:
|
||||
|
|
@ -138,18 +139,17 @@ See [`agent-orchestrator.yaml.example`](agent-orchestrator.yaml.example) for the
|
|||
|
||||
## Plugin Architecture
|
||||
|
||||
Eight slots. Every abstraction is swappable.
|
||||
Seven plugin slots. Lifecycle stays in core.
|
||||
|
||||
| Slot | Default | Alternatives |
|
||||
| --------- | ----------- | ------------------------ |
|
||||
| Runtime | tmux | docker, k8s, process |
|
||||
| Runtime | tmux | process |
|
||||
| Agent | claude-code | codex, aider, opencode |
|
||||
| Workspace | worktree | clone |
|
||||
| Tracker | github | linear |
|
||||
| SCM | github | — |
|
||||
| Notifier | desktop | slack, composio, webhook |
|
||||
| Tracker | github | linear, gitlab |
|
||||
| SCM | github | gitlab |
|
||||
| Notifier | desktop | slack, discord, composio, webhook, openclaw |
|
||||
| Terminal | iterm2 | web |
|
||||
| Lifecycle | core | — |
|
||||
|
||||
All interfaces defined in [`packages/core/src/types.ts`](packages/core/src/types.ts). A plugin implements one interface and exports a `PluginModule`. That's it.
|
||||
|
||||
|
|
|
|||
21
SETUP.md
21
SETUP.md
|
|
@ -505,11 +505,11 @@ lsof -ti:3000 | xargs kill
|
|||
**Solution:**
|
||||
|
||||
```bash
|
||||
# Check worktreeDir permissions
|
||||
ls -la ~/.worktrees
|
||||
# AO stores runtime data under ~/.agent-orchestrator/
|
||||
ls -la ~/.agent-orchestrator
|
||||
|
||||
# Create directory if missing
|
||||
mkdir -p ~/.worktrees
|
||||
# Create the base directory if missing
|
||||
mkdir -p ~/.agent-orchestrator
|
||||
|
||||
# Check disk space
|
||||
df -h
|
||||
|
|
@ -618,10 +618,10 @@ projects:
|
|||
path: ~/backend
|
||||
sessionPrefix: api
|
||||
|
||||
mobile:
|
||||
repo: org/mobile
|
||||
path: ~/mobile
|
||||
sessionPrefix: mob
|
||||
docs:
|
||||
repo: org/docs
|
||||
path: ~/docs
|
||||
sessionPrefix: doc
|
||||
```
|
||||
|
||||
See [examples/multi-project.yaml](./examples/multi-project.yaml) for full example.
|
||||
|
|
@ -817,11 +817,10 @@ ao session ls --json | jq -r '.[] | select(.status == "merged") | .id' | xargs -
|
|||
|
||||
Yes! Each orchestrator instance should have:
|
||||
|
||||
- Different data directory (`dataDir`)
|
||||
- Different dashboard port (`port`) — e.g., 3000 for project A, 3001 for project B
|
||||
- Different config file
|
||||
- Different config location or project paths
|
||||
|
||||
Terminal WebSocket ports are auto-detected by default, so you typically only need to set `port:` differently. If you need explicit control, you can also set `terminalPort:` and `directTerminalPort:` per config.
|
||||
AO derives runtime directories from the config location, so separate config locations already produce separate hash-scoped runtime paths under `~/.agent-orchestrator/`. Terminal WebSocket ports are auto-detected by default, so you typically only need to set `port:` differently. If you need explicit control, you can also set `terminalPort:` and `directTerminalPort:` per config.
|
||||
|
||||
Useful for:
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
# Agent Orchestrator Configuration
|
||||
# Copy to agent-orchestrator.yaml and customize.
|
||||
|
||||
# Where to store session metadata
|
||||
dataDir: ~/.agent-orchestrator
|
||||
|
||||
# Where to create workspaces (git worktrees, clones)
|
||||
worktreeDir: ~/.worktrees
|
||||
# Runtime data directories are auto-derived from this config location under:
|
||||
# ~/.agent-orchestrator/{hash}-{projectId}/
|
||||
# You usually do not need to configure paths manually.
|
||||
|
||||
# Web dashboard port
|
||||
port: 3000
|
||||
|
|
@ -17,14 +15,27 @@ port: 3000
|
|||
|
||||
# Default plugins (these are the defaults — you can omit this section)
|
||||
defaults:
|
||||
runtime: tmux # tmux | process | docker | kubernetes | ssh | e2b
|
||||
agent: claude-code # claude-code | codex | aider | goose | custom
|
||||
runtime: tmux # tmux | process
|
||||
agent: claude-code # claude-code | codex | aider | opencode
|
||||
# orchestrator:
|
||||
# agent: claude-code
|
||||
# worker:
|
||||
# agent: codex
|
||||
workspace: worktree # worktree | clone | copy
|
||||
notifiers: [desktop] # desktop | slack | discord | webhook | email | openclaw
|
||||
workspace: worktree # worktree | clone
|
||||
notifiers: [desktop] # desktop | slack | discord | webhook | composio | openclaw
|
||||
|
||||
# Installer-managed external plugins (optional)
|
||||
# plugins:
|
||||
# - name: owasp-auditor
|
||||
# source: registry # registry | npm | local
|
||||
# package: "@ao-plugins/owasp-auditor"
|
||||
# version: "^0.1.0"
|
||||
# enabled: true
|
||||
#
|
||||
# - name: local-dev-plugin
|
||||
# source: local
|
||||
# path: ./plugins/local-dev-plugin
|
||||
# enabled: true
|
||||
|
||||
# Projects — at minimum, specify repo and path
|
||||
projects:
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ ao start <url> # Clone repo, auto-configure, and start
|
|||
ao start ~/other-repo # Add a new project and start
|
||||
ao stop # Stop everything (dashboard, orchestrator, lifecycle worker)
|
||||
ao status # Overview of all sessions
|
||||
ao status --watch # Live-updating terminal status view
|
||||
ao dashboard # Open web dashboard in browser
|
||||
```
|
||||
|
||||
|
|
@ -36,6 +37,6 @@ ao update # Update local AO install (source install
|
|||
ao config-help # Show full config schema reference
|
||||
```
|
||||
|
||||
`ao doctor` checks PATH and launcher resolution, required binaries, tmux and GitHub CLI health, config support directories, stale AO temp files, and core build/runtime sanity.
|
||||
`ao doctor` checks PATH and launcher resolution, required binaries, configured plugin resolution, tmux and GitHub CLI health, config support directories, stale AO temp files, and core build/runtime sanity.
|
||||
|
||||
`ao update` fast-forwards the local install on `main`, reinstalls dependencies, clean-rebuilds core packages, refreshes the launcher, and runs smoke tests. Use `ao update --skip-smoke` to stop after rebuild, or `ao update --smoke-only` to rerun just the smoke checks.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,114 @@
|
|||
# AO Plugin Spec
|
||||
|
||||
This document defines the runtime contract and packaging requirements for Agent Orchestrator plugins.
|
||||
|
||||
## Runtime Contract
|
||||
|
||||
Plugins are standard Node.js modules that export a `PluginModule`:
|
||||
|
||||
```ts
|
||||
export interface PluginModule<T = unknown> {
|
||||
manifest: PluginManifest;
|
||||
create(config?: Record<string, unknown>): T;
|
||||
detect?(): boolean;
|
||||
}
|
||||
```
|
||||
|
||||
Minimum manifest shape:
|
||||
|
||||
```ts
|
||||
export interface PluginManifest {
|
||||
name: string;
|
||||
slot: PluginSlot;
|
||||
description: string;
|
||||
version: string;
|
||||
}
|
||||
```
|
||||
|
||||
AO accepts either a direct named export or a default export that satisfies this shape.
|
||||
|
||||
## Supported Slots
|
||||
|
||||
Current core slot types:
|
||||
|
||||
- `runtime`
|
||||
- `agent`
|
||||
- `workspace`
|
||||
- `tracker`
|
||||
- `scm`
|
||||
- `notifier`
|
||||
- `terminal`
|
||||
|
||||
The manifest `slot` determines where AO registers the plugin and which config surface can reference it.
|
||||
|
||||
## Packaging Requirements
|
||||
|
||||
Published plugins should:
|
||||
|
||||
- ship built JavaScript, not raw TypeScript-only entrypoints
|
||||
- export an ESM entrypoint through `exports` or `main`
|
||||
- declare a semver dependency on `@composio/ao-core`
|
||||
- keep side effects out of module top-level code where possible
|
||||
|
||||
Recommended package shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@composio/ao-plugin-example",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"files": ["dist"]
|
||||
}
|
||||
```
|
||||
|
||||
## Config Descriptors
|
||||
|
||||
Project config enables plugins through `plugins:` entries:
|
||||
|
||||
```yaml
|
||||
plugins:
|
||||
- name: openclaw
|
||||
source: registry
|
||||
package: "@composio/ao-plugin-notifier-openclaw"
|
||||
version: "0.1.1"
|
||||
```
|
||||
|
||||
Descriptor fields:
|
||||
|
||||
- `name`: logical plugin name shown in CLI UX
|
||||
- `source`: one of `registry`, `npm`, or `local`
|
||||
- `package`: package name for registry/npm-backed plugins
|
||||
- `version`: requested or installed version for store-backed plugins
|
||||
- `path`: local filesystem path for `source: local`
|
||||
- `enabled`: optional flag, defaults to `true`
|
||||
|
||||
## Marketplace Registry
|
||||
|
||||
AO’s bundled marketplace catalog lives at:
|
||||
|
||||
- `packages/cli/src/assets/plugin-registry.json`
|
||||
|
||||
Registry entries provide AO-specific metadata on top of the runtime contract:
|
||||
|
||||
- `id`
|
||||
- `package`
|
||||
- `slot`
|
||||
- `description`
|
||||
- `source`
|
||||
- `latestVersion`
|
||||
- `setupAction` when post-install guidance is needed
|
||||
|
||||
## Installation Model
|
||||
|
||||
Registry and npm plugins install into the AO-managed store:
|
||||
|
||||
- `~/.agent-orchestrator/plugins/`
|
||||
|
||||
That store is shared across projects. `agent-orchestrator.yaml` remains the source of truth for whether a plugin is enabled in a given repo.
|
||||
|
|
@ -0,0 +1,222 @@
|
|||
# GraphQL Batching Implementation for Issue #608
|
||||
|
||||
## Summary
|
||||
|
||||
This implementation adds GraphQL batch PR enrichment to the orchestrator polling loop, reducing GitHub API calls from N×3 calls to ~1 call per polling cycle.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The orchestrator runs a status loop that polls GitHub for ALL active sessions every 30 seconds. For each PR, it needs:
|
||||
|
||||
1. PR state (merged, closed, open) - `getPRState()`
|
||||
2. CI status - `getCISummary()`
|
||||
3. Review decision - `getReviewDecision()`
|
||||
4. Merge readiness (optional, for approved PRs) - `getMergeability()`
|
||||
|
||||
With the current implementation:
|
||||
- 10 active PRs = 30 API calls per poll = 3,600 calls/hour
|
||||
- 20 active PRs = 60+ API calls per poll = 7,200+ calls/hour
|
||||
|
||||
This exceeds GitHub's rate limit of 5,000 API calls/hour.
|
||||
|
||||
## Solution: GraphQL Batch Query
|
||||
|
||||
Using GraphQL aliases, we can query multiple PRs in a single request:
|
||||
|
||||
```graphql
|
||||
query BatchPRs(
|
||||
$pr0Owner: String!, $pr0Name: String!, $pr0Number: Int!,
|
||||
$pr1Owner: String!, $pr1Name: String!, $pr1Number: Int!
|
||||
) {
|
||||
pr0: repository(owner: $pr0Owner, name: $pr0Name) {
|
||||
pullRequest(number: $pr0Number) {
|
||||
title, state, additions, deletions, isDraft,
|
||||
mergeable, mergeStateStatus, reviewDecision,
|
||||
reviews(last: 5) { nodes { author { login }, state } },
|
||||
commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }
|
||||
}
|
||||
}
|
||||
pr1: repository(owner: $pr1Owner, name: $pr1Name) {
|
||||
pullRequest(number: $pr1Number) {
|
||||
# same fields as pr0
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Core Type Extensions (`packages/core/src/types.ts`)
|
||||
|
||||
Added new interface for batch enrichment:
|
||||
|
||||
```typescript
|
||||
export interface PREnrichmentData {
|
||||
state: PRState;
|
||||
ciStatus: CIStatus;
|
||||
reviewDecision: ReviewDecision;
|
||||
mergeable: boolean;
|
||||
title?: string;
|
||||
additions?: number;
|
||||
deletions?: number;
|
||||
isDraft?: boolean;
|
||||
hasConflicts?: boolean;
|
||||
isBehind?: boolean;
|
||||
blockers?: string[];
|
||||
}
|
||||
```
|
||||
|
||||
Extended SCM interface with optional batch method:
|
||||
|
||||
```typescript
|
||||
export interface SCM {
|
||||
// ... existing methods
|
||||
|
||||
/**
|
||||
* Batch fetch PR data for multiple PRs in a single GraphQL query.
|
||||
* Used by the orchestrator to poll all active sessions efficiently.
|
||||
*/
|
||||
enrichSessionsPRBatch?(prs: PRInfo[]): Promise<Map<string, PREnrichmentData>>;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. GraphQL Batch Module (`packages/plugins/scm-github/src/graphql-batch.ts`)
|
||||
|
||||
New module with:
|
||||
- `generateBatchQuery()` - Dynamically generates GraphQL queries with aliases
|
||||
- `enrichSessionsPRBatch()` - Main entry point that:
|
||||
- Deduplicates PRs by key
|
||||
- Splits into batches of 25 PRs (MAX_BATCH_SIZE)
|
||||
- Executes queries via `gh api graphql`
|
||||
- Returns Map<key, PREnrichmentData>
|
||||
|
||||
Key features:
|
||||
- Batch size limit of 25 PRs per query (well under GitHub's complexity limit)
|
||||
- Graceful handling of missing/deleted PRs
|
||||
- Error handling at batch level (one failed PR doesn't break the batch)
|
||||
- CI status parsing from statusCheckRollup for comprehensive CI detection
|
||||
|
||||
### 3. GitHub Plugin Integration (`packages/plugins/scm-github/src/index.ts`)
|
||||
|
||||
Added implementation of `enrichSessionsPRBatch()`:
|
||||
|
||||
```typescript
|
||||
async enrichSessionsPRBatch(prs: PRInfo[]): Promise<Map<string, PREnrichmentData>> {
|
||||
return enrichSessionsPRBatch(prs);
|
||||
}
|
||||
```
|
||||
|
||||
The method is optional in the SCM interface, ensuring backward compatibility.
|
||||
|
||||
### 4. Lifecycle Manager Updates (`packages/core/src/lifecycle-manager.ts`)
|
||||
|
||||
Added batch enrichment to the polling loop:
|
||||
|
||||
1. **Cache variable**: `prEnrichmentCache` - Map cleared at each poll cycle
|
||||
2. **Populate function**: `populatePREnrichmentCache()` - Groups PRs by SCM plugin and calls batch enrichment
|
||||
3. **Poll cycle update**: Calls `populatePREnrichmentCache()` before checking sessions
|
||||
4. **Status detection**: Uses cached data when available, falls back to individual calls on cache miss
|
||||
|
||||
```typescript
|
||||
// At start of pollAll()
|
||||
await populatePREnrichmentCache(sessionsToCheck);
|
||||
|
||||
// In determineStatus()
|
||||
const prKey = `${session.pr.owner}/${session.pr.repo}#${session.pr.number}`;
|
||||
const cachedData = prEnrichmentCache.get(prKey);
|
||||
if (cachedData) {
|
||||
// Use cached data - no API calls
|
||||
} else {
|
||||
// Fall back to individual calls
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Unit Tests (`packages/plugins/scm-github/test/graphql-batch.test.ts`)
|
||||
|
||||
Added comprehensive tests for:
|
||||
- Single PR query generation
|
||||
- Multiple PR query generation with different aliases
|
||||
- Empty PR array handling
|
||||
- Required field inclusion in queries
|
||||
- Sequential numeric alias generation
|
||||
- Special characters in owner/repo names
|
||||
|
||||
## Performance Impact
|
||||
|
||||
### API Call Reduction
|
||||
|
||||
| Active PRs | Before | After (Batch) | Reduction |
|
||||
|-------------|---------|-----------------|------------|
|
||||
| 5 | 15 | 1 | 93% |
|
||||
| 10 | 30 | 1 | 97% |
|
||||
| 20 | 60 | 1 | 98% |
|
||||
| 50 | 150 | 2 | 99% |
|
||||
|
||||
### Hourly Rate Limit Usage (30s polling)
|
||||
|
||||
| Active PRs | Before (calls) | After (calls) | % of 5,000 Limit |
|
||||
|-------------|----------------|-----------------|-------------------|
|
||||
| 10 | 3,600 | 120 | 2.4% ✅ |
|
||||
| 20 | 7,200 ❌ | 240 | 4.8% ✅ |
|
||||
| 50 | 18,000 ❌ | 600 | 12% ✅ |
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
The implementation maintains full backward compatibility:
|
||||
|
||||
1. **Optional SCM method**: `enrichSessionsPRBatch()` is optional in the SCM interface
|
||||
2. **Graceful fallback**: If batch enrichment fails or isn't available, the lifecycle manager falls back to individual API calls
|
||||
3. **No breaking changes**: All existing SCM methods (`getPRState`, `getCISummary`, `getReviewDecision`, `getMergeability`) remain unchanged
|
||||
|
||||
## Edge Cases Handled
|
||||
|
||||
| Case | Handling |
|
||||
|------|----------|
|
||||
| PR deleted during polling | Returns enrichment data with state "closed" and appropriate blockers |
|
||||
| GraphQL query failure | Falls back to individual API calls |
|
||||
| Mixed SCM plugins | Groups PRs by plugin and calls batch enrichment for each group |
|
||||
| Batch size > MAX_BATCH_SIZE | Splits into multiple batches |
|
||||
| Cache miss | Falls back to individual API calls |
|
||||
|
||||
## GraphQL Rate Limits
|
||||
|
||||
GitHub GraphQL uses a points-based system. Our implementation:
|
||||
- Uses ~50 points per PR (estimated)
|
||||
- Allows ~100 PRs per hour within the 5,000 point limit
|
||||
- Stays well under complexity limits with MAX_BATCH_SIZE=25
|
||||
|
||||
Note: Actual point costs should be monitored in production and MAX_BATCH_SIZE adjusted if needed.
|
||||
|
||||
## Future Improvements
|
||||
|
||||
1. **Metrics**: Add observability metrics for batch query success/failure rates
|
||||
2. **Cache persistence**: Consider caching enrichment data across poll cycles for stable PRs
|
||||
3. **Dynamic batching**: Auto-tune batch size based on GraphQL point usage
|
||||
4. **Feature flag**: Add feature flag for gradual rollout
|
||||
|
||||
## Testing
|
||||
|
||||
Run the unit tests:
|
||||
|
||||
```bash
|
||||
cd packages/plugins/scm-github
|
||||
npm test graphql-batch.test.ts
|
||||
```
|
||||
|
||||
Integration testing should verify:
|
||||
- Batch queries work with real GitHub repos
|
||||
- PR state detection is accurate
|
||||
- CI status parsing matches individual calls
|
||||
- Review decision detection is accurate
|
||||
- Error handling works as expected
|
||||
|
||||
## Related Issues
|
||||
|
||||
- Issue #608: GraphQL batching for orchestrator polling
|
||||
- PR #617: Previous batching optimization (1 call per PR)
|
||||
|
||||
## References
|
||||
|
||||
- GitHub GraphQL API: https://docs.github.com/en/graphql
|
||||
- GraphQL Aliases: https://graphql.org/learn/queries/#aliases
|
||||
- gh CLI GraphQL: https://cli.github.com/manual/gh_api_graphql
|
||||
|
|
@ -0,0 +1,573 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Runtime Terminal Port and Project-ID Hardening</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0b1020;
|
||||
--panel: #111a33;
|
||||
--text: #e5ecff;
|
||||
--muted: #9fb0df;
|
||||
--accent: #71a2ff;
|
||||
--border: #2a3d74;
|
||||
--code: #0e1530;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: radial-gradient(1200px 700px at 10% -20%, #223562 0%, var(--bg) 50%);
|
||||
color: var(--text);
|
||||
line-height: 1.55;
|
||||
}
|
||||
main {
|
||||
max-width: 980px;
|
||||
margin: 32px auto;
|
||||
padding: 24px;
|
||||
background: color-mix(in srgb, var(--panel) 92%, black 8%);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
h1, h2, h3 { margin-top: 1.25em; line-height: 1.25; }
|
||||
h1 { margin-top: 0; font-size: 1.9rem; }
|
||||
h2 {
|
||||
font-size: 1.3rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 0.35rem;
|
||||
}
|
||||
h3 { font-size: 1.05rem; color: #c8d8ff; }
|
||||
p, li { color: var(--text); }
|
||||
.meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(180px, 1fr));
|
||||
gap: 10px 20px;
|
||||
margin: 12px 0 22px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.meta strong { color: var(--text); }
|
||||
ul, ol { padding-left: 1.2rem; }
|
||||
code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.95em;
|
||||
background: var(--code);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 0.06rem 0.35rem;
|
||||
color: #c6f0ff;
|
||||
}
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 1.4rem 0;
|
||||
}
|
||||
.muted { color: var(--muted); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Runtime Terminal Port and Project-ID Hardening</h1>
|
||||
|
||||
<div class="meta">
|
||||
<div><strong>Status:</strong> Draft</div>
|
||||
<div><strong>Author:</strong> Agent Orchestrator</div>
|
||||
<div><strong>Date:</strong> 2026-03-28</div>
|
||||
<div><strong>Target Merge:</strong> <code>main</code></div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<h2>Overview</h2>
|
||||
<p>
|
||||
This design documents two production issues that surfaced in first-run and npm-installed flows:
|
||||
</p>
|
||||
<ol>
|
||||
<li>Direct terminal stuck on <code>CONNECTING...</code> when runtime ports differ from client bundle fallback.</li>
|
||||
<li><code>/api/spawn</code> receiving a session ID as <code>projectId</code>, leading to deep-core <code>Unknown project</code> failures.</li>
|
||||
</ol>
|
||||
<p>
|
||||
The implementation introduces runtime configuration discovery for terminal WebSocket connection and stricter semantic validation for project identifiers at API and page-data boundaries.
|
||||
</p>
|
||||
|
||||
<h2>Problem Statement</h2>
|
||||
|
||||
<h3>Problem A: Terminal WebSocket Port Drift</h3>
|
||||
<ul>
|
||||
<li><code>ao start</code> can auto-select terminal ports at runtime (for example <code>14802/14803</code>) when defaults are occupied.</li>
|
||||
<li>Direct terminal server listens on <code>DIRECT_TERMINAL_PORT</code> at runtime.</li>
|
||||
<li>Browser client previously relied on build-time <code>NEXT_PUBLIC_DIRECT_TERMINAL_PORT</code>, with fallback <code>14801</code>.</li>
|
||||
<li>In prebuilt Next.js client bundles, <code>NEXT_PUBLIC_*</code> values are embedded at build time.</li>
|
||||
<li>Result: client can attempt <code>ws://...:14801</code> while server is on <code>14803</code>, leaving UI in permanent <code>CONNECTING</code>.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Problem B: Project ID / Session ID Domain Confusion</h3>
|
||||
<ul>
|
||||
<li>Session IDs and project IDs share syntax (<code>[a-zA-Z0-9_-]+</code>).</li>
|
||||
<li>Orchestrator session IDs are generated as <code>${project.sessionPrefix}-orchestrator</code>, which can visually resemble project keys.</li>
|
||||
<li><code>/api/spawn</code> previously validated only identifier shape, not membership in <code>config.projects</code>.</li>
|
||||
<li>Invalid but syntactically valid IDs reached core spawn logic and failed late with <code>500</code>.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Root Cause Analysis</h2>
|
||||
<h3>A. Build-Time vs Runtime Config Boundary Mismatch</h3>
|
||||
<ul>
|
||||
<li>The server process controls actual runtime port assignment.</li>
|
||||
<li>The browser bundle cannot safely depend on build-time env values for runtime-selected ports.</li>
|
||||
<li>There was no first-party runtime endpoint for client port discovery.</li>
|
||||
</ul>
|
||||
|
||||
<h3>B. Namespace Collision and Inconsistent Validation</h3>
|
||||
<ul>
|
||||
<li>Project IDs and session IDs were treated as plain strings at API boundaries.</li>
|
||||
<li>Some routes sanitize project filters against config; others previously did not.</li>
|
||||
<li>Validation was format-only in <code>/api/spawn</code> instead of semantic (<code>is configured project</code>).</li>
|
||||
</ul>
|
||||
|
||||
<h2>Goals</h2>
|
||||
<ol>
|
||||
<li>Make direct terminal connection deterministic across runtime-selected ports in prebuilt deployments.</li>
|
||||
<li>Ensure <code>/api/spawn</code> rejects non-configured project IDs early and predictably.</li>
|
||||
<li>Normalize dashboard project filter values so invalid query state cannot poison project context.</li>
|
||||
<li>Preserve backwards compatibility for existing default-port setups.</li>
|
||||
</ol>
|
||||
|
||||
<h2>Non-Goals</h2>
|
||||
<ol>
|
||||
<li>Redesign session ID format.</li>
|
||||
<li>Introduce full typed ID wrappers across all packages in this change.</li>
|
||||
<li>Remove existing reverse-proxy path-based WS support.</li>
|
||||
</ol>
|
||||
|
||||
<h2>Proposed Design</h2>
|
||||
|
||||
<h3>1. Runtime Terminal Config Endpoint</h3>
|
||||
<p>Add <code>GET /api/runtime/terminal</code> (dynamic, no-store):</p>
|
||||
<ul>
|
||||
<li><code>terminalPort</code> from <code>TERMINAL_PORT</code> (normalized, fallback <code>14800</code>)</li>
|
||||
<li><code>directTerminalPort</code> from <code>DIRECT_TERMINAL_PORT</code> (normalized, fallback <code>14801</code>)</li>
|
||||
<li><code>proxyWsPath</code> from <code>TERMINAL_WS_PATH</code>/<code>NEXT_PUBLIC_TERMINAL_WS_PATH</code> (normalized path or <code>null</code>)</li>
|
||||
</ul>
|
||||
|
||||
<h3>2. Runtime-Aware DirectTerminal Connection</h3>
|
||||
<p>Update client connection flow:</p>
|
||||
<ol>
|
||||
<li>Resolve build-time values if available.</li>
|
||||
<li>Fetch <code>/api/runtime/terminal</code> before socket connect when needed.</li>
|
||||
<li>Parse and normalize returned values.</li>
|
||||
<li>Build WS URL from runtime values.</li>
|
||||
<li>Reuse the same runtime-aware logic on reconnect attempts.</li>
|
||||
</ol>
|
||||
<p class="muted">Default ports remain unchanged; runtime-shifted ports become deterministic.</p>
|
||||
|
||||
<h3>3. Semantic Project Validation in <code>/api/spawn</code></h3>
|
||||
<p>Before calling spawn, verify <code>config.projects[projectId]</code> exists. If missing:</p>
|
||||
<ul>
|
||||
<li>Return <code>404</code> with <code>Unknown project: <id></code></li>
|
||||
<li>Record structured observability failure reason</li>
|
||||
<li>Do not invoke core spawn path</li>
|
||||
</ul>
|
||||
|
||||
<h3>4. Dashboard Project Filter Normalization</h3>
|
||||
<ul>
|
||||
<li>keep <code>"all"</code></li>
|
||||
<li>keep only configured project IDs</li>
|
||||
<li>otherwise fallback to first valid configured project (or primary fallback)</li>
|
||||
</ul>
|
||||
|
||||
<h2>Flow Chart</h2>
|
||||
|
||||
<style>
|
||||
.flow-section {
|
||||
margin: 1.5rem 0 2.5rem;
|
||||
padding: 1.5rem;
|
||||
background: rgba(0,0,0,0.2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
}
|
||||
.flow-section-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--accent);
|
||||
margin: 0 0 1.2rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.flow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
}
|
||||
.flow-node {
|
||||
position: relative;
|
||||
padding: 12px 20px;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
max-width: 520px;
|
||||
width: 100%;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.flow-node strong { color: #fff; }
|
||||
.flow-node code {
|
||||
font-size: 0.82rem;
|
||||
padding: 0.04rem 0.3rem;
|
||||
}
|
||||
.node-action {
|
||||
background: linear-gradient(135deg, #1a2a55, #1e3468);
|
||||
border: 1px solid #3456a0;
|
||||
color: #cdd9f5;
|
||||
}
|
||||
.node-decision {
|
||||
background: linear-gradient(135deg, #2a1a44, #3a2060);
|
||||
border: 1px solid #6a4ca0;
|
||||
color: #ddd0f5;
|
||||
border-radius: 10px;
|
||||
padding: 14px 24px;
|
||||
}
|
||||
.node-success {
|
||||
background: linear-gradient(135deg, #0d2a1a, #143a24);
|
||||
border: 1px solid #2a8050;
|
||||
color: #b0e8c8;
|
||||
}
|
||||
.node-error {
|
||||
background: linear-gradient(135deg, #2a0d0d, #3a1414);
|
||||
border: 1px solid #803030;
|
||||
color: #e8b0b0;
|
||||
}
|
||||
.node-endpoint {
|
||||
background: linear-gradient(135deg, #1a2040, #1e2855);
|
||||
border: 1px solid #4070b0;
|
||||
color: #a8c8f0;
|
||||
}
|
||||
.flow-arrow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
padding: 2px 0;
|
||||
line-height: 1;
|
||||
}
|
||||
.flow-arrow .arrow-line {
|
||||
width: 2px;
|
||||
height: 14px;
|
||||
background: var(--border);
|
||||
}
|
||||
.flow-arrow .arrow-head {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 5px solid transparent;
|
||||
border-right: 5px solid transparent;
|
||||
border-top: 6px solid var(--border);
|
||||
}
|
||||
.flow-arrow .arrow-label {
|
||||
margin: 2px 0;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.flow-branch {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
}
|
||||
.flow-branch-arm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
gap: 0;
|
||||
}
|
||||
.branch-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.branch-yes { background: #1a3a24; color: #70d898; border: 1px solid #2a6040; }
|
||||
.branch-no { background: #3a1a1a; color: #e88080; border: 1px solid #603030; }
|
||||
.branch-match { background: #1a2a44; color: #70a8e8; border: 1px solid #2a5080; }
|
||||
.flow-node .sub {
|
||||
display: block;
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
/* Filter table */
|
||||
.filter-table {
|
||||
width: 100%;
|
||||
max-width: 540px;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
margin: 0 auto;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.filter-table th {
|
||||
background: #1a2550;
|
||||
color: var(--accent);
|
||||
text-align: left;
|
||||
padding: 10px 16px;
|
||||
font-weight: 700;
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.filter-table td {
|
||||
padding: 9px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.filter-table tr:nth-child(even) td { background: rgba(0,0,0,0.15); }
|
||||
.filter-table tr:nth-child(odd) td { background: rgba(0,0,0,0.08); }
|
||||
.filter-table .result-keep { color: #70d898; }
|
||||
.filter-table .result-fallback { color: #e8c870; }
|
||||
</style>
|
||||
|
||||
<!-- TERMINAL CONNECTION FLOW -->
|
||||
<div class="flow-section">
|
||||
<div class="flow-section-title">Terminal Connection Flow</div>
|
||||
<div class="flow">
|
||||
|
||||
<div class="flow-node node-action">
|
||||
<strong>1.</strong> User runs <code>ao start <project-path></code>
|
||||
</div>
|
||||
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
|
||||
|
||||
<div class="flow-node node-action">
|
||||
<strong>2.</strong> CLI runtime setup — <code>buildDashboardEnv()</code>
|
||||
<span class="sub">Picks <code>TERMINAL_PORT</code> / <code>DIRECT_TERMINAL_PORT</code> at runtime</span>
|
||||
</div>
|
||||
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
|
||||
|
||||
<div class="flow-node node-action">
|
||||
<strong>3.</strong> <code>start-all</code> launches servers
|
||||
<span class="sub">Next.js server + direct-terminal-ws on <code>DIRECT_TERMINAL_PORT</code></span>
|
||||
</div>
|
||||
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
|
||||
|
||||
<div class="flow-node node-action">
|
||||
<strong>4.</strong> Browser opens session page
|
||||
</div>
|
||||
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
|
||||
|
||||
<div class="flow-node node-decision">
|
||||
<strong>5.</strong> <code>resolveConnectionConfig()</code>
|
||||
<span class="sub">Build-time <code>NEXT_PUBLIC_*</code> values available and valid?</span>
|
||||
</div>
|
||||
<div class="flow-arrow"><div class="arrow-line" style="height:6px"></div></div>
|
||||
|
||||
<div class="flow-branch">
|
||||
<div class="flow-branch-arm">
|
||||
<span class="branch-label branch-yes">Yes</span>
|
||||
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
|
||||
<div class="flow-node node-success" style="font-size:0.82rem;">
|
||||
Use build-time values directly
|
||||
</div>
|
||||
</div>
|
||||
<div class="flow-branch-arm">
|
||||
<span class="branch-label branch-no">No</span>
|
||||
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
|
||||
<div class="flow-node node-endpoint" style="font-size:0.82rem;">
|
||||
<code>GET /api/runtime/terminal</code>
|
||||
<span class="sub">Read runtime env ports, normalize, return config</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-label">merge</div><div class="arrow-line"></div><div class="arrow-head"></div></div>
|
||||
|
||||
<div class="flow-node node-action">
|
||||
<strong>6.</strong> Client builds WS URL from resolved runtime config
|
||||
</div>
|
||||
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
|
||||
|
||||
<div class="flow-node node-action">
|
||||
<strong>7.</strong> WebSocket connect to direct-terminal-ws
|
||||
</div>
|
||||
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
|
||||
|
||||
<div class="flow-node node-action">
|
||||
<strong>8.</strong> Resolve tmux session
|
||||
<span class="sub">Exact match first → hash-prefixed suffix fallback</span>
|
||||
</div>
|
||||
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
|
||||
|
||||
<div class="flow-node node-success">
|
||||
<strong>9.</strong> PTY attach succeeds — terminal interactive (<code>CONNECTED</code>)
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SPAWN PATH FLOW -->
|
||||
<div class="flow-section">
|
||||
<div class="flow-section-title">Spawn Path</div>
|
||||
<div class="flow">
|
||||
|
||||
<div class="flow-node node-action">
|
||||
<strong>A.</strong> User triggers spawn
|
||||
<span class="sub">Dashboard / mobile / API caller</span>
|
||||
</div>
|
||||
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
|
||||
|
||||
<div class="flow-node node-endpoint">
|
||||
<strong>B.</strong> <code>POST /api/spawn</code> with <code>body.projectId</code>
|
||||
</div>
|
||||
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
|
||||
|
||||
<div class="flow-node node-action">
|
||||
<strong>C.</strong> <code>validateIdentifier(projectId)</code>
|
||||
<span class="sub">Syntax check — format only</span>
|
||||
</div>
|
||||
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
|
||||
|
||||
<div class="flow-node node-decision">
|
||||
<strong>D.</strong> Semantic guard: <code>config.projects[projectId]</code> exists?
|
||||
</div>
|
||||
<div class="flow-arrow"><div class="arrow-line" style="height:6px"></div></div>
|
||||
|
||||
<div class="flow-branch">
|
||||
<div class="flow-branch-arm">
|
||||
<span class="branch-label branch-yes">Yes</span>
|
||||
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
|
||||
<div class="flow-node node-action" style="font-size:0.82rem;">
|
||||
<code>sessionManager.spawn()</code>
|
||||
</div>
|
||||
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
|
||||
<div class="flow-node node-success" style="font-size:0.82rem;">
|
||||
<code>201 Created</code> + session payload
|
||||
<span class="sub">Dashboard/SSE shows active state</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flow-branch-arm">
|
||||
<span class="branch-label branch-no">No</span>
|
||||
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
|
||||
<div class="flow-node node-error" style="font-size:0.82rem;">
|
||||
<code>404</code> — <code>Unknown project: <id></code>
|
||||
<span class="sub">Stop early — core spawn not invoked</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PROJECT FILTER NORMALIZATION -->
|
||||
<div class="flow-section">
|
||||
<div class="flow-section-title">Project Filter Normalization</div>
|
||||
<div class="flow" style="gap: 12px;">
|
||||
<div class="flow-node node-endpoint" style="margin-bottom:8px;">
|
||||
Incoming <code>?project=<value></code> from dashboard query
|
||||
</div>
|
||||
<table class="filter-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Condition</th>
|
||||
<th>Result</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>value == <code>"all"</code></td>
|
||||
<td class="result-keep">Keep <code>"all"</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>value ∈ configured projects</td>
|
||||
<td class="result-keep">Keep value</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>otherwise (e.g. session ID like <code>mono-orchestrator</code>)</td>
|
||||
<td class="result-fallback">Fallback to primary valid project</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p style="font-size:0.82rem; color:var(--muted); text-align:center; margin-top:4px;">
|
||||
Invalid values cannot become active project context.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Alternatives Considered</h2>
|
||||
<ol>
|
||||
<li>Keep fixed ports only (<code>14800/14801</code>) and disable auto-shift. Rejected: blocks multi-instance startup and fails on legitimate port conflicts.</li>
|
||||
<li>Continue relying on <code>NEXT_PUBLIC_*</code> runtime injection. Rejected: production client bundles are build-time materialized.</li>
|
||||
<li>Accept any <code>projectId</code> in <code>/api/spawn</code> and let core throw. Rejected: late 500 errors and poor API ergonomics.</li>
|
||||
</ol>
|
||||
|
||||
<h2>Risks and Mitigations</h2>
|
||||
<ol>
|
||||
<li>Runtime endpoint unavailable. Mitigation: client retains safe fallback and reconnect logic.</li>
|
||||
<li>Reverse-proxy deployments with custom WS path. Mitigation: preserve proxy-path precedence and include runtime proxy field.</li>
|
||||
<li>Behavior change for invalid project query. Mitigation: normalization affects only unknown IDs; valid IDs and <code>all</code> remain unchanged.</li>
|
||||
</ol>
|
||||
|
||||
<h2>Validation Plan</h2>
|
||||
<ul>
|
||||
<li><code>GET /api/runtime/terminal</code> returns runtime env ports.</li>
|
||||
<li><code>POST /api/spawn</code> returns <code>404</code> for unknown project and does not call spawn.</li>
|
||||
<li>Project filter normalization keeps valid IDs, keeps <code>all</code>, and falls back on unknown IDs.</li>
|
||||
<li>Existing <code>DirectTerminal</code> URL construction tests remain green.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Rollout Plan</h2>
|
||||
<ol>
|
||||
<li>Merge patch to main.</li>
|
||||
<li>Release new npm package version containing web/client and API updates.</li>
|
||||
<li>Announce behavior note:
|
||||
<ul>
|
||||
<li>default-port users unaffected</li>
|
||||
<li>runtime-shifted port users no longer hit <code>CONNECTING</code> deadlock</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<h2>Release Checklist (Commands)</h2>
|
||||
<pre><code># 1) Push branch and open PR
|
||||
git push origin fix-runtime-terminal-projectid-hardening
|
||||
|
||||
# 2) Ensure patch changeset exists (this PR includes one)
|
||||
ls .changeset/five-lamps-heal.md
|
||||
|
||||
# 3) CI validation (already required by repo workflows)
|
||||
pnpm --filter @composio/ao-web test -- \
|
||||
src/__tests__/api-routes.test.ts \
|
||||
src/lib/__tests__/dashboard-page-data.test.ts \
|
||||
src/components/__tests__/DirectTerminal.test.ts
|
||||
|
||||
# 4) After PR merge: version packages from changesets
|
||||
pnpm changeset version
|
||||
|
||||
# 5) Commit version bumps and changelogs
|
||||
git add .
|
||||
git commit -m "chore(release): version packages for runtime terminal + spawn hardening"
|
||||
|
||||
# 6) Publish
|
||||
pnpm release
|
||||
|
||||
# 7) Post-release smoke check
|
||||
npm i -g @composio/ao@latest
|
||||
ao start <project-path>
|
||||
# verify terminal works when direct terminal port is non-default</code></pre>
|
||||
|
||||
<h2>Acceptance Criteria</h2>
|
||||
<ol>
|
||||
<li>Direct terminal connects in npm prebuilt flow even when runtime direct port is not <code>14801</code>.</li>
|
||||
<li><code>/api/spawn</code> never returns <code>500</code> for unknown-but-valid-format project IDs.</li>
|
||||
<li>Invalid <code>project</code> query values do not become active project state.</li>
|
||||
<li>Existing default-port flows remain functional without configuration changes.</li>
|
||||
</ol>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -7,6 +7,7 @@ export default tseslint.config(
|
|||
{
|
||||
ignores: [
|
||||
"**/dist/**",
|
||||
"**/dist-server/**",
|
||||
"**/node_modules/**",
|
||||
"**/.next/**",
|
||||
"**/coverage/**",
|
||||
|
|
@ -15,7 +16,6 @@ export default tseslint.config(
|
|||
"packages/web/postcss.config.mjs",
|
||||
"test-clipboard*.mjs",
|
||||
"test-clipboard*.sh",
|
||||
"packages/mobile/**",
|
||||
],
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
# @composio/ao
|
||||
|
||||
## 0.2.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @composio/ao-cli@0.2.2
|
||||
|
||||
## 0.2.1
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@composio/ao",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"description": "Orchestrate parallel AI coding agents — global CLI wrapper",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
# @composio/ao-cli
|
||||
|
||||
## 0.2.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [5315e4e]
|
||||
- @composio/ao-web@0.2.2
|
||||
|
||||
## 0.2.1
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,9 +1,26 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { Command } from "commander";
|
||||
|
||||
const { mockRunRepoScript, mockFindConfigFile } = vi.hoisted(() => ({
|
||||
const {
|
||||
mockRunRepoScript,
|
||||
mockFindConfigFile,
|
||||
mockLoadConfig,
|
||||
mockCreatePluginRegistry,
|
||||
mockDetectOpenClawInstallation,
|
||||
mockValidateToken,
|
||||
mockRegistry,
|
||||
} = vi.hoisted(() => ({
|
||||
mockRunRepoScript: vi.fn(),
|
||||
mockFindConfigFile: vi.fn(),
|
||||
mockLoadConfig: vi.fn(),
|
||||
mockCreatePluginRegistry: vi.fn(),
|
||||
mockDetectOpenClawInstallation: vi.fn(),
|
||||
mockValidateToken: vi.fn(),
|
||||
mockRegistry: {
|
||||
loadFromConfig: vi.fn(),
|
||||
list: vi.fn(),
|
||||
get: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/script-runner.js", () => ({
|
||||
|
|
@ -11,28 +28,107 @@ vi.mock("../../src/lib/script-runner.js", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("@composio/ao-core", () => ({
|
||||
createPluginRegistry: (...args: unknown[]) => mockCreatePluginRegistry(...args),
|
||||
findConfigFile: (...args: unknown[]) => mockFindConfigFile(...args),
|
||||
loadConfig: vi.fn(),
|
||||
getObservabilityBaseDir: () => "/tmp/.agent-orchestrator/observability",
|
||||
loadConfig: (...args: unknown[]) => mockLoadConfig(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/openclaw-probe.js", () => ({
|
||||
probeGateway: vi.fn(),
|
||||
validateToken: vi.fn(),
|
||||
detectOpenClawInstallation: (...args: unknown[]) => mockDetectOpenClawInstallation(...args),
|
||||
validateToken: (...args: unknown[]) => mockValidateToken(...args),
|
||||
}));
|
||||
|
||||
import { registerDoctor } from "../../src/commands/doctor.js";
|
||||
|
||||
function manifest(slot: string, name: string) {
|
||||
return { slot, name, description: `${name} plugin`, version: "1.0.0" };
|
||||
}
|
||||
|
||||
function makeConfig() {
|
||||
return {
|
||||
configPath: "/tmp/agent-orchestrator.yaml",
|
||||
port: 3000,
|
||||
readyThresholdMs: 300_000,
|
||||
defaults: {
|
||||
runtime: "tmux",
|
||||
agent: "claude-code",
|
||||
workspace: "worktree",
|
||||
notifiers: ["alerts"],
|
||||
orchestrator: { agent: "codex" },
|
||||
worker: { agent: "claude-code" },
|
||||
},
|
||||
projects: {
|
||||
"my-app": {
|
||||
name: "My App",
|
||||
repo: "org/my-app",
|
||||
path: "/tmp/my-app",
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "app",
|
||||
runtime: "tmux",
|
||||
agent: "claude-code",
|
||||
workspace: "worktree",
|
||||
tracker: { plugin: "github" },
|
||||
scm: { plugin: "github" },
|
||||
orchestrator: { agent: "codex" },
|
||||
worker: { agent: "claude-code" },
|
||||
},
|
||||
},
|
||||
notifiers: {
|
||||
alerts: { plugin: "slack" },
|
||||
},
|
||||
notificationRouting: {
|
||||
urgent: ["alerts"],
|
||||
action: ["alerts"],
|
||||
warning: ["alerts"],
|
||||
info: ["alerts"],
|
||||
},
|
||||
reactions: {},
|
||||
};
|
||||
}
|
||||
|
||||
describe("doctor command", () => {
|
||||
let program: Command;
|
||||
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
||||
let processExitSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
program = new Command();
|
||||
program.exitOverride();
|
||||
registerDoctor(program);
|
||||
|
||||
consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
processExitSpy = vi.spyOn(process, "exit").mockImplementation((code) => {
|
||||
throw new Error(`process.exit(${code})`);
|
||||
});
|
||||
|
||||
mockRunRepoScript.mockReset();
|
||||
mockRunRepoScript.mockResolvedValue(0);
|
||||
|
||||
mockFindConfigFile.mockReset();
|
||||
mockFindConfigFile.mockReturnValue(null);
|
||||
|
||||
mockLoadConfig.mockReset();
|
||||
|
||||
mockCreatePluginRegistry.mockReset();
|
||||
mockCreatePluginRegistry.mockReturnValue(mockRegistry);
|
||||
|
||||
mockRegistry.loadFromConfig.mockReset();
|
||||
mockRegistry.loadFromConfig.mockResolvedValue(undefined);
|
||||
mockRegistry.list.mockReset();
|
||||
mockRegistry.list.mockReturnValue([]);
|
||||
mockRegistry.get.mockReset();
|
||||
mockRegistry.get.mockReturnValue(null);
|
||||
|
||||
mockDetectOpenClawInstallation.mockReset();
|
||||
mockDetectOpenClawInstallation.mockResolvedValue({
|
||||
state: "running",
|
||||
gatewayUrl: "http://127.0.0.1:18789",
|
||||
probe: { httpStatus: 200 },
|
||||
});
|
||||
mockValidateToken.mockReset();
|
||||
mockValidateToken.mockResolvedValue({ valid: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -50,4 +146,108 @@ describe("doctor command", () => {
|
|||
|
||||
expect(mockRunRepoScript).toHaveBeenCalledWith("ao-doctor.sh", ["--fix"]);
|
||||
});
|
||||
|
||||
it("checks configured plugin references when config is present", async () => {
|
||||
const config = makeConfig();
|
||||
mockFindConfigFile.mockReturnValue(config.configPath);
|
||||
mockLoadConfig.mockReturnValue(config);
|
||||
|
||||
mockRegistry.list.mockImplementation((slot: string) => {
|
||||
switch (slot) {
|
||||
case "runtime":
|
||||
return [manifest("runtime", "tmux")];
|
||||
case "agent":
|
||||
return [manifest("agent", "claude-code"), manifest("agent", "codex")];
|
||||
case "workspace":
|
||||
return [manifest("workspace", "worktree")];
|
||||
case "tracker":
|
||||
return [manifest("tracker", "github")];
|
||||
case "scm":
|
||||
return [manifest("scm", "github")];
|
||||
case "notifier":
|
||||
return [manifest("notifier", "slack")];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
await program.parseAsync(["node", "test", "doctor"]);
|
||||
|
||||
expect(mockCreatePluginRegistry).toHaveBeenCalledTimes(1);
|
||||
expect(mockRegistry.loadFromConfig).toHaveBeenCalledWith(config, expect.any(Function));
|
||||
|
||||
const output = consoleLogSpy.mock.calls.map((call) => call[0]).join("\n");
|
||||
expect(output).toContain('defaults.runtime -> runtime plugin "tmux"');
|
||||
expect(output).toContain('projects.my-app.scm.plugin -> scm plugin "github"');
|
||||
expect(output).toContain('defaults.notifiers: alerts (plugin: slack) -> notifier plugin "slack"');
|
||||
});
|
||||
|
||||
it("fails when a referenced plugin cannot be loaded", async () => {
|
||||
const config = makeConfig();
|
||||
config.projects["my-app"].scm = { plugin: "gitlab" };
|
||||
mockFindConfigFile.mockReturnValue(config.configPath);
|
||||
mockLoadConfig.mockReturnValue(config);
|
||||
|
||||
mockRegistry.list.mockImplementation((slot: string) => {
|
||||
switch (slot) {
|
||||
case "runtime":
|
||||
return [manifest("runtime", "tmux")];
|
||||
case "agent":
|
||||
return [manifest("agent", "claude-code"), manifest("agent", "codex")];
|
||||
case "workspace":
|
||||
return [manifest("workspace", "worktree")];
|
||||
case "tracker":
|
||||
return [manifest("tracker", "github")];
|
||||
case "scm":
|
||||
return [manifest("scm", "github")];
|
||||
case "notifier":
|
||||
return [manifest("notifier", "slack")];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
await expect(program.parseAsync(["node", "test", "doctor"])).rejects.toThrow("process.exit(1)");
|
||||
|
||||
const output = consoleLogSpy.mock.calls.map((call) => call[0]).join("\n");
|
||||
expect(output).toContain('projects.my-app.scm.plugin references scm plugin "gitlab"');
|
||||
});
|
||||
|
||||
it("resolves notifier aliases when sending test notifications", async () => {
|
||||
const config = makeConfig();
|
||||
const mockNotifier = { notify: vi.fn().mockResolvedValue(undefined) };
|
||||
mockFindConfigFile.mockReturnValue(config.configPath);
|
||||
mockLoadConfig.mockReturnValue(config);
|
||||
|
||||
mockRegistry.list.mockImplementation((slot: string) => {
|
||||
switch (slot) {
|
||||
case "runtime":
|
||||
return [manifest("runtime", "tmux")];
|
||||
case "agent":
|
||||
return [manifest("agent", "claude-code"), manifest("agent", "codex")];
|
||||
case "workspace":
|
||||
return [manifest("workspace", "worktree")];
|
||||
case "tracker":
|
||||
return [manifest("tracker", "github")];
|
||||
case "scm":
|
||||
return [manifest("scm", "github")];
|
||||
case "notifier":
|
||||
return [manifest("notifier", "slack")];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
});
|
||||
mockRegistry.get.mockImplementation((slot: string, name: string) => {
|
||||
if (slot === "notifier" && name === "slack") {
|
||||
return mockNotifier;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
await program.parseAsync(["node", "test", "doctor", "--test-notify"]);
|
||||
|
||||
expect(mockRegistry.get).toHaveBeenCalledWith("notifier", "slack");
|
||||
expect(mockNotifier.notify).toHaveBeenCalledTimes(1);
|
||||
expect(processExitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,305 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Command } from "commander";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import type { PluginManifest, PluginModule } from "@composio/ao-core";
|
||||
|
||||
const {
|
||||
mockFindConfigFile,
|
||||
mockGetLatestPublishedPackageVersion,
|
||||
mockImportPluginModuleFromSource,
|
||||
mockInstallPackageIntoStore,
|
||||
mockReadInstalledPackageVersion,
|
||||
mockRunSetupAction,
|
||||
mockUninstallPackageFromStore,
|
||||
} = vi.hoisted(() => ({
|
||||
mockFindConfigFile: vi.fn(),
|
||||
mockGetLatestPublishedPackageVersion: vi.fn(),
|
||||
mockImportPluginModuleFromSource: vi.fn(),
|
||||
mockInstallPackageIntoStore: vi.fn(),
|
||||
mockReadInstalledPackageVersion: vi.fn(),
|
||||
mockRunSetupAction: vi.fn(),
|
||||
mockUninstallPackageFromStore: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@composio/ao-core", async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
findConfigFile: (...args: unknown[]) => mockFindConfigFile(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../src/lib/plugin-store.js", () => ({
|
||||
getLatestPublishedPackageVersion: (...args: unknown[]) =>
|
||||
mockGetLatestPublishedPackageVersion(...args),
|
||||
importPluginModuleFromSource: (...args: unknown[]) =>
|
||||
mockImportPluginModuleFromSource(...args),
|
||||
installPackageIntoStore: (...args: unknown[]) => mockInstallPackageIntoStore(...args),
|
||||
readInstalledPackageVersion: (...args: unknown[]) => mockReadInstalledPackageVersion(...args),
|
||||
uninstallPackageFromStore: (...args: unknown[]) => mockUninstallPackageFromStore(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/commands/setup.js", () => ({
|
||||
runSetupAction: (...args: unknown[]) => mockRunSetupAction(...args),
|
||||
}));
|
||||
|
||||
import { registerPlugin } from "../../src/commands/plugin.js";
|
||||
|
||||
const OPENCLAW_PACKAGE = "@composio/ao-plugin-notifier-openclaw";
|
||||
const GOOSE_PACKAGE = "@example/ao-plugin-agent-goose";
|
||||
|
||||
function makePlugin(slot: PluginManifest["slot"], name: string): PluginModule {
|
||||
return {
|
||||
manifest: {
|
||||
name,
|
||||
slot,
|
||||
description: `Test ${slot} plugin: ${name}`,
|
||||
version: "0.0.1",
|
||||
},
|
||||
create: vi.fn(() => ({ name })),
|
||||
};
|
||||
}
|
||||
|
||||
function createProgram(): Command {
|
||||
const program = new Command();
|
||||
registerPlugin(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
function writeConfig(configPath: string, extra: string[] = []): void {
|
||||
writeFileSync(
|
||||
configPath,
|
||||
[
|
||||
"port: 3000",
|
||||
"defaults:",
|
||||
" runtime: tmux",
|
||||
" agent: claude-code",
|
||||
" workspace: worktree",
|
||||
" notifiers: [desktop]",
|
||||
...extra,
|
||||
"projects:",
|
||||
" my-app:",
|
||||
" name: my-app",
|
||||
" repo: owner/repo",
|
||||
` path: ${join(tmpdir(), "my-app")}`,
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
describe("plugin command", () => {
|
||||
let tempDir: string;
|
||||
let configPath: string;
|
||||
let registryCachePath: string;
|
||||
const storeVersions = new Map<string, string>();
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "ao-plugin-command-test-"));
|
||||
configPath = join(tempDir, "agent-orchestrator.yaml");
|
||||
registryCachePath = join(tempDir, "plugin-registry-cache.json");
|
||||
writeConfig(configPath);
|
||||
process.env["AO_PLUGIN_REGISTRY_CACHE_PATH"] = registryCachePath;
|
||||
|
||||
mockFindConfigFile.mockReturnValue(configPath);
|
||||
mockRunSetupAction.mockReset();
|
||||
mockGetLatestPublishedPackageVersion.mockReset();
|
||||
mockImportPluginModuleFromSource.mockReset();
|
||||
mockInstallPackageIntoStore.mockReset();
|
||||
mockReadInstalledPackageVersion.mockReset();
|
||||
mockUninstallPackageFromStore.mockReset();
|
||||
storeVersions.clear();
|
||||
|
||||
Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true });
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
mockReadInstalledPackageVersion.mockImplementation((packageName: string) => {
|
||||
return storeVersions.get(packageName) ?? null;
|
||||
});
|
||||
|
||||
mockInstallPackageIntoStore.mockImplementation(async (packageName: string, version?: string) => {
|
||||
const resolved = version ?? "0.0.1";
|
||||
storeVersions.set(packageName, resolved);
|
||||
return resolved;
|
||||
});
|
||||
|
||||
mockUninstallPackageFromStore.mockImplementation(async (packageName: string) => {
|
||||
return storeVersions.delete(packageName);
|
||||
});
|
||||
|
||||
mockGetLatestPublishedPackageVersion.mockImplementation(async (packageName: string) => {
|
||||
if (packageName === GOOSE_PACKAGE) return "1.1.0";
|
||||
return "0.0.1";
|
||||
});
|
||||
|
||||
mockImportPluginModuleFromSource.mockImplementation(async (specifier: string) => {
|
||||
if (specifier === OPENCLAW_PACKAGE) return { default: makePlugin("notifier", "openclaw") };
|
||||
if (specifier === GOOSE_PACKAGE) return { default: makePlugin("agent", "goose") };
|
||||
throw new Error(`Not found: ${specifier}`);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env["AO_PLUGIN_REGISTRY_CACHE_PATH"];
|
||||
delete process.env["AO_PLUGIN_REGISTRY_URL"];
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("refreshes the marketplace registry cache and uses it for list/search", async () => {
|
||||
const program = createProgram();
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => [
|
||||
{
|
||||
id: "tracker-jira",
|
||||
package: "@example/ao-plugin-tracker-jira",
|
||||
slot: "tracker",
|
||||
description: "Tracker plugin: Jira issues",
|
||||
source: "registry",
|
||||
latestVersion: "0.3.0",
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await program.parseAsync(["node", "test", "plugin", "list", "--refresh"]);
|
||||
|
||||
let output = vi
|
||||
.mocked(console.log)
|
||||
.mock.calls.map((call) => call.join(" "))
|
||||
.join("\n");
|
||||
expect(output).toContain("tracker-jira");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.mocked(console.log).mockClear();
|
||||
|
||||
const searchProgram = createProgram();
|
||||
await searchProgram.parseAsync(["node", "test", "plugin", "search", "jira"]);
|
||||
|
||||
output = vi
|
||||
.mocked(console.log)
|
||||
.mock.calls.map((call) => call.join(" "))
|
||||
.join("\n");
|
||||
expect(output).toContain("tracker-jira");
|
||||
});
|
||||
|
||||
it("creates a plugin scaffold in non-interactive mode", async () => {
|
||||
const targetDir = join(tempDir, "acme-alerts");
|
||||
const program = createProgram();
|
||||
|
||||
await program.parseAsync([
|
||||
"node",
|
||||
"test",
|
||||
"plugin",
|
||||
"create",
|
||||
targetDir,
|
||||
"--name",
|
||||
"Acme Alerts",
|
||||
"--slot",
|
||||
"notifier",
|
||||
"--description",
|
||||
"Notifier plugin for Acme alerts",
|
||||
"--author",
|
||||
"Alice",
|
||||
"--package-name",
|
||||
"@alice/ao-plugin-notifier-acme-alerts",
|
||||
"--non-interactive",
|
||||
]);
|
||||
|
||||
expect(existsSync(join(targetDir, "package.json"))).toBe(true);
|
||||
expect(existsSync(join(targetDir, "src", "index.ts"))).toBe(true);
|
||||
expect(existsSync(join(targetDir, "README.md"))).toBe(true);
|
||||
|
||||
const packageJson = JSON.parse(readFileSync(join(targetDir, "package.json"), "utf-8")) as {
|
||||
name: string;
|
||||
author?: string;
|
||||
dependencies?: Record<string, string>;
|
||||
};
|
||||
expect(packageJson.name).toBe("@alice/ao-plugin-notifier-acme-alerts");
|
||||
expect(packageJson.author).toBe("Alice");
|
||||
expect(packageJson.dependencies?.["@composio/ao-core"]).toBe("^0.2.0");
|
||||
|
||||
const entrypoint = readFileSync(join(targetDir, "src", "index.ts"), "utf-8");
|
||||
expect(entrypoint).toContain('slot: "notifier" as const');
|
||||
expect(entrypoint).toContain('name: "acme-alerts"');
|
||||
});
|
||||
|
||||
it("installs a marketplace plugin through the AO-managed store before writing config", async () => {
|
||||
const program = createProgram();
|
||||
|
||||
await program.parseAsync(["node", "test", "plugin", "install", "notifier-openclaw"]);
|
||||
|
||||
const parsed = parseYaml(readFileSync(configPath, "utf-8")) as {
|
||||
plugins?: Array<Record<string, string>>;
|
||||
};
|
||||
expect(parsed.plugins).toHaveLength(1);
|
||||
expect(parsed.plugins?.[0]).toMatchObject({
|
||||
name: "openclaw",
|
||||
source: "registry",
|
||||
package: OPENCLAW_PACKAGE,
|
||||
version: "0.1.1",
|
||||
});
|
||||
expect(mockInstallPackageIntoStore).toHaveBeenCalledWith(OPENCLAW_PACKAGE, "0.1.1");
|
||||
|
||||
// Install now always runs setup (auto-detect in non-TTY instead of deferring)
|
||||
expect(mockRunSetupAction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updates an npm plugin and persists the resolved store version", async () => {
|
||||
writeConfig(configPath, [
|
||||
"plugins:",
|
||||
" - name: goose",
|
||||
" source: npm",
|
||||
` package: "${GOOSE_PACKAGE}"`,
|
||||
" version: 1.0.0",
|
||||
]);
|
||||
storeVersions.set(GOOSE_PACKAGE, "1.0.0");
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(["node", "test", "plugin", "update", "goose"]);
|
||||
|
||||
const parsed = parseYaml(readFileSync(configPath, "utf-8")) as {
|
||||
plugins?: Array<Record<string, string>>;
|
||||
};
|
||||
expect(parsed.plugins?.[0]).toMatchObject({
|
||||
name: "goose",
|
||||
source: "npm",
|
||||
package: GOOSE_PACKAGE,
|
||||
version: "1.1.0",
|
||||
});
|
||||
expect(storeVersions.get(GOOSE_PACKAGE)).toBe("1.1.0");
|
||||
expect(mockInstallPackageIntoStore).toHaveBeenCalledWith(GOOSE_PACKAGE, "1.1.0");
|
||||
});
|
||||
|
||||
it("rolls back the store version when an update fails verification", async () => {
|
||||
writeConfig(configPath, [
|
||||
"plugins:",
|
||||
" - name: goose",
|
||||
" source: npm",
|
||||
` package: "${GOOSE_PACKAGE}"`,
|
||||
" version: 1.0.0",
|
||||
]);
|
||||
storeVersions.set(GOOSE_PACKAGE, "1.0.0");
|
||||
mockImportPluginModuleFromSource.mockImplementation(async (specifier: string) => {
|
||||
if (specifier === GOOSE_PACKAGE) return {};
|
||||
throw new Error(`Not found: ${specifier}`);
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
|
||||
await expect(
|
||||
program.parseAsync(["node", "test", "plugin", "update", "goose"]),
|
||||
).rejects.toThrow("Failed to update plugin");
|
||||
|
||||
const parsed = parseYaml(readFileSync(configPath, "utf-8")) as {
|
||||
plugins?: Array<Record<string, string>>;
|
||||
};
|
||||
expect(parsed.plugins?.[0]?.["version"]).toBe("1.0.0");
|
||||
expect(storeVersions.get(GOOSE_PACKAGE)).toBe("1.0.0");
|
||||
expect(mockInstallPackageIntoStore).toHaveBeenLastCalledWith(GOOSE_PACKAGE, "1.0.0");
|
||||
});
|
||||
});
|
||||
|
|
@ -36,6 +36,11 @@ vi.mock("../../src/lib/plugins.js", () => ({
|
|||
processName: "claude",
|
||||
detectActivity: mockDetectActivity,
|
||||
}),
|
||||
getAgentByNameFromRegistry: () => ({
|
||||
name: "claude-code",
|
||||
processName: "claude",
|
||||
detectActivity: mockDetectActivity,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/session-utils.js", () => ({
|
||||
|
|
@ -53,6 +58,7 @@ vi.mock("@composio/ao-core", () => ({
|
|||
|
||||
vi.mock("../../src/lib/create-session-manager.js", () => ({
|
||||
getSessionManager: async () => mockSessionManager,
|
||||
getPluginRegistry: async () => ({ get: vi.fn(), list: vi.fn(), register: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { Command } from "commander";
|
||||
|
|
|
|||
|
|
@ -19,9 +19,10 @@ const { mockReadFileSync, mockWriteFileSync, mockExistsSync, mockMkdirSync } = v
|
|||
mockMkdirSync: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockProbeGateway, mockValidateToken } = vi.hoisted(() => ({
|
||||
const { mockProbeGateway, mockValidateToken, mockDetectOpenClawInstallation } = vi.hoisted(() => ({
|
||||
mockProbeGateway: vi.fn(),
|
||||
mockValidateToken: vi.fn(),
|
||||
mockDetectOpenClawInstallation: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@composio/ao-core", () => ({
|
||||
|
|
@ -42,6 +43,7 @@ vi.mock("node:fs", async (importOriginal) => {
|
|||
vi.mock("../../src/lib/openclaw-probe.js", () => ({
|
||||
probeGateway: (...args: unknown[]) => mockProbeGateway(...args),
|
||||
validateToken: (...args: unknown[]) => mockValidateToken(...args),
|
||||
detectOpenClawInstallation: (...args: unknown[]) => mockDetectOpenClawInstallation(...args),
|
||||
DEFAULT_OPENCLAW_URL: "http://127.0.0.1:18789",
|
||||
HOOKS_PATH: "/hooks/agent",
|
||||
}));
|
||||
|
|
@ -282,6 +284,60 @@ describe("setup openclaw command", () => {
|
|||
expect(writtenYaml).toContain("wakeMode: now");
|
||||
});
|
||||
|
||||
it("defaults OpenClaw routing to urgent + action only", async () => {
|
||||
const program = createProgram();
|
||||
|
||||
await program.parseAsync([
|
||||
"node",
|
||||
"test",
|
||||
"setup",
|
||||
"openclaw",
|
||||
"--url",
|
||||
"http://127.0.0.1:18789/hooks/agent",
|
||||
"--token",
|
||||
"tok",
|
||||
"--non-interactive",
|
||||
]);
|
||||
|
||||
const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string;
|
||||
const parsed = parseYaml(writtenYaml) as {
|
||||
notificationRouting?: Record<string, string[]>;
|
||||
};
|
||||
|
||||
expect(parsed.notificationRouting?.["urgent"]).toContain("openclaw");
|
||||
expect(parsed.notificationRouting?.["action"]).toContain("openclaw");
|
||||
expect(parsed.notificationRouting?.["warning"]).not.toContain("openclaw");
|
||||
expect(parsed.notificationRouting?.["info"]).not.toContain("openclaw");
|
||||
});
|
||||
|
||||
it("supports overriding the routing preset in non-interactive mode", async () => {
|
||||
const program = createProgram();
|
||||
|
||||
await program.parseAsync([
|
||||
"node",
|
||||
"test",
|
||||
"setup",
|
||||
"openclaw",
|
||||
"--url",
|
||||
"http://127.0.0.1:18789/hooks/agent",
|
||||
"--token",
|
||||
"tok",
|
||||
"--routing-preset",
|
||||
"all",
|
||||
"--non-interactive",
|
||||
]);
|
||||
|
||||
const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string;
|
||||
const parsed = parseYaml(writtenYaml) as {
|
||||
notificationRouting?: Record<string, string[]>;
|
||||
};
|
||||
|
||||
expect(parsed.notificationRouting?.["urgent"]).toContain("openclaw");
|
||||
expect(parsed.notificationRouting?.["action"]).toContain("openclaw");
|
||||
expect(parsed.notificationRouting?.["warning"]).toContain("openclaw");
|
||||
expect(parsed.notificationRouting?.["info"]).toContain("openclaw");
|
||||
});
|
||||
|
||||
it("merges existing allowedSessionKeyPrefixes in openclaw.json", async () => {
|
||||
const openclawConfigPath = join(homedir(), ".openclaw", "openclaw.json");
|
||||
|
||||
|
|
@ -427,7 +483,12 @@ describe("setup openclaw command", () => {
|
|||
expect(mockWriteFileSync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("exits when --url missing in non-interactive mode", async () => {
|
||||
it("exits when --url missing and gateway unreachable in non-interactive mode", async () => {
|
||||
mockDetectOpenClawInstallation.mockResolvedValue({
|
||||
state: "missing",
|
||||
gatewayUrl: "http://127.0.0.1:18789",
|
||||
probe: { reachable: false, error: "ECONNREFUSED" },
|
||||
});
|
||||
const program = createProgram();
|
||||
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ vi.mock("../../src/lib/metadata.js", () => ({
|
|||
|
||||
let tmpDir: string;
|
||||
let configPath: string;
|
||||
let cwdSpy: ReturnType<typeof vi.spyOn> | undefined;
|
||||
|
||||
import { Command } from "commander";
|
||||
import { registerSpawn } from "../../src/commands/spawn.js";
|
||||
|
|
@ -126,6 +127,8 @@ beforeEach(() => {
|
|||
});
|
||||
|
||||
afterEach(() => {
|
||||
cwdSpy?.mockRestore();
|
||||
cwdSpy = undefined;
|
||||
const projectBaseDir = getProjectBaseDir(configPath, join(tmpDir, "main-repo"));
|
||||
if (projectBaseDir) {
|
||||
rmSync(projectBaseDir, { recursive: true, force: true });
|
||||
|
|
@ -198,6 +201,58 @@ describe("spawn command", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("auto-detects the project from a nested cwd in multi-project configs", async () => {
|
||||
(mockConfigRef.current as Record<string, unknown>).projects = {
|
||||
frontend: {
|
||||
name: "Frontend",
|
||||
repo: "org/frontend",
|
||||
path: join(tmpDir, "frontend"),
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "fe",
|
||||
},
|
||||
backend: {
|
||||
name: "Backend",
|
||||
repo: "org/backend",
|
||||
path: join(tmpDir, "backend"),
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "be",
|
||||
},
|
||||
};
|
||||
|
||||
const backendSubdir = join(tmpDir, "backend", "packages", "api");
|
||||
mkdirSync(backendSubdir, { recursive: true });
|
||||
cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(backendSubdir);
|
||||
|
||||
const fakeSession: Session = {
|
||||
id: "be-1",
|
||||
projectId: "backend",
|
||||
status: "spawning",
|
||||
activity: null,
|
||||
branch: "feat/INT-42",
|
||||
issueId: "INT-42",
|
||||
pr: null,
|
||||
workspacePath: "/tmp/wt",
|
||||
runtimeHandle: { id: "hash-be-1", runtimeName: "tmux", data: {} },
|
||||
agentInfo: null,
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
mockSessionManager.spawn.mockResolvedValue(fakeSession);
|
||||
|
||||
await program.parseAsync(["node", "test", "spawn", "INT-42"]);
|
||||
|
||||
expect(mockEnsureLifecycleWorker).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ configPath: expect.any(String) }),
|
||||
"backend",
|
||||
);
|
||||
expect(mockSessionManager.spawn).toHaveBeenCalledWith({
|
||||
projectId: "backend",
|
||||
issueId: "INT-42",
|
||||
});
|
||||
});
|
||||
|
||||
it("spawns without issueId when none provided", async () => {
|
||||
const fakeSession: Session = {
|
||||
id: "app-1",
|
||||
|
|
|
|||
|
|
@ -44,6 +44,10 @@ const {
|
|||
mockStopLifecycleWorker: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockDetectOpenClawInstallation } = vi.hoisted(() => ({
|
||||
mockDetectOpenClawInstallation: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/shell.js", () => ({
|
||||
tmux: vi.fn(),
|
||||
exec: mockExec,
|
||||
|
|
@ -149,6 +153,10 @@ vi.mock("../../src/lib/project-detection.js", () => ({
|
|||
formatProjectTypeForDisplay: vi.fn().mockReturnValue(""),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/openclaw-probe.js", () => ({
|
||||
detectOpenClawInstallation: (...args: unknown[]) => mockDetectOpenClawInstallation(...args),
|
||||
}));
|
||||
|
||||
// Mock node:child_process — start.ts imports spawn for dashboard + browser open
|
||||
vi.mock("node:child_process", async (importOriginal) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
|
|
@ -215,6 +223,12 @@ beforeEach(() => {
|
|||
});
|
||||
mockStopLifecycleWorker.mockReset();
|
||||
mockStopLifecycleWorker.mockResolvedValue(true);
|
||||
mockDetectOpenClawInstallation.mockReset();
|
||||
mockDetectOpenClawInstallation.mockResolvedValue({
|
||||
state: "missing",
|
||||
gatewayUrl: "http://127.0.0.1:18789",
|
||||
probe: { reachable: false, error: "not running" },
|
||||
});
|
||||
mockSpawn.mockClear();
|
||||
});
|
||||
|
||||
|
|
@ -367,6 +381,50 @@ describe("start command — project resolution", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("start command — OpenClaw preflight", () => {
|
||||
it("warns when OpenClaw is configured but offline", async () => {
|
||||
mockConfigRef.current = {
|
||||
...makeConfig({ "my-app": makeProject() }),
|
||||
notifiers: {
|
||||
openclaw: {
|
||||
plugin: "openclaw",
|
||||
url: "http://127.0.0.1:18789/hooks/agent",
|
||||
},
|
||||
},
|
||||
};
|
||||
mockDetectOpenClawInstallation.mockResolvedValue({
|
||||
state: "installed-but-stopped",
|
||||
gatewayUrl: "http://127.0.0.1:18789",
|
||||
probe: { reachable: false, error: "not running" },
|
||||
});
|
||||
|
||||
await program.parseAsync(["node", "test", "start", "--no-dashboard", "--no-orchestrator"]);
|
||||
|
||||
const output = vi
|
||||
.mocked(console.log)
|
||||
.mock.calls.map((c) => c.join(" "))
|
||||
.join("\n");
|
||||
expect(output).toContain("OpenClaw is configured but the gateway is not reachable");
|
||||
});
|
||||
|
||||
it("suggests setup when OpenClaw is running but not configured", async () => {
|
||||
mockConfigRef.current = makeConfig({ "my-app": makeProject() });
|
||||
mockDetectOpenClawInstallation.mockResolvedValue({
|
||||
state: "running",
|
||||
gatewayUrl: "http://127.0.0.1:18789",
|
||||
probe: { reachable: true, httpStatus: 200 },
|
||||
});
|
||||
|
||||
await program.parseAsync(["node", "test", "start", "--no-dashboard", "--no-orchestrator"]);
|
||||
|
||||
const output = vi
|
||||
.mocked(console.log)
|
||||
.mock.calls.map((c) => c.join(" "))
|
||||
.join("\n");
|
||||
expect(output).toContain("ao setup openclaw");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// URL detection — `ao start <url>` triggers handleUrlStart
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import {
|
|||
type Session,
|
||||
type SessionManager,
|
||||
type ActivityState,
|
||||
getSessionsDir,
|
||||
} from "@composio/ao-core";
|
||||
|
||||
const {
|
||||
|
|
@ -28,6 +27,7 @@ const {
|
|||
mockGetReviewDecision,
|
||||
mockGetPendingComments,
|
||||
mockSessionManager,
|
||||
mockGetPluginRegistry,
|
||||
sessionsDirRef,
|
||||
} = vi.hoisted(() => ({
|
||||
mockTmux: vi.fn(),
|
||||
|
|
@ -49,6 +49,7 @@ const {
|
|||
send: vi.fn(),
|
||||
claimPR: vi.fn(),
|
||||
},
|
||||
mockGetPluginRegistry: vi.fn(),
|
||||
sessionsDirRef: { current: "" },
|
||||
}));
|
||||
|
||||
|
|
@ -95,6 +96,13 @@ vi.mock("../../src/lib/plugins.js", () => ({
|
|||
getSessionInfo: mockIntrospect,
|
||||
getActivityState: mockGetActivityState,
|
||||
}),
|
||||
getAgentByNameFromRegistry: () => ({
|
||||
name: "claude-code",
|
||||
processName: "claude",
|
||||
detectActivity: () => "idle",
|
||||
getSessionInfo: mockIntrospect,
|
||||
getActivityState: mockGetActivityState,
|
||||
}),
|
||||
getSCM: () => ({
|
||||
name: "github",
|
||||
detectPR: mockDetectPR,
|
||||
|
|
@ -115,6 +123,26 @@ vi.mock("../../src/lib/plugins.js", () => ({
|
|||
mergePR: vi.fn(),
|
||||
closePR: vi.fn(),
|
||||
}),
|
||||
getSCMFromRegistry: () => ({
|
||||
name: "github",
|
||||
detectPR: mockDetectPR,
|
||||
getCISummary: mockGetCISummary,
|
||||
getReviewDecision: mockGetReviewDecision,
|
||||
getPendingComments: mockGetPendingComments,
|
||||
getAutomatedComments: vi.fn().mockResolvedValue([]),
|
||||
getCIChecks: vi.fn().mockResolvedValue([]),
|
||||
getReviews: vi.fn().mockResolvedValue([]),
|
||||
getMergeability: vi.fn().mockResolvedValue({
|
||||
mergeable: true,
|
||||
ciPassing: true,
|
||||
approved: false,
|
||||
noConflicts: true,
|
||||
blockers: [],
|
||||
}),
|
||||
getPRState: vi.fn().mockResolvedValue("open"),
|
||||
mergePR: vi.fn(),
|
||||
closePR: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
/** Parse a key=value metadata file into a Record<string, string>. */
|
||||
|
|
@ -179,6 +207,7 @@ function makeSession(overrides: Partial<Session> & { id: string; projectId: stri
|
|||
|
||||
vi.mock("../../src/lib/create-session-manager.js", () => ({
|
||||
getSessionManager: async (): Promise<SessionManager> => mockSessionManager as SessionManager,
|
||||
getPluginRegistry: (...args: unknown[]) => mockGetPluginRegistry(...args),
|
||||
}));
|
||||
|
||||
let tmpDir: string;
|
||||
|
|
@ -189,6 +218,9 @@ import { registerStatus } from "../../src/commands/status.js";
|
|||
|
||||
let program: Command;
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
let setIntervalSpy: ReturnType<typeof vi.spyOn> | undefined;
|
||||
let clearIntervalSpy: ReturnType<typeof vi.spyOn> | undefined;
|
||||
let processOnceSpy: ReturnType<typeof vi.spyOn> | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "ao-status-test-"));
|
||||
|
|
@ -221,8 +253,8 @@ beforeEach(() => {
|
|||
reactions: {},
|
||||
} as Record<string, unknown>;
|
||||
|
||||
// Calculate and create sessions directory for hash-based architecture
|
||||
sessionsDir = getSessionsDir(configPath, join(tmpDir, "main-repo"));
|
||||
// Keep test metadata under the temp fixture directory instead of ~/.agent-orchestrator.
|
||||
sessionsDir = join(tmpDir, "sessions");
|
||||
mkdirSync(sessionsDir, { recursive: true });
|
||||
sessionsDirRef.current = sessionsDir;
|
||||
|
||||
|
|
@ -254,6 +286,9 @@ beforeEach(() => {
|
|||
mockSessionManager.get.mockReset();
|
||||
mockSessionManager.spawn.mockReset();
|
||||
mockSessionManager.send.mockReset();
|
||||
mockGetPluginRegistry.mockReset();
|
||||
// Default registry: no tracker
|
||||
mockGetPluginRegistry.mockResolvedValue({ get: vi.fn().mockReturnValue(null), list: vi.fn(), register: vi.fn() });
|
||||
|
||||
// Default: list reads from sessionsDir
|
||||
mockSessionManager.list.mockImplementation(async () => {
|
||||
|
|
@ -262,6 +297,12 @@ beforeEach(() => {
|
|||
});
|
||||
|
||||
afterEach(() => {
|
||||
setIntervalSpy?.mockRestore();
|
||||
setIntervalSpy = undefined;
|
||||
clearIntervalSpy?.mockRestore();
|
||||
clearIntervalSpy = undefined;
|
||||
processOnceSpy?.mockRestore();
|
||||
processOnceSpy = undefined;
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
|
@ -540,6 +581,81 @@ describe("status command", () => {
|
|||
expect(parsed[0].pendingThreads).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects --watch with --json", async () => {
|
||||
await expect(program.parseAsync(["node", "test", "status", "--watch", "--json"])).rejects.toThrow(
|
||||
"process.exit(1)",
|
||||
);
|
||||
|
||||
const errors = vi
|
||||
.mocked(console.error)
|
||||
.mock.calls.map((c) => c[0])
|
||||
.join("\n");
|
||||
expect(errors).toContain("--watch cannot be used with --json");
|
||||
});
|
||||
|
||||
it("rejects non-positive watch intervals", async () => {
|
||||
await expect(program.parseAsync(["node", "test", "status", "--watch", "--interval", "0"])).rejects.toThrow(
|
||||
"process.exit(1)",
|
||||
);
|
||||
|
||||
const errors = vi
|
||||
.mocked(console.error)
|
||||
.mock.calls.map((c) => c[0])
|
||||
.join("\n");
|
||||
expect(errors).toContain("--interval must be a positive integer");
|
||||
});
|
||||
|
||||
it("ignores --interval entirely when --watch is not set", async () => {
|
||||
mockTmux.mockResolvedValue(null);
|
||||
mockSessionManager.list.mockResolvedValue([]);
|
||||
|
||||
// Invalid value (0) should NOT cause an error without --watch
|
||||
await expect(
|
||||
program.parseAsync(["node", "test", "status", "--interval", "0"]),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
// Valid value should also be silently ignored without --watch
|
||||
await expect(
|
||||
program.parseAsync(["node", "test", "status", "--interval", "10"]),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("schedules watch refreshes with the requested interval", async () => {
|
||||
mockTmux.mockResolvedValue(null);
|
||||
setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockImplementation(() => 1 as never);
|
||||
|
||||
await program.parseAsync(["node", "test", "status", "--watch", "--interval", "3"]);
|
||||
|
||||
expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 3000);
|
||||
|
||||
const output = consoleSpy.mock.calls.map((c) => c[0]).join("\n");
|
||||
expect(output).toContain("Refreshing every 3s. Press Ctrl+C to exit.");
|
||||
});
|
||||
|
||||
it("cleans up the watch timer on shutdown signals", async () => {
|
||||
mockTmux.mockResolvedValue(null);
|
||||
|
||||
const watchTimer = { id: "watch-timer" } as unknown as ReturnType<typeof setInterval>;
|
||||
setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockImplementation(() => watchTimer);
|
||||
clearIntervalSpy = vi.spyOn(globalThis, "clearInterval").mockImplementation(() => undefined);
|
||||
|
||||
const signalHandlers = new Map<string, () => void>();
|
||||
processOnceSpy = vi.spyOn(process, "once").mockImplementation((event, listener) => {
|
||||
if (event === "SIGINT" || event === "SIGTERM") {
|
||||
signalHandlers.set(event, listener as () => void);
|
||||
}
|
||||
return process;
|
||||
});
|
||||
|
||||
await program.parseAsync(["node", "test", "status", "--watch"]);
|
||||
|
||||
expect(signalHandlers.has("SIGINT")).toBe(true);
|
||||
expect(signalHandlers.has("SIGTERM")).toBe(true);
|
||||
|
||||
expect(() => signalHandlers.get("SIGINT")?.()).toThrow("process.exit(0)");
|
||||
expect(clearIntervalSpy).toHaveBeenCalledWith(watchTimer);
|
||||
});
|
||||
|
||||
it("falls back to PR number from metadata URL when SCM fails", async () => {
|
||||
writeFileSync(
|
||||
join(sessionsDir, "app-1"),
|
||||
|
|
@ -830,4 +946,220 @@ describe("status command", () => {
|
|||
project: "my-app",
|
||||
});
|
||||
});
|
||||
|
||||
// ── lines 262-266: loadConfig() throws → fallback to tmux discovery ───────
|
||||
it("falls back to tmux session discovery when loadConfig throws", async () => {
|
||||
// The vi.mock for @composio/ao-core uses `() => mockConfigRef.current`.
|
||||
// Setting current to a throwing getter makes loadConfig throw.
|
||||
// Simpler: use a Proxy-based trick — but easiest is a getter that throws.
|
||||
const originalCurrent = mockConfigRef.current;
|
||||
Object.defineProperty(mockConfigRef, "current", {
|
||||
get() {
|
||||
throw new Error("no config file");
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// No tmux sessions — fallback should print the banner with "No config found"
|
||||
mockTmux.mockImplementation(async (...args: string[]) => {
|
||||
if (args[0] === "list-sessions") return null;
|
||||
return null;
|
||||
});
|
||||
mockIntrospect.mockResolvedValue(null);
|
||||
|
||||
try {
|
||||
await program.parseAsync(["node", "test", "status"]);
|
||||
} finally {
|
||||
// Restore mockConfigRef.current to a plain data property
|
||||
Object.defineProperty(mockConfigRef, "current", {
|
||||
value: originalCurrent,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
const output = consoleSpy.mock.calls.map((c) => c[0]).join("\n");
|
||||
expect(output).toContain("No config found");
|
||||
expect(output).toContain("Falling back to session discovery");
|
||||
});
|
||||
|
||||
// ── lines 269-271: unknown --project flag ───────────────────────────────
|
||||
it("exits with error when --project refers to an unknown project", async () => {
|
||||
mockTmux.mockResolvedValue(null);
|
||||
mockSessionManager.list.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
program.parseAsync(["node", "test", "status", "--project", "no-such-project"]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
|
||||
const errors = vi
|
||||
.mocked(console.error)
|
||||
.mock.calls.map((c) => c[0])
|
||||
.join("\n");
|
||||
expect(errors).toContain("Unknown project: no-such-project");
|
||||
});
|
||||
|
||||
// ── lines 388, 390-396, 402-405: tracker unverified-issues warning ────────
|
||||
it("shows unverified issues warning when tracker returns merged-unverified issues", async () => {
|
||||
const mockListIssues = vi.fn().mockResolvedValue([{ id: "ISS-1" }, { id: "ISS-2" }]);
|
||||
const mockTracker = { listIssues: mockListIssues };
|
||||
|
||||
mockConfigRef.current = {
|
||||
...(mockConfigRef.current as Record<string, unknown>),
|
||||
projects: {
|
||||
"my-app": {
|
||||
name: "My App",
|
||||
repo: "org/my-app",
|
||||
path: join(tmpDir, "main-repo"),
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "app",
|
||||
scm: { plugin: "github" },
|
||||
tracker: { plugin: "linear" },
|
||||
},
|
||||
},
|
||||
} as Record<string, unknown>;
|
||||
|
||||
// Use the hoisted mockGetPluginRegistry fn to surface our tracker
|
||||
mockGetPluginRegistry.mockResolvedValueOnce({
|
||||
get: vi.fn().mockReturnValue(mockTracker),
|
||||
list: vi.fn(),
|
||||
register: vi.fn(),
|
||||
});
|
||||
|
||||
mockSessionManager.list.mockResolvedValue([]);
|
||||
mockTmux.mockResolvedValue(null);
|
||||
|
||||
await program.parseAsync(["node", "test", "status"]);
|
||||
|
||||
const output = consoleSpy.mock.calls.map((c) => c[0]).join("\n");
|
||||
expect(output).toContain("awaiting verification");
|
||||
expect(mockListIssues).toHaveBeenCalledWith(
|
||||
{ state: "open", labels: ["merged-unverified"], limit: 20 },
|
||||
expect.objectContaining({ tracker: { plugin: "linear" } }),
|
||||
);
|
||||
});
|
||||
|
||||
// ── line 398: tracker listIssues() rejects → swallowed silently ───────────
|
||||
it("handles tracker listIssues failure gracefully without crashing", async () => {
|
||||
const mockListIssues = vi.fn().mockRejectedValue(new Error("tracker down"));
|
||||
const mockTracker = { listIssues: mockListIssues };
|
||||
|
||||
mockConfigRef.current = {
|
||||
...(mockConfigRef.current as Record<string, unknown>),
|
||||
projects: {
|
||||
"my-app": {
|
||||
name: "My App",
|
||||
repo: "org/my-app",
|
||||
path: join(tmpDir, "main-repo"),
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "app",
|
||||
scm: { plugin: "github" },
|
||||
tracker: { plugin: "linear" },
|
||||
},
|
||||
},
|
||||
} as Record<string, unknown>;
|
||||
|
||||
mockGetPluginRegistry.mockResolvedValueOnce({
|
||||
get: vi.fn().mockReturnValue(mockTracker),
|
||||
list: vi.fn(),
|
||||
register: vi.fn(),
|
||||
});
|
||||
|
||||
mockSessionManager.list.mockResolvedValue([]);
|
||||
mockTmux.mockResolvedValue(null);
|
||||
|
||||
// Must not throw
|
||||
await expect(program.parseAsync(["node", "test", "status"])).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
// ── lines 65-69 (isTTY branch) + 255-256 (maybeClearScreen on refresh) ───
|
||||
it("writes clear-screen escape when stdout is a TTY during watch refresh", async () => {
|
||||
mockTmux.mockResolvedValue(null);
|
||||
mockSessionManager.list.mockResolvedValue([]);
|
||||
|
||||
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
const originalIsTTY = process.stdout.isTTY;
|
||||
Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });
|
||||
|
||||
let capturedCallback: (() => void) | undefined;
|
||||
setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockImplementation((fn) => {
|
||||
capturedCallback = fn as () => void;
|
||||
return 77 as never;
|
||||
});
|
||||
clearIntervalSpy = vi
|
||||
.spyOn(globalThis, "clearInterval")
|
||||
.mockImplementation(() => undefined);
|
||||
processOnceSpy = vi.spyOn(process, "once").mockImplementation((_e, _l) => process);
|
||||
|
||||
await program.parseAsync(["node", "test", "status", "--watch", "--interval", "5"]);
|
||||
|
||||
expect(capturedCallback).toBeDefined();
|
||||
// Fire interval callback — this calls renderStatus(true) which calls maybeClearScreen()
|
||||
capturedCallback!();
|
||||
// Allow promises to settle
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
expect(writeSpy).toHaveBeenCalledWith("\x1Bc");
|
||||
|
||||
Object.defineProperty(process.stdout, "isTTY", {
|
||||
value: originalIsTTY,
|
||||
configurable: true,
|
||||
});
|
||||
writeSpy.mockRestore();
|
||||
});
|
||||
|
||||
// ── lines 424-425: watch guard skips render when already in progress ──────
|
||||
it("skips a watch refresh when the previous render is still in progress", async () => {
|
||||
let renderCount = 0;
|
||||
let unblockSlowRender!: () => void;
|
||||
const slowRenderFinished = new Promise<void>((res) => {
|
||||
unblockSlowRender = res;
|
||||
});
|
||||
|
||||
mockSessionManager.list.mockImplementation(async () => {
|
||||
renderCount++;
|
||||
if (renderCount === 2) {
|
||||
// First watch-refresh (second overall list call) — block deliberately
|
||||
await slowRenderFinished;
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
mockTmux.mockResolvedValue(null);
|
||||
|
||||
let capturedCallback: (() => void) | undefined;
|
||||
setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockImplementation((fn) => {
|
||||
capturedCallback = fn as () => void;
|
||||
return 55 as never;
|
||||
});
|
||||
clearIntervalSpy = vi
|
||||
.spyOn(globalThis, "clearInterval")
|
||||
.mockImplementation(() => undefined);
|
||||
processOnceSpy = vi.spyOn(process, "once").mockImplementation((_e, _l) => process);
|
||||
|
||||
const originalIsTTY = process.stdout.isTTY;
|
||||
Object.defineProperty(process.stdout, "isTTY", { value: false, configurable: true });
|
||||
|
||||
await program.parseAsync(["node", "test", "status", "--watch"]);
|
||||
|
||||
// First interval tick — starts a slow render
|
||||
capturedCallback!();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
const countAfterFirst = renderCount;
|
||||
|
||||
// Second tick while first is still pending — `rendering` guard should block it
|
||||
capturedCallback!();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(renderCount).toBe(countAfterFirst); // no additional list() call
|
||||
|
||||
// Unblock slow render
|
||||
unblockSlowRender();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
Object.defineProperty(process.stdout, "isTTY", {
|
||||
value: originalIsTTY,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,39 @@
|
|||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { probeGateway, validateToken } from "../../src/lib/openclaw-probe.js";
|
||||
|
||||
const { mockExistsSync, mockSpawnSync } = vi.hoisted(() => ({
|
||||
mockExistsSync: vi.fn(),
|
||||
mockSpawnSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:fs", async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
existsSync: (...args: unknown[]) => mockExistsSync(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("node:child_process", async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
spawnSync: (...args: unknown[]) => mockSpawnSync(...args),
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
detectOpenClawInstallation,
|
||||
probeGateway,
|
||||
validateToken,
|
||||
} from "../../src/lib/openclaw-probe.js";
|
||||
|
||||
describe("openclaw-probe", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
mockExistsSync.mockReset();
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
mockSpawnSync.mockReset();
|
||||
mockSpawnSync.mockReturnValue({ status: 1, stdout: "" });
|
||||
});
|
||||
|
||||
describe("probeGateway", () => {
|
||||
|
|
@ -65,6 +95,51 @@ describe("openclaw-probe", () => {
|
|||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe("http://127.0.0.1:18789");
|
||||
});
|
||||
|
||||
it("strips /hooks/agent when probing a hooks URL", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ status: 200, ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await probeGateway("http://127.0.0.1:18789/hooks/agent");
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe("http://127.0.0.1:18789");
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectOpenClawInstallation", () => {
|
||||
it("reports missing when binary/config are absent and gateway is down", async () => {
|
||||
const fetchMock = vi.fn().mockRejectedValue(new Error("ECONNREFUSED"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await detectOpenClawInstallation();
|
||||
|
||||
expect(result.state).toBe("missing");
|
||||
expect(result.binaryPath).toBeUndefined();
|
||||
expect(result.configPath).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports installed-but-stopped when binary exists but gateway is down", async () => {
|
||||
mockSpawnSync.mockReturnValue({ status: 0, stdout: "/usr/local/bin/openclaw\n" });
|
||||
const fetchMock = vi.fn().mockRejectedValue(new Error("ECONNREFUSED"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await detectOpenClawInstallation();
|
||||
|
||||
expect(result.state).toBe("installed-but-stopped");
|
||||
expect(result.binaryPath).toBe("/usr/local/bin/openclaw");
|
||||
});
|
||||
|
||||
it("reports running when gateway is reachable", async () => {
|
||||
mockSpawnSync.mockReturnValue({ status: 0, stdout: "/usr/local/bin/openclaw\n" });
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
const fetchMock = vi.fn().mockResolvedValue({ status: 200, ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await detectOpenClawInstallation();
|
||||
|
||||
expect(result.state).toBe("running");
|
||||
expect(result.configPath).toContain(".openclaw/openclaw.json");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateToken", () => {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,20 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { getAgent, getAgentByName } from "../../src/lib/plugins.js";
|
||||
import type { OrchestratorConfig } from "@composio/ao-core";
|
||||
import {
|
||||
getAgent,
|
||||
getAgentByName,
|
||||
getAgentByNameFromRegistry,
|
||||
getSCMFromRegistry,
|
||||
} from "../../src/lib/plugins.js";
|
||||
import type { Agent, OrchestratorConfig, PluginRegistry, SCM } from "@composio/ao-core";
|
||||
|
||||
function makeConfig(
|
||||
defaultAgent: string,
|
||||
projects?: Record<string, { agent?: string }>,
|
||||
projects?: Record<string, { agent?: string; scm?: { plugin: string } }>,
|
||||
): OrchestratorConfig {
|
||||
return {
|
||||
dataDir: "/tmp",
|
||||
worktreeDir: "/tmp/wt",
|
||||
configPath: "/tmp/agent-orchestrator.yaml",
|
||||
port: 3000,
|
||||
readyThresholdMs: 300_000,
|
||||
defaults: { runtime: "tmux", agent: defaultAgent, workspace: "worktree", notifiers: [] },
|
||||
projects: Object.fromEntries(
|
||||
Object.entries(projects ?? { app: {} }).map(([id, p]) => [
|
||||
|
|
@ -23,6 +28,23 @@ function makeConfig(
|
|||
} as OrchestratorConfig;
|
||||
}
|
||||
|
||||
function makeRegistry(entries: {
|
||||
agent?: Record<string, Agent>;
|
||||
scm?: Record<string, SCM>;
|
||||
}): PluginRegistry {
|
||||
return {
|
||||
register: () => {},
|
||||
get: (slot, name) => {
|
||||
if (slot === "agent") return (entries.agent?.[name] ?? null) as Agent | null;
|
||||
if (slot === "scm") return (entries.scm?.[name] ?? null) as SCM | null;
|
||||
return null;
|
||||
},
|
||||
list: () => [],
|
||||
loadBuiltins: async () => {},
|
||||
loadFromConfig: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
describe("getAgent", () => {
|
||||
it("returns claude-code agent by default", () => {
|
||||
const config = makeConfig("claude-code");
|
||||
|
|
@ -75,3 +97,57 @@ describe("getAgentByName", () => {
|
|||
expect(() => getAgentByName("unknown")).toThrow("Unknown agent plugin: unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("registry-backed resolution", () => {
|
||||
it("returns an agent from the shared registry", () => {
|
||||
const registry = makeRegistry({
|
||||
agent: {
|
||||
goose: {
|
||||
name: "goose",
|
||||
processName: "goose",
|
||||
instructions: "",
|
||||
launch: async () => {
|
||||
throw new Error("not implemented");
|
||||
},
|
||||
detectActivity: () => "idle",
|
||||
getSessionInfo: async () => null,
|
||||
} as unknown as Agent,
|
||||
},
|
||||
});
|
||||
|
||||
expect(getAgentByNameFromRegistry(registry, "goose").name).toBe("goose");
|
||||
});
|
||||
|
||||
it("returns an scm plugin from the shared registry", () => {
|
||||
const registry = makeRegistry({
|
||||
scm: {
|
||||
gitlab: {
|
||||
name: "gitlab",
|
||||
getPR: async () => null,
|
||||
detectPR: async () => null,
|
||||
getPRState: async () => "open",
|
||||
getReviewDecision: async () => null,
|
||||
getPendingComments: async () => 0,
|
||||
getAutomatedComments: async () => [],
|
||||
getCIChecks: async () => [],
|
||||
getCISummary: async () => null,
|
||||
getReviews: async () => [],
|
||||
getMergeability: async () => ({
|
||||
mergeable: true,
|
||||
ciPassing: true,
|
||||
approved: true,
|
||||
noConflicts: true,
|
||||
blockers: [],
|
||||
}),
|
||||
mergePR: async () => {},
|
||||
closePR: async () => {},
|
||||
} as unknown as SCM,
|
||||
},
|
||||
});
|
||||
const config = makeConfig("claude-code", {
|
||||
myapp: { agent: "claude-code", scm: { plugin: "gitlab" } },
|
||||
});
|
||||
|
||||
expect(getSCMFromRegistry(registry, config, "myapp").name).toBe("gitlab");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { findProjectForDirectory } from "../../src/lib/project-resolution.js";
|
||||
|
||||
describe("findProjectForDirectory", () => {
|
||||
it("returns a project when cwd is inside a project subdirectory", () => {
|
||||
const projectId = findProjectForDirectory(
|
||||
{
|
||||
frontend: { path: "/repos/frontend" },
|
||||
backend: { path: "/repos/backend" },
|
||||
},
|
||||
"/repos/backend/packages/api",
|
||||
);
|
||||
|
||||
expect(projectId).toBe("backend");
|
||||
});
|
||||
|
||||
it("prefers the deepest matching project path", () => {
|
||||
const projectId = findProjectForDirectory(
|
||||
{
|
||||
monorepo: { path: "/repos/mono" },
|
||||
docs: { path: "/repos/mono/docs" },
|
||||
},
|
||||
"/repos/mono/docs/guides",
|
||||
);
|
||||
|
||||
expect(projectId).toBe("docs");
|
||||
});
|
||||
|
||||
it("returns null when cwd is outside every configured project", () => {
|
||||
const projectId = findProjectForDirectory(
|
||||
{
|
||||
frontend: { path: "/repos/frontend" },
|
||||
},
|
||||
"/repos/backend",
|
||||
);
|
||||
|
||||
expect(projectId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@composio/ao-cli",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"description": "CLI for agent-orchestrator — the `ao` command",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
"node": ">=20.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"build": "tsc && cp -r src/assets dist/",
|
||||
"dev": "tsx src/index.ts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
[
|
||||
{
|
||||
"id": "agent-codex",
|
||||
"package": "@composio/ao-plugin-agent-codex",
|
||||
"slot": "agent",
|
||||
"description": "Agent plugin: OpenAI Codex CLI",
|
||||
"source": "registry",
|
||||
"latestVersion": "0.2.0"
|
||||
},
|
||||
{
|
||||
"id": "agent-aider",
|
||||
"package": "@composio/ao-plugin-agent-aider",
|
||||
"slot": "agent",
|
||||
"description": "Agent plugin: Aider CLI",
|
||||
"source": "registry",
|
||||
"latestVersion": "0.2.0"
|
||||
},
|
||||
{
|
||||
"id": "agent-opencode",
|
||||
"package": "@composio/ao-plugin-agent-opencode",
|
||||
"slot": "agent",
|
||||
"description": "Agent plugin: OpenCode CLI",
|
||||
"source": "registry",
|
||||
"latestVersion": "0.2.0"
|
||||
},
|
||||
{
|
||||
"id": "notifier-openclaw",
|
||||
"package": "@composio/ao-plugin-notifier-openclaw",
|
||||
"slot": "notifier",
|
||||
"description": "Notifier plugin: OpenClaw webhook notifications",
|
||||
"source": "registry",
|
||||
"setupAction": "openclaw-setup",
|
||||
"latestVersion": "0.1.1"
|
||||
},
|
||||
{
|
||||
"id": "notifier-discord",
|
||||
"package": "@composio/ao-plugin-notifier-discord",
|
||||
"slot": "notifier",
|
||||
"description": "Notifier plugin: Discord notifications",
|
||||
"source": "registry",
|
||||
"latestVersion": "0.1.0"
|
||||
},
|
||||
{
|
||||
"id": "tracker-gitlab",
|
||||
"package": "@composio/ao-plugin-tracker-gitlab",
|
||||
"slot": "tracker",
|
||||
"description": "Tracker plugin: GitLab issues",
|
||||
"source": "registry",
|
||||
"latestVersion": "0.1.1"
|
||||
},
|
||||
{
|
||||
"id": "scm-gitlab",
|
||||
"package": "@composio/ao-plugin-scm-gitlab",
|
||||
"slot": "scm",
|
||||
"description": "SCM plugin: GitLab pull/merge requests",
|
||||
"source": "registry",
|
||||
"latestVersion": "0.1.1"
|
||||
}
|
||||
]
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import chalk from "chalk";
|
||||
import type { Command } from "commander";
|
||||
import { loadConfig } from "@composio/ao-core";
|
||||
|
|
@ -58,11 +60,21 @@ export function registerDashboard(program: Command): void {
|
|||
config.directTerminalPort,
|
||||
);
|
||||
|
||||
const child = spawn("npx", ["next", "dev", "-p", String(port)], {
|
||||
cwd: webDir,
|
||||
stdio: ["inherit", "inherit", "pipe"],
|
||||
env,
|
||||
});
|
||||
// In dev mode (monorepo), use `pnpm run dev` which starts Next.js AND
|
||||
// the terminal WebSocket servers via concurrently. Without the WS servers,
|
||||
// the live terminal in the dashboard won't work.
|
||||
const isDevMode = existsSync(resolve(webDir, "server"));
|
||||
const child = isDevMode
|
||||
? spawn("pnpm", ["run", "dev"], {
|
||||
cwd: webDir,
|
||||
stdio: ["inherit", "inherit", "pipe"],
|
||||
env,
|
||||
})
|
||||
: spawn("npx", ["next", "dev", "-p", String(port)], {
|
||||
cwd: webDir,
|
||||
stdio: ["inherit", "inherit", "pipe"],
|
||||
env,
|
||||
});
|
||||
|
||||
const stderrChunks: string[] = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,20 @@
|
|||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { Command } from "commander";
|
||||
import chalk from "chalk";
|
||||
import { findConfigFile, loadConfig, type Notifier, type OrchestratorConfig } from "@composio/ao-core";
|
||||
import {
|
||||
createPluginRegistry,
|
||||
findConfigFile,
|
||||
getObservabilityBaseDir,
|
||||
loadConfig,
|
||||
type Notifier,
|
||||
type OrchestratorConfig,
|
||||
type PluginRegistry,
|
||||
type PluginSlot,
|
||||
} from "@composio/ao-core";
|
||||
import { runRepoScript } from "../lib/script-runner.js";
|
||||
import { probeGateway, validateToken } from "../lib/openclaw-probe.js";
|
||||
import { detectOpenClawInstallation, validateToken } from "../lib/openclaw-probe.js";
|
||||
import { importPluginModuleFromSource } from "../lib/plugin-store.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers — match the PASS / WARN / FAIL style of ao-doctor.sh
|
||||
|
|
@ -30,10 +42,194 @@ function makeFailCounter(): { fail: (msg: string) => void; count: () => number }
|
|||
};
|
||||
}
|
||||
|
||||
type CheckedPluginSlot = Extract<
|
||||
PluginSlot,
|
||||
"runtime" | "agent" | "workspace" | "tracker" | "scm" | "notifier"
|
||||
>;
|
||||
|
||||
interface PluginReference {
|
||||
slot: CheckedPluginSlot;
|
||||
pluginName: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
interface NotifierTarget {
|
||||
label: string;
|
||||
pluginName: string;
|
||||
}
|
||||
|
||||
async function loadPluginRegistry(config: OrchestratorConfig): Promise<PluginRegistry> {
|
||||
const registry = createPluginRegistry();
|
||||
await registry.loadFromConfig(config, importPluginModuleFromSource);
|
||||
return registry;
|
||||
}
|
||||
|
||||
function addPluginReference(
|
||||
refs: PluginReference[],
|
||||
slot: CheckedPluginSlot,
|
||||
pluginName: string | undefined,
|
||||
source: string,
|
||||
): void {
|
||||
if (!pluginName) return;
|
||||
refs.push({ slot, pluginName, source });
|
||||
}
|
||||
|
||||
function resolveNotifierTarget(config: OrchestratorConfig, ref: string): NotifierTarget {
|
||||
const configured = config.notifiers?.[ref];
|
||||
if (configured?.plugin) {
|
||||
return { label: ref, pluginName: configured.plugin };
|
||||
}
|
||||
return { label: ref, pluginName: ref };
|
||||
}
|
||||
|
||||
function collectPluginReferences(config: OrchestratorConfig): PluginReference[] {
|
||||
const refs: PluginReference[] = [];
|
||||
|
||||
addPluginReference(refs, "runtime", config.defaults.runtime, "defaults.runtime");
|
||||
addPluginReference(refs, "agent", config.defaults.agent, "defaults.agent");
|
||||
addPluginReference(refs, "workspace", config.defaults.workspace, "defaults.workspace");
|
||||
addPluginReference(refs, "agent", config.defaults.orchestrator?.agent, "defaults.orchestrator.agent");
|
||||
addPluginReference(refs, "agent", config.defaults.worker?.agent, "defaults.worker.agent");
|
||||
|
||||
for (const notifierName of config.defaults.notifiers ?? []) {
|
||||
const target = resolveNotifierTarget(config, notifierName);
|
||||
addPluginReference(
|
||||
refs,
|
||||
"notifier",
|
||||
target.pluginName,
|
||||
`defaults.notifiers: ${target.label} (plugin: ${target.pluginName})`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const [priority, notifierNames] of Object.entries(config.notificationRouting ?? {})) {
|
||||
for (const notifierName of notifierNames) {
|
||||
const target = resolveNotifierTarget(config, notifierName);
|
||||
addPluginReference(
|
||||
refs,
|
||||
"notifier",
|
||||
target.pluginName,
|
||||
`notificationRouting.${priority}: ${target.label} (plugin: ${target.pluginName})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, notifierConfig] of Object.entries(config.notifiers ?? {})) {
|
||||
addPluginReference(
|
||||
refs,
|
||||
"notifier",
|
||||
notifierConfig.plugin,
|
||||
`notifiers.${name} (plugin: ${notifierConfig.plugin})`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const [projectId, project] of Object.entries(config.projects)) {
|
||||
addPluginReference(refs, "runtime", project.runtime, `projects.${projectId}.runtime`);
|
||||
addPluginReference(refs, "agent", project.agent, `projects.${projectId}.agent`);
|
||||
addPluginReference(refs, "workspace", project.workspace, `projects.${projectId}.workspace`);
|
||||
addPluginReference(
|
||||
refs,
|
||||
"agent",
|
||||
project.orchestrator?.agent,
|
||||
`projects.${projectId}.orchestrator.agent`,
|
||||
);
|
||||
addPluginReference(refs, "agent", project.worker?.agent, `projects.${projectId}.worker.agent`);
|
||||
addPluginReference(
|
||||
refs,
|
||||
"tracker",
|
||||
project.tracker?.plugin,
|
||||
`projects.${projectId}.tracker.plugin`,
|
||||
);
|
||||
addPluginReference(refs, "scm", project.scm?.plugin, `projects.${projectId}.scm.plugin`);
|
||||
}
|
||||
|
||||
return refs;
|
||||
}
|
||||
|
||||
async function checkPluginResolution(
|
||||
config: OrchestratorConfig,
|
||||
fail: (msg: string) => void,
|
||||
): Promise<PluginRegistry> {
|
||||
console.log("");
|
||||
console.log("Plugin resolution:");
|
||||
|
||||
const registry = await loadPluginRegistry(config);
|
||||
const loadedBySlot = new Map<CheckedPluginSlot, Set<string>>();
|
||||
const slots: CheckedPluginSlot[] = [
|
||||
"runtime",
|
||||
"agent",
|
||||
"workspace",
|
||||
"tracker",
|
||||
"scm",
|
||||
"notifier",
|
||||
];
|
||||
|
||||
for (const slot of slots) {
|
||||
loadedBySlot.set(
|
||||
slot,
|
||||
new Set(registry.list(slot).map((manifest) => manifest.name)),
|
||||
);
|
||||
}
|
||||
|
||||
const references = collectPluginReferences(config);
|
||||
if (references.length === 0) {
|
||||
warn("No plugin references found in config.");
|
||||
return registry;
|
||||
}
|
||||
|
||||
for (const ref of references) {
|
||||
const loaded = loadedBySlot.get(ref.slot);
|
||||
if (loaded?.has(ref.pluginName)) {
|
||||
pass(`${ref.source} -> ${ref.slot} plugin "${ref.pluginName}"`);
|
||||
} else {
|
||||
fail(
|
||||
`${ref.source} references ${ref.slot} plugin "${ref.pluginName}", but it could not be loaded. ` +
|
||||
`Fix: install the plugin or correct the config value.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return registry;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Notifier connectivity checks (Gap 2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface OpenClawHealthSummary {
|
||||
lastSuccessAt: string | null;
|
||||
lastFailureAt: string | null;
|
||||
lastFailureError: string | null;
|
||||
totalSent: number;
|
||||
totalFailed: number;
|
||||
}
|
||||
|
||||
function formatTimestamp(timestamp: string | null): string | null {
|
||||
if (!timestamp) return null;
|
||||
const date = new Date(timestamp);
|
||||
return Number.isNaN(date.getTime()) ? timestamp : date.toLocaleString();
|
||||
}
|
||||
|
||||
function readOpenClawHealth(config: OrchestratorConfig): OpenClawHealthSummary | null {
|
||||
if (!config.configPath) return null;
|
||||
const healthPath = join(getObservabilityBaseDir(config.configPath), "openclaw-health.json");
|
||||
if (!existsSync(healthPath)) return null;
|
||||
|
||||
try {
|
||||
const raw = readFileSync(healthPath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
if (typeof parsed !== "object" || parsed === null) return null;
|
||||
return {
|
||||
lastSuccessAt: typeof parsed.lastSuccessAt === "string" ? parsed.lastSuccessAt : null,
|
||||
lastFailureAt: typeof parsed.lastFailureAt === "string" ? parsed.lastFailureAt : null,
|
||||
lastFailureError: typeof parsed.lastFailureError === "string" ? parsed.lastFailureError : null,
|
||||
totalSent: typeof parsed.totalSent === "number" ? parsed.totalSent : 0,
|
||||
totalFailed: typeof parsed.totalFailed === "number" ? parsed.totalFailed : 0,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkOpenClawNotifier(
|
||||
config: OrchestratorConfig,
|
||||
fail: (msg: string) => void,
|
||||
|
|
@ -53,33 +249,58 @@ async function checkOpenClawNotifier(
|
|||
const envVarMatch = rawToken?.match(/^\$\{([^}]+)\}$/);
|
||||
const token = (envVarMatch ? process.env[envVarMatch[1]] : rawToken) ?? process.env["OPENCLAW_HOOKS_TOKEN"];
|
||||
|
||||
// Step 1: Probe gateway reachability
|
||||
const probe = await probeGateway(url);
|
||||
if (!probe.reachable) {
|
||||
fail(
|
||||
`OpenClaw gateway is not reachable at ${url}. ` +
|
||||
`Fix: ensure OpenClaw is running (openclaw status) or fix the URL in your config`,
|
||||
const installation = await detectOpenClawInstallation(url);
|
||||
if (installation.state === "running") {
|
||||
pass(
|
||||
`OpenClaw gateway detected at ${installation.gatewayUrl} (HTTP ${installation.probe.httpStatus})`,
|
||||
);
|
||||
} else if (installation.state === "installed-but-stopped") {
|
||||
const installHint = installation.binaryPath
|
||||
? `installed at ${installation.binaryPath}`
|
||||
: "configured on this machine";
|
||||
fail(`OpenClaw is ${installHint} but the gateway is not running at ${installation.gatewayUrl}`);
|
||||
} else {
|
||||
fail(
|
||||
`OpenClaw is not installed locally and the gateway is not reachable at ${installation.gatewayUrl}. ` +
|
||||
`Fix: install/start OpenClaw or update the notifier URL`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
pass(`OpenClaw gateway is reachable at ${url} (HTTP ${probe.httpStatus})`);
|
||||
|
||||
// Step 2: Validate auth token if present
|
||||
if (!token) {
|
||||
warn(
|
||||
"OpenClaw token is not set. Fix: set OPENCLAW_HOOKS_TOKEN env var or add token to notifiers.openclaw in config",
|
||||
);
|
||||
} else if (installation.state === "running") {
|
||||
const tokenResult = await validateToken(installation.gatewayUrl, token);
|
||||
if (!tokenResult.valid) {
|
||||
fail(`OpenClaw token validation failed: ${tokenResult.error}`);
|
||||
} else {
|
||||
pass("OpenClaw token is valid");
|
||||
}
|
||||
}
|
||||
|
||||
const health = readOpenClawHealth(config);
|
||||
if (!health) {
|
||||
warn("No OpenClaw notification history recorded yet");
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenResult = await validateToken(url, token);
|
||||
if (!tokenResult.valid) {
|
||||
fail(`OpenClaw token validation failed: ${tokenResult.error}`);
|
||||
return;
|
||||
const lastSuccess = formatTimestamp(health.lastSuccessAt);
|
||||
if (lastSuccess) {
|
||||
pass(`OpenClaw last successful notification: ${lastSuccess}`);
|
||||
} else {
|
||||
warn("OpenClaw has not recorded a successful notification yet");
|
||||
}
|
||||
|
||||
pass("OpenClaw token is valid");
|
||||
if (health.lastFailureAt) {
|
||||
const lastFailure = formatTimestamp(health.lastFailureAt);
|
||||
warn(
|
||||
`OpenClaw last failure: ${lastFailure ?? health.lastFailureAt} (${health.lastFailureError ?? "unknown error"})`,
|
||||
);
|
||||
}
|
||||
|
||||
pass(`OpenClaw notification totals: ${health.totalSent} sent, ${health.totalFailed} failed`);
|
||||
}
|
||||
|
||||
async function checkNotifierConnectivity(
|
||||
|
|
@ -114,29 +335,35 @@ async function checkNotifierConnectivity(
|
|||
|
||||
async function sendTestNotifications(
|
||||
config: OrchestratorConfig,
|
||||
registry: PluginRegistry,
|
||||
fail: (msg: string) => void,
|
||||
): Promise<void> {
|
||||
const { createPluginRegistry } = await import("@composio/ao-core");
|
||||
const registry = createPluginRegistry();
|
||||
await registry.loadFromConfig(config);
|
||||
|
||||
const activeNotifierNames = config.defaults?.notifiers ?? [];
|
||||
const configuredNotifiers = Object.keys(config.notifiers ?? {});
|
||||
const configuredNotifiers = Object.entries(config.notifiers ?? {});
|
||||
const targets = new Map<string, NotifierTarget>();
|
||||
|
||||
// Combine both lists (defaults + configured) and deduplicate
|
||||
const allNames = [...new Set([...activeNotifierNames, ...configuredNotifiers])];
|
||||
for (const [name, notifierConfig] of configuredNotifiers) {
|
||||
targets.set(notifierConfig.plugin, { label: name, pluginName: notifierConfig.plugin });
|
||||
}
|
||||
|
||||
if (allNames.length === 0) {
|
||||
for (const name of activeNotifierNames) {
|
||||
const target = resolveNotifierTarget(config, name);
|
||||
if (!targets.has(target.pluginName)) {
|
||||
targets.set(target.pluginName, target);
|
||||
}
|
||||
}
|
||||
|
||||
if (targets.size === 0) {
|
||||
warn("No notifiers to test. Fix: configure notifiers in your agent-orchestrator.yaml");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\nSending test notification to ${allNames.length} notifier(s)...\n`);
|
||||
console.log(`\nSending test notification to ${targets.size} notifier(s)...\n`);
|
||||
|
||||
for (const name of allNames) {
|
||||
const notifier = registry.get<Notifier>("notifier", name);
|
||||
for (const target of targets.values()) {
|
||||
const notifier = registry.get<Notifier>("notifier", target.pluginName);
|
||||
if (!notifier) {
|
||||
warn(`${name}: plugin not loaded (may not be installed)`);
|
||||
warn(`${target.label}: plugin "${target.pluginName}" not loaded (may not be installed)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -153,10 +380,10 @@ async function sendTestNotifications(
|
|||
};
|
||||
|
||||
await notifier.notify(testEvent);
|
||||
pass(`${name}: test notification sent`);
|
||||
pass(`${target.label}: test notification sent`);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
fail(`${name}: ${message}`);
|
||||
fail(`${target.label}: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -192,18 +419,20 @@ export function registerDoctor(program: Command): void {
|
|||
const configPath = findConfigFile();
|
||||
if (configPath) {
|
||||
let config: ReturnType<typeof loadConfig> | undefined;
|
||||
let registry: PluginRegistry | undefined;
|
||||
try {
|
||||
config = loadConfig(configPath);
|
||||
registry = await checkPluginResolution(config, fail);
|
||||
await checkNotifierConnectivity(config, fail);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
warn(`Notifier connectivity check failed: ${message}`);
|
||||
fail(`Config-aware doctor checks failed: ${message}`);
|
||||
}
|
||||
|
||||
// 3. Send test notifications if requested (separate catch for accurate errors)
|
||||
if (opts.testNotify && config) {
|
||||
if (opts.testNotify && config && registry) {
|
||||
try {
|
||||
await sendTestNotifications(config, fail);
|
||||
await sendTestNotifications(config, registry, fail);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
fail(`Sending test notifications failed: ${message}`);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,602 @@
|
|||
import { readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import chalk from "chalk";
|
||||
import type { Command } from "commander";
|
||||
import {
|
||||
createPluginRegistry,
|
||||
findConfigFile,
|
||||
loadConfig,
|
||||
type PluginSlot,
|
||||
type InstalledPluginConfig,
|
||||
type OrchestratorConfig,
|
||||
} from "@composio/ao-core";
|
||||
import { parseDocument } from "yaml";
|
||||
import {
|
||||
buildPluginDescriptor,
|
||||
findMarketplacePlugin,
|
||||
loadMarketplaceCatalog,
|
||||
normalizeImportedPluginModule,
|
||||
refreshMarketplaceCatalog,
|
||||
} from "../lib/plugin-marketplace.js";
|
||||
import {
|
||||
getLatestPublishedPackageVersion,
|
||||
importPluginModuleFromSource,
|
||||
installPackageIntoStore,
|
||||
readInstalledPackageVersion,
|
||||
uninstallPackageFromStore,
|
||||
} from "../lib/plugin-store.js";
|
||||
import {
|
||||
buildDefaultPackageName,
|
||||
normalizePluginName,
|
||||
resolveScaffoldDirectory,
|
||||
scaffoldPlugin,
|
||||
} from "../lib/plugin-scaffold.js";
|
||||
import { runSetupAction } from "./setup.js";
|
||||
|
||||
function findInstalledPlugin(
|
||||
plugins: InstalledPluginConfig[],
|
||||
descriptor: InstalledPluginConfig,
|
||||
): InstalledPluginConfig | undefined {
|
||||
return plugins.find((plugin) => {
|
||||
if (descriptor.package && plugin.package === descriptor.package) return true;
|
||||
if (descriptor.path && plugin.path === descriptor.path) return true;
|
||||
return plugin.name === descriptor.name;
|
||||
});
|
||||
}
|
||||
|
||||
function upsertInstalledPlugin(
|
||||
plugins: InstalledPluginConfig[],
|
||||
descriptor: InstalledPluginConfig,
|
||||
): InstalledPluginConfig[] {
|
||||
const existing = findInstalledPlugin(plugins, descriptor);
|
||||
if (!existing) {
|
||||
return [...plugins, descriptor];
|
||||
}
|
||||
|
||||
return plugins.map((plugin) => (plugin === existing ? { ...existing, ...descriptor } : plugin));
|
||||
}
|
||||
|
||||
function writePluginsConfig(configPath: string, plugins: InstalledPluginConfig[]): void {
|
||||
const rawYaml = readFileSync(configPath, "utf-8");
|
||||
const doc = parseDocument(rawYaml);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rawConfig = (doc.toJS() as Record<string, any>) ?? {};
|
||||
if (plugins.length === 0) {
|
||||
delete rawConfig.plugins;
|
||||
} else {
|
||||
rawConfig.plugins = plugins;
|
||||
}
|
||||
doc.contents = doc.createNode(rawConfig) as typeof doc.contents;
|
||||
const rendered = doc.toString({ indent: 2 });
|
||||
const tempPath = `${configPath}.tmp.${process.pid}.${Date.now()}`;
|
||||
writeFileSync(tempPath, rendered, "utf-8");
|
||||
renameSync(tempPath, configPath);
|
||||
}
|
||||
|
||||
function matchesPluginReference(plugin: InstalledPluginConfig, reference: string): boolean {
|
||||
const marketplacePlugin = findMarketplacePlugin(reference);
|
||||
return (
|
||||
plugin.name === reference ||
|
||||
plugin.package === reference ||
|
||||
plugin.path === reference ||
|
||||
(marketplacePlugin !== undefined &&
|
||||
(plugin.package === marketplacePlugin.package || plugin.name === marketplacePlugin.id))
|
||||
);
|
||||
}
|
||||
|
||||
async function rollbackManagedPackageInstall(
|
||||
packageName: string,
|
||||
previousVersion: string | null,
|
||||
): Promise<void> {
|
||||
if (previousVersion) {
|
||||
await installPackageIntoStore(packageName, previousVersion);
|
||||
return;
|
||||
}
|
||||
|
||||
await uninstallPackageFromStore(packageName);
|
||||
}
|
||||
|
||||
function formatInstallSummary(descriptor: InstalledPluginConfig, configPath: string): string {
|
||||
const version = descriptor.version ? ` @ ${descriptor.version}` : "";
|
||||
return `Installed ${descriptor.name}${version} (${descriptor.source}) into ${configPath}`;
|
||||
}
|
||||
|
||||
async function installOrVerifyPlugin(
|
||||
config: OrchestratorConfig,
|
||||
descriptor: InstalledPluginConfig,
|
||||
specifier: string,
|
||||
): Promise<InstalledPluginConfig> {
|
||||
if (!descriptor.package || descriptor.source === "local") {
|
||||
return verifyPluginDescriptor(config, descriptor, specifier);
|
||||
}
|
||||
|
||||
const previousVersion = readInstalledPackageVersion(descriptor.package);
|
||||
let installCompleted = false;
|
||||
|
||||
try {
|
||||
const installedVersion = await installPackageIntoStore(descriptor.package, descriptor.version);
|
||||
installCompleted = true;
|
||||
return await verifyPluginDescriptor(
|
||||
config,
|
||||
{ ...descriptor, version: installedVersion },
|
||||
descriptor.package,
|
||||
);
|
||||
} catch (err) {
|
||||
if (installCompleted) {
|
||||
try {
|
||||
await rollbackManagedPackageInstall(descriptor.package, previousVersion);
|
||||
} catch (rollbackErr) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const rollbackMessage =
|
||||
rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
|
||||
throw new Error(
|
||||
`${message}\nRollback failed for ${descriptor.package}: ${rollbackMessage}`,
|
||||
{ cause: rollbackErr },
|
||||
);
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveTargetVersion(plugin: InstalledPluginConfig): Promise<string> {
|
||||
if (!plugin.package) {
|
||||
throw new Error(`Plugin ${plugin.name} does not have a package-backed source and cannot be updated.`);
|
||||
}
|
||||
|
||||
const marketplacePlugin =
|
||||
findMarketplacePlugin(plugin.package) ?? findMarketplacePlugin(plugin.name);
|
||||
if (marketplacePlugin?.latestVersion) {
|
||||
return marketplacePlugin.latestVersion;
|
||||
}
|
||||
|
||||
return getLatestPublishedPackageVersion(plugin.package);
|
||||
}
|
||||
|
||||
async function updateManagedPlugin(
|
||||
configPath: string,
|
||||
config: OrchestratorConfig,
|
||||
plugin: InstalledPluginConfig,
|
||||
): Promise<"updated" | "skipped"> {
|
||||
if (!plugin.package || plugin.source === "local") {
|
||||
throw new Error(`Plugin ${plugin.name} is local-only and cannot be updated through the AO store.`);
|
||||
}
|
||||
|
||||
const currentVersion = readInstalledPackageVersion(plugin.package) ?? plugin.version ?? null;
|
||||
const targetVersion = await resolveTargetVersion(plugin);
|
||||
|
||||
if (currentVersion === targetVersion) {
|
||||
if (plugin.version !== currentVersion && currentVersion) {
|
||||
writePluginsConfig(
|
||||
configPath,
|
||||
upsertInstalledPlugin(config.plugins ?? [], { ...plugin, version: currentVersion }),
|
||||
);
|
||||
}
|
||||
console.log(chalk.dim(`${plugin.name} is already up to date (${targetVersion}).`));
|
||||
return "skipped";
|
||||
}
|
||||
|
||||
const previousVersion = readInstalledPackageVersion(plugin.package);
|
||||
let installCompleted = false;
|
||||
|
||||
try {
|
||||
const installedVersion = await installPackageIntoStore(plugin.package, targetVersion);
|
||||
installCompleted = true;
|
||||
const verifiedDescriptor = await verifyPluginDescriptor(
|
||||
config,
|
||||
{ ...plugin, version: installedVersion },
|
||||
plugin.package,
|
||||
);
|
||||
writePluginsConfig(
|
||||
configPath,
|
||||
upsertInstalledPlugin(config.plugins ?? [], verifiedDescriptor),
|
||||
);
|
||||
console.log(
|
||||
chalk.green(
|
||||
`Updated ${verifiedDescriptor.name} from ${currentVersion ?? "not installed"} to ${installedVersion}`,
|
||||
),
|
||||
);
|
||||
return "updated";
|
||||
} catch (err) {
|
||||
if (installCompleted) {
|
||||
try {
|
||||
await rollbackManagedPackageInstall(plugin.package, previousVersion);
|
||||
} catch (rollbackErr) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const rollbackMessage =
|
||||
rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
|
||||
throw new Error(
|
||||
`${message}\nRollback failed for ${plugin.package}: ${rollbackMessage}`,
|
||||
{ cause: rollbackErr },
|
||||
);
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyPluginDescriptor(
|
||||
config: OrchestratorConfig,
|
||||
descriptor: InstalledPluginConfig,
|
||||
specifier: string,
|
||||
): Promise<InstalledPluginConfig> {
|
||||
const imported = normalizeImportedPluginModule(await importPluginModuleFromSource(specifier));
|
||||
if (!imported) {
|
||||
throw new Error(`Imported module ${specifier} does not export a valid AO plugin`);
|
||||
}
|
||||
|
||||
const normalizedDescriptor: InstalledPluginConfig = {
|
||||
...descriptor,
|
||||
name: imported.manifest.name,
|
||||
};
|
||||
|
||||
const registry = createPluginRegistry();
|
||||
const tempConfig: OrchestratorConfig = {
|
||||
...config,
|
||||
plugins: upsertInstalledPlugin(config.plugins ?? [], normalizedDescriptor),
|
||||
};
|
||||
const originalWarn = console.warn;
|
||||
console.warn = () => {};
|
||||
try {
|
||||
await registry.loadFromConfig(tempConfig, importPluginModuleFromSource);
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
|
||||
const registered = registry.get(imported.manifest.slot, imported.manifest.name);
|
||||
if (!registered) {
|
||||
throw new Error(
|
||||
`Plugin ${imported.manifest.name} imported successfully but did not load through the registry`,
|
||||
);
|
||||
}
|
||||
|
||||
return normalizedDescriptor;
|
||||
}
|
||||
|
||||
function printPluginListFromCatalog(
|
||||
config: OrchestratorConfig | null,
|
||||
installedOnly: boolean,
|
||||
catalog: ReturnType<typeof loadMarketplaceCatalog>,
|
||||
slotFilter?: string,
|
||||
): void {
|
||||
const installed = config?.plugins ?? [];
|
||||
|
||||
if (installedOnly) {
|
||||
const filtered = slotFilter
|
||||
? installed.filter((p) => {
|
||||
const entry = catalog.find((e) => e.id === p.name || e.package === p.package);
|
||||
return entry?.slot === slotFilter;
|
||||
})
|
||||
: installed;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
console.log(chalk.dim(slotFilter ? `No installed plugins with type "${slotFilter}".` : "No plugins are installed in this config."));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const plugin of filtered) {
|
||||
const source = plugin.source === "local" ? plugin.path : plugin.package;
|
||||
const version = plugin.version ? ` @ ${plugin.version}` : "";
|
||||
console.log(
|
||||
`${chalk.green(plugin.name)}${chalk.dim(version)} ${chalk.dim(`(${plugin.source}) ${source ?? ""}`)}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const filteredCatalog = slotFilter ? catalog.filter((p) => p.slot === slotFilter) : catalog;
|
||||
|
||||
if (filteredCatalog.length === 0) {
|
||||
console.log(chalk.dim(slotFilter ? `No marketplace plugins with type "${slotFilter}".` : "No marketplace plugins found."));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const plugin of filteredCatalog) {
|
||||
const isInstalled = installed.some(
|
||||
(entry) => entry.package === plugin.package || entry.name === plugin.id,
|
||||
);
|
||||
const marker = isInstalled ? chalk.green("installed") : chalk.dim("available");
|
||||
console.log(`${chalk.cyan(plugin.id)} ${chalk.dim(`(${plugin.slot})`)} ${marker}`);
|
||||
console.log(chalk.dim(` ${plugin.description}`));
|
||||
}
|
||||
}
|
||||
|
||||
export function registerPlugin(program: Command): void {
|
||||
const plugin = program.command("plugin").description("Browse and manage AO plugins");
|
||||
|
||||
plugin
|
||||
.command("list")
|
||||
.description("List bundled marketplace plugins")
|
||||
.option("--installed", "Show only plugins installed in the current config")
|
||||
.option("--type <slot>", "Filter by plugin slot (e.g. agent, notifier, tracker)")
|
||||
.option("--refresh", "Fetch the latest registry and update the local marketplace cache")
|
||||
.action(async (opts: { installed?: boolean; type?: string; refresh?: boolean }) => {
|
||||
let config: OrchestratorConfig | null = null;
|
||||
const configPath = findConfigFile();
|
||||
if (configPath) {
|
||||
config = loadConfig(configPath);
|
||||
}
|
||||
const catalog = opts.refresh === true ? await refreshMarketplaceCatalog() : loadMarketplaceCatalog();
|
||||
printPluginListFromCatalog(config, opts.installed === true, catalog, opts.type);
|
||||
});
|
||||
|
||||
plugin
|
||||
.command("search")
|
||||
.description("Search the bundled marketplace catalog")
|
||||
.argument("<query>", "Search term")
|
||||
.action((query: string) => {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const matches = loadMarketplaceCatalog().filter((plugin) => {
|
||||
return (
|
||||
plugin.id.toLowerCase().includes(normalizedQuery) ||
|
||||
plugin.package.toLowerCase().includes(normalizedQuery) ||
|
||||
plugin.description.toLowerCase().includes(normalizedQuery) ||
|
||||
plugin.slot.toLowerCase().includes(normalizedQuery)
|
||||
);
|
||||
});
|
||||
|
||||
if (matches.length === 0) {
|
||||
console.log(chalk.dim(`No marketplace plugins matched "${query}".`));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const match of matches) {
|
||||
console.log(`${chalk.cyan(match.id)} ${chalk.dim(`(${match.slot})`)}`);
|
||||
console.log(chalk.dim(` ${match.description}`));
|
||||
}
|
||||
});
|
||||
|
||||
plugin
|
||||
.command("create")
|
||||
.description("Scaffold a new AO plugin package")
|
||||
.argument("[directory]", "Target directory for the new plugin")
|
||||
.option("--name <name>", "Display/plugin name")
|
||||
.option("--slot <slot>", "Plugin slot: runtime | agent | workspace | tracker | scm | notifier | terminal")
|
||||
.option("--description <description>", "Short plugin description")
|
||||
.option("--author <author>", "Package author")
|
||||
.option("--package-name <packageName>", "npm package name")
|
||||
.option("--non-interactive", "Skip prompts and require explicit values for required fields")
|
||||
.action(
|
||||
async (
|
||||
directory: string | undefined,
|
||||
opts: {
|
||||
name?: string;
|
||||
slot?: string;
|
||||
description?: string;
|
||||
author?: string;
|
||||
packageName?: string;
|
||||
nonInteractive?: boolean;
|
||||
},
|
||||
) => {
|
||||
const slotOptions: PluginSlot[] = [
|
||||
"runtime",
|
||||
"agent",
|
||||
"workspace",
|
||||
"tracker",
|
||||
"scm",
|
||||
"notifier",
|
||||
"terminal",
|
||||
];
|
||||
const isInteractive = process.stdin.isTTY && opts.nonInteractive !== true;
|
||||
let name = opts.name;
|
||||
let slot = slotOptions.find((candidate) => candidate === opts.slot);
|
||||
let description = opts.description;
|
||||
let author = opts.author ?? process.env["USER"] ?? process.env["USERNAME"];
|
||||
let packageName = opts.packageName;
|
||||
|
||||
if (isInteractive) {
|
||||
const clack = await import("@clack/prompts");
|
||||
clack.intro(chalk.bgCyan(chalk.black(" ao plugin create ")));
|
||||
|
||||
if (!name) {
|
||||
const value = await clack.text({
|
||||
message: "Plugin display name:",
|
||||
placeholder: "My Plugin",
|
||||
validate: (input) => {
|
||||
if (!input) return "Plugin name is required";
|
||||
try {
|
||||
normalizePluginName(input);
|
||||
} catch (err) {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
},
|
||||
});
|
||||
if (clack.isCancel(value)) {
|
||||
clack.cancel("Plugin creation cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
name = value as string;
|
||||
}
|
||||
|
||||
if (!slot) {
|
||||
const value = await clack.select({
|
||||
message: "Plugin slot:",
|
||||
options: slotOptions.map((candidate) => ({ value: candidate, label: candidate })),
|
||||
});
|
||||
if (clack.isCancel(value)) {
|
||||
clack.cancel("Plugin creation cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
slot = value as PluginSlot;
|
||||
}
|
||||
|
||||
if (!description) {
|
||||
const value = await clack.text({
|
||||
message: "Short description:",
|
||||
placeholder: `AO ${slot} plugin`,
|
||||
validate: (input) => (!input ? "Description is required" : undefined),
|
||||
});
|
||||
if (clack.isCancel(value)) {
|
||||
clack.cancel("Plugin creation cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
description = value as string;
|
||||
}
|
||||
|
||||
if (!author) {
|
||||
const value = await clack.text({
|
||||
message: "Author:",
|
||||
placeholder: "Your Name",
|
||||
});
|
||||
if (clack.isCancel(value)) {
|
||||
clack.cancel("Plugin creation cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
author = (value as string) || undefined;
|
||||
}
|
||||
|
||||
if (!packageName) {
|
||||
const value = await clack.text({
|
||||
message: "Package name:",
|
||||
initialValue: buildDefaultPackageName(slot, name),
|
||||
validate: (input) => (!input ? "Package name is required" : undefined),
|
||||
});
|
||||
if (clack.isCancel(value)) {
|
||||
clack.cancel("Plugin creation cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
packageName = value as string;
|
||||
}
|
||||
}
|
||||
|
||||
if (!name) throw new Error("--name is required");
|
||||
if (!slot) throw new Error("--slot is required");
|
||||
if (!description) throw new Error("--description is required");
|
||||
|
||||
const targetDirectory = resolveScaffoldDirectory(name, directory);
|
||||
const createdDir = scaffoldPlugin({
|
||||
author,
|
||||
description,
|
||||
directory: targetDirectory,
|
||||
displayName: name,
|
||||
packageName: packageName ?? buildDefaultPackageName(slot, name),
|
||||
slot,
|
||||
});
|
||||
|
||||
console.log(chalk.green(`Created plugin scaffold at ${createdDir}`));
|
||||
console.log(chalk.dim(` Package: ${packageName ?? buildDefaultPackageName(slot, name)}`));
|
||||
console.log(chalk.dim(` Slot: ${slot}`));
|
||||
console.log(chalk.dim(" Next: npm install && npm run build"));
|
||||
},
|
||||
);
|
||||
|
||||
plugin
|
||||
.command("install")
|
||||
.description("Install a plugin into the current config")
|
||||
.argument("<reference>", "Marketplace id, package name, or local path")
|
||||
.option("--url <url>", "OpenClaw webhook URL (passed to setup when installing notifier-openclaw)")
|
||||
.option("--token <token>", "OpenClaw hooks auth token (passed to setup when installing notifier-openclaw)")
|
||||
.action(async (reference: string, opts: { url?: string; token?: string }) => {
|
||||
const configPath = findConfigFile();
|
||||
if (!configPath) {
|
||||
throw new Error("No agent-orchestrator.yaml found. Run 'ao start' first.");
|
||||
}
|
||||
|
||||
const config = loadConfig(configPath);
|
||||
const { descriptor, specifier, setupAction } = buildPluginDescriptor(reference, configPath);
|
||||
const verifiedDescriptor = await installOrVerifyPlugin(config, descriptor, specifier);
|
||||
const previousPlugins = config.plugins ?? [];
|
||||
const nextPlugins = upsertInstalledPlugin(previousPlugins, verifiedDescriptor);
|
||||
writePluginsConfig(configPath, nextPlugins);
|
||||
|
||||
console.log(chalk.green(formatInstallSummary(verifiedDescriptor, configPath)));
|
||||
|
||||
if (setupAction === "openclaw-setup") {
|
||||
// Always run setup — interactive in TTY, auto-detect in non-TTY.
|
||||
// Non-interactive setup will auto-detect OpenClaw on localhost if
|
||||
// no --url is given and the gateway is reachable.
|
||||
try {
|
||||
await runSetupAction({ url: opts.url, token: opts.token });
|
||||
} catch (err) {
|
||||
// Rollback: restore previous plugin list so a failed setup
|
||||
// doesn't leave a half-configured notifier enabled in config.
|
||||
writePluginsConfig(configPath, previousPlugins);
|
||||
console.log(chalk.dim("Rolled back plugin config after setup failure."));
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
plugin
|
||||
.command("update")
|
||||
.description("Update installer-managed plugins in the current config")
|
||||
.argument("[reference]", "Installed plugin name, marketplace id, or package name")
|
||||
.option("--all", "Update all installer-managed plugins in the current config")
|
||||
.action(async (reference: string | undefined, opts: { all?: boolean }) => {
|
||||
const configPath = findConfigFile();
|
||||
if (!configPath) {
|
||||
throw new Error("No agent-orchestrator.yaml found. Run 'ao start' first.");
|
||||
}
|
||||
|
||||
if (!reference && opts.all !== true) {
|
||||
throw new Error("Specify a plugin reference or pass --all.");
|
||||
}
|
||||
|
||||
let config = loadConfig(configPath);
|
||||
const managedPlugins = (config.plugins ?? []).filter((plugin) => plugin.source !== "local");
|
||||
const targets = opts.all
|
||||
? managedPlugins
|
||||
: managedPlugins.filter((plugin) => reference && matchesPluginReference(plugin, reference));
|
||||
|
||||
if (targets.length === 0) {
|
||||
throw new Error(
|
||||
opts.all
|
||||
? "No installer-managed plugins are configured in this project."
|
||||
: `Plugin ${reference} is not installed in this config`,
|
||||
);
|
||||
}
|
||||
|
||||
const failures: string[] = [];
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const target of targets) {
|
||||
try {
|
||||
const result = await updateManagedPlugin(configPath, config, target);
|
||||
if (result === "updated") {
|
||||
updated++;
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
config = loadConfig(configPath);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
failures.push(`${target.name}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (updated > 0 || skipped > 0) {
|
||||
console.log(
|
||||
chalk.dim(
|
||||
`Plugin update summary: ${updated} updated, ${skipped} already current, ${failures.length} failed.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
throw new Error(`Failed to update plugin(s):\n ${failures.join("\n ")}`);
|
||||
}
|
||||
});
|
||||
|
||||
plugin
|
||||
.command("uninstall")
|
||||
.description("Remove a plugin from the current config")
|
||||
.argument("<reference>", "Installed plugin name, package name, or local path")
|
||||
.action((reference: string) => {
|
||||
const configPath = findConfigFile();
|
||||
if (!configPath) {
|
||||
throw new Error("No agent-orchestrator.yaml found. Run 'ao start' first.");
|
||||
}
|
||||
|
||||
const config = loadConfig(configPath);
|
||||
const nextPlugins = (config.plugins ?? []).filter((plugin) => {
|
||||
return !matchesPluginReference(plugin, reference);
|
||||
});
|
||||
|
||||
if (nextPlugins.length === (config.plugins ?? []).length) {
|
||||
throw new Error(`Plugin ${reference} is not installed in this config`);
|
||||
}
|
||||
|
||||
writePluginsConfig(configPath, nextPlugins);
|
||||
console.log(chalk.green(`Removed ${reference} from ${configPath}`));
|
||||
});
|
||||
}
|
||||
|
|
@ -10,8 +10,8 @@ import {
|
|||
loadConfig,
|
||||
} from "@composio/ao-core";
|
||||
import { exec, tmux } from "../lib/shell.js";
|
||||
import { getAgentByName } from "../lib/plugins.js";
|
||||
import { getSessionManager } from "../lib/create-session-manager.js";
|
||||
import { getAgentByName, getAgentByNameFromRegistry } from "../lib/plugins.js";
|
||||
import { getPluginRegistry, getSessionManager } from "../lib/create-session-manager.js";
|
||||
|
||||
/**
|
||||
* Resolve session context: tmux target name and Agent plugin.
|
||||
|
|
@ -26,6 +26,7 @@ async function resolveSessionContext(sessionName: string): Promise<{
|
|||
}> {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const registry = await getPluginRegistry(config);
|
||||
const sm = await getSessionManager(config);
|
||||
const session = await sm.get(sessionName);
|
||||
if (session) {
|
||||
|
|
@ -37,7 +38,7 @@ async function resolveSessionContext(sessionName: string): Promise<{
|
|||
return {
|
||||
tmuxTarget,
|
||||
runtimeName,
|
||||
agent: getAgentByName(agentName),
|
||||
agent: getAgentByNameFromRegistry(registry, agentName),
|
||||
session,
|
||||
sessionManager: sm,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -51,17 +51,30 @@ export function registerSession(program: Command): void {
|
|||
continue;
|
||||
}
|
||||
|
||||
for (const s of projectSessions) {
|
||||
// Get live branch from worktree if available
|
||||
let branchStr = s.branch || "";
|
||||
if (s.workspacePath) {
|
||||
const liveBranch = await git(["branch", "--show-current"], s.workspacePath);
|
||||
if (liveBranch) branchStr = liveBranch;
|
||||
}
|
||||
// Pre-fetch all branches and activities in parallel
|
||||
const branches = await Promise.all(
|
||||
projectSessions.map(async (s) => {
|
||||
if (s.workspacePath) {
|
||||
return git(["branch", "--show-current"], s.workspacePath).catch(() => null);
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
);
|
||||
|
||||
// Get tmux activity age
|
||||
const tmuxTarget = s.runtimeHandle?.id ?? s.id;
|
||||
const activityTs = await getTmuxActivity(tmuxTarget);
|
||||
const activities = await Promise.all(
|
||||
projectSessions.map((s) => {
|
||||
const tmuxTarget = s.runtimeHandle?.id ?? s.id;
|
||||
return getTmuxActivity(tmuxTarget).catch(() => null);
|
||||
}),
|
||||
);
|
||||
|
||||
for (let i = 0; i < projectSessions.length; i++) {
|
||||
const s = projectSessions[i];
|
||||
const liveBranch = branches[i];
|
||||
const activityTs = activities[i];
|
||||
|
||||
// Priority: live branch from workspace > metadata branch > empty string
|
||||
const branchStr = (s.workspacePath && liveBranch) ? liveBranch : (s.branch || "");
|
||||
const age = activityTs ? formatAge(activityTs) : "-";
|
||||
|
||||
const parts = [chalk.green(s.id), chalk.dim(`(${age})`)];
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { findConfigFile } from "@composio/ao-core";
|
|||
import {
|
||||
probeGateway,
|
||||
validateToken,
|
||||
detectOpenClawInstallation,
|
||||
DEFAULT_OPENCLAW_URL,
|
||||
HOOKS_PATH,
|
||||
} from "../lib/openclaw-probe.js";
|
||||
|
|
@ -40,11 +41,27 @@ interface SetupOptions {
|
|||
url?: string;
|
||||
token?: string;
|
||||
nonInteractive?: boolean;
|
||||
routingPreset?: OpenClawRoutingPreset;
|
||||
}
|
||||
|
||||
type OpenClawRoutingPreset = "urgent-only" | "urgent-action" | "all";
|
||||
|
||||
interface ResolvedConfig {
|
||||
url: string;
|
||||
token: string;
|
||||
routingPreset: OpenClawRoutingPreset;
|
||||
}
|
||||
|
||||
const OPENCLAW_ROUTING_PRESETS = {
|
||||
"urgent-only": ["urgent"],
|
||||
"urgent-action": ["urgent", "action"],
|
||||
all: ["urgent", "action", "warning", "info"],
|
||||
} as const;
|
||||
|
||||
const NOTIFICATION_PRIORITIES = ["urgent", "action", "warning", "info"] as const;
|
||||
|
||||
function isRoutingPreset(value: string | undefined): value is OpenClawRoutingPreset {
|
||||
return value === "urgent-only" || value === "urgent-action" || value === "all";
|
||||
}
|
||||
|
||||
function normalizeOpenClawHooksUrl(url: string): string {
|
||||
|
|
@ -174,7 +191,26 @@ async function interactiveSetup(existingUrl?: string): Promise<ResolvedConfig> {
|
|||
}
|
||||
}
|
||||
|
||||
return { url, token: tokenValue };
|
||||
const routingPreset = await clack.select({
|
||||
message: "Which notifications should OpenClaw receive?",
|
||||
initialValue: "urgent-action",
|
||||
options: [
|
||||
{ value: "urgent-action", label: "Urgent + action (recommended)" },
|
||||
{ value: "urgent-only", label: "Urgent only" },
|
||||
{ value: "all", label: "All priorities" },
|
||||
],
|
||||
});
|
||||
|
||||
if (clack.isCancel(routingPreset)) {
|
||||
clack.cancel("Setup cancelled.");
|
||||
throw new SetupAbortedError("Setup cancelled.", 0);
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
token: tokenValue,
|
||||
routingPreset: routingPreset as OpenClawRoutingPreset,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -182,14 +218,22 @@ async function interactiveSetup(existingUrl?: string): Promise<ResolvedConfig> {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function nonInteractiveSetup(opts: SetupOptions): Promise<ResolvedConfig> {
|
||||
const rawUrl = opts.url ?? process.env["OPENCLAW_GATEWAY_URL"];
|
||||
let rawUrl = opts.url ?? process.env["OPENCLAW_GATEWAY_URL"];
|
||||
const token = opts.token ?? process.env["OPENCLAW_HOOKS_TOKEN"];
|
||||
|
||||
// Auto-detect OpenClaw if no URL was given explicitly
|
||||
if (!rawUrl) {
|
||||
throw new SetupAbortedError(
|
||||
"Error: --url is required in non-interactive mode.\n" +
|
||||
" Example: ao setup openclaw --url http://127.0.0.1:18789/hooks/agent --token YOUR_TOKEN --non-interactive",
|
||||
);
|
||||
const installation = await detectOpenClawInstallation();
|
||||
if (installation.state === "running") {
|
||||
rawUrl = `${installation.gatewayUrl}${HOOKS_PATH}`;
|
||||
console.log(chalk.dim(`Auto-detected OpenClaw gateway at ${installation.gatewayUrl}`));
|
||||
} else {
|
||||
throw new SetupAbortedError(
|
||||
"Error: OpenClaw gateway not reachable and no --url provided.\n" +
|
||||
" Start OpenClaw first, or pass --url explicitly:\n" +
|
||||
" Example: ao setup openclaw --url http://127.0.0.1:18789/hooks/agent --token YOUR_TOKEN --non-interactive",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let url = rawUrl;
|
||||
|
|
@ -209,7 +253,9 @@ async function nonInteractiveSetup(opts: SetupOptions): Promise<ResolvedConfig>
|
|||
// token yet. We write both configs first, then the user restarts the gateway.
|
||||
console.log(chalk.dim("Skipping pre-validation (token will be written to both configs)."));
|
||||
|
||||
return { url, token: resolvedToken };
|
||||
const routingPreset = isRoutingPreset(opts.routingPreset) ? opts.routingPreset : "urgent-action";
|
||||
|
||||
return { url, token: resolvedToken, routingPreset };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -252,12 +298,14 @@ function writeOpenClawConfig(
|
|||
rawConfig.defaults.notifiers.push("openclaw");
|
||||
}
|
||||
|
||||
// Add "openclaw" to notificationRouting so notifications actually fire
|
||||
// (AO prefers per-priority routing over defaults.notifiers)
|
||||
const defaultsWithoutOpenClaw = (rawConfig.defaults.notifiers as string[]).filter(
|
||||
(name) => name !== "openclaw",
|
||||
);
|
||||
|
||||
// AO routes notifications by priority. Preserve existing notifiers and only
|
||||
// adjust OpenClaw membership across the standard priority buckets.
|
||||
if (!rawConfig.notificationRouting) {
|
||||
// Seed from existing defaults.notifiers so we don't silently drop notifiers
|
||||
// (e.g. desktop) that the user already had for all priorities.
|
||||
const base = [...new Set([...(rawConfig.defaults.notifiers as string[]), "openclaw"])];
|
||||
const base = [...new Set(defaultsWithoutOpenClaw)];
|
||||
rawConfig.notificationRouting = {
|
||||
urgent: [...base],
|
||||
action: [...base],
|
||||
|
|
@ -265,14 +313,22 @@ function writeOpenClawConfig(
|
|||
info: [...base],
|
||||
};
|
||||
} else if (typeof rawConfig.notificationRouting === "object") {
|
||||
for (const priority of Object.keys(rawConfig.notificationRouting)) {
|
||||
const list = rawConfig.notificationRouting[priority];
|
||||
if (Array.isArray(list) && !list.includes("openclaw")) {
|
||||
list.push("openclaw");
|
||||
for (const priority of NOTIFICATION_PRIORITIES) {
|
||||
if (!Array.isArray(rawConfig.notificationRouting[priority])) {
|
||||
rawConfig.notificationRouting[priority] = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const selectedPriorities = new Set(OPENCLAW_ROUTING_PRESETS[resolved.routingPreset]);
|
||||
for (const priority of NOTIFICATION_PRIORITIES) {
|
||||
const list = rawConfig.notificationRouting[priority] as string[];
|
||||
rawConfig.notificationRouting[priority] = list.filter((name) => name !== "openclaw");
|
||||
if (selectedPriorities.has(priority)) {
|
||||
rawConfig.notificationRouting[priority].push("openclaw");
|
||||
}
|
||||
}
|
||||
|
||||
// Update the document tree from the modified plain object while preserving comments
|
||||
doc.setIn(["notifiers"], rawConfig.notifiers);
|
||||
doc.setIn(["defaults"], rawConfig.defaults);
|
||||
|
|
@ -438,7 +494,11 @@ export function registerSetup(program: Command): void {
|
|||
.description("Connect AO notifications to an OpenClaw gateway")
|
||||
.option("--url <url>", "OpenClaw webhook URL (e.g. http://127.0.0.1:18789/hooks/agent)")
|
||||
.option("--token <token>", "OpenClaw hooks auth token")
|
||||
.option("--non-interactive", "Skip prompts — requires --url (token auto-generated if not provided)")
|
||||
.option(
|
||||
"--routing-preset <preset>",
|
||||
"OpenClaw routing preset: urgent-only | urgent-action | all",
|
||||
)
|
||||
.option("--non-interactive", "Skip prompts — auto-detects OpenClaw if --url not provided (token auto-generated if not provided)")
|
||||
.action(async (opts: SetupOptions) => {
|
||||
try {
|
||||
await runSetupAction(opts);
|
||||
|
|
@ -452,7 +512,7 @@ export function registerSetup(program: Command): void {
|
|||
});
|
||||
}
|
||||
|
||||
async function runSetupAction(opts: SetupOptions): Promise<void> {
|
||||
export async function runSetupAction(opts: SetupOptions): Promise<void> {
|
||||
const nonInteractive = opts.nonInteractive || !process.stdin.isTTY;
|
||||
|
||||
// --- Find existing config ------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import {
|
|||
getSiblings,
|
||||
formatPlanTree,
|
||||
TERMINAL_STATUSES,
|
||||
expandHome,
|
||||
type OrchestratorConfig,
|
||||
type DecomposerConfig,
|
||||
DEFAULT_DECOMPOSER_CONFIG,
|
||||
|
|
@ -19,6 +18,7 @@ import { banner } from "../lib/format.js";
|
|||
import { getSessionManager } from "../lib/create-session-manager.js";
|
||||
import { ensureLifecycleWorker } from "../lib/lifecycle-service.js";
|
||||
import { preflight } from "../lib/preflight.js";
|
||||
import { findProjectForDirectory } from "../lib/project-resolution.js";
|
||||
|
||||
/**
|
||||
* Auto-detect the project ID from the config.
|
||||
|
|
@ -43,10 +43,9 @@ function autoDetectProject(config: OrchestratorConfig): string {
|
|||
|
||||
// Try matching cwd to a project path
|
||||
const cwd = resolve(process.cwd());
|
||||
for (const [id, project] of Object.entries(config.projects)) {
|
||||
if (project.path && resolve(expandHome(project.path)) === cwd) {
|
||||
return id;
|
||||
}
|
||||
const matchedProjectId = findProjectForDirectory(config.projects, cwd);
|
||||
if (matchedProjectId) {
|
||||
return matchedProjectId;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import {
|
|||
findWebDir,
|
||||
buildDashboardEnv,
|
||||
waitForPortAndOpen,
|
||||
openUrl,
|
||||
isPortAvailable,
|
||||
findFreePort,
|
||||
MAX_PORT_SCAN,
|
||||
|
|
@ -52,11 +53,16 @@ import { isHumanCaller } from "../lib/caller-context.js";
|
|||
import { detectEnvironment } from "../lib/detect-env.js";
|
||||
import { detectAgentRuntime, detectAvailableAgents, type DetectedAgent } from "../lib/detect-agent.js";
|
||||
import { detectDefaultBranch } from "../lib/git-utils.js";
|
||||
import { promptConfirm, promptSelect } from "../lib/prompts.js";
|
||||
import {
|
||||
detectProjectType,
|
||||
generateRulesFromTemplates,
|
||||
formatProjectTypeForDisplay,
|
||||
} from "../lib/project-detection.js";
|
||||
import { formatCommandError } from "../lib/cli-errors.js";
|
||||
import { detectOpenClawInstallation } from "../lib/openclaw-probe.js";
|
||||
import { applyOpenClawCredentials } from "../lib/credential-resolver.js";
|
||||
import { findProjectForDirectory } from "../lib/project-resolution.js";
|
||||
|
||||
const DEFAULT_PORT = 3000;
|
||||
const IS_TTY = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
||||
|
|
@ -101,32 +107,22 @@ async function resolveProject(
|
|||
// Multiple projects — try matching cwd to a project path
|
||||
// Note: loadConfig() already expands ~ in project paths via expandPaths()
|
||||
const currentDir = resolve(cwd());
|
||||
for (const [id, proj] of Object.entries(config.projects)) {
|
||||
if (resolve(proj.path) === currentDir) {
|
||||
return { projectId: id, project: proj };
|
||||
}
|
||||
const matchedProjectId = findProjectForDirectory(config.projects, currentDir);
|
||||
if (matchedProjectId) {
|
||||
return { projectId: matchedProjectId, project: config.projects[matchedProjectId] };
|
||||
}
|
||||
|
||||
// No match — prompt if interactive, otherwise error
|
||||
if (isHumanCaller()) {
|
||||
console.log(chalk.yellow(`\nMultiple projects configured. Which one would you like to ${action}?\n`));
|
||||
projectIds.forEach((id, i) => console.log(` ${i + 1}. ${config.projects[id].name ?? id} (${id})`));
|
||||
|
||||
const { createInterface } = await import("node:readline/promises");
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
try {
|
||||
const choice = await rl.question(`\n Choose project [1-${projectIds.length}]: `);
|
||||
|
||||
const idx = Number.parseInt(choice.trim(), 10);
|
||||
if (!Number.isFinite(idx) || idx < 1 || idx > projectIds.length) {
|
||||
throw new Error("Please enter a valid number from the list");
|
||||
}
|
||||
|
||||
const projectId = projectIds[idx - 1];
|
||||
return { projectId, project: config.projects[projectId] };
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
const projectId = await promptSelect(
|
||||
`Choose project to ${action}:`,
|
||||
projectIds.map((id) => ({
|
||||
value: id,
|
||||
label: config.projects[id].name ?? id,
|
||||
hint: id,
|
||||
})),
|
||||
);
|
||||
return { projectId, project: config.projects[projectId] };
|
||||
} else {
|
||||
throw new Error(
|
||||
`Multiple projects configured. Specify which one to ${action}:\n ${projectIds.map((id) => `ao ${action} ${id}`).join("\n ")}`,
|
||||
|
|
@ -170,24 +166,60 @@ function canPromptForInstall(): boolean {
|
|||
return isHumanCaller() && IS_TTY;
|
||||
}
|
||||
|
||||
function genericInstallHints(command: string): string[] {
|
||||
switch (command) {
|
||||
case "node":
|
||||
case "npm":
|
||||
return ["Install Node.js/npm from https://nodejs.org/"];
|
||||
case "pnpm":
|
||||
return [
|
||||
"corepack enable && corepack prepare pnpm@latest --activate",
|
||||
"npm install -g pnpm",
|
||||
];
|
||||
case "pipx":
|
||||
return [
|
||||
"python3 -m pip install --user pipx",
|
||||
"python3 -m pipx ensurepath",
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt the user to optionally switch orchestrator/worker agents at startup.
|
||||
* Shows only agents detected on the current system (reuses detectAvailableAgents).
|
||||
* Returns the chosen agents
|
||||
*/
|
||||
async function promptAgentSelection(): Promise<{
|
||||
orchestratorAgent: string;
|
||||
workerAgent: string
|
||||
} | null> {
|
||||
if (canPromptForInstall()) {
|
||||
const available = await detectAvailableAgents();
|
||||
if (available.length === 0) {
|
||||
console.log(chalk.yellow("No agent runtimes detected — using existing config."));
|
||||
return null;
|
||||
}
|
||||
|
||||
const agentOptions = available.map((a) => ({ value: a.name, label: a.displayName }));
|
||||
|
||||
const orchestratorAgent = await promptSelect("Orchestrator agent:", agentOptions);
|
||||
const workerAgent = await promptSelect("Worker agent:", agentOptions);
|
||||
|
||||
return { orchestratorAgent, workerAgent };
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function askYesNo(
|
||||
question: string,
|
||||
defaultYes = true,
|
||||
nonInteractiveDefault = defaultYes,
|
||||
): Promise<boolean> {
|
||||
if (!canPromptForInstall()) return nonInteractiveDefault;
|
||||
|
||||
const { createInterface } = await import("node:readline/promises");
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
try {
|
||||
const suffix = defaultYes ? "[Y/n]" : "[y/N]";
|
||||
const answer = await rl.question(`${question} ${suffix}: `);
|
||||
const normalized = answer.trim().toLowerCase();
|
||||
if (!normalized) return defaultYes;
|
||||
return normalized === "y" || normalized === "yes";
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
return await promptConfirm(question, defaultYes);
|
||||
}
|
||||
|
||||
function gitInstallAttempts(): InstallAttempt[] {
|
||||
|
|
@ -246,7 +278,16 @@ function ghInstallAttempts(): InstallAttempt[] {
|
|||
async function runInteractiveCommand(cmd: string, args: string[]): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(cmd, args, { stdio: "inherit" });
|
||||
child.once("error", reject);
|
||||
child.once("error", (err) => {
|
||||
reject(
|
||||
formatCommandError(err, {
|
||||
cmd,
|
||||
args,
|
||||
action: "run an interactive installer",
|
||||
installHints: genericInstallHints(cmd),
|
||||
}),
|
||||
);
|
||||
});
|
||||
child.once("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
|
|
@ -338,40 +379,37 @@ async function promptInstallAgentRuntime(available: DetectedAgent[]): Promise<De
|
|||
|
||||
console.log(chalk.yellow("⚠ No supported agent runtime detected."));
|
||||
console.log(chalk.dim(" You can install one now (recommended) or continue and install later.\n"));
|
||||
const skipOption = AGENT_INSTALL_OPTIONS.length + 1;
|
||||
AGENT_INSTALL_OPTIONS.forEach((option, i) => {
|
||||
const command = [option.cmd, ...option.args].join(" ");
|
||||
console.log(` ${i + 1}. ${option.label} (${option.id}) — ${command}`);
|
||||
});
|
||||
console.log(` ${skipOption}. Skip for now\n`);
|
||||
const choice = await promptSelect(
|
||||
"Choose runtime to install:",
|
||||
[
|
||||
...AGENT_INSTALL_OPTIONS.map((option) => ({
|
||||
value: option.id,
|
||||
label: option.label,
|
||||
hint: [option.cmd, ...option.args].join(" "),
|
||||
})),
|
||||
{ value: "skip", label: "Skip for now" },
|
||||
],
|
||||
);
|
||||
if (choice === "skip") {
|
||||
return available;
|
||||
}
|
||||
|
||||
const { createInterface } = await import("node:readline/promises");
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const selected = AGENT_INSTALL_OPTIONS.find((option) => option.id === choice);
|
||||
if (!selected) {
|
||||
return available;
|
||||
}
|
||||
|
||||
console.log(chalk.dim(` Installing ${selected.label}...`));
|
||||
try {
|
||||
const choice = await rl.question(` Choose runtime to install [1-${skipOption}]: `);
|
||||
const idx = Number.parseInt(choice.trim(), 10);
|
||||
if (!Number.isFinite(idx) || idx < 1 || idx > skipOption) {
|
||||
return available;
|
||||
await runInteractiveCommand(selected.cmd, selected.args);
|
||||
const refreshed = await detectAvailableAgents();
|
||||
if (refreshed.length > 0) {
|
||||
console.log(chalk.green(` ✓ ${selected.label} installed successfully`));
|
||||
}
|
||||
if (idx === skipOption) {
|
||||
return available;
|
||||
}
|
||||
|
||||
const selected = AGENT_INSTALL_OPTIONS[idx - 1];
|
||||
console.log(chalk.dim(` Installing ${selected.label}...`));
|
||||
try {
|
||||
await runInteractiveCommand(selected.cmd, selected.args);
|
||||
const refreshed = await detectAvailableAgents();
|
||||
if (refreshed.length > 0) {
|
||||
console.log(chalk.green(` ✓ ${selected.label} installed successfully`));
|
||||
}
|
||||
return refreshed;
|
||||
} catch {
|
||||
console.log(chalk.yellow(` ⚠ Could not install ${selected.label} automatically.`));
|
||||
return available;
|
||||
}
|
||||
} finally {
|
||||
rl.close();
|
||||
return refreshed;
|
||||
} catch {
|
||||
console.log(chalk.yellow(` ⚠ Could not install ${selected.label} automatically.`));
|
||||
return available;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -742,7 +780,15 @@ async function startDashboard(
|
|||
}
|
||||
|
||||
child.on("error", (err) => {
|
||||
console.error(chalk.red("Dashboard failed to start:"), err.message);
|
||||
const cmd = isDevMode ? "pnpm" : "node";
|
||||
const args = isDevMode ? ["run", "dev"] : [resolve(webDir, "dist-server", "start-all.js")];
|
||||
const formatted = formatCommandError(err, {
|
||||
cmd,
|
||||
args,
|
||||
action: "start the AO dashboard",
|
||||
installHints: genericInstallHints(cmd),
|
||||
});
|
||||
console.error(chalk.red("Dashboard failed to start:"), formatted.message);
|
||||
// Emit synthetic exit so callers listening on "exit" can clean up
|
||||
child.emit("exit", 1, null);
|
||||
});
|
||||
|
|
@ -806,6 +852,43 @@ async function ensureTmux(): Promise<void> {
|
|||
process.exit(1);
|
||||
}
|
||||
|
||||
async function warnAboutOpenClawStatus(config: OrchestratorConfig): Promise<void> {
|
||||
const openclawConfig = config.notifiers?.["openclaw"];
|
||||
const openclawConfigured =
|
||||
openclawConfig !== null && openclawConfig !== undefined &&
|
||||
typeof openclawConfig === "object" &&
|
||||
openclawConfig.plugin === "openclaw";
|
||||
const configuredUrl =
|
||||
openclawConfigured && typeof openclawConfig.url === "string" ? openclawConfig.url : undefined;
|
||||
|
||||
try {
|
||||
const installation = configuredUrl
|
||||
? await detectOpenClawInstallation(configuredUrl)
|
||||
: await detectOpenClawInstallation();
|
||||
|
||||
if (openclawConfigured) {
|
||||
if (installation.state !== "running") {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`⚠ OpenClaw is configured but the gateway is not reachable at ${installation.gatewayUrl}. Notifications may fail until it is running.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (installation.state === "running") {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`⚠ OpenClaw is running at ${installation.gatewayUrl} but AO is not configured to use it. Run \`ao setup openclaw\` if you want OpenClaw notifications.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// OpenClaw probing is advisory for `ao start`; never block startup on it.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared startup logic: launch dashboard + orchestrator session, print summary.
|
||||
* Used by both normal and URL-based start flows.
|
||||
|
|
@ -822,6 +905,21 @@ async function runStartup(
|
|||
if (runtime === "tmux") {
|
||||
await ensureTmux();
|
||||
}
|
||||
await warnAboutOpenClawStatus(config);
|
||||
|
||||
// Only inject OpenClaw credentials when the project actually uses OpenClaw.
|
||||
// This avoids exposing API keys to projects/plugins that don't need them.
|
||||
const openclawNotifier = config.notifiers?.["openclaw"];
|
||||
const hasOpenClaw =
|
||||
openclawNotifier !== null && openclawNotifier !== undefined &&
|
||||
typeof openclawNotifier === "object" && openclawNotifier.plugin === "openclaw";
|
||||
if (hasOpenClaw) {
|
||||
const injectedKeys = applyOpenClawCredentials();
|
||||
if (injectedKeys.length > 0) {
|
||||
const names = injectedKeys.map((k) => k.key).join(", ");
|
||||
console.log(chalk.dim(` Resolved from OpenClaw config: ${names}`));
|
||||
}
|
||||
}
|
||||
|
||||
const sessionId = `${project.sessionPrefix}-orchestrator`;
|
||||
const shouldStartLifecycle = opts?.dashboard !== false || opts?.orchestrator !== false;
|
||||
|
|
@ -1011,6 +1109,7 @@ export function registerStart(program: Command): void {
|
|||
.option("--no-dashboard", "Skip starting the dashboard server")
|
||||
.option("--no-orchestrator", "Skip starting the orchestrator agent")
|
||||
.option("--rebuild", "Clean and rebuild dashboard before starting")
|
||||
.option("--interactive", "Prompt to configure config settings")
|
||||
.action(
|
||||
async (
|
||||
projectArg?: string,
|
||||
|
|
@ -1018,6 +1117,7 @@ export function registerStart(program: Command): void {
|
|||
dashboard?: boolean;
|
||||
orchestrator?: boolean;
|
||||
rebuild?: boolean;
|
||||
interactive?: boolean;
|
||||
},
|
||||
) => {
|
||||
try {
|
||||
|
|
@ -1101,25 +1201,22 @@ export function registerStart(program: Command): void {
|
|||
console.log(` PID: ${running.pid} | Up since: ${running.startedAt}`);
|
||||
console.log(` Projects: ${running.projects.join(", ")}\n`);
|
||||
|
||||
// Interactive menu
|
||||
const { createInterface } = await import("node:readline/promises");
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
console.log(" 1. Open dashboard (keep current)");
|
||||
console.log(" 2. Start new orchestrator on this project");
|
||||
console.log(" 3. Override — restart everything");
|
||||
console.log(" 4. Quit\n");
|
||||
const choice = await rl.question(" Choice [1-4]: ");
|
||||
rl.close();
|
||||
const choice = await promptSelect(
|
||||
"AO is already running. What do you want to do?",
|
||||
[
|
||||
{ value: "open", label: "Open dashboard", hint: "Keep the current instance" },
|
||||
{ value: "new", label: "Start new orchestrator", hint: "Add a new session for this project" },
|
||||
{ value: "restart", label: "Restart everything", hint: "Stop the current instance first" },
|
||||
{ value: "quit", label: "Quit" },
|
||||
],
|
||||
"open",
|
||||
);
|
||||
|
||||
if (choice.trim() === "1") {
|
||||
if (choice === "open") {
|
||||
const url = `http://localhost:${running.port}`;
|
||||
const [cmd, args]: [string, string[]] =
|
||||
process.platform === "win32"
|
||||
? ["cmd.exe", ["/c", "start", "", url]]
|
||||
: [process.platform === "linux" ? "xdg-open" : "open", [url]];
|
||||
spawn(cmd, args, { stdio: "ignore" });
|
||||
openUrl(url);
|
||||
process.exit(0);
|
||||
} else if (choice.trim() === "2") {
|
||||
} else if (choice === "new") {
|
||||
// Generate unique orchestrator: same project, new session
|
||||
const rawYaml = readFileSync(config.configPath, "utf-8");
|
||||
const rawConfig = yamlParse(rawYaml);
|
||||
|
|
@ -1149,7 +1246,7 @@ export function registerStart(program: Command): void {
|
|||
projectId = newId;
|
||||
project = config.projects[newId];
|
||||
// Continue to startup below
|
||||
} else if (choice.trim() === "3") {
|
||||
} else if (choice === "restart") {
|
||||
try { process.kill(running.pid, "SIGTERM"); } catch { /* already dead */ }
|
||||
if (!(await waitForExit(running.pid, 5000))) {
|
||||
console.log(chalk.yellow(" Process didn't exit cleanly, sending SIGKILL..."));
|
||||
|
|
@ -1172,9 +1269,26 @@ export function registerStart(program: Command): void {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Agent selection prompt (Step 10)──
|
||||
const agentOverride = opts?.interactive ? await promptAgentSelection() : null;
|
||||
if (agentOverride) {
|
||||
const { orchestratorAgent, workerAgent } = agentOverride;
|
||||
|
||||
const rawYaml = readFileSync(config.configPath, "utf-8");
|
||||
const rawConfig = yamlParse(rawYaml);
|
||||
const proj = rawConfig.projects[projectId];
|
||||
proj.orchestrator = { ...(proj.orchestrator ?? {}), agent: orchestratorAgent };
|
||||
proj.worker = { ...(proj.worker ?? {}), agent: workerAgent };
|
||||
writeFileSync(config.configPath, yamlStringify(rawConfig, { indent: 2 }));
|
||||
console.log(chalk.dim(` ✓ Saved to ${config.configPath}\n`));
|
||||
|
||||
config = loadConfig(config.configPath);
|
||||
project = config.projects[projectId];
|
||||
}
|
||||
|
||||
const actualPort = await runStartup(config, projectId, project, opts);
|
||||
|
||||
// ── Register in running.json (Step 10) ──
|
||||
// ── Register in running.json (Step 11) ──
|
||||
await register({
|
||||
pid: process.pid,
|
||||
configPath: config.configPath,
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ import {
|
|||
reviewDecisionIcon,
|
||||
padCol,
|
||||
} from "../lib/format.js";
|
||||
import { getAgentByName, getSCM } from "../lib/plugins.js";
|
||||
import { getSessionManager } from "../lib/create-session-manager.js";
|
||||
import { getAgentByName, getAgentByNameFromRegistry, getSCMFromRegistry } from "../lib/plugins.js";
|
||||
import { getPluginRegistry, getSessionManager } from "../lib/create-session-manager.js";
|
||||
|
||||
interface SessionInfo {
|
||||
name: string;
|
||||
|
|
@ -44,6 +44,30 @@ interface SessionInfo {
|
|||
activity: ActivityState | null;
|
||||
}
|
||||
|
||||
interface StatusOptions {
|
||||
project?: string;
|
||||
json?: boolean;
|
||||
watch?: boolean;
|
||||
interval?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_WATCH_INTERVAL_SECONDS = 5;
|
||||
|
||||
function parseWatchIntervalSeconds(value?: string): number {
|
||||
if (!value) return DEFAULT_WATCH_INTERVAL_SECONDS;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (Number.isNaN(parsed) || parsed <= 0) {
|
||||
throw new Error("--interval must be a positive integer number of seconds.");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function maybeClearScreen(): void {
|
||||
if (process.stdout.isTTY) {
|
||||
process.stdout.write("\x1Bc");
|
||||
}
|
||||
}
|
||||
|
||||
async function gatherSessionInfo(
|
||||
session: Session,
|
||||
agent: Agent,
|
||||
|
|
@ -210,157 +234,217 @@ export function registerStatus(program: Command): void {
|
|||
.description("Show all sessions with branch, activity, PR, and CI status")
|
||||
.option("-p, --project <id>", "Filter by project ID")
|
||||
.option("--json", "Output as JSON")
|
||||
.action(async (opts: { project?: string; json?: boolean }) => {
|
||||
let config: ReturnType<typeof loadConfig>;
|
||||
try {
|
||||
config = loadConfig();
|
||||
} catch {
|
||||
console.log(chalk.yellow("No config found. Run `ao init` first."));
|
||||
console.log(chalk.dim("Falling back to session discovery...\n"));
|
||||
await showFallbackStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.project && !config.projects[opts.project]) {
|
||||
console.error(chalk.red(`Unknown project: ${opts.project}`));
|
||||
.option("-w, --watch", "Refresh the status view continuously")
|
||||
.option("--interval <seconds>", "Refresh interval in seconds (default: 5)")
|
||||
.action(async (opts: StatusOptions) => {
|
||||
if (opts.watch && opts.json) {
|
||||
console.error(chalk.red("--watch cannot be used with --json."));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Use session manager to list sessions (metadata-based, not tmux-based)
|
||||
const sm = await getSessionManager(config);
|
||||
const sessions = await sm.list(opts.project);
|
||||
|
||||
if (!opts.json) {
|
||||
console.log(banner("AGENT ORCHESTRATOR STATUS"));
|
||||
console.log();
|
||||
let watchIntervalSeconds = DEFAULT_WATCH_INTERVAL_SECONDS;
|
||||
if (opts.watch) {
|
||||
try {
|
||||
watchIntervalSeconds = parseWatchIntervalSeconds(opts.interval);
|
||||
} catch (err) {
|
||||
console.error(chalk.red(err instanceof Error ? err.message : String(err)));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Group sessions by project
|
||||
const byProject = new Map<string, Session[]>();
|
||||
for (const s of sessions) {
|
||||
const list = byProject.get(s.projectId) ?? [];
|
||||
list.push(s);
|
||||
byProject.set(s.projectId, list);
|
||||
}
|
||||
const renderStatus = async (refreshing = false): Promise<void> => {
|
||||
if (refreshing) {
|
||||
maybeClearScreen();
|
||||
}
|
||||
|
||||
// Show projects that have no sessions too (if not filtered)
|
||||
const projectIds = opts.project ? [opts.project] : Object.keys(config.projects);
|
||||
const jsonOutput: SessionInfo[] = [];
|
||||
let totalWorkers = 0;
|
||||
let totalOrchestrators = 0;
|
||||
let config: ReturnType<typeof loadConfig>;
|
||||
try {
|
||||
config = loadConfig();
|
||||
} catch {
|
||||
console.log(chalk.yellow("No config found. Run `ao init` first."));
|
||||
console.log(chalk.dim("Falling back to session discovery...\n"));
|
||||
await showFallbackStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const projectId of projectIds) {
|
||||
const projectConfig = config.projects[projectId];
|
||||
if (!projectConfig) continue;
|
||||
if (opts.project && !config.projects[opts.project]) {
|
||||
console.error(chalk.red(`Unknown project: ${opts.project}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const projectSessions = (byProject.get(projectId) ?? []).sort((a, b) =>
|
||||
a.id.localeCompare(b.id),
|
||||
);
|
||||
|
||||
// Resolve agent and SCM for this project
|
||||
const agentName = projectConfig.agent ?? config.defaults.agent;
|
||||
const agent = getAgentByName(agentName);
|
||||
const scm = getSCM(config, projectId);
|
||||
// Use session manager to list sessions (metadata-based, not tmux-based)
|
||||
const sm = await getSessionManager(config);
|
||||
const registry = await getPluginRegistry(config);
|
||||
const sessions = await sm.list(opts.project);
|
||||
|
||||
if (!opts.json) {
|
||||
console.log(header(projectConfig.name || projectId));
|
||||
}
|
||||
|
||||
if (projectSessions.length === 0) {
|
||||
if (!opts.json) {
|
||||
console.log(chalk.dim(" (no active sessions)"));
|
||||
console.log(banner("AGENT ORCHESTRATOR STATUS"));
|
||||
if (opts.watch) {
|
||||
console.log(
|
||||
chalk.dim(
|
||||
`Refreshing every ${watchIntervalSeconds}s. Press Ctrl+C to exit.`,
|
||||
),
|
||||
);
|
||||
console.log();
|
||||
} else {
|
||||
console.log();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Gather all session info in parallel
|
||||
const infoPromises = projectSessions.map((s) => gatherSessionInfo(s, agent, scm, config));
|
||||
const sessionInfos = await Promise.all(infoPromises);
|
||||
// Group sessions by project
|
||||
const byProject = new Map<string, Session[]>();
|
||||
for (const s of sessions) {
|
||||
const list = byProject.get(s.projectId) ?? [];
|
||||
list.push(s);
|
||||
byProject.set(s.projectId, list);
|
||||
}
|
||||
|
||||
const orchestrators = sessionInfos.filter((info) => info.role === "orchestrator");
|
||||
const workers = sessionInfos.filter((info) => info.role === "worker");
|
||||
// Show projects that have no sessions too (if not filtered)
|
||||
const projectIds = opts.project ? [opts.project] : Object.keys(config.projects);
|
||||
const jsonOutput: SessionInfo[] = [];
|
||||
let totalWorkers = 0;
|
||||
let totalOrchestrators = 0;
|
||||
|
||||
totalWorkers += workers.length;
|
||||
totalOrchestrators += orchestrators.length;
|
||||
for (const projectId of projectIds) {
|
||||
const projectConfig = config.projects[projectId];
|
||||
if (!projectConfig) continue;
|
||||
|
||||
for (const info of sessionInfos) {
|
||||
if (opts.json) {
|
||||
jsonOutput.push(info);
|
||||
const projectSessions = (byProject.get(projectId) ?? []).sort((a, b) =>
|
||||
a.id.localeCompare(b.id),
|
||||
);
|
||||
|
||||
// Resolve agent and SCM for this project via the shared registry
|
||||
const agentName = projectConfig.agent ?? config.defaults.agent;
|
||||
const agent = getAgentByNameFromRegistry(registry, agentName);
|
||||
const scm = getSCMFromRegistry(registry, config, projectId);
|
||||
|
||||
if (!opts.json) {
|
||||
console.log(header(projectConfig.name || projectId));
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (orchestrators.length > 0) {
|
||||
for (const info of orchestrators) {
|
||||
printOrchestratorRow(info);
|
||||
if (projectSessions.length === 0) {
|
||||
if (!opts.json) {
|
||||
console.log(chalk.dim(" (no active sessions)"));
|
||||
console.log();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (workers.length === 0) {
|
||||
console.log(chalk.dim(" (no active sessions)"));
|
||||
console.log();
|
||||
continue;
|
||||
}
|
||||
// Gather all session info in parallel
|
||||
const infoPromises = projectSessions.map((s) => gatherSessionInfo(s, agent, scm, config));
|
||||
const sessionInfos = await Promise.all(infoPromises);
|
||||
|
||||
printTableHeader();
|
||||
for (const info of workers) {
|
||||
printSessionRow(info);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
const orchestrators = sessionInfos.filter((info) => info.role === "orchestrator");
|
||||
const workers = sessionInfos.filter((info) => info.role === "worker");
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(jsonOutput, null, 2));
|
||||
} else {
|
||||
console.log(
|
||||
chalk.dim(
|
||||
` ${totalWorkers} active session${totalWorkers !== 1 ? "s" : ""} across ${projectIds.length} project${projectIds.length !== 1 ? "s" : ""}` +
|
||||
(totalOrchestrators > 0
|
||||
? ` · ${totalOrchestrators} orchestrator${totalOrchestrators !== 1 ? "s" : ""}`
|
||||
: ""),
|
||||
),
|
||||
);
|
||||
totalWorkers += workers.length;
|
||||
totalOrchestrators += orchestrators.length;
|
||||
|
||||
// Check for issues awaiting verification across all projects
|
||||
try {
|
||||
const { createPluginRegistry } = await import("@composio/ao-core");
|
||||
const registry = createPluginRegistry();
|
||||
await registry.loadFromConfig(config, (pkg: string) => import(pkg));
|
||||
|
||||
let unverifiedTotal = 0;
|
||||
for (const projectId of projectIds) {
|
||||
const project: ProjectConfig | undefined = config.projects[projectId];
|
||||
if (!project?.tracker) continue;
|
||||
const tracker = registry.get<Tracker>("tracker", project.tracker.plugin);
|
||||
if (!tracker?.listIssues) continue;
|
||||
try {
|
||||
const issues = await tracker.listIssues(
|
||||
{ state: "open", labels: ["merged-unverified"], limit: 20 },
|
||||
project,
|
||||
);
|
||||
unverifiedTotal += issues.length;
|
||||
} catch {
|
||||
// Tracker query failed — not critical
|
||||
for (const info of sessionInfos) {
|
||||
if (opts.json) {
|
||||
jsonOutput.push(info);
|
||||
}
|
||||
}
|
||||
|
||||
if (unverifiedTotal > 0) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
` ⚠ ${unverifiedTotal} issue${unverifiedTotal !== 1 ? "s" : ""} awaiting verification (use \`ao verify --list\` to see them)`,
|
||||
),
|
||||
);
|
||||
if (opts.json) {
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
// Plugin registry or tracker unavailable — skip silently
|
||||
|
||||
if (orchestrators.length > 0) {
|
||||
for (const info of orchestrators) {
|
||||
printOrchestratorRow(info);
|
||||
}
|
||||
}
|
||||
|
||||
if (workers.length === 0) {
|
||||
console.log(chalk.dim(" (no active sessions)"));
|
||||
console.log();
|
||||
continue;
|
||||
}
|
||||
|
||||
printTableHeader();
|
||||
for (const info of workers) {
|
||||
printSessionRow(info);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
console.log();
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(jsonOutput, null, 2));
|
||||
} else {
|
||||
console.log(
|
||||
chalk.dim(
|
||||
` ${totalWorkers} active session${totalWorkers !== 1 ? "s" : ""} across ${projectIds.length} project${projectIds.length !== 1 ? "s" : ""}` +
|
||||
(totalOrchestrators > 0
|
||||
? ` · ${totalOrchestrators} orchestrator${totalOrchestrators !== 1 ? "s" : ""}`
|
||||
: ""),
|
||||
),
|
||||
);
|
||||
|
||||
// Check for issues awaiting verification across all projects
|
||||
try {
|
||||
let unverifiedTotal = 0;
|
||||
for (const projectId of projectIds) {
|
||||
const project: ProjectConfig | undefined = config.projects[projectId];
|
||||
if (!project?.tracker) continue;
|
||||
const tracker = registry.get<Tracker>("tracker", project.tracker.plugin);
|
||||
if (!tracker?.listIssues) continue;
|
||||
try {
|
||||
const issues = await tracker.listIssues(
|
||||
{ state: "open", labels: ["merged-unverified"], limit: 20 },
|
||||
project,
|
||||
);
|
||||
unverifiedTotal += issues.length;
|
||||
} catch {
|
||||
// Tracker query failed — not critical
|
||||
}
|
||||
}
|
||||
|
||||
if (unverifiedTotal > 0) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
` ⚠ ${unverifiedTotal} issue${unverifiedTotal !== 1 ? "s" : ""} awaiting verification (use \`ao verify --list\` to see them)`,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Plugin registry or tracker unavailable — skip silently
|
||||
}
|
||||
|
||||
console.log();
|
||||
}
|
||||
};
|
||||
|
||||
await renderStatus();
|
||||
|
||||
if (!opts.watch) {
|
||||
return;
|
||||
}
|
||||
|
||||
let rendering = false;
|
||||
const watchTimer = setInterval(() => {
|
||||
if (rendering) return;
|
||||
rendering = true;
|
||||
void renderStatus(true)
|
||||
.catch((err) => {
|
||||
console.error(
|
||||
chalk.red(
|
||||
`Watch refresh failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
),
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
rendering = false;
|
||||
});
|
||||
}, watchIntervalSeconds * 1000);
|
||||
|
||||
const shutdown = (): void => {
|
||||
clearInterval(watchTimer);
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.once("SIGINT", shutdown);
|
||||
process.once("SIGTERM", shutdown);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -380,13 +464,13 @@ async function showFallbackStatus(): Promise<void> {
|
|||
// Use claude-code as default agent for fallback introspection
|
||||
const agent = getAgentByName("claude-code");
|
||||
|
||||
for (const session of allTmux.sort()) {
|
||||
const activityTs = await getTmuxActivity(session);
|
||||
const lastActivity = activityTs ? formatAge(activityTs) : "-";
|
||||
console.log(` ${chalk.green(session)} ${chalk.dim(`(${lastActivity})`)}`);
|
||||
const sortedSessions = allTmux.sort();
|
||||
|
||||
// Pre-fetch activity and introspection in parallel
|
||||
const details = await Promise.all(
|
||||
sortedSessions.map(async (session) => {
|
||||
const activityTsPromise = getTmuxActivity(session).catch(() => null);
|
||||
|
||||
// Try introspection even without config
|
||||
try {
|
||||
const sessionObj: Session = {
|
||||
id: session,
|
||||
projectId: "",
|
||||
|
|
@ -402,12 +486,27 @@ async function showFallbackStatus(): Promise<void> {
|
|||
lastActivityAt: new Date(),
|
||||
metadata: {},
|
||||
};
|
||||
const introspection = await agent.getSessionInfo(sessionObj);
|
||||
if (introspection?.summary) {
|
||||
console.log(` ${chalk.dim("Claude:")} ${introspection.summary.slice(0, 65)}`);
|
||||
}
|
||||
} catch {
|
||||
// Not critical
|
||||
|
||||
const introspectionPromise = agent.getSessionInfo(sessionObj).catch(() => null);
|
||||
|
||||
const [activityTs, introspection] = await Promise.all([
|
||||
activityTsPromise,
|
||||
introspectionPromise,
|
||||
]);
|
||||
|
||||
return { activityTs, introspection };
|
||||
}),
|
||||
);
|
||||
|
||||
for (let i = 0; i < sortedSessions.length; i++) {
|
||||
const session = sortedSessions[i];
|
||||
const { activityTs, introspection } = details[i];
|
||||
|
||||
const lastActivity = activityTs ? formatAge(activityTs) : "-";
|
||||
console.log(` ${chalk.green(session)} ${chalk.dim(`(${lastActivity})`)}`);
|
||||
|
||||
if (introspection?.summary) {
|
||||
console.log(` ${chalk.dim("Claude:")} ${introspection.summary.slice(0, 65)}`);
|
||||
}
|
||||
}
|
||||
console.log();
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
type PluginRegistry,
|
||||
loadConfig,
|
||||
} from "@composio/ao-core";
|
||||
import { importPluginModuleFromSource } from "../lib/plugin-store.js";
|
||||
|
||||
/**
|
||||
* Resolve the target project from config.
|
||||
|
|
@ -57,7 +58,7 @@ async function getTracker(
|
|||
// directly, so we replicate the same pattern from create-session-manager.
|
||||
const { createPluginRegistry } = await import("@composio/ao-core");
|
||||
const registry = createPluginRegistry();
|
||||
await registry.loadFromConfig(config, (pkg: string) => import(pkg));
|
||||
await registry.loadFromConfig(config, importPluginModuleFromSource);
|
||||
|
||||
const tracker = registry.get<Tracker>("tracker", project.tracker.plugin);
|
||||
if (!tracker) {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { registerVerify } from "./commands/verify.js";
|
|||
import { registerDoctor } from "./commands/doctor.js";
|
||||
import { registerUpdate } from "./commands/update.js";
|
||||
import { registerSetup } from "./commands/setup.js";
|
||||
import { registerPlugin } from "./commands/plugin.js";
|
||||
import { getConfigInstruction } from "./lib/config-instruction.js";
|
||||
|
||||
const program = new Command();
|
||||
|
|
@ -40,6 +41,7 @@ registerVerify(program);
|
|||
registerDoctor(program);
|
||||
registerUpdate(program);
|
||||
registerSetup(program);
|
||||
registerPlugin(program);
|
||||
|
||||
program
|
||||
.command("config-help")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
export interface CommandErrorOptions {
|
||||
cmd: string;
|
||||
args?: string[];
|
||||
action?: string;
|
||||
installHints?: string[];
|
||||
}
|
||||
|
||||
function formatHints(hints: string[] | undefined): string {
|
||||
if (!hints || hints.length === 0) return "";
|
||||
return `\nInstall hint${hints.length === 1 ? "" : "s"}:\n ${hints.join("\n ")}`;
|
||||
}
|
||||
|
||||
export function formatCommandError(err: unknown, options: CommandErrorOptions): Error {
|
||||
const command = [options.cmd, ...(options.args ?? [])].join(" ").trim();
|
||||
const action = options.action ?? "run the command";
|
||||
const code =
|
||||
err && typeof err === "object" && "code" in err ? String((err as { code?: unknown }).code) : undefined;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
||||
if (code === "ENOENT") {
|
||||
return new Error(
|
||||
`${options.cmd} is not installed or not on PATH, so AO could not ${action}.${formatHints(options.installHints)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (code === "EACCES") {
|
||||
return new Error(
|
||||
`${options.cmd} exists but AO could not execute it due to a permission error while trying to ${action}. ` +
|
||||
`Check that the binary is executable and accessible to this user.${formatHints(options.installHints)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return new Error(`Failed to ${action}: ${command}${message ? `\n${message}` : ""}`);
|
||||
}
|
||||
|
|
@ -8,11 +8,13 @@ export function getConfigInstruction(): string {
|
|||
# File: agent-orchestrator.yaml
|
||||
|
||||
# ── Top-level settings ──────────────────────────────────────────────
|
||||
# Runtime data paths are auto-derived from the config location under:
|
||||
# ~/.agent-orchestrator/{hash}-{projectId}/
|
||||
|
||||
port: 3000 # Dashboard port (default: 3000, auto-finds free port if busy)
|
||||
terminalPort: 3001 # Terminal WebSocket port (default: 3001)
|
||||
directTerminalPort: 3003 # Direct terminal WebSocket port (default: 3003)
|
||||
readyThresholdMs: 300000 # Ms before "ready" session becomes "idle" (default: 5 min)
|
||||
port: 3000 # Dashboard port
|
||||
terminalPort: 14800 # Optional terminal WebSocket port override
|
||||
directTerminalPort: 14801 # Optional direct terminal WebSocket port override
|
||||
readyThresholdMs: 300000 # Ms before "ready" becomes "idle" (default: 5 min)
|
||||
|
||||
# ── Default plugins ─────────────────────────────────────────────────
|
||||
# These apply to all projects unless overridden per-project.
|
||||
|
|
@ -21,12 +23,26 @@ defaults:
|
|||
runtime: tmux # tmux | process
|
||||
agent: claude-code # claude-code | aider | codex | opencode
|
||||
workspace: worktree # worktree | clone
|
||||
notifiers: # List of active notifier plugins
|
||||
notifiers:
|
||||
- desktop # desktop | discord | slack | webhook | composio | openclaw
|
||||
orchestrator:
|
||||
agent: claude-code # Agent for orchestrator sessions (optional override)
|
||||
agent: claude-code # Optional override for orchestrator sessions
|
||||
worker:
|
||||
agent: claude-code # Agent for worker sessions (optional override)
|
||||
agent: claude-code # Optional override for worker sessions
|
||||
|
||||
# ── Installer-managed marketplace plugins (optional) ───────────────
|
||||
# External plugins are declared here. Built-ins do not need entries.
|
||||
|
||||
plugins:
|
||||
- name: owasp-auditor
|
||||
source: registry # registry | npm | local
|
||||
package: "@ao-plugins/owasp-auditor"
|
||||
version: "^0.1.0"
|
||||
enabled: true
|
||||
- name: local-dev-plugin
|
||||
source: local
|
||||
path: ./plugins/local-dev-plugin
|
||||
enabled: true
|
||||
|
||||
# ── Projects ────────────────────────────────────────────────────────
|
||||
# Each key is a project ID (typically the repo directory name).
|
||||
|
|
@ -46,7 +62,7 @@ projects:
|
|||
|
||||
# ── Agent configuration (optional) ────────────────────────────
|
||||
agentConfig:
|
||||
permissions: auto # auto | manual — agent permission mode
|
||||
permissions: permissionless # permissionless | default | auto-edit | suggest
|
||||
model: claude-sonnet-4-20250514
|
||||
|
||||
# ── Agent rules (optional) ────────────────────────────────────
|
||||
|
|
@ -88,8 +104,9 @@ projects:
|
|||
|
||||
# ── Per-project reaction overrides (optional) ─────────────────
|
||||
# reactions:
|
||||
# ci-failure:
|
||||
# enabled: true
|
||||
# ci-failed:
|
||||
# auto: true
|
||||
# retries: 2
|
||||
|
||||
# ── Notification channels (optional) ────────────────────────────────
|
||||
|
||||
|
|
@ -112,13 +129,15 @@ notifiers:
|
|||
# Route notifications by priority level.
|
||||
|
||||
notificationRouting:
|
||||
critical:
|
||||
urgent:
|
||||
- desktop
|
||||
- slack
|
||||
high:
|
||||
- desktop
|
||||
low:
|
||||
action:
|
||||
- desktop
|
||||
warning:
|
||||
- slack
|
||||
info:
|
||||
- composio
|
||||
|
||||
# ── Available plugins ───────────────────────────────────────────────
|
||||
#
|
||||
|
|
|
|||
|
|
@ -16,25 +16,37 @@ import {
|
|||
type PluginRegistry,
|
||||
type LifecycleManager,
|
||||
} from "@composio/ao-core";
|
||||
import { importPluginModuleFromSource } from "./plugin-store.js";
|
||||
|
||||
let registryPromise: Promise<PluginRegistry> | null = null;
|
||||
const registryPromises = new Map<string, Promise<PluginRegistry>>();
|
||||
|
||||
function getRegistryCacheKey(config: OrchestratorConfig): string {
|
||||
return config.configPath || "__default__";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the plugin registry.
|
||||
* Caches the Promise (not the resolved value) so concurrent callers
|
||||
* await the same initialization rather than racing.
|
||||
*/
|
||||
async function getRegistry(config: OrchestratorConfig): Promise<PluginRegistry> {
|
||||
export async function getPluginRegistry(config: OrchestratorConfig): Promise<PluginRegistry> {
|
||||
const cacheKey = getRegistryCacheKey(config);
|
||||
let registryPromise = registryPromises.get(cacheKey);
|
||||
|
||||
if (!registryPromise) {
|
||||
registryPromise = (async () => {
|
||||
const registry = createPluginRegistry();
|
||||
// Pass CLI's import context so pnpm strict resolution can find plugin packages.
|
||||
// Core can't resolve @composio/ao-plugin-* from its own module context because
|
||||
// they aren't in core's dependencies. The CLI has them as workspace deps.
|
||||
await registry.loadFromConfig(config, (pkg: string) => import(pkg));
|
||||
// Prefer the AO-managed plugin store when a package is installed there,
|
||||
// but still fall back to the CLI/workspace dependency tree for built-ins.
|
||||
await registry.loadFromConfig(config, importPluginModuleFromSource);
|
||||
return registry;
|
||||
})();
|
||||
})().catch((err) => {
|
||||
registryPromises.delete(cacheKey);
|
||||
throw err;
|
||||
});
|
||||
registryPromises.set(cacheKey, registryPromise);
|
||||
}
|
||||
|
||||
return registryPromise;
|
||||
}
|
||||
|
||||
|
|
@ -45,7 +57,7 @@ async function getRegistry(config: OrchestratorConfig): Promise<PluginRegistry>
|
|||
export async function getSessionManager(
|
||||
config: OrchestratorConfig,
|
||||
): Promise<OpenCodeSessionManager> {
|
||||
const registry = await getRegistry(config);
|
||||
const registry = await getPluginRegistry(config);
|
||||
return createSessionManager({ config, registry });
|
||||
}
|
||||
|
||||
|
|
@ -57,7 +69,7 @@ export async function getLifecycleManager(
|
|||
config: OrchestratorConfig,
|
||||
projectId?: string,
|
||||
): Promise<LifecycleManager> {
|
||||
const registry = await getRegistry(config);
|
||||
const registry = await getPluginRegistry(config);
|
||||
const sessionManager = createSessionManager({ config, registry });
|
||||
return createLifecycleManager({ config, registry, sessionManager, projectId });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
/**
|
||||
* Shared credential resolver — resolves API keys from multiple sources.
|
||||
*
|
||||
* Resolution order per key:
|
||||
* 1. Environment variable (already set in process.env)
|
||||
* 2. OpenClaw config (~/.openclaw/openclaw.json → keys section)
|
||||
*
|
||||
* Call `applyOpenClawCredentials()` early in CLI startup so spawned agent
|
||||
* sessions inherit the resolved values via the parent process environment.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
/** Keys that AO agents commonly need and OpenClaw may already store. */
|
||||
const RESOLVABLE_KEYS = [
|
||||
"ANTHROPIC_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
"GITHUB_TOKEN",
|
||||
"GOOGLE_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
] as const;
|
||||
|
||||
type ResolvableKey = (typeof RESOLVABLE_KEYS)[number];
|
||||
|
||||
interface ResolvedCredential {
|
||||
key: ResolvableKey;
|
||||
value: string;
|
||||
source: "env" | "openclaw";
|
||||
}
|
||||
|
||||
interface AppliedCredential {
|
||||
key: ResolvableKey;
|
||||
source: "openclaw";
|
||||
}
|
||||
|
||||
function readOpenClawKeys(): Record<string, string> {
|
||||
const keys: Record<string, string> = {};
|
||||
|
||||
const configPath = join(homedir(), ".openclaw", "openclaw.json");
|
||||
if (!existsSync(configPath)) return keys;
|
||||
|
||||
try {
|
||||
const raw = readFileSync(configPath, "utf-8");
|
||||
const config = JSON.parse(raw) as Record<string, unknown>;
|
||||
|
||||
// OpenClaw stores keys in a top-level "keys" object:
|
||||
// { "keys": { "ANTHROPIC_API_KEY": "sk-ant-...", ... } }
|
||||
const keysSection = config.keys;
|
||||
if (keysSection && typeof keysSection === "object") {
|
||||
for (const [k, v] of Object.entries(keysSection as Record<string, unknown>)) {
|
||||
if (typeof v === "string" && v.length > 0) {
|
||||
keys[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check top-level env / environment block (some OpenClaw setups):
|
||||
// { "env": { "ANTHROPIC_API_KEY": "sk-ant-..." } }
|
||||
const envSection = config.env ?? config.environment;
|
||||
if (envSection && typeof envSection === "object") {
|
||||
for (const [k, v] of Object.entries(envSection as Record<string, unknown>)) {
|
||||
if (typeof v === "string" && v.length > 0 && !(k in keys)) {
|
||||
keys[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Malformed config — silently skip.
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a single credential from all available sources.
|
||||
* Returns the value and where it came from, or null if not found anywhere.
|
||||
*/
|
||||
export function resolveCredential(key: ResolvableKey): ResolvedCredential | null {
|
||||
const envValue = process.env[key];
|
||||
if (envValue && envValue.length > 0) {
|
||||
return { key, value: envValue, source: "env" };
|
||||
}
|
||||
|
||||
const openclawKeys = readOpenClawKeys();
|
||||
const openclawValue = openclawKeys[key];
|
||||
if (openclawValue) {
|
||||
return { key, value: openclawValue, source: "openclaw" };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate `process.env` with API keys found in OpenClaw config that are
|
||||
* not already set in the environment. This makes them available to all
|
||||
* spawned agent sessions (tmux inherits the parent env).
|
||||
*
|
||||
* Returns the list of keys that were injected from OpenClaw.
|
||||
*/
|
||||
export function applyOpenClawCredentials(): AppliedCredential[] {
|
||||
const openclawKeys = readOpenClawKeys();
|
||||
const applied: AppliedCredential[] = [];
|
||||
|
||||
for (const key of RESOLVABLE_KEYS) {
|
||||
if (process.env[key] && process.env[key]!.length > 0) continue;
|
||||
|
||||
const value = openclawKeys[key];
|
||||
if (value) {
|
||||
process.env[key] = value;
|
||||
applied.push({ key, source: "openclaw" });
|
||||
}
|
||||
}
|
||||
|
||||
return applied;
|
||||
}
|
||||
|
||||
export { RESOLVABLE_KEYS, type ResolvableKey, type ResolvedCredential, type AppliedCredential };
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
import type { PluginModule } from "@composio/ao-core";
|
||||
import { isHumanCaller } from "./caller-context.js";
|
||||
import { promptSelect } from "./prompts.js";
|
||||
|
||||
export interface DetectedAgent {
|
||||
name: string;
|
||||
|
|
@ -71,27 +72,12 @@ export async function detectAgentRuntime(preDetected?: DetectedAgent[]): Promise
|
|||
return available.find((a) => a.name === "claude-code")?.name ?? available[0].name;
|
||||
}
|
||||
|
||||
// Interactive: prompt human to pick using node:readline (no external deps)
|
||||
const { createInterface } = await import("node:readline/promises");
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
|
||||
try {
|
||||
console.log("\n Multiple agent runtimes detected:\n");
|
||||
available.forEach((a, i) => {
|
||||
console.log(` ${i + 1}. ${a.displayName} (${a.name})`);
|
||||
});
|
||||
console.log();
|
||||
|
||||
const answer = await rl.question(` Choose default agent [1-${available.length}]: `);
|
||||
|
||||
const idx = parseInt(answer.trim(), 10) - 1;
|
||||
if (idx >= 0 && idx < available.length) {
|
||||
return available[idx].name;
|
||||
}
|
||||
|
||||
// Invalid input — default to first
|
||||
return available[0].name;
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
return await promptSelect(
|
||||
"Choose default agent runtime:",
|
||||
available.map((agent) => ({
|
||||
value: agent.name,
|
||||
label: agent.displayName,
|
||||
hint: agent.name,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,11 @@
|
|||
* Probes the OpenClaw gateway for reachability and validates auth tokens.
|
||||
*/
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
export interface ProbeResult {
|
||||
reachable: boolean;
|
||||
httpStatus?: number;
|
||||
|
|
@ -16,10 +21,37 @@ export interface TokenValidation {
|
|||
error?: string;
|
||||
}
|
||||
|
||||
export interface OpenClawInstallation {
|
||||
state: "missing" | "installed-but-stopped" | "running";
|
||||
gatewayUrl: string;
|
||||
binaryPath?: string;
|
||||
configPath?: string;
|
||||
probe: ProbeResult;
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 3_000;
|
||||
const DEFAULT_OPENCLAW_URL = "http://127.0.0.1:18789";
|
||||
const HOOKS_PATH = "/hooks/agent";
|
||||
|
||||
function normalizeGatewayBaseUrl(url: string): string {
|
||||
const normalized = url.trim().replace(/\/+$/, "");
|
||||
return normalized.endsWith(HOOKS_PATH)
|
||||
? normalized.slice(0, -HOOKS_PATH.length)
|
||||
: normalized;
|
||||
}
|
||||
|
||||
function resolveOpenClawBinaryPath(): string | undefined {
|
||||
const shell = process.env["SHELL"] ?? "/bin/sh";
|
||||
if (!shell.startsWith("/")) return undefined;
|
||||
const result = spawnSync(shell, ["-lc", "command -v openclaw"], {
|
||||
encoding: "utf-8",
|
||||
});
|
||||
|
||||
if (result.status !== 0) return undefined;
|
||||
const path = result.stdout.trim();
|
||||
return path || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe the OpenClaw gateway for reachability.
|
||||
* Sends a GET to the base URL (not /hooks/agent) with a short timeout.
|
||||
|
|
@ -28,7 +60,7 @@ export async function probeGateway(
|
|||
baseUrl: string = DEFAULT_OPENCLAW_URL,
|
||||
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
||||
): Promise<ProbeResult> {
|
||||
const url = baseUrl.replace(/\/+$/, "");
|
||||
const url = normalizeGatewayBaseUrl(baseUrl);
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
|
|
@ -54,6 +86,29 @@ export async function probeGateway(
|
|||
}
|
||||
}
|
||||
|
||||
export async function detectOpenClawInstallation(
|
||||
baseUrl: string = DEFAULT_OPENCLAW_URL,
|
||||
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
||||
): Promise<OpenClawInstallation> {
|
||||
const gatewayUrl = normalizeGatewayBaseUrl(baseUrl);
|
||||
const binaryPath = resolveOpenClawBinaryPath();
|
||||
const configPath = join(homedir(), ".openclaw", "openclaw.json");
|
||||
const hasConfig = existsSync(configPath);
|
||||
const probe = await probeGateway(gatewayUrl, timeoutMs);
|
||||
|
||||
return {
|
||||
state: probe.reachable
|
||||
? "running"
|
||||
: binaryPath || hasConfig
|
||||
? "installed-but-stopped"
|
||||
: "missing",
|
||||
gatewayUrl,
|
||||
binaryPath,
|
||||
configPath: hasConfig ? configPath : undefined,
|
||||
probe,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an auth token against the OpenClaw hooks endpoint.
|
||||
* Sends a lightweight test payload and checks the response.
|
||||
|
|
@ -125,4 +180,4 @@ export async function validateToken(
|
|||
}
|
||||
}
|
||||
|
||||
export { DEFAULT_OPENCLAW_URL, HOOKS_PATH };
|
||||
export { DEFAULT_OPENCLAW_URL, HOOKS_PATH, normalizeGatewayBaseUrl };
|
||||
|
|
|
|||
|
|
@ -0,0 +1,230 @@
|
|||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, isAbsolute, join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { resolveLocalPluginEntrypoint, type InstalledPluginConfig, type PluginSlot } from "@composio/ao-core";
|
||||
|
||||
const registryData = createRequire(import.meta.url)("../assets/plugin-registry.json") as unknown[];
|
||||
|
||||
export interface MarketplacePluginEntry {
|
||||
id: string;
|
||||
package: string;
|
||||
slot: PluginSlot;
|
||||
description: string;
|
||||
source: "registry";
|
||||
setupAction?: "openclaw-setup";
|
||||
latestVersion?: string;
|
||||
}
|
||||
|
||||
export const BUNDLED_MARKETPLACE_PLUGIN_CATALOG = registryData as MarketplacePluginEntry[];
|
||||
export const DEFAULT_REMOTE_MARKETPLACE_REGISTRY_URL =
|
||||
"https://raw.githubusercontent.com/ComposioHQ/agent-orchestrator/main/packages/cli/src/assets/plugin-registry.json";
|
||||
|
||||
const MARKETPLACE_CACHE_FILE = "plugin-registry.json";
|
||||
const MARKETPLACE_FETCH_TIMEOUT_MS = 30_000;
|
||||
|
||||
function isPluginSlot(value: unknown): value is PluginSlot {
|
||||
return (
|
||||
value === "runtime" ||
|
||||
value === "agent" ||
|
||||
value === "workspace" ||
|
||||
value === "tracker" ||
|
||||
value === "scm" ||
|
||||
value === "notifier" ||
|
||||
value === "terminal"
|
||||
);
|
||||
}
|
||||
|
||||
function isMarketplacePluginEntry(value: unknown): value is MarketplacePluginEntry {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const candidate = value as Partial<MarketplacePluginEntry>;
|
||||
return (
|
||||
typeof candidate.id === "string" &&
|
||||
typeof candidate.package === "string" &&
|
||||
isPluginSlot(candidate.slot) &&
|
||||
typeof candidate.description === "string" &&
|
||||
candidate.source === "registry"
|
||||
);
|
||||
}
|
||||
|
||||
function mergeMarketplaceCatalogs(
|
||||
primary: MarketplacePluginEntry[],
|
||||
fallback: MarketplacePluginEntry[],
|
||||
): MarketplacePluginEntry[] {
|
||||
const merged = new Map<string, MarketplacePluginEntry>();
|
||||
for (const entry of fallback) {
|
||||
merged.set(entry.id, entry);
|
||||
}
|
||||
for (const entry of primary) {
|
||||
merged.set(entry.id, entry);
|
||||
}
|
||||
return [...merged.values()];
|
||||
}
|
||||
|
||||
export function getMarketplaceRegistryCachePath(): string {
|
||||
const override = process.env["AO_PLUGIN_REGISTRY_CACHE_PATH"];
|
||||
if (override && override.trim().length > 0) {
|
||||
return override;
|
||||
}
|
||||
return join(homedir(), ".agent-orchestrator", MARKETPLACE_CACHE_FILE);
|
||||
}
|
||||
|
||||
function validateMarketplaceCatalog(payload: unknown, sourceLabel: string): MarketplacePluginEntry[] {
|
||||
if (!Array.isArray(payload)) {
|
||||
throw new Error(`${sourceLabel} did not return a registry array.`);
|
||||
}
|
||||
|
||||
const entries = payload.filter(isMarketplacePluginEntry);
|
||||
if (entries.length !== payload.length) {
|
||||
throw new Error(`${sourceLabel} returned invalid marketplace registry entries.`);
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function readMarketplaceCatalogFile(filePath: string): MarketplacePluginEntry[] | null {
|
||||
if (!existsSync(filePath)) return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(filePath, "utf-8")) as unknown;
|
||||
return validateMarketplaceCatalog(parsed, filePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeMarketplaceCatalogCache(entries: MarketplacePluginEntry[]): void {
|
||||
const cachePath = getMarketplaceRegistryCachePath();
|
||||
mkdirSync(dirname(cachePath), { recursive: true });
|
||||
const tempPath = `${cachePath}.tmp.${process.pid}.${Date.now()}`;
|
||||
writeFileSync(tempPath, `${JSON.stringify(entries, null, 2)}\n`, "utf-8");
|
||||
renameSync(tempPath, cachePath);
|
||||
}
|
||||
|
||||
export function loadMarketplaceCatalog(): MarketplacePluginEntry[] {
|
||||
const cached = readMarketplaceCatalogFile(getMarketplaceRegistryCachePath());
|
||||
if (!cached) {
|
||||
return [...BUNDLED_MARKETPLACE_PLUGIN_CATALOG];
|
||||
}
|
||||
return mergeMarketplaceCatalogs(cached, BUNDLED_MARKETPLACE_PLUGIN_CATALOG);
|
||||
}
|
||||
|
||||
export async function refreshMarketplaceCatalog(
|
||||
url = process.env["AO_PLUGIN_REGISTRY_URL"] ?? DEFAULT_REMOTE_MARKETPLACE_REGISTRY_URL,
|
||||
): Promise<MarketplacePluginEntry[]> {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(MARKETPLACE_FETCH_TIMEOUT_MS) });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch marketplace registry from ${url} (HTTP ${response.status}).`);
|
||||
}
|
||||
|
||||
const parsed = (await response.json()) as unknown;
|
||||
const remoteEntries = validateMarketplaceCatalog(parsed, url);
|
||||
const mergedEntries = mergeMarketplaceCatalogs(remoteEntries, BUNDLED_MARKETPLACE_PLUGIN_CATALOG);
|
||||
writeMarketplaceCatalogCache(mergedEntries);
|
||||
return mergedEntries;
|
||||
}
|
||||
|
||||
export { normalizeImportedPluginModule } from "@composio/ao-core";
|
||||
|
||||
export function isLocalPluginReference(reference: string): boolean {
|
||||
return (
|
||||
reference.startsWith("./") ||
|
||||
reference.startsWith("../") ||
|
||||
reference.startsWith("/") ||
|
||||
reference.startsWith("~/") ||
|
||||
isAbsolute(reference)
|
||||
);
|
||||
}
|
||||
|
||||
export function findMarketplacePlugin(reference: string): MarketplacePluginEntry | undefined {
|
||||
return loadMarketplaceCatalog().find(
|
||||
(plugin) => plugin.id === reference || plugin.package === reference,
|
||||
);
|
||||
}
|
||||
|
||||
function expandHomePath(value: string): string {
|
||||
return value.startsWith("~/") ? join(homedir(), value.slice(2)) : value;
|
||||
}
|
||||
|
||||
function parseNpmPackageReference(reference: string): { packageName: string; version?: string } {
|
||||
if (reference.startsWith("@")) {
|
||||
const slashIndex = reference.indexOf("/");
|
||||
const versionSeparator = slashIndex >= 0 ? reference.indexOf("@", slashIndex + 1) : -1;
|
||||
if (versionSeparator > 0) {
|
||||
return {
|
||||
packageName: reference.slice(0, versionSeparator),
|
||||
version: reference.slice(versionSeparator + 1),
|
||||
};
|
||||
}
|
||||
return { packageName: reference };
|
||||
}
|
||||
|
||||
const versionSeparator = reference.lastIndexOf("@");
|
||||
if (versionSeparator > 0) {
|
||||
return {
|
||||
packageName: reference.slice(0, versionSeparator),
|
||||
version: reference.slice(versionSeparator + 1),
|
||||
};
|
||||
}
|
||||
|
||||
return { packageName: reference };
|
||||
}
|
||||
|
||||
export function buildPluginDescriptor(
|
||||
reference: string,
|
||||
configPath: string,
|
||||
): {
|
||||
descriptor: InstalledPluginConfig;
|
||||
specifier: string;
|
||||
setupAction?: MarketplacePluginEntry["setupAction"];
|
||||
} {
|
||||
const marketplacePlugin = findMarketplacePlugin(reference);
|
||||
if (marketplacePlugin) {
|
||||
return {
|
||||
descriptor: {
|
||||
name: marketplacePlugin.id,
|
||||
source: marketplacePlugin.source,
|
||||
package: marketplacePlugin.package,
|
||||
version: marketplacePlugin.latestVersion,
|
||||
enabled: true,
|
||||
},
|
||||
specifier: marketplacePlugin.package,
|
||||
setupAction: marketplacePlugin.setupAction,
|
||||
};
|
||||
}
|
||||
|
||||
if (isLocalPluginReference(reference)) {
|
||||
const expandedReference = expandHomePath(reference);
|
||||
const absolutePath = isAbsolute(expandedReference)
|
||||
? expandedReference
|
||||
: resolve(dirname(configPath), expandedReference);
|
||||
const entrypoint = resolveLocalPluginEntrypoint(absolutePath);
|
||||
if (!entrypoint) {
|
||||
throw new Error(`Could not resolve a plugin entrypoint from ${reference}`);
|
||||
}
|
||||
|
||||
return {
|
||||
descriptor: {
|
||||
name: reference,
|
||||
source: "local",
|
||||
path: reference,
|
||||
enabled: true,
|
||||
},
|
||||
specifier: pathToFileURL(entrypoint).href,
|
||||
};
|
||||
}
|
||||
|
||||
const { packageName, version } = parseNpmPackageReference(reference);
|
||||
|
||||
return {
|
||||
descriptor: {
|
||||
name: reference,
|
||||
source: "npm",
|
||||
package: packageName,
|
||||
version,
|
||||
enabled: true,
|
||||
},
|
||||
specifier: packageName,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import type { PluginSlot } from "@composio/ao-core";
|
||||
|
||||
export interface PluginScaffoldInput {
|
||||
author?: string;
|
||||
description: string;
|
||||
directory: string;
|
||||
displayName: string;
|
||||
packageName: string;
|
||||
slot: PluginSlot;
|
||||
}
|
||||
|
||||
const CORE_VERSION_RANGE = "^0.2.0";
|
||||
const TYPESCRIPT_VERSION = "^5.7.0";
|
||||
const NODE_TYPES_VERSION = "^25.2.3";
|
||||
|
||||
const SLOT_HINTS: Record<PluginSlot, string> = {
|
||||
runtime: "Implement a Runtime-compatible object from create() and wire up create/destroy/send lifecycle methods.",
|
||||
agent: "Implement an Agent-compatible object from create() so AO can launch, inspect, and restore sessions.",
|
||||
workspace: "Implement a Workspace-compatible object from create() for setup, cleanup, and isolation behavior.",
|
||||
tracker: "Implement a Tracker-compatible object from create() for issue list/read/update operations.",
|
||||
scm: "Implement an SCM-compatible object from create() for branch, PR, CI, and review operations.",
|
||||
notifier: "Implement a Notifier-compatible object from create() for notify() delivery logic.",
|
||||
terminal: "Implement a Terminal-compatible object from create() for attach/open UX.",
|
||||
};
|
||||
|
||||
function slugify(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
export function normalizePluginName(value: string): string {
|
||||
const normalized = slugify(value);
|
||||
if (!normalized) {
|
||||
throw new Error("Plugin name must contain at least one letter or number.");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function buildDefaultPackageName(slot: PluginSlot, pluginName: string): string {
|
||||
return `ao-plugin-${slot}-${normalizePluginName(pluginName)}`;
|
||||
}
|
||||
|
||||
export function resolveScaffoldDirectory(displayName: string, targetDir?: string): string {
|
||||
return resolve(targetDir && targetDir.trim().length > 0 ? targetDir : normalizePluginName(displayName));
|
||||
}
|
||||
|
||||
function ensureDirectoryIsWritable(targetDir: string): void {
|
||||
if (!existsSync(targetDir)) return;
|
||||
if (readdirSync(targetDir).length === 0) return;
|
||||
throw new Error(`Target directory ${targetDir} already exists and is not empty.`);
|
||||
}
|
||||
|
||||
function buildPackageJson(input: PluginScaffoldInput): string {
|
||||
const manifestName = normalizePluginName(input.displayName);
|
||||
return `${JSON.stringify(
|
||||
{
|
||||
name: input.packageName,
|
||||
version: "0.1.0",
|
||||
description: input.description,
|
||||
license: "MIT",
|
||||
author: input.author,
|
||||
type: "module",
|
||||
main: "dist/index.js",
|
||||
types: "dist/index.d.ts",
|
||||
exports: {
|
||||
".": {
|
||||
types: "./dist/index.d.ts",
|
||||
import: "./dist/index.js",
|
||||
},
|
||||
},
|
||||
files: ["dist"],
|
||||
scripts: {
|
||||
build: "tsc",
|
||||
typecheck: "tsc --noEmit",
|
||||
clean: "rm -rf dist",
|
||||
},
|
||||
dependencies: {
|
||||
"@composio/ao-core": CORE_VERSION_RANGE,
|
||||
},
|
||||
devDependencies: {
|
||||
"@types/node": NODE_TYPES_VERSION,
|
||||
typescript: TYPESCRIPT_VERSION,
|
||||
},
|
||||
keywords: ["agent-orchestrator", "plugin", input.slot, manifestName],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
}
|
||||
|
||||
function buildTsConfig(): string {
|
||||
return `${JSON.stringify(
|
||||
{
|
||||
compilerOptions: {
|
||||
target: "ES2022",
|
||||
module: "Node16",
|
||||
moduleResolution: "Node16",
|
||||
lib: ["ES2022"],
|
||||
declaration: true,
|
||||
sourceMap: true,
|
||||
strict: true,
|
||||
esModuleInterop: true,
|
||||
skipLibCheck: true,
|
||||
forceConsistentCasingInFileNames: true,
|
||||
outDir: "dist",
|
||||
rootDir: "src",
|
||||
},
|
||||
include: ["src"],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
}
|
||||
|
||||
function buildIndexTs(input: PluginScaffoldInput): string {
|
||||
const manifestName = normalizePluginName(input.displayName);
|
||||
const displayName = input.displayName.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
|
||||
const description = input.description.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
|
||||
|
||||
return `import type { PluginModule } from "@composio/ao-core";
|
||||
|
||||
export const manifest = {
|
||||
name: "${manifestName}",
|
||||
slot: "${input.slot}" as const,
|
||||
description: "${description}",
|
||||
version: "0.1.0",
|
||||
displayName: "${displayName}",
|
||||
};
|
||||
|
||||
const plugin: PluginModule = {
|
||||
manifest,
|
||||
create(config?: Record<string, unknown>) {
|
||||
return {
|
||||
name: manifest.name,
|
||||
config: config ?? {},
|
||||
// TODO: replace this placeholder with a real ${input.slot} implementation.
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
`;
|
||||
}
|
||||
|
||||
function buildReadme(input: PluginScaffoldInput): string {
|
||||
const manifestName = normalizePluginName(input.displayName);
|
||||
const authorLine = input.author ? `Author: ${input.author}\n\n` : "";
|
||||
return `# ${input.displayName}
|
||||
|
||||
${input.description}
|
||||
|
||||
${authorLine}Package: \`${input.packageName}\`
|
||||
|
||||
## Development
|
||||
|
||||
\`\`\`bash
|
||||
npm install
|
||||
npm run build
|
||||
\`\`\`
|
||||
|
||||
## AO Config
|
||||
|
||||
Local development:
|
||||
|
||||
\`\`\`yaml
|
||||
plugins:
|
||||
- name: ${manifestName}
|
||||
source: local
|
||||
path: ./${manifestName}
|
||||
\`\`\`
|
||||
|
||||
Published package:
|
||||
|
||||
\`\`\`yaml
|
||||
plugins:
|
||||
- name: ${manifestName}
|
||||
source: npm
|
||||
package: ${input.packageName}
|
||||
\`\`\`
|
||||
|
||||
## Next Steps
|
||||
|
||||
${SLOT_HINTS[input.slot]}
|
||||
`;
|
||||
}
|
||||
|
||||
export function scaffoldPlugin(input: PluginScaffoldInput): string {
|
||||
const targetDir = resolve(input.directory);
|
||||
ensureDirectoryIsWritable(targetDir);
|
||||
mkdirSync(join(targetDir, "src"), { recursive: true });
|
||||
|
||||
writeFileSync(join(targetDir, "package.json"), buildPackageJson(input), "utf-8");
|
||||
writeFileSync(join(targetDir, "tsconfig.json"), buildTsConfig(), "utf-8");
|
||||
writeFileSync(join(targetDir, "README.md"), buildReadme(input), "utf-8");
|
||||
writeFileSync(join(targetDir, ".gitignore"), "dist/\nnode_modules/\n", "utf-8");
|
||||
writeFileSync(join(targetDir, "src", "index.ts"), buildIndexTs(input), "utf-8");
|
||||
|
||||
return targetDir;
|
||||
}
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { exec } from "./shell.js";
|
||||
import { formatCommandError } from "./cli-errors.js";
|
||||
|
||||
interface PluginStoreManifest {
|
||||
name: string;
|
||||
private: boolean;
|
||||
type: "module";
|
||||
}
|
||||
|
||||
const STORE_MANIFEST: PluginStoreManifest = {
|
||||
name: "ao-plugin-store",
|
||||
private: true,
|
||||
type: "module",
|
||||
};
|
||||
|
||||
function getPluginStorePackageJsonPath(): string {
|
||||
return join(getPluginStoreRoot(), "package.json");
|
||||
}
|
||||
|
||||
function getInstalledPackageJsonPath(packageName: string): string {
|
||||
return join(getPluginStoreRoot(), "node_modules", ...packageName.split("/"), "package.json");
|
||||
}
|
||||
|
||||
function getStoreRequire(): NodeRequire {
|
||||
const packageJsonPath = getPluginStorePackageJsonPath();
|
||||
ensurePluginStore();
|
||||
return createRequire(packageJsonPath);
|
||||
}
|
||||
|
||||
function isPackageSpecifier(specifier: string): boolean {
|
||||
return !(
|
||||
specifier.startsWith("file:") ||
|
||||
specifier.startsWith("node:") ||
|
||||
specifier.startsWith("/") ||
|
||||
specifier.startsWith("./") ||
|
||||
specifier.startsWith("../")
|
||||
);
|
||||
}
|
||||
|
||||
async function runNpmInStore(args: string[]): Promise<void> {
|
||||
const storeRoot = ensurePluginStore();
|
||||
try {
|
||||
await exec("npm", args, { cwd: storeRoot });
|
||||
} catch (err) {
|
||||
throw formatCommandError(err, {
|
||||
cmd: "npm",
|
||||
args,
|
||||
action: "manage AO marketplace plugins",
|
||||
installHints: ["Install Node.js/npm from https://nodejs.org/ and re-run the command."],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function getPluginStoreRoot(): string {
|
||||
return join(homedir(), ".agent-orchestrator", "plugins");
|
||||
}
|
||||
|
||||
export function ensurePluginStore(): string {
|
||||
const rootDir = getPluginStoreRoot();
|
||||
mkdirSync(rootDir, { recursive: true });
|
||||
|
||||
const packageJsonPath = getPluginStorePackageJsonPath();
|
||||
if (!existsSync(packageJsonPath)) {
|
||||
writeFileSync(packageJsonPath, `${JSON.stringify(STORE_MANIFEST, null, 2)}\n`, "utf-8");
|
||||
}
|
||||
|
||||
return rootDir;
|
||||
}
|
||||
|
||||
export function readInstalledPackageVersion(packageName: string): string | null {
|
||||
const packageJsonPath = getInstalledPackageJsonPath(packageName);
|
||||
if (!existsSync(packageJsonPath)) return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as { version?: unknown };
|
||||
return typeof parsed.version === "string" && parsed.version.length > 0 ? parsed.version : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function installPackageIntoStore(
|
||||
packageName: string,
|
||||
version?: string,
|
||||
): Promise<string> {
|
||||
const requested = version ? `${packageName}@${version}` : packageName;
|
||||
await runNpmInStore(["install", "--save-exact", requested]);
|
||||
|
||||
const installedVersion = readInstalledPackageVersion(packageName);
|
||||
if (!installedVersion) {
|
||||
throw new Error(`Package ${packageName} was installed into the AO plugin store but no version was resolved afterwards.`);
|
||||
}
|
||||
|
||||
return installedVersion;
|
||||
}
|
||||
|
||||
export async function uninstallPackageFromStore(packageName: string): Promise<boolean> {
|
||||
if (!readInstalledPackageVersion(packageName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await runNpmInStore(["uninstall", packageName]);
|
||||
return readInstalledPackageVersion(packageName) === null;
|
||||
}
|
||||
|
||||
export function tryResolveInstalledPluginSpecifier(packageName: string): string | null {
|
||||
try {
|
||||
const resolvedPath = getStoreRequire().resolve(packageName);
|
||||
return pathToFileURL(resolvedPath).href;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function importPluginModuleFromSource(specifier: string): Promise<unknown> {
|
||||
if (isPackageSpecifier(specifier)) {
|
||||
const storeSpecifier = tryResolveInstalledPluginSpecifier(specifier);
|
||||
if (storeSpecifier) {
|
||||
return import(storeSpecifier);
|
||||
}
|
||||
}
|
||||
|
||||
return import(specifier);
|
||||
}
|
||||
|
||||
export async function getLatestPublishedPackageVersion(packageName: string): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await exec("npm", ["view", packageName, "version", "--json"]);
|
||||
const parsed = JSON.parse(stdout) as unknown;
|
||||
if (typeof parsed === "string" && parsed.length > 0) {
|
||||
return parsed;
|
||||
}
|
||||
} catch (err) {
|
||||
throw formatCommandError(err, {
|
||||
cmd: "npm",
|
||||
args: ["view", packageName, "version", "--json"],
|
||||
action: `resolve the latest published version for ${packageName}`,
|
||||
installHints: ["Install Node.js/npm from https://nodejs.org/ and re-run the command."],
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(`npm did not return a usable version for ${packageName}.`);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Agent, OrchestratorConfig, SCM } from "@composio/ao-core";
|
||||
import type { Agent, OrchestratorConfig, PluginRegistry, SCM } from "@composio/ao-core";
|
||||
import claudeCodePlugin from "@composio/ao-plugin-agent-claude-code";
|
||||
import codexPlugin from "@composio/ao-plugin-agent-codex";
|
||||
import aiderPlugin from "@composio/ao-plugin-agent-aider";
|
||||
|
|
@ -23,11 +23,7 @@ const scmPlugins: Record<string, { create(): SCM }> = {
|
|||
export function getAgent(config: OrchestratorConfig, projectId?: string): Agent {
|
||||
const agentName =
|
||||
(projectId ? config.projects[projectId]?.agent : undefined) || config.defaults.agent;
|
||||
const plugin = agentPlugins[agentName];
|
||||
if (!plugin) {
|
||||
throw new Error(`Unknown agent plugin: ${agentName}`);
|
||||
}
|
||||
return plugin.create();
|
||||
return getAgentByName(agentName);
|
||||
}
|
||||
|
||||
/** Get an agent by name directly (for fallback/no-config scenarios). */
|
||||
|
|
@ -39,6 +35,15 @@ export function getAgentByName(name: string): Agent {
|
|||
return plugin.create();
|
||||
}
|
||||
|
||||
/** Get an agent by name from the shared registry. */
|
||||
export function getAgentByNameFromRegistry(registry: PluginRegistry, name: string): Agent {
|
||||
const plugin = registry.get<Agent>("agent", name);
|
||||
if (!plugin) {
|
||||
throw new Error(`Unknown agent plugin: ${name}`);
|
||||
}
|
||||
return plugin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the SCM plugin for a project (or fall back to "github").
|
||||
*/
|
||||
|
|
@ -50,3 +55,17 @@ export function getSCM(config: OrchestratorConfig, projectId: string): SCM {
|
|||
}
|
||||
return plugin.create();
|
||||
}
|
||||
|
||||
/** Resolve the SCM plugin for a project from the shared registry. */
|
||||
export function getSCMFromRegistry(
|
||||
registry: PluginRegistry,
|
||||
config: OrchestratorConfig,
|
||||
projectId: string,
|
||||
): SCM {
|
||||
const scmName = config.projects[projectId]?.scm?.plugin || "github";
|
||||
const plugin = registry.get<SCM>("scm", scmName);
|
||||
if (!plugin) {
|
||||
throw new Error(`Unknown SCM plugin: ${scmName}`);
|
||||
}
|
||||
return plugin;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
import { isAbsolute, relative, resolve } from "node:path";
|
||||
|
||||
interface ProjectWithPath {
|
||||
path: string;
|
||||
}
|
||||
|
||||
function isWithinProject(projectPath: string, currentDir: string): boolean {
|
||||
const relativePath = relative(projectPath, currentDir);
|
||||
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the best matching project for the current directory.
|
||||
* When multiple project paths contain the cwd, prefer the deepest match.
|
||||
*/
|
||||
export function findProjectForDirectory<T extends ProjectWithPath>(
|
||||
projects: Record<string, T>,
|
||||
currentDir: string,
|
||||
): string | null {
|
||||
const resolvedCurrentDir = resolve(currentDir);
|
||||
|
||||
const matches = Object.entries(projects)
|
||||
.filter(([, project]) => isWithinProject(resolve(project.path), resolvedCurrentDir))
|
||||
.sort(([, a], [, b]) => resolve(b.path).length - resolve(a.path).length);
|
||||
|
||||
return matches[0]?.[0] ?? null;
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import chalk from "chalk";
|
||||
import { confirm, isCancel, select, type Option } from "@clack/prompts";
|
||||
|
||||
type SelectOption<T extends string> = Option<T>;
|
||||
|
||||
export async function promptConfirm(message: string, initialValue = true): Promise<boolean> {
|
||||
const result = await confirm({ message, initialValue });
|
||||
if (isCancel(result)) {
|
||||
console.log(chalk.yellow("\nCancelled."));
|
||||
process.exit(0);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function promptSelect<T extends string>(
|
||||
message: string,
|
||||
options: SelectOption<T>[],
|
||||
initialValue?: T,
|
||||
): Promise<T> {
|
||||
const result = await select({
|
||||
message,
|
||||
options,
|
||||
...(initialValue !== undefined ? { initialValue } : {}),
|
||||
});
|
||||
if (isCancel(result)) {
|
||||
console.log(chalk.yellow("\nRequest Cancelled."));
|
||||
process.exit(0);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import { createRequire } from "node:module";
|
|||
import { fileURLToPath } from "node:url";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { formatCommandError } from "./cli-errors.js";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
|
@ -52,6 +53,32 @@ export async function findFreePort(start: number, maxScan = MAX_PORT_SCAN): Prom
|
|||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a URL in the user's browser without throwing back into the caller.
|
||||
*/
|
||||
export function openUrl(url: string): void {
|
||||
const [cmd, args]: [string, string[]] =
|
||||
process.platform === "win32"
|
||||
? ["cmd.exe", ["/c", "start", "", url]]
|
||||
: [process.platform === "linux" ? "xdg-open" : "open", [url]];
|
||||
const browser = spawn(cmd, args, { stdio: "ignore" });
|
||||
browser.on("error", (err) => {
|
||||
console.warn(
|
||||
formatCommandError(err, {
|
||||
cmd,
|
||||
args,
|
||||
action: `open ${url} in a browser`,
|
||||
installHints:
|
||||
process.platform === "linux"
|
||||
? ["Install xdg-utils so `xdg-open` is available on PATH."]
|
||||
: process.platform === "win32"
|
||||
? []
|
||||
: [],
|
||||
}).message,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll until a port is accepting connections, then open a URL in the browser.
|
||||
* Respects an AbortSignal so the caller can cancel if the dashboard process
|
||||
|
|
@ -67,14 +94,7 @@ export async function waitForPortAndOpen(
|
|||
while (!signal.aborted && Date.now() - start < timeoutMs) {
|
||||
const free = await isPortAvailable(port);
|
||||
if (!free) {
|
||||
// Windows: `start` is a cmd.exe builtin (no start.exe), so must run via shell.
|
||||
// The empty "" arg is the window title required by `start` before the URL.
|
||||
const [cmd, args]: [string, string[]] =
|
||||
process.platform === "win32"
|
||||
? ["cmd.exe", ["/c", "start", "", url]]
|
||||
: [process.platform === "linux" ? "xdg-open" : "open", [url]];
|
||||
const browser = spawn(cmd, args, { stdio: "ignore" });
|
||||
browser.on("error", () => {});
|
||||
openUrl(url);
|
||||
return;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
|
|
|
|||
|
|
@ -11,5 +11,9 @@ export default defineConfig({
|
|||
maxThreads: 8,
|
||||
},
|
||||
},
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["lcov"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# @agent-orchestrator/core
|
||||
# @composio/ao-core
|
||||
|
||||
Core services, types, and configuration for the Agent Orchestrator system.
|
||||
|
||||
|
|
@ -88,16 +88,16 @@ Loads plugins and provides access to them:
|
|||
- `get<T>(slot, name)` — get plugin by slot + name
|
||||
- `list(slot)` — list all plugins for a slot
|
||||
- `loadBuiltins(config?)` — load built-in plugins (runtime-tmux, agent-claude-code, etc.)
|
||||
- `loadFromConfig(config)` — load plugins from config (npm packages, local paths)
|
||||
- `loadFromConfig(config)` — load built-ins today; external plugin descriptors are the marketplace extension point
|
||||
|
||||
**Built-in plugins** (loaded by default):
|
||||
|
||||
- runtime-tmux, runtime-process
|
||||
- agent-claude-code, agent-codex, agent-aider, agent-opencode
|
||||
- workspace-worktree, workspace-clone
|
||||
- tracker-github, tracker-linear
|
||||
- scm-github
|
||||
- notifier-desktop, notifier-slack, notifier-composio, notifier-webhook
|
||||
- tracker-github, tracker-linear, tracker-gitlab
|
||||
- scm-github, scm-gitlab
|
||||
- notifier-desktop, notifier-discord, notifier-slack, notifier-composio, notifier-openclaw, notifier-webhook
|
||||
- terminal-iterm2, terminal-web
|
||||
|
||||
### `src/config.ts` — Configuration Loading
|
||||
|
|
@ -106,12 +106,12 @@ Loads and validates `agent-orchestrator.yaml`:
|
|||
|
||||
**Main config sections:**
|
||||
|
||||
- `dataDir` — where session metadata lives (~/.agent-orchestrator)
|
||||
- `worktreeDir` — where workspaces are created (~/.worktrees)
|
||||
- Runtime data paths are auto-derived from the config location under `~/.agent-orchestrator/{hash}-{projectId}/`
|
||||
- `port` — web dashboard port (default 3000, set different values for multiple projects)
|
||||
- `terminalPort` — terminal WebSocket port (auto-detected if not set)
|
||||
- `directTerminalPort` — direct terminal WebSocket port (auto-detected if not set)
|
||||
- `defaults` — default plugins (runtime, agent, workspace, notifiers)
|
||||
- `plugins` — installer-managed external plugin descriptors (registry, npm, or local)
|
||||
- `projects` — per-project config (repo, path, branch, symlinks, reactions, agentRules)
|
||||
- `notifiers` — notification channel config (Slack webhooks, etc.)
|
||||
- `notificationRouting` — which notifiers get which priority events
|
||||
|
|
@ -125,7 +125,7 @@ Loads and validates `agent-orchestrator.yaml`:
|
|||
|
||||
1. Edit `src/types.ts` → `Session` interface
|
||||
2. Edit `src/services/session-manager.ts` → initialize field in `spawn()`
|
||||
3. Rebuild: `pnpm --filter @agent-orchestrator/core build`
|
||||
3. Rebuild: `pnpm --filter @composio/ao-core build`
|
||||
|
||||
### Adding an Event Type
|
||||
|
||||
|
|
@ -188,13 +188,13 @@ Migration notes:
|
|||
|
||||
```bash
|
||||
# Run all core tests
|
||||
pnpm --filter @agent-orchestrator/core test
|
||||
pnpm --filter @composio/ao-core test
|
||||
|
||||
# Run in watch mode
|
||||
pnpm --filter @agent-orchestrator/core test -- --watch
|
||||
pnpm --filter @composio/ao-core test -- --watch
|
||||
|
||||
# Run specific test
|
||||
pnpm --filter @agent-orchestrator/core test -- session-manager.test.ts
|
||||
pnpm --filter @composio/ao-core test -- session-manager.test.ts
|
||||
```
|
||||
|
||||
Tests are in `src/__tests__/`:
|
||||
|
|
@ -209,10 +209,10 @@ Tests are in `src/__tests__/`:
|
|||
|
||||
```bash
|
||||
# Build core
|
||||
pnpm --filter @agent-orchestrator/core build
|
||||
pnpm --filter @composio/ao-core build
|
||||
|
||||
# Typecheck
|
||||
pnpm --filter @agent-orchestrator/core typecheck
|
||||
pnpm --filter @composio/ao-core typecheck
|
||||
```
|
||||
|
||||
This package is a dependency of all other packages. Build it first if working on the codebase.
|
||||
|
|
@ -221,7 +221,7 @@ This package is a dependency of all other packages. Build it first if working on
|
|||
|
||||
**Why flat metadata files?**
|
||||
|
||||
- Debuggability: `cat ~/.agent-orchestrator/my-app-3` shows full state
|
||||
- Debuggability: `cat ~/.agent-orchestrator/<hash>-my-app/sessions/app-3` shows full state
|
||||
- No database dependency (survives crashes, easy to inspect)
|
||||
- Backwards-compatible with bash script orchestrator
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ describe("Config Loading", () => {
|
|||
originalCwd = process.cwd();
|
||||
originalEnv = { ...process.env };
|
||||
|
||||
// Clear AO_CONFIG_PATH to ensure test isolation
|
||||
delete process.env.AO_CONFIG_PATH;
|
||||
|
||||
// Change to test directory
|
||||
process.chdir(testDir);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { createPluginRegistry } from "../plugin-registry.js";
|
||||
import type { PluginModule, PluginManifest, OrchestratorConfig } from "../types.js";
|
||||
|
||||
|
|
@ -366,4 +369,89 @@ describe("loadFromConfig", () => {
|
|||
expect(importedPackages.length).toBeGreaterThan(0);
|
||||
expect(importedPackages).toContain("@composio/ao-plugin-runtime-tmux");
|
||||
});
|
||||
|
||||
it("loads external package plugins from config.plugins", async () => {
|
||||
const registry = createPluginRegistry();
|
||||
const agentPlugin = makePlugin("agent", "goose");
|
||||
const config = makeOrchestratorConfig({
|
||||
configPath: "/tmp/agent-orchestrator.yaml",
|
||||
plugins: [
|
||||
{
|
||||
name: "goose",
|
||||
source: "npm",
|
||||
package: "@example/ao-plugin-agent-goose",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await registry.loadFromConfig(config, async (specifier: string) => {
|
||||
if (specifier === "@example/ao-plugin-agent-goose") {
|
||||
return { default: agentPlugin };
|
||||
}
|
||||
throw new Error(`Not found: ${specifier}`);
|
||||
});
|
||||
|
||||
expect(registry.list("agent")).toContainEqual(
|
||||
expect.objectContaining({ name: "goose", slot: "agent" }),
|
||||
);
|
||||
expect(registry.get("agent", "goose")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("loads local plugins relative to the config file", async () => {
|
||||
const registry = createPluginRegistry();
|
||||
const tmpConfigDir = mkdtempSync(join(tmpdir(), "ao-plugin-registry-"));
|
||||
const localPluginDir = join(tmpConfigDir, "plugins", "role-qa");
|
||||
mkdirSync(join(localPluginDir, "dist"), { recursive: true });
|
||||
writeFileSync(join(localPluginDir, "dist", "index.js"), "export default {};\n");
|
||||
writeFileSync(
|
||||
join(localPluginDir, "package.json"),
|
||||
JSON.stringify({ name: "role-qa", main: "dist/index.js" }),
|
||||
);
|
||||
|
||||
const config = makeOrchestratorConfig({
|
||||
configPath: join(tmpConfigDir, "agent-orchestrator.yaml"),
|
||||
plugins: [
|
||||
{
|
||||
name: "gitlab-plus",
|
||||
source: "local",
|
||||
path: "./plugins/role-qa",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let importedSpecifier = "";
|
||||
await registry.loadFromConfig(config, async (specifier: string) => {
|
||||
if (specifier.startsWith("file:")) {
|
||||
importedSpecifier = specifier;
|
||||
return makePlugin("tracker", "gitlab-plus");
|
||||
}
|
||||
throw new Error(`Not found: ${specifier}`);
|
||||
});
|
||||
|
||||
expect(importedSpecifier).toContain("/plugins/role-qa/dist/index.js");
|
||||
expect(registry.get("tracker", "gitlab-plus")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("skips disabled external plugins", async () => {
|
||||
const registry = createPluginRegistry();
|
||||
const config = makeOrchestratorConfig({
|
||||
configPath: "/tmp/agent-orchestrator.yaml",
|
||||
plugins: [
|
||||
{
|
||||
name: "goose",
|
||||
source: "npm",
|
||||
package: "@example/ao-plugin-agent-goose",
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
const importFn = vi.fn(async (_specifier: string) => {
|
||||
throw new Error("should not import disabled plugin");
|
||||
});
|
||||
|
||||
await registry.loadFromConfig(config, importFn);
|
||||
|
||||
expect(importFn).not.toHaveBeenCalledWith("@example/ao-plugin-agent-goose");
|
||||
expect(registry.get("agent", "goose")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import { join } from "node:path";
|
|||
import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readMetadataRaw } from "../metadata.js";
|
||||
import { getSessionsDir } from "../paths.js";
|
||||
import { escalateSession, recoverSession } from "../recovery/actions.js";
|
||||
import { getSessionsDir, getProjectBaseDir } from "../paths.js";
|
||||
import { cleanupSession, escalateSession, recoverSession } from "../recovery/actions.js";
|
||||
import { runRecovery } from "../recovery/manager.js";
|
||||
import { getRecoveryLogPath, scanAllSessions } from "../recovery/scanner.js";
|
||||
import {
|
||||
|
|
@ -13,7 +13,7 @@ import {
|
|||
type RecoveryAssessment,
|
||||
type RecoveryContext,
|
||||
} from "../recovery/types.js";
|
||||
import type { OrchestratorConfig, PluginRegistry } from "../types.js";
|
||||
import type { OrchestratorConfig, PluginRegistry, Runtime, Workspace } from "../types.js";
|
||||
|
||||
function makeConfig(rootDir: string): OrchestratorConfig {
|
||||
return {
|
||||
|
|
@ -101,6 +101,12 @@ describe("recoverSession", () => {
|
|||
|
||||
afterEach(() => {
|
||||
if (rootDir) {
|
||||
const configPath = join(rootDir, "agent-orchestrator.yaml");
|
||||
const projectPath = join(rootDir, "project");
|
||||
const projectBaseDir = getProjectBaseDir(configPath, projectPath);
|
||||
if (existsSync(projectBaseDir)) {
|
||||
rmSync(projectBaseDir, { recursive: true, force: true });
|
||||
}
|
||||
rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
|
@ -215,6 +221,12 @@ describe("escalateSession", () => {
|
|||
|
||||
afterEach(() => {
|
||||
if (rootDir) {
|
||||
const configPath = join(rootDir, "agent-orchestrator.yaml");
|
||||
const projectPath = join(rootDir, "project");
|
||||
const projectBaseDir = getProjectBaseDir(configPath, projectPath);
|
||||
if (existsSync(projectBaseDir)) {
|
||||
rmSync(projectBaseDir, { recursive: true, force: true });
|
||||
}
|
||||
rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
|
@ -242,11 +254,135 @@ describe("escalateSession", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("cleanupSession", () => {
|
||||
let rootDir: string;
|
||||
|
||||
afterEach(() => {
|
||||
if (rootDir) {
|
||||
const configPath = join(rootDir, "agent-orchestrator.yaml");
|
||||
const projectPath = join(rootDir, "project");
|
||||
const projectBaseDir = getProjectBaseDir(configPath, projectPath);
|
||||
if (existsSync(projectBaseDir)) {
|
||||
rmSync(projectBaseDir, { recursive: true, force: true });
|
||||
}
|
||||
rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("continues cleanup and calls deleteMetadata even when workspace.destroy throws", async () => {
|
||||
rootDir = join(tmpdir(), `ao-recovery-${randomUUID()}`);
|
||||
mkdirSync(rootDir, { recursive: true });
|
||||
mkdirSync(join(rootDir, "project"), { recursive: true });
|
||||
writeFileSync(join(rootDir, "agent-orchestrator.yaml"), "projects: {}\n", "utf-8");
|
||||
|
||||
const config = makeConfig(rootDir);
|
||||
const workspacePath = join(rootDir, "worktree");
|
||||
const mockWorkspace: Workspace = {
|
||||
name: "worktree",
|
||||
create: vi.fn(),
|
||||
destroy: vi.fn().mockRejectedValue(new Error("Workspace destroy failed")),
|
||||
list: vi.fn(),
|
||||
exists: vi.fn(),
|
||||
};
|
||||
const registry: PluginRegistry = {
|
||||
register: vi.fn(),
|
||||
get: vi.fn().mockImplementation((slot: string) => {
|
||||
if (slot === "workspace") return mockWorkspace;
|
||||
return null;
|
||||
}),
|
||||
list: vi.fn().mockReturnValue([]),
|
||||
loadBuiltins: vi.fn().mockResolvedValue(undefined),
|
||||
loadFromConfig: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const assessment = makeAssessment({
|
||||
action: "cleanup",
|
||||
classification: "dead",
|
||||
runtimeAlive: false,
|
||||
workspaceExists: true,
|
||||
workspacePath,
|
||||
rawMetadata: {
|
||||
...makeAssessment().rawMetadata,
|
||||
worktree: workspacePath,
|
||||
},
|
||||
});
|
||||
const context = makeContext(rootDir);
|
||||
|
||||
const result = await cleanupSession(assessment, config, registry, context);
|
||||
const sessionsDir = getSessionsDir(config.configPath, config.projects.app.path);
|
||||
|
||||
expect(mockWorkspace.destroy).toHaveBeenCalled();
|
||||
expect(result.success).toBe(true);
|
||||
expect(existsSync(join(sessionsDir, "app-1"))).toBe(false);
|
||||
});
|
||||
|
||||
it("continues cleanup and calls workspace.destroy and deleteMetadata even when runtime.destroy throws", async () => {
|
||||
rootDir = join(tmpdir(), `ao-recovery-${randomUUID()}`);
|
||||
mkdirSync(rootDir, { recursive: true });
|
||||
mkdirSync(join(rootDir, "project"), { recursive: true });
|
||||
writeFileSync(join(rootDir, "agent-orchestrator.yaml"), "projects: {}\n", "utf-8");
|
||||
|
||||
const config = makeConfig(rootDir);
|
||||
const workspacePath = join(rootDir, "worktree");
|
||||
const mockRuntime: Runtime = {
|
||||
name: "tmux",
|
||||
create: vi.fn(),
|
||||
destroy: vi.fn().mockRejectedValue(new Error("Runtime destroy failed")),
|
||||
sendMessage: vi.fn(),
|
||||
getOutput: vi.fn(),
|
||||
isAlive: vi.fn(),
|
||||
};
|
||||
const mockWorkspace: Workspace = {
|
||||
name: "worktree",
|
||||
create: vi.fn(),
|
||||
destroy: vi.fn().mockResolvedValue(undefined),
|
||||
list: vi.fn(),
|
||||
exists: vi.fn(),
|
||||
};
|
||||
const registry: PluginRegistry = {
|
||||
register: vi.fn(),
|
||||
get: vi.fn().mockImplementation((slot: string) => {
|
||||
if (slot === "runtime") return mockRuntime;
|
||||
if (slot === "workspace") return mockWorkspace;
|
||||
return null;
|
||||
}),
|
||||
list: vi.fn().mockReturnValue([]),
|
||||
loadBuiltins: vi.fn().mockResolvedValue(undefined),
|
||||
loadFromConfig: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const assessment = makeAssessment({
|
||||
action: "cleanup",
|
||||
classification: "partial",
|
||||
runtimeAlive: true,
|
||||
workspaceExists: true,
|
||||
workspacePath,
|
||||
rawMetadata: {
|
||||
...makeAssessment().rawMetadata,
|
||||
worktree: workspacePath,
|
||||
},
|
||||
});
|
||||
const context = makeContext(rootDir);
|
||||
|
||||
const result = await cleanupSession(assessment, config, registry, context);
|
||||
const sessionsDir = getSessionsDir(config.configPath, config.projects.app.path);
|
||||
|
||||
expect(mockRuntime.destroy).toHaveBeenCalled();
|
||||
expect(mockWorkspace.destroy).toHaveBeenCalled();
|
||||
expect(result.success).toBe(true);
|
||||
expect(existsSync(join(sessionsDir, "app-1"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recovery manager and scanner", () => {
|
||||
let rootDir: string;
|
||||
|
||||
afterEach(() => {
|
||||
if (rootDir) {
|
||||
const configPath = join(rootDir, "agent-orchestrator.yaml");
|
||||
const projectPath = join(rootDir, "project");
|
||||
const projectBaseDir = getProjectBaseDir(configPath, projectPath);
|
||||
if (existsSync(projectBaseDir)) {
|
||||
rmSync(projectBaseDir, { recursive: true, force: true });
|
||||
}
|
||||
rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -121,4 +121,94 @@ describe("recovery validator", () => {
|
|||
expect(mockOrchestratorAgent.isProcessRunning).toHaveBeenCalled();
|
||||
expect(mockWorkerAgent.isProcessRunning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sets runtimeAlive to false when runtime.isAlive throws an error", async () => {
|
||||
rootDir = join(tmpdir(), `ao-recovery-validator-${randomUUID()}`);
|
||||
mkdirSync(rootDir, { recursive: true });
|
||||
const projectPath = join(rootDir, "project");
|
||||
mkdirSync(projectPath, { recursive: true });
|
||||
writeFileSync(join(rootDir, "agent-orchestrator.yaml"), "projects: {}\n", "utf-8");
|
||||
|
||||
const mockRuntime: Runtime = {
|
||||
name: "tmux",
|
||||
create: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
getOutput: vi.fn(),
|
||||
isAlive: vi.fn().mockRejectedValue(new Error("Runtime check failed")),
|
||||
};
|
||||
const mockWorkspace: Workspace = {
|
||||
name: "worktree",
|
||||
create: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
list: vi.fn(),
|
||||
exists: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
const mockAgent: Agent = {
|
||||
name: "mock-agent",
|
||||
processName: "mock-agent",
|
||||
getLaunchCommand: vi.fn(),
|
||||
getEnvironment: vi.fn(),
|
||||
detectActivity: vi.fn(),
|
||||
getActivityState: vi.fn(),
|
||||
isProcessRunning: vi.fn().mockResolvedValue(false),
|
||||
getSessionInfo: vi.fn(),
|
||||
};
|
||||
const registry: PluginRegistry = {
|
||||
register: vi.fn(),
|
||||
get: vi.fn().mockImplementation((slot: string) => {
|
||||
if (slot === "runtime") return mockRuntime;
|
||||
if (slot === "workspace") return mockWorkspace;
|
||||
if (slot === "agent") return mockAgent;
|
||||
return null;
|
||||
}),
|
||||
list: vi.fn().mockReturnValue([]),
|
||||
loadBuiltins: vi.fn().mockResolvedValue(undefined),
|
||||
loadFromConfig: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const config: OrchestratorConfig = {
|
||||
configPath: join(rootDir, "agent-orchestrator.yaml"),
|
||||
port: 3000,
|
||||
readyThresholdMs: 300_000,
|
||||
defaults: {
|
||||
runtime: "tmux",
|
||||
agent: "mock-agent",
|
||||
workspace: "worktree",
|
||||
notifiers: ["desktop"],
|
||||
},
|
||||
projects: {
|
||||
app: {
|
||||
name: "app",
|
||||
repo: "org/repo",
|
||||
path: projectPath,
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "app",
|
||||
},
|
||||
},
|
||||
notifiers: {},
|
||||
notificationRouting: {
|
||||
urgent: ["desktop"],
|
||||
action: ["desktop"],
|
||||
warning: [],
|
||||
info: [],
|
||||
},
|
||||
reactions: {},
|
||||
};
|
||||
const scanned: ScannedSession = {
|
||||
sessionId: "app-1",
|
||||
projectId: "app",
|
||||
project: config.projects.app,
|
||||
sessionsDir: getSessionsDir(config.configPath, projectPath),
|
||||
rawMetadata: {
|
||||
worktree: projectPath,
|
||||
status: "working",
|
||||
runtimeHandle: JSON.stringify({ id: "rt-1", runtimeName: "tmux", data: {} }),
|
||||
},
|
||||
};
|
||||
|
||||
const assessment = await validateSession(scanned, config, registry);
|
||||
|
||||
expect(mockRuntime.isAlive).toHaveBeenCalled();
|
||||
expect(assessment.runtimeAlive).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,283 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { createSessionManager } from "../session-manager.js";
|
||||
import { writeMetadata } from "../metadata.js";
|
||||
import type { OrchestratorConfig, PluginRegistry, Agent } from "../types.js";
|
||||
import { setupTestContext, teardownTestContext, makeHandle, type TestContext } from "./test-utils.js";
|
||||
|
||||
// Mock child_process module with custom promisify
|
||||
vi.mock("node:child_process", () => {
|
||||
const execFileMock = vi.fn() as any;
|
||||
// Implement custom promisify to return { stdout, stderr } objects
|
||||
execFileMock[Symbol.for("nodejs.util.promisify.custom")] = (...args: any[]) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFileMock(...args, (error: any, stdout: string, stderr: string) => {
|
||||
if (error) {
|
||||
reject(Object.assign(error, { stdout, stderr }));
|
||||
} else {
|
||||
resolve({ stdout, stderr });
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
return {
|
||||
execFile: execFileMock,
|
||||
};
|
||||
});
|
||||
|
||||
let ctx: TestContext;
|
||||
let sessionsDir: string;
|
||||
let mockRegistry: PluginRegistry;
|
||||
let config: OrchestratorConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
ctx = setupTestContext();
|
||||
({ sessionsDir, mockRegistry, config } = ctx);
|
||||
|
||||
// Create an opencode agent mock
|
||||
const opencodeAgent: Agent = {
|
||||
name: "opencode",
|
||||
processName: "opencode",
|
||||
getLaunchCommand: vi.fn().mockReturnValue("opencode start"),
|
||||
getEnvironment: vi.fn().mockReturnValue({}),
|
||||
detectActivity: vi.fn().mockReturnValue("active"),
|
||||
getActivityState: vi.fn().mockResolvedValue({ state: "active" }),
|
||||
isProcessRunning: vi.fn().mockResolvedValue(true),
|
||||
getSessionInfo: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
|
||||
// Update registry to include opencode agent
|
||||
const originalGet = mockRegistry.get;
|
||||
mockRegistry.get = vi.fn().mockImplementation((slot: string, name?: string) => {
|
||||
if (slot === "agent" && name === "opencode") {
|
||||
return opencodeAgent;
|
||||
}
|
||||
return (originalGet as any)(slot, name);
|
||||
});
|
||||
|
||||
// Set project to use opencode agent
|
||||
config.projects["my-app"]!.agent = "opencode";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
teardownTestContext(ctx);
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("deleteSession retry loop", () => {
|
||||
it("verifies retry count - calls execFileAsync 3 times when all attempts fail", async () => {
|
||||
const { execFile } = await import("node:child_process");
|
||||
|
||||
// Setup: Create a session with opencode agent
|
||||
writeMetadata(sessionsDir, "app-1", {
|
||||
worktree: "/tmp/ws",
|
||||
branch: "main",
|
||||
status: "working",
|
||||
project: "my-app",
|
||||
agent: "opencode",
|
||||
opencodeSessionId: "ses_test_123",
|
||||
runtimeHandle: JSON.stringify(makeHandle("rt-1")),
|
||||
});
|
||||
|
||||
let deleteCallCount = 0;
|
||||
const mockError = new Error("OpenCode delete failed");
|
||||
|
||||
vi.mocked(execFile).mockImplementation(((file: string, args: string[], options: any, callback?: any) => {
|
||||
const cb = typeof options === "function" ? options : callback;
|
||||
if (!cb) return null as any;
|
||||
|
||||
const argsArray = Array.isArray(args) ? args : [];
|
||||
if (argsArray[1] === "delete") {
|
||||
deleteCallCount++;
|
||||
cb(mockError, "", "");
|
||||
} else if (argsArray[1] === "list") {
|
||||
cb(null, "[]", "");
|
||||
}
|
||||
return null as any;
|
||||
}) as any);
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
|
||||
// Execute kill with purgeOpenCode option
|
||||
await sm.kill("app-1", { purgeOpenCode: true });
|
||||
|
||||
// Verify delete was called 3 times (one for each retry)
|
||||
expect(deleteCallCount).toBe(3);
|
||||
});
|
||||
|
||||
it("verifies retry delays - confirms delays are 0ms, 200ms, 600ms", async () => {
|
||||
const { execFile } = await import("node:child_process");
|
||||
vi.useFakeTimers();
|
||||
|
||||
writeMetadata(sessionsDir, "app-2", {
|
||||
worktree: "/tmp/ws",
|
||||
branch: "main",
|
||||
status: "working",
|
||||
project: "my-app",
|
||||
agent: "opencode",
|
||||
opencodeSessionId: "ses_test_456",
|
||||
runtimeHandle: JSON.stringify(makeHandle("rt-2")),
|
||||
});
|
||||
|
||||
const callTimes: number[] = [];
|
||||
const mockError = new Error("OpenCode delete failed");
|
||||
|
||||
vi.mocked(execFile).mockImplementation(((file: string, args: string[], options: any, callback?: any) => {
|
||||
const cb = typeof options === "function" ? options : callback;
|
||||
if (!cb) return null as any;
|
||||
|
||||
const argsArray = Array.isArray(args) ? args : [];
|
||||
if (argsArray[1] === "delete") {
|
||||
callTimes.push(Date.now());
|
||||
cb(mockError, "", "");
|
||||
} else if (argsArray[1] === "list") {
|
||||
cb(null, "[]", "");
|
||||
}
|
||||
return null as any;
|
||||
}) as any);
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
const killPromise = sm.kill("app-2", { purgeOpenCode: true });
|
||||
|
||||
// Run all timers to completion
|
||||
await vi.runAllTimersAsync();
|
||||
await killPromise;
|
||||
|
||||
// Verify we have 3 calls
|
||||
expect(callTimes).toHaveLength(3);
|
||||
|
||||
// Calculate delays between calls
|
||||
const delay1 = callTimes[1]! - callTimes[0]!; // Should be 200ms
|
||||
const delay2 = callTimes[2]! - callTimes[1]!; // Should be 600ms
|
||||
|
||||
expect(delay1).toBe(200);
|
||||
expect(delay2).toBe(600);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("verifies all retries are attempted when deletion fails", async () => {
|
||||
const { execFile } = await import("node:child_process");
|
||||
|
||||
writeMetadata(sessionsDir, "app-3", {
|
||||
worktree: "/tmp/ws",
|
||||
branch: "main",
|
||||
status: "working",
|
||||
project: "my-app",
|
||||
agent: "opencode",
|
||||
opencodeSessionId: "ses_test_789",
|
||||
runtimeHandle: JSON.stringify(makeHandle("rt-3")),
|
||||
});
|
||||
|
||||
const lastError = new Error("Final error after retries");
|
||||
let deleteCallCount = 0;
|
||||
|
||||
vi.mocked(execFile).mockImplementation(((file: string, args: string[], options: any, callback?: any) => {
|
||||
const cb = typeof options === "function" ? options : callback;
|
||||
if (!cb) return null as any;
|
||||
|
||||
const argsArray = Array.isArray(args) ? args : [];
|
||||
if (argsArray[1] === "delete") {
|
||||
deleteCallCount++;
|
||||
const error = deleteCallCount === 3 ? lastError : new Error(`Error ${deleteCallCount}`);
|
||||
cb(error, "", "");
|
||||
} else if (argsArray[1] === "list") {
|
||||
cb(null, "[]", "");
|
||||
}
|
||||
return null as any;
|
||||
}) as any);
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
|
||||
// The kill function catches and ignores deleteOpenCodeSession() failures,
|
||||
// so this test verifies that all retry attempts are made despite errors
|
||||
await sm.kill("app-3", { purgeOpenCode: true });
|
||||
|
||||
// Verify all 3 delete attempts were made
|
||||
expect(deleteCallCount).toBe(3);
|
||||
});
|
||||
|
||||
it("verifies early success exit - stops after first success without unnecessary retries", async () => {
|
||||
const { execFile } = await import("node:child_process");
|
||||
|
||||
writeMetadata(sessionsDir, "app-4", {
|
||||
worktree: "/tmp/ws",
|
||||
branch: "main",
|
||||
status: "working",
|
||||
project: "my-app",
|
||||
agent: "opencode",
|
||||
opencodeSessionId: "ses_test_abc",
|
||||
runtimeHandle: JSON.stringify(makeHandle("rt-4")),
|
||||
});
|
||||
|
||||
let deleteCallCount = 0;
|
||||
|
||||
vi.mocked(execFile).mockImplementation(((file: string, args: string[], options: any, callback?: any) => {
|
||||
const cb = typeof options === "function" ? options : callback;
|
||||
if (!cb) return null as any;
|
||||
|
||||
const argsArray = Array.isArray(args) ? args : [];
|
||||
if (argsArray[1] === "delete") {
|
||||
deleteCallCount++;
|
||||
if (deleteCallCount === 1) {
|
||||
// First attempt fails
|
||||
cb(new Error("First attempt failed"), "", "");
|
||||
} else {
|
||||
// Second attempt succeeds
|
||||
cb(null, "", "");
|
||||
}
|
||||
} else if (argsArray[1] === "list") {
|
||||
cb(null, "[]", "");
|
||||
}
|
||||
return null as any;
|
||||
}) as any);
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await sm.kill("app-4", { purgeOpenCode: true });
|
||||
|
||||
// Verify delete was called exactly 2 times (failed once, succeeded on second)
|
||||
expect(deleteCallCount).toBe(2);
|
||||
});
|
||||
|
||||
it("verifies session-not-found handling - exits gracefully without retrying", async () => {
|
||||
const { execFile } = await import("node:child_process");
|
||||
|
||||
writeMetadata(sessionsDir, "app-5", {
|
||||
worktree: "/tmp/ws",
|
||||
branch: "main",
|
||||
status: "working",
|
||||
project: "my-app",
|
||||
agent: "opencode",
|
||||
opencodeSessionId: "ses_test_def",
|
||||
runtimeHandle: JSON.stringify(makeHandle("rt-5")),
|
||||
});
|
||||
|
||||
const notFoundError = new Error("Session not found: ses_test_def") as Error & {
|
||||
stderr?: string;
|
||||
stdout?: string;
|
||||
};
|
||||
notFoundError.stderr = "Error: session not found: ses_test_def";
|
||||
|
||||
let deleteCallCount = 0;
|
||||
|
||||
vi.mocked(execFile).mockImplementation(((file: string, args: string[], options: any, callback?: any) => {
|
||||
const cb = typeof options === "function" ? options : callback;
|
||||
if (!cb) return null as any;
|
||||
|
||||
const argsArray = Array.isArray(args) ? args : [];
|
||||
if (argsArray[1] === "delete") {
|
||||
deleteCallCount++;
|
||||
cb(notFoundError, "", "");
|
||||
} else if (argsArray[1] === "list") {
|
||||
cb(null, "[]", "");
|
||||
}
|
||||
return null as any;
|
||||
}) as any);
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await sm.kill("app-5", { purgeOpenCode: true });
|
||||
|
||||
// Verify delete was called only once - no retries for "not found" errors
|
||||
expect(deleteCallCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -215,6 +215,9 @@ describe("send", () => {
|
|||
runtimeHandle: JSON.stringify(makeHandle("rt-1")),
|
||||
});
|
||||
|
||||
vi.mocked(mockRuntime.isAlive).mockResolvedValue(true);
|
||||
vi.mocked(mockRuntime.getOutput).mockResolvedValueOnce("before").mockResolvedValue("after");
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await sm.send("app-1", "hello");
|
||||
|
||||
|
|
@ -247,6 +250,9 @@ describe("send", () => {
|
|||
runtimeHandle: JSON.stringify(makeHandle("rt-1")),
|
||||
});
|
||||
|
||||
vi.mocked(mockRuntime.isAlive).mockResolvedValue(true);
|
||||
vi.mocked(mockRuntime.getOutput).mockResolvedValueOnce("before").mockResolvedValue("after");
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await sm.send("app-1", "hello");
|
||||
|
||||
|
|
@ -351,7 +357,7 @@ describe("send", () => {
|
|||
makeHandle("rt-1"),
|
||||
"do not confirm on visibility",
|
||||
);
|
||||
});
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
describe("remap", () => {
|
||||
|
|
|
|||
|
|
@ -181,12 +181,40 @@ const DefaultPluginsSchema = z.object({
|
|||
worker: RoleAgentDefaultsSchema,
|
||||
});
|
||||
|
||||
const InstalledPluginConfigSchema = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
source: z.enum(["registry", "npm", "local"]),
|
||||
package: z.string().optional(),
|
||||
version: z.string().optional(),
|
||||
path: z.string().optional(),
|
||||
enabled: z.boolean().default(true),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.source === "local" && !value.path) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["path"],
|
||||
message: "Local plugins require a path",
|
||||
});
|
||||
}
|
||||
|
||||
if ((value.source === "registry" || value.source === "npm") && !value.package) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["package"],
|
||||
message: "Registry and npm plugins require a package name",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const OrchestratorConfigSchema = z.object({
|
||||
port: z.number().default(3000),
|
||||
terminalPort: z.number().optional(),
|
||||
directTerminalPort: z.number().optional(),
|
||||
readyThresholdMs: z.number().nonnegative().default(300_000),
|
||||
defaults: DefaultPluginsSchema.default({}),
|
||||
plugins: z.array(InstalledPluginConfigSchema).default([]),
|
||||
projects: z.record(ProjectConfigSchema),
|
||||
notifiers: z.record(NotifierConfigSchema).default({}),
|
||||
notificationRouting: z.record(z.array(z.string())).default({
|
||||
|
|
@ -216,6 +244,12 @@ function expandPaths(config: OrchestratorConfig): OrchestratorConfig {
|
|||
project.path = expandHome(project.path);
|
||||
}
|
||||
|
||||
for (const plugin of config.plugins ?? []) {
|
||||
if (plugin.path) {
|
||||
plugin.path = expandHome(plugin.path);
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,13 @@ export {
|
|||
} from "./config.js";
|
||||
|
||||
// Plugin registry
|
||||
export { createPluginRegistry } from "./plugin-registry.js";
|
||||
export {
|
||||
createPluginRegistry,
|
||||
isPluginModule,
|
||||
normalizeImportedPluginModule,
|
||||
resolveLocalPluginEntrypoint,
|
||||
resolvePackageExportsEntry,
|
||||
} from "./plugin-registry.js";
|
||||
|
||||
// Metadata — flat-file session metadata read/write
|
||||
export {
|
||||
|
|
@ -115,6 +121,7 @@ export {
|
|||
export type {
|
||||
ObservabilityMetricName,
|
||||
ObservabilityHealthStatus,
|
||||
ObservabilityLevel,
|
||||
ObservabilitySummary,
|
||||
ProjectObserver,
|
||||
} from "./observability.js";
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import {
|
|||
type Session,
|
||||
type EventPriority,
|
||||
type ProjectConfig as _ProjectConfig,
|
||||
type PREnrichmentData,
|
||||
} from "./types.js";
|
||||
import { updateMetadata } from "./metadata.js";
|
||||
import { getSessionsDir } from "./paths.js";
|
||||
|
|
@ -198,6 +199,133 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
let polling = false; // re-entrancy guard
|
||||
let allCompleteEmitted = false; // guard against repeated all_complete
|
||||
|
||||
/**
|
||||
* Cache for PR enrichment data within a single poll cycle.
|
||||
* Cleared at the start of each pollAll() call.
|
||||
* Key format: "${owner}/${repo}#${number}"
|
||||
*/
|
||||
const prEnrichmentCache = new Map<string, PREnrichmentData>();
|
||||
|
||||
/**
|
||||
* Populate the PR enrichment cache using batch GraphQL queries.
|
||||
* This is called once per poll cycle to fetch data for all PRs efficiently.
|
||||
*/
|
||||
async function populatePREnrichmentCache(sessions: Session[]): Promise<void> {
|
||||
// Clear previous cache
|
||||
prEnrichmentCache.clear();
|
||||
|
||||
// Collect all unique PRs
|
||||
const prs = sessions
|
||||
.map((s) => s.pr)
|
||||
.filter((pr): pr is NonNullable<typeof pr> => pr !== null);
|
||||
|
||||
// Deduplicate by key
|
||||
const uniquePRs = Array.from(
|
||||
new Map(prs.map((pr) => [`${pr.owner}/${pr.repo}#${pr.number}`, pr])).values(),
|
||||
);
|
||||
|
||||
if (uniquePRs.length === 0) return;
|
||||
|
||||
// Group by SCM plugin and batch fetch for each group
|
||||
const prsByPlugin = new Map<string, typeof uniquePRs>();
|
||||
for (const pr of uniquePRs) {
|
||||
// Find the project for this PR
|
||||
const project = Object.values(config.projects).find((p) => {
|
||||
const [owner, repo] = p.repo.split("/");
|
||||
return owner === pr.owner && repo === pr.repo;
|
||||
});
|
||||
if (!project?.scm) continue;
|
||||
|
||||
const pluginKey = project.scm.plugin;
|
||||
if (!prsByPlugin.has(pluginKey)) {
|
||||
prsByPlugin.set(pluginKey, []);
|
||||
}
|
||||
const pluginPRs = prsByPlugin.get(pluginKey);
|
||||
if (pluginPRs) {
|
||||
pluginPRs.push(pr);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch enrichment data for each plugin's PRs
|
||||
for (const [pluginKey, pluginPRs] of prsByPlugin) {
|
||||
const scm = registry.get<SCM>("scm", pluginKey);
|
||||
if (!scm?.enrichSessionsPRBatch) continue;
|
||||
|
||||
const batchStartTime = Date.now();
|
||||
try {
|
||||
const enrichmentData = await scm.enrichSessionsPRBatch(
|
||||
pluginPRs,
|
||||
{
|
||||
recordSuccess(_data) {
|
||||
const batchDuration = Date.now() - batchStartTime;
|
||||
observer?.recordOperation({
|
||||
metric: "graphql_batch",
|
||||
operation: "batch_enrichment",
|
||||
correlationId: createCorrelationId("graphql-batch"),
|
||||
outcome: "success",
|
||||
projectId: scopedProjectId,
|
||||
durationMs: batchDuration,
|
||||
data: {
|
||||
plugin: pluginKey,
|
||||
prCount: pluginPRs.length,
|
||||
prKeys: pluginPRs.map((pr) => `${pr.owner}/${pr.repo}#${pr.number}`),
|
||||
},
|
||||
level: "info",
|
||||
});
|
||||
},
|
||||
recordFailure(data) {
|
||||
const batchDuration = Date.now() - batchStartTime;
|
||||
observer?.recordOperation({
|
||||
metric: "graphql_batch",
|
||||
operation: "batch_enrichment",
|
||||
correlationId: createCorrelationId("graphql-batch"),
|
||||
outcome: "failure",
|
||||
reason: data.error,
|
||||
level: "warn",
|
||||
data: {
|
||||
plugin: pluginKey,
|
||||
prCount: pluginPRs.length,
|
||||
error: data.error,
|
||||
durationMs: batchDuration,
|
||||
},
|
||||
});
|
||||
},
|
||||
log(level, message) {
|
||||
// Log to stderr for observability
|
||||
process.stderr.write(
|
||||
JSON.stringify({
|
||||
source: "ao-graphql-batch",
|
||||
level,
|
||||
message,
|
||||
plugin: pluginKey,
|
||||
timestamp: new Date().toISOString(),
|
||||
}) + "\n"
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Merge into cache
|
||||
for (const [key, data] of enrichmentData) {
|
||||
prEnrichmentCache.set(key, data);
|
||||
}
|
||||
} catch (err) {
|
||||
// Batch fetch failed - individual calls will still work
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
const batchCorrelationId = createCorrelationId("batch-enrichment");
|
||||
observer?.recordOperation?.({
|
||||
metric: "lifecycle_poll",
|
||||
operation: "batch_enrichment",
|
||||
correlationId: batchCorrelationId,
|
||||
outcome: "failure",
|
||||
reason: errorMsg,
|
||||
level: "warn",
|
||||
data: { plugin: pluginKey, prCount: pluginPRs.length },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if idle time exceeds the agent-stuck threshold. */
|
||||
function isIdleBeyondThreshold(session: Session, idleTimestamp: Date): boolean {
|
||||
const stuckReaction = getReactionConfigForSession(session, "agent-stuck");
|
||||
|
|
@ -311,6 +439,43 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
// 4. Check PR state if PR exists
|
||||
if (session.pr && scm) {
|
||||
try {
|
||||
// Try to use cached enrichment data from batch GraphQL query
|
||||
const prKey = `${session.pr.owner}/${session.pr.repo}#${session.pr.number}`;
|
||||
const cachedData = prEnrichmentCache.get(prKey);
|
||||
|
||||
if (cachedData) {
|
||||
// Use cached enrichment data - avoids individual API calls
|
||||
if (cachedData.state === PR_STATE.MERGED) return "merged";
|
||||
if (cachedData.state === PR_STATE.CLOSED) return "killed";
|
||||
|
||||
// Check CI
|
||||
if (cachedData.ciStatus === CI_STATUS.FAILING) return "ci_failed";
|
||||
|
||||
// Check reviews
|
||||
if (cachedData.reviewDecision === "changes_requested")
|
||||
return "changes_requested";
|
||||
if (cachedData.reviewDecision === "approved" || cachedData.reviewDecision === "none") {
|
||||
// Check merge readiness — treat "none" (no reviewers required)
|
||||
// as "approved" so CI-green PRs reach "mergeable" status
|
||||
// and fire the merge.ready event / approved-and-green reaction.
|
||||
if (cachedData.mergeable) return "mergeable";
|
||||
if (cachedData.reviewDecision === "approved") return "approved";
|
||||
}
|
||||
if (cachedData.reviewDecision === "pending") return "review_pending";
|
||||
|
||||
// 4b. Post-PR stuck detection: agent has a PR open but is idle beyond
|
||||
// threshold. This catches the case where step 2's stuck check was
|
||||
// bypassed (getActivityState returned null) or the idle timestamp
|
||||
// wasn't available during step 2 but the session has been at pr_open
|
||||
// for a long time. Without this, sessions get stuck at "pr_open" forever.
|
||||
if (detectedIdleTimestamp && isIdleBeyondThreshold(session, detectedIdleTimestamp)) {
|
||||
return "stuck";
|
||||
}
|
||||
|
||||
return "pr_open";
|
||||
}
|
||||
|
||||
// Fall back to individual API calls if no cached data
|
||||
const prState = await scm.getPRState(session.pr);
|
||||
if (prState === PR_STATE.MERGED) return "merged";
|
||||
if (prState === PR_STATE.CLOSED) return "killed";
|
||||
|
|
@ -324,7 +489,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
if (reviewDecision === "changes_requested") return "changes_requested";
|
||||
if (reviewDecision === "approved" || reviewDecision === "none") {
|
||||
// Check merge readiness — treat "none" (no reviewers required)
|
||||
// the same as "approved" so CI-green PRs reach "mergeable" status
|
||||
// as "approved" so CI-green PRs reach "mergeable" status
|
||||
// and fire the merge.ready event / approved-and-green reaction.
|
||||
const mergeReady = await scm.getMergeability(session.pr);
|
||||
if (mergeReady.mergeable) return "mergeable";
|
||||
|
|
@ -811,6 +976,10 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
return tracked !== undefined && tracked !== s.status;
|
||||
});
|
||||
|
||||
// Populate PR enrichment cache using batch GraphQL queries
|
||||
// This reduces API calls from N×3 to 1 per poll cycle
|
||||
await populatePREnrichmentCache(sessionsToCheck);
|
||||
|
||||
// Poll all sessions concurrently
|
||||
await Promise.allSettled(sessionsToCheck.map((s) => checkSession(s)));
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export type ObservabilityMetricName =
|
|||
| "api_request"
|
||||
| "claim_pr"
|
||||
| "cleanup"
|
||||
| "graphql_batch"
|
||||
| "kill"
|
||||
| "lifecycle_poll"
|
||||
| "restore"
|
||||
|
|
|
|||
|
|
@ -7,7 +7,11 @@
|
|||
* 3. Local file paths specified in config
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, statSync } from "node:fs";
|
||||
import { dirname, isAbsolute, join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type {
|
||||
InstalledPluginConfig,
|
||||
PluginSlot,
|
||||
PluginManifest,
|
||||
PluginModule,
|
||||
|
|
@ -18,6 +22,8 @@ import type {
|
|||
/** Map from "slot:name" → plugin instance */
|
||||
type PluginMap = Map<string, { manifest: PluginManifest; instance: unknown }>;
|
||||
|
||||
const LOCAL_PLUGIN_ENTRY_CANDIDATES = ["dist/index.js", "index.js"] as const;
|
||||
|
||||
function makeKey(slot: PluginSlot, name: string): string {
|
||||
return `${slot}:${name}`;
|
||||
}
|
||||
|
|
@ -70,7 +76,7 @@ function extractPluginConfig(
|
|||
const matches = hasExplicitPlugin ? configuredPlugin === name : notifierName === name;
|
||||
if (matches) {
|
||||
const { plugin: _plugin, ...rest } = notifierConfig as Record<string, unknown>;
|
||||
return rest;
|
||||
return config.configPath ? { ...rest, configPath: config.configPath } : rest;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -78,6 +84,129 @@ function extractPluginConfig(
|
|||
return undefined;
|
||||
}
|
||||
|
||||
export function isPluginModule(value: unknown): value is PluginModule {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const candidate = value as Partial<PluginModule>;
|
||||
return Boolean(candidate.manifest && typeof candidate.create === "function");
|
||||
}
|
||||
|
||||
export function normalizeImportedPluginModule(value: unknown): PluginModule | null {
|
||||
if (isPluginModule(value)) return value;
|
||||
|
||||
if (value && typeof value === "object" && "default" in value) {
|
||||
const defaultExport = (value as { default?: unknown }).default;
|
||||
if (isPluginModule(defaultExport)) return defaultExport;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveConfigRelativePath(targetPath: string, configPath?: string): string {
|
||||
if (isAbsolute(targetPath)) return targetPath;
|
||||
const baseDir = configPath ? dirname(configPath) : process.cwd();
|
||||
return resolve(baseDir, targetPath);
|
||||
}
|
||||
|
||||
export function resolvePackageExportsEntry(exportsField: unknown): string | null {
|
||||
if (typeof exportsField === "string") return exportsField;
|
||||
if (!exportsField || typeof exportsField !== "object") return null;
|
||||
|
||||
const exportsRecord = exportsField as Record<string, unknown>;
|
||||
const dotEntry = exportsRecord["."];
|
||||
|
||||
if (typeof dotEntry === "string") return dotEntry;
|
||||
if (dotEntry && typeof dotEntry === "object") {
|
||||
const importEntry = (dotEntry as Record<string, unknown>)["import"];
|
||||
if (typeof importEntry === "string") return importEntry;
|
||||
const defaultEntry = (dotEntry as Record<string, unknown>)["default"];
|
||||
if (typeof defaultEntry === "string") return defaultEntry;
|
||||
}
|
||||
|
||||
const importEntry = exportsRecord["import"];
|
||||
if (typeof importEntry === "string") return importEntry;
|
||||
|
||||
const defaultEntry = exportsRecord["default"];
|
||||
if (typeof defaultEntry === "string") return defaultEntry;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveLocalPluginEntrypoint(pluginPath: string): string | null {
|
||||
if (!existsSync(pluginPath)) return null;
|
||||
|
||||
let stat;
|
||||
try {
|
||||
stat = statSync(pluginPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (stat.isFile()) return pluginPath;
|
||||
if (!stat.isDirectory()) return null;
|
||||
|
||||
const packageJsonPath = join(pluginPath, "package.json");
|
||||
if (existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const raw = readFileSync(packageJsonPath, "utf-8");
|
||||
const packageJson = JSON.parse(raw) as {
|
||||
exports?: unknown;
|
||||
module?: unknown;
|
||||
main?: unknown;
|
||||
};
|
||||
|
||||
const exportsEntry = resolvePackageExportsEntry(packageJson.exports);
|
||||
if (exportsEntry) {
|
||||
const resolvedEntry = resolve(pluginPath, exportsEntry);
|
||||
if (existsSync(resolvedEntry)) return resolvedEntry;
|
||||
}
|
||||
|
||||
if (typeof packageJson.module === "string") {
|
||||
const moduleEntry = resolve(pluginPath, packageJson.module);
|
||||
if (existsSync(moduleEntry)) return moduleEntry;
|
||||
}
|
||||
|
||||
if (typeof packageJson.main === "string") {
|
||||
const mainEntry = resolve(pluginPath, packageJson.main);
|
||||
if (existsSync(mainEntry)) return mainEntry;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to common entrypoint guesses below.
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of LOCAL_PLUGIN_ENTRY_CANDIDATES) {
|
||||
const entry = join(pluginPath, candidate);
|
||||
if (existsSync(entry)) return entry;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function inferPackageSpecifier(value: string | undefined): string | null {
|
||||
if (!value) return null;
|
||||
if (value.startsWith(".") || value.startsWith("/")) return null;
|
||||
return value.startsWith("@") || value.includes("/") ? value : null;
|
||||
}
|
||||
|
||||
function resolvePluginSpecifier(
|
||||
plugin: InstalledPluginConfig,
|
||||
config: OrchestratorConfig,
|
||||
): string | null {
|
||||
switch (plugin.source) {
|
||||
case "local": {
|
||||
if (!plugin.path) return null;
|
||||
const absolutePath = resolveConfigRelativePath(plugin.path, config.configPath);
|
||||
const entrypoint = resolveLocalPluginEntrypoint(absolutePath);
|
||||
return entrypoint ? pathToFileURL(entrypoint).href : null;
|
||||
}
|
||||
case "registry":
|
||||
case "npm":
|
||||
return plugin.package ?? inferPackageSpecifier(plugin.name);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createPluginRegistry(): PluginRegistry {
|
||||
const plugins: PluginMap = new Map();
|
||||
|
||||
|
|
@ -111,8 +240,8 @@ export function createPluginRegistry(): PluginRegistry {
|
|||
const doImport = importFn ?? ((pkg: string) => import(pkg));
|
||||
for (const builtin of BUILTIN_PLUGINS) {
|
||||
try {
|
||||
const mod = (await doImport(builtin.pkg)) as PluginModule;
|
||||
if (mod.manifest && typeof mod.create === "function") {
|
||||
const mod = normalizeImportedPluginModule(await doImport(builtin.pkg));
|
||||
if (mod) {
|
||||
const pluginConfig = orchestratorConfig
|
||||
? extractPluginConfig(builtin.slot, builtin.name, orchestratorConfig)
|
||||
: undefined;
|
||||
|
|
@ -131,8 +260,27 @@ export function createPluginRegistry(): PluginRegistry {
|
|||
// Load built-ins with orchestrator config so plugins receive their settings
|
||||
await this.loadBuiltins(config, importFn);
|
||||
|
||||
// Then, load any additional plugins specified in project configs
|
||||
// (future: support npm package names and local file paths)
|
||||
const doImport = importFn ?? ((pkg: string) => import(pkg));
|
||||
|
||||
for (const plugin of config.plugins ?? []) {
|
||||
if (plugin.enabled === false) continue;
|
||||
|
||||
const specifier = resolvePluginSpecifier(plugin, config);
|
||||
if (!specifier) {
|
||||
console.warn(`[plugin-registry] Could not resolve specifier for plugin "${plugin.name}" (source: ${plugin.source})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const mod = normalizeImportedPluginModule(await doImport(specifier));
|
||||
if (!mod) continue;
|
||||
|
||||
const pluginConfig = extractPluginConfig(mod.manifest.slot, mod.manifest.name, config);
|
||||
this.register(mod, pluginConfig);
|
||||
} catch (error) {
|
||||
console.warn(`[plugin-registry] Failed to load plugin "${specifier}":`, error);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ import { safeJsonParse } from "./utils/validation.js";
|
|||
import { resolveAgentSelection, resolveSessionRole } from "./agent-selection.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const OPENCODE_DISCOVERY_TIMEOUT_MS = 2_000;
|
||||
const OPENCODE_DISCOVERY_TIMEOUT_MS = 10_000;
|
||||
const OPENCODE_INTERACTIVE_DISCOVERY_TIMEOUT_MS = 10_000;
|
||||
|
||||
function errorIncludesSessionNotFound(err: unknown): boolean {
|
||||
|
|
@ -1372,7 +1372,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
AO_CALLER_TYPE: "orchestrator",
|
||||
AO_PROJECT_ID: orchestratorConfig.projectId,
|
||||
AO_CONFIG_PATH: config.configPath,
|
||||
...(config.port !== undefined && config.port !== null && { AO_PORT: String(config.port) }),
|
||||
...(config.port !== undefined && config.port !== null && { AO_PORT: String(config.port) })
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -1487,7 +1487,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
|
||||
let enrichTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
const enrichTimeout = new Promise<void>((resolve) => {
|
||||
enrichTimeoutId = setTimeout(resolve, 2_000);
|
||||
enrichTimeoutId = setTimeout(resolve, OPENCODE_DISCOVERY_TIMEOUT_MS + 2_000);
|
||||
});
|
||||
const enrichPromise = ensureHandleAndEnrich(
|
||||
session,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { ObservabilityLevel } from "./observability.js";
|
||||
|
||||
/**
|
||||
* Agent Orchestrator — Core Type Definitions
|
||||
*
|
||||
|
|
@ -592,6 +594,21 @@ export interface SCM {
|
|||
|
||||
/** Check if PR is ready to merge */
|
||||
getMergeability(pr: PRInfo): Promise<MergeReadiness>;
|
||||
|
||||
/**
|
||||
* Batch fetch PR data for multiple PRs in a single GraphQL query.
|
||||
* Used by the orchestrator to poll all active sessions efficiently.
|
||||
*
|
||||
* This is an optimization method that, when implemented, can dramatically
|
||||
* reduce API calls by fetching data for multiple PRs in one request
|
||||
* instead of calling getPRState/getCISummary/getReviewDecision separately
|
||||
* for each PR.
|
||||
*
|
||||
* @param prs - Array of PR information to fetch data for
|
||||
* @param observer - Optional observer for batch operation metrics
|
||||
* @returns Map keyed by "${owner}/${repo}#${number}" containing enrichment data
|
||||
*/
|
||||
enrichSessionsPRBatch?(prs: PRInfo[], observer?: BatchObserver): Promise<Map<string, PREnrichmentData>>;
|
||||
}
|
||||
|
||||
// --- PR Types ---
|
||||
|
|
@ -718,6 +735,59 @@ export interface MergeReadiness {
|
|||
blockers: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch enrichment data returned by SCM plugins.
|
||||
* Contains all the information the orchestrator needs for status detection.
|
||||
*/
|
||||
export interface PREnrichmentData {
|
||||
/** Current PR state */
|
||||
state: PRState;
|
||||
/** Overall CI status */
|
||||
ciStatus: CIStatus;
|
||||
/** Review decision */
|
||||
reviewDecision: ReviewDecision;
|
||||
/** Whether the PR is mergeable based on CI, reviews, and merge state */
|
||||
mergeable: boolean;
|
||||
/** PR title */
|
||||
title?: string;
|
||||
/** Number of additions */
|
||||
additions?: number;
|
||||
/** Number of deletions */
|
||||
deletions?: number;
|
||||
/** Whether PR is a draft */
|
||||
isDraft?: boolean;
|
||||
/** Whether PR has merge conflicts */
|
||||
hasConflicts?: boolean;
|
||||
/** Whether PR is behind base branch */
|
||||
isBehind?: boolean;
|
||||
/** List of blockers preventing merge */
|
||||
blockers?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Observer for GraphQL batch PR enrichment operations.
|
||||
* Used by SCM plugins to report batch success/failure to the observability system.
|
||||
*/
|
||||
export interface BatchObserver {
|
||||
/** Record a successful batch enrichment */
|
||||
recordSuccess(data: {
|
||||
batchIndex: number;
|
||||
totalBatches: number;
|
||||
prCount: number;
|
||||
durationMs: number;
|
||||
}): void;
|
||||
/** Record a failed batch enrichment */
|
||||
recordFailure(data: {
|
||||
batchIndex: number;
|
||||
totalBatches: number;
|
||||
prCount: number;
|
||||
error: string;
|
||||
durationMs: number;
|
||||
}): void;
|
||||
/** Log a message at a specific level */
|
||||
log(level: ObservabilityLevel, message: string): void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// NOTIFIER — Plugin Slot 6 (PRIMARY INTERFACE)
|
||||
// =============================================================================
|
||||
|
|
@ -901,6 +971,9 @@ export interface OrchestratorConfig {
|
|||
/** Default plugin selections */
|
||||
defaults: DefaultPlugins;
|
||||
|
||||
/** Installer-managed external plugin descriptors */
|
||||
plugins?: InstalledPluginConfig[];
|
||||
|
||||
/** Project configurations */
|
||||
projects: Record<string, ProjectConfig>;
|
||||
|
||||
|
|
@ -927,6 +1000,28 @@ export interface DefaultPlugins {
|
|||
};
|
||||
}
|
||||
|
||||
export type InstalledPluginSource = "registry" | "npm" | "local";
|
||||
|
||||
export interface InstalledPluginConfig {
|
||||
/** Stable logical plugin name used in config and CLI UX */
|
||||
name: string;
|
||||
|
||||
/** Where the plugin should be resolved from */
|
||||
source: InstalledPluginSource;
|
||||
|
||||
/** Package name for registry/npm-managed plugins */
|
||||
package?: string;
|
||||
|
||||
/** Requested version/range for installer-managed plugins */
|
||||
version?: string;
|
||||
|
||||
/** Filesystem path for local plugins */
|
||||
path?: string;
|
||||
|
||||
/** Installer-managed enable flag (defaults to true) */
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface RoleAgentConfig {
|
||||
agent?: string;
|
||||
agentConfig?: AgentSpecificConfig;
|
||||
|
|
|
|||
|
|
@ -16,5 +16,11 @@ export default defineConfig({
|
|||
),
|
||||
"@composio/ao-plugin-scm-github": resolve(__dirname, "../plugins/scm-github/src/index.ts"),
|
||||
},
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: ["src/__tests__/**", "src/index.ts", "src/recovery/index.ts"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
"description": "Integration tests — requires real binaries and tmux",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "echo 'Skipped — run test:integration instead'",
|
||||
"test": "vitest run --config vitest.config.ts",
|
||||
"test:integration": "vitest run --config vitest.config.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
|
||||
# Native build folders (generated by `expo prebuild`)
|
||||
android/
|
||||
ios/
|
||||
|
||||
# Native
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# npm (project uses pnpm)
|
||||
package-lock.json
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
{
|
||||
"expo": {
|
||||
"name": "Agent Orchestrator",
|
||||
"slug": "ao-mobile",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "dark",
|
||||
"splash": {
|
||||
"image": "./assets/splash.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#0d1117"
|
||||
},
|
||||
"assetBundlePatterns": [
|
||||
"**/*"
|
||||
],
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.composio.ao-mobile",
|
||||
"infoPlist": {
|
||||
"UIBackgroundModes": [
|
||||
"fetch",
|
||||
"processing",
|
||||
"remote-notification"
|
||||
]
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#0d1117"
|
||||
},
|
||||
"package": "com.composio.aomobile",
|
||||
"usesCleartextTraffic": true
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-background-task",
|
||||
[
|
||||
"expo-notifications",
|
||||
{
|
||||
"color": "#f85149",
|
||||
"defaultChannel": "ao-respond"
|
||||
}
|
||||
]
|
||||
],
|
||||
"extra": {
|
||||
"eas": {
|
||||
"projectId": "de59ca61-8fd4-47bb-bdea-6e1a50ade3df"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 23 KiB |
|
|
@ -1,6 +0,0 @@
|
|||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ["babel-preset-expo"],
|
||||
};
|
||||
};
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
{
|
||||
"cli": {
|
||||
"version": ">= 12.0.0"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal",
|
||||
"ios": {
|
||||
"simulator": false
|
||||
}
|
||||
},
|
||||
"production": {}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
import { registerRootComponent } from "expo";
|
||||
import App from "./src/App";
|
||||
|
||||
registerRootComponent(App);
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
const { getDefaultConfig } = require("expo/metro-config");
|
||||
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
// Disable package exports resolution — some packages (math-intrinsics, expo-application)
|
||||
// have invalid or incomplete `exports` fields that cause Metro bundling failures.
|
||||
// Falling back to classic file-based resolution fixes these.
|
||||
config.resolver.unstable_enablePackageExports = false;
|
||||
|
||||
module.exports = config;
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
{
|
||||
"name": "@composio/ao-mobile",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.1.2",
|
||||
"@react-navigation/native": "^6.1.18",
|
||||
"@react-navigation/native-stack": "^6.11.0",
|
||||
"expo": "~53.0.0",
|
||||
"expo-application": "~6.1.5",
|
||||
"expo-background-task": "~0.2.8",
|
||||
"expo-notifications": "~0.31.5",
|
||||
"expo-status-bar": "~2.2.3",
|
||||
"expo-task-manager": "~13.1.6",
|
||||
"react": "19.0.0",
|
||||
"react-native": "0.79.6",
|
||||
"react-native-safe-area-context": "5.4.0",
|
||||
"react-native-screens": "~4.11.1",
|
||||
"react-native-webview": "13.13.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.24.0",
|
||||
"@types/react": "~19.0.10",
|
||||
"typescript": "~5.8.3"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
import React, { useEffect } from "react";
|
||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||
import * as Notifications from "expo-notifications";
|
||||
import { BackendProvider } from "./context/BackendContext";
|
||||
import RootNavigator from "./navigation/RootNavigator";
|
||||
import { navigationRef } from "./navigation/RootNavigator";
|
||||
import { setupNotifications } from "./notifications";
|
||||
import { registerBackgroundTask } from "./notifications/backgroundTask";
|
||||
|
||||
/** Navigate to a session when a notification is tapped. */
|
||||
function handleNotificationResponse(response: Notifications.NotificationResponse) {
|
||||
const data = response.notification.request.content.data as
|
||||
| { sessionId?: string }
|
||||
| undefined;
|
||||
const sessionId = data?.sessionId;
|
||||
if (!sessionId) return;
|
||||
|
||||
// Wait for navigation to be ready (cold-start case)
|
||||
const tryNavigate = () => {
|
||||
if (navigationRef.isReady()) {
|
||||
navigationRef.navigate("SessionDetail", { sessionId });
|
||||
} else {
|
||||
setTimeout(tryNavigate, 100);
|
||||
}
|
||||
};
|
||||
tryNavigate();
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
useEffect(() => {
|
||||
void setupNotifications();
|
||||
void registerBackgroundTask();
|
||||
|
||||
// Handle notification tapped while app was killed or in background
|
||||
void Notifications.getLastNotificationResponseAsync().then((response) => {
|
||||
if (response) handleNotificationResponse(response);
|
||||
});
|
||||
|
||||
// Handle notification tapped while app is running
|
||||
const sub = Notifications.addNotificationResponseReceivedListener(handleNotificationResponse);
|
||||
return () => sub.remove();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<BackendProvider>
|
||||
<RootNavigator />
|
||||
</BackendProvider>
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
import React from "react";
|
||||
import { View, Text, StyleSheet } from "react-native";
|
||||
import { ATTENTION_COLORS, type AttentionLevel } from "../types";
|
||||
|
||||
interface Props {
|
||||
level: AttentionLevel;
|
||||
}
|
||||
|
||||
const LABELS: Record<AttentionLevel, string> = {
|
||||
merge: "MERGE",
|
||||
respond: "RESPOND",
|
||||
review: "REVIEW",
|
||||
pending: "PENDING",
|
||||
working: "WORKING",
|
||||
done: "DONE",
|
||||
};
|
||||
|
||||
export default function AttentionBadge({ level }: Props) {
|
||||
const color = ATTENTION_COLORS[level];
|
||||
return (
|
||||
<View style={[styles.badge, { borderColor: color, backgroundColor: color + "22" }]}>
|
||||
<Text style={[styles.label, { color }]}>{LABELS[level]}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
badge: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 4,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
},
|
||||
label: {
|
||||
fontSize: 10,
|
||||
fontWeight: "700",
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,143 +0,0 @@
|
|||
import React from "react";
|
||||
import { View, Text, TouchableOpacity, StyleSheet } from "react-native";
|
||||
import {
|
||||
getAttentionLevel,
|
||||
relativeTime,
|
||||
ATTENTION_COLORS,
|
||||
type DashboardSession,
|
||||
} from "../types";
|
||||
import AttentionBadge from "./AttentionBadge";
|
||||
|
||||
interface Props {
|
||||
session: DashboardSession;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
export default function SessionCard({ session, onPress }: Props) {
|
||||
const level = getAttentionLevel(session);
|
||||
const color = ATTENTION_COLORS[level];
|
||||
const time = relativeTime(session.lastActivityAt);
|
||||
|
||||
return (
|
||||
<TouchableOpacity style={[styles.card, { borderLeftColor: color }]} onPress={onPress} activeOpacity={0.75}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.id} numberOfLines={1} ellipsizeMode="middle">
|
||||
{session.id}
|
||||
</Text>
|
||||
<AttentionBadge level={level} />
|
||||
</View>
|
||||
|
||||
{session.issueLabel || session.issueTitle ? (
|
||||
<Text style={styles.issue} numberOfLines={1}>
|
||||
{session.issueLabel ? `${session.issueLabel}: ` : ""}
|
||||
{session.issueTitle ?? ""}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{session.summary && !session.summaryIsFallback ? (
|
||||
<Text style={styles.summary} numberOfLines={2}>
|
||||
{session.summary}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<View style={styles.footer}>
|
||||
{session.branch ? (
|
||||
<Text style={styles.branch} numberOfLines={1}>
|
||||
{session.branch}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text style={styles.time}>{time}</Text>
|
||||
</View>
|
||||
|
||||
{session.pr ? (
|
||||
<View style={styles.prRow}>
|
||||
<Text style={styles.prLabel}>PR #{session.pr.number}</Text>
|
||||
{session.pr.ciStatus !== "none" && (
|
||||
<Text
|
||||
style={[
|
||||
styles.ciStatus,
|
||||
{
|
||||
color:
|
||||
session.pr.ciStatus === "passing"
|
||||
? "#3fb950"
|
||||
: session.pr.ciStatus === "failing"
|
||||
? "#f85149"
|
||||
: "#8b949e",
|
||||
},
|
||||
]}
|
||||
>
|
||||
{session.pr.ciStatus.toUpperCase()}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: "#161b22",
|
||||
borderLeftWidth: 3,
|
||||
borderRadius: 8,
|
||||
padding: 14,
|
||||
marginHorizontal: 12,
|
||||
marginVertical: 5,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 6,
|
||||
},
|
||||
id: {
|
||||
color: "#8b949e",
|
||||
fontSize: 12,
|
||||
fontFamily: "monospace",
|
||||
flex: 1,
|
||||
marginRight: 8,
|
||||
},
|
||||
issue: {
|
||||
color: "#e6edf3",
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
marginBottom: 4,
|
||||
},
|
||||
summary: {
|
||||
color: "#8b949e",
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
marginBottom: 6,
|
||||
},
|
||||
footer: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
},
|
||||
branch: {
|
||||
color: "#58a6ff",
|
||||
fontSize: 12,
|
||||
fontFamily: "monospace",
|
||||
flex: 1,
|
||||
},
|
||||
time: {
|
||||
color: "#8b949e",
|
||||
fontSize: 12,
|
||||
},
|
||||
prRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginTop: 6,
|
||||
gap: 8,
|
||||
},
|
||||
prLabel: {
|
||||
color: "#3fb950",
|
||||
fontSize: 12,
|
||||
fontWeight: "600",
|
||||
},
|
||||
ciStatus: {
|
||||
fontSize: 11,
|
||||
fontWeight: "700",
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
import React from "react";
|
||||
import { View, Text, StyleSheet } from "react-native";
|
||||
import type { DashboardStats } from "../types";
|
||||
|
||||
interface Props {
|
||||
stats: DashboardStats;
|
||||
}
|
||||
|
||||
export default function StatBar({ stats }: Props) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<StatItem label="Sessions" value={stats.totalSessions} color="#e6edf3" />
|
||||
<StatItem label="Working" value={stats.workingSessions} color="#58a6ff" />
|
||||
<StatItem label="PRs" value={stats.openPRs} color="#3fb950" />
|
||||
<StatItem label="Review" value={stats.needsReview} color="#d29922" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function StatItem({ label, value, color }: { label: string; value: number; color: string }) {
|
||||
return (
|
||||
<View style={styles.item}>
|
||||
<Text style={[styles.value, { color }]}>{value}</Text>
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-around",
|
||||
backgroundColor: "#161b22",
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#30363d",
|
||||
},
|
||||
item: {
|
||||
alignItems: "center",
|
||||
},
|
||||
value: {
|
||||
fontSize: 20,
|
||||
fontWeight: "700",
|
||||
},
|
||||
label: {
|
||||
fontSize: 11,
|
||||
color: "#8b949e",
|
||||
marginTop: 2,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
import React, { createContext, useContext, useState, useCallback, useEffect } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import type { DashboardSession, SessionsResponse } from "../types";
|
||||
|
||||
const STORAGE_KEY = "@ao_backend_url";
|
||||
const TERMINAL_WS_OVERRIDE_KEY = "@ao_terminal_ws_url";
|
||||
const DEFAULT_URL = "http://192.168.1.1:3000";
|
||||
|
||||
interface BackendContextValue {
|
||||
backendUrl: string;
|
||||
setBackendUrl: (url: string) => Promise<void>;
|
||||
/** WebSocket URL for the terminal server. Auto-derived unless manually overridden. */
|
||||
terminalWsUrl: string;
|
||||
/** Manual override for terminal WS URL (for ngrok / different host). Empty = auto-derive. */
|
||||
terminalWsOverride: string;
|
||||
setTerminalWsOverride: (url: string) => Promise<void>;
|
||||
fetchSessions: () => Promise<SessionsResponse>;
|
||||
fetchSession: (id: string) => Promise<DashboardSession>;
|
||||
sendMessage: (id: string, message: string) => Promise<void>;
|
||||
killSession: (id: string) => Promise<void>;
|
||||
restoreSession: (id: string) => Promise<void>;
|
||||
mergePR: (prNumber: number) => Promise<void>;
|
||||
spawnSession: (projectId: string, issueId?: string) => Promise<DashboardSession>;
|
||||
}
|
||||
|
||||
const BackendContext = createContext<BackendContextValue | null>(null);
|
||||
|
||||
function deriveTerminalWsUrl(backendUrl: string): string {
|
||||
try {
|
||||
const url = new URL(backendUrl);
|
||||
return `ws://${url.hostname}:14801`;
|
||||
} catch {
|
||||
return "ws://192.168.1.1:14801";
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert an ngrok https URL to a wss URL for WebSocket connections */
|
||||
function normalizeWsUrl(url: string): string {
|
||||
return url.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://");
|
||||
}
|
||||
|
||||
export function BackendProvider({ children }: { children: React.ReactNode }) {
|
||||
const [backendUrl, setBackendUrlState] = useState(DEFAULT_URL);
|
||||
const [terminalWsOverride, setTerminalWsOverrideState] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
AsyncStorage.getItem(STORAGE_KEY),
|
||||
AsyncStorage.getItem(TERMINAL_WS_OVERRIDE_KEY),
|
||||
]).then(([storedBackend, storedWs]) => {
|
||||
if (storedBackend) setBackendUrlState(storedBackend);
|
||||
if (storedWs) setTerminalWsOverrideState(storedWs);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setBackendUrl = useCallback(async (url: string) => {
|
||||
const trimmed = url.trim().replace(/\/$/, "");
|
||||
setBackendUrlState(trimmed);
|
||||
await AsyncStorage.setItem(STORAGE_KEY, trimmed);
|
||||
}, []);
|
||||
|
||||
const setTerminalWsOverride = useCallback(async (url: string) => {
|
||||
const trimmed = url.trim().replace(/\/$/, "");
|
||||
setTerminalWsOverrideState(trimmed);
|
||||
await AsyncStorage.setItem(TERMINAL_WS_OVERRIDE_KEY, trimmed);
|
||||
}, []);
|
||||
|
||||
// Use override if set, otherwise derive from backendUrl
|
||||
const terminalWsUrl = terminalWsOverride
|
||||
? normalizeWsUrl(terminalWsOverride)
|
||||
: deriveTerminalWsUrl(backendUrl);
|
||||
|
||||
const apiFetch = useCallback(
|
||||
async (path: string, options?: RequestInit) => {
|
||||
const url = `${backendUrl}${path}`;
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
headers: { "Content-Type": "application/json", ...(options?.headers ?? {}) },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => res.statusText);
|
||||
throw new Error(`${res.status}: ${text}`);
|
||||
}
|
||||
return res;
|
||||
},
|
||||
[backendUrl],
|
||||
);
|
||||
|
||||
const fetchSessions = useCallback(async (): Promise<SessionsResponse> => {
|
||||
const res = await apiFetch("/api/sessions");
|
||||
return res.json() as Promise<SessionsResponse>;
|
||||
}, [apiFetch]);
|
||||
|
||||
const fetchSession = useCallback(async (id: string): Promise<DashboardSession> => {
|
||||
const res = await apiFetch(`/api/sessions/${encodeURIComponent(id)}`);
|
||||
return res.json() as Promise<DashboardSession>;
|
||||
}, [apiFetch]);
|
||||
|
||||
const sendMessage = useCallback(async (id: string, message: string): Promise<void> => {
|
||||
await apiFetch(`/api/sessions/${encodeURIComponent(id)}/message`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
}, [apiFetch]);
|
||||
|
||||
const killSession = useCallback(async (id: string): Promise<void> => {
|
||||
await apiFetch(`/api/sessions/${encodeURIComponent(id)}/kill`, { method: "POST" });
|
||||
}, [apiFetch]);
|
||||
|
||||
const restoreSession = useCallback(async (id: string): Promise<void> => {
|
||||
await apiFetch(`/api/sessions/${encodeURIComponent(id)}/restore`, { method: "POST" });
|
||||
}, [apiFetch]);
|
||||
|
||||
const mergePR = useCallback(async (prNumber: number): Promise<void> => {
|
||||
await apiFetch(`/api/prs/${prNumber}/merge`, { method: "POST" });
|
||||
}, [apiFetch]);
|
||||
|
||||
const spawnSession = useCallback(async (projectId: string, issueId?: string): Promise<DashboardSession> => {
|
||||
const res = await apiFetch("/api/spawn", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ projectId, ...(issueId ? { issueId } : {}) }),
|
||||
});
|
||||
const data = (await res.json()) as { session: DashboardSession };
|
||||
return data.session;
|
||||
}, [apiFetch]);
|
||||
|
||||
return (
|
||||
<BackendContext.Provider
|
||||
value={{
|
||||
backendUrl,
|
||||
setBackendUrl,
|
||||
terminalWsUrl,
|
||||
terminalWsOverride,
|
||||
setTerminalWsOverride,
|
||||
fetchSessions,
|
||||
fetchSession,
|
||||
sendMessage,
|
||||
killSession,
|
||||
restoreSession,
|
||||
mergePR,
|
||||
spawnSession,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</BackendContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useBackend(): BackendContextValue {
|
||||
const ctx = useContext(BackendContext);
|
||||
if (!ctx) throw new Error("useBackend must be used inside BackendProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { AppState, type AppStateStatus } from "react-native";
|
||||
import { useBackend } from "../context/BackendContext";
|
||||
import type { DashboardSession } from "../types";
|
||||
|
||||
const POLL_INTERVAL = 5_000;
|
||||
|
||||
interface UseSessionOptions {
|
||||
/** Set to false to disable polling. Defaults to true. */
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface UseSessionResult {
|
||||
session: DashboardSession | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
export function useSession(id: string, options?: UseSessionOptions): UseSessionResult {
|
||||
const enabled = options?.enabled ?? true;
|
||||
const { fetchSession } = useBackend();
|
||||
const [session, setSession] = useState<DashboardSession | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
// Generation counter — incremented on cleanup to invalidate in-flight fetches
|
||||
// from a previous effect run (e.g. when backend URL changes).
|
||||
const fetchGenRef = useRef(0);
|
||||
|
||||
const doFetch = useCallback(async () => {
|
||||
const gen = fetchGenRef.current;
|
||||
try {
|
||||
const data = await fetchSession(id);
|
||||
if (gen !== fetchGenRef.current) return;
|
||||
setSession(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (gen !== fetchGenRef.current) return;
|
||||
setError(err instanceof Error ? err.message : "Failed to load session");
|
||||
} finally {
|
||||
if (gen === fetchGenRef.current) setLoading(false);
|
||||
}
|
||||
}, [fetchSession, id]);
|
||||
|
||||
const startPolling = useCallback(() => {
|
||||
doFetch();
|
||||
intervalRef.current = setInterval(doFetch, POLL_INTERVAL);
|
||||
}, [doFetch]);
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setSession(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
startPolling();
|
||||
|
||||
const handleAppState = (nextState: AppStateStatus) => {
|
||||
if (nextState === "active") {
|
||||
stopPolling();
|
||||
startPolling();
|
||||
} else {
|
||||
stopPolling();
|
||||
}
|
||||
};
|
||||
|
||||
const sub = AppState.addEventListener("change", handleAppState);
|
||||
|
||||
return () => {
|
||||
fetchGenRef.current++; // Invalidate in-flight fetches from this effect run
|
||||
stopPolling();
|
||||
sub.remove();
|
||||
};
|
||||
}, [enabled, startPolling, stopPolling]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
if (!enabled) return;
|
||||
setLoading(true);
|
||||
doFetch();
|
||||
}, [enabled, doFetch]);
|
||||
|
||||
return { session, loading, error, refresh };
|
||||
}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import { AppState } from "react-native";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { getAttentionLevel, type AttentionLevel, type DashboardSession } from "../types";
|
||||
import { scheduleNotification } from "../notifications";
|
||||
import { NOTIFY_STATE_KEY } from "../notifications/backgroundTask";
|
||||
|
||||
/**
|
||||
* Minimum time (ms) before re-notifying for the same session.
|
||||
* Prevents spam when an agent oscillates between working <-> waiting_input.
|
||||
*/
|
||||
const COOLDOWN_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
export function useSessionNotifications(sessions: DashboardSession[]): void {
|
||||
const prevLevels = useRef<Record<string, AttentionLevel>>({});
|
||||
// Tracks when we last sent a notification per session
|
||||
const lastNotifiedAt = useRef<Record<string, number>>({});
|
||||
// Skip the very first render — don't notify for state that existed before app opened
|
||||
const isFirstRender = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (sessions.length === 0) return;
|
||||
|
||||
const nextLevels: Record<string, AttentionLevel> = {};
|
||||
const now = Date.now();
|
||||
// Only notify when app is in background — no point interrupting the user
|
||||
// if they're actively looking at the session list
|
||||
const isBackground = AppState.currentState !== "active";
|
||||
|
||||
for (const session of sessions) {
|
||||
const level = getAttentionLevel(session);
|
||||
nextLevels[session.id] = level;
|
||||
|
||||
if (!isFirstRender.current) {
|
||||
const prev = prevLevels.current[session.id];
|
||||
const lastNotified = lastNotifiedAt.current[session.id] ?? 0;
|
||||
const cooldownExpired = now - lastNotified > COOLDOWN_MS;
|
||||
|
||||
if (level === "respond" && (prev !== "respond" || cooldownExpired)) {
|
||||
if (isBackground || prev !== "respond") {
|
||||
void scheduleNotification(session, "respond").catch(() => {});
|
||||
lastNotifiedAt.current[session.id] = now;
|
||||
}
|
||||
} else if (level === "merge" && (prev !== "merge" || cooldownExpired)) {
|
||||
if (isBackground || prev !== "merge") {
|
||||
void scheduleNotification(session, "merge").catch(() => {});
|
||||
lastNotifiedAt.current[session.id] = now;
|
||||
}
|
||||
} else if (level === "review" && (prev !== "review" || cooldownExpired)) {
|
||||
if (isBackground || prev !== "review") {
|
||||
void scheduleNotification(session, "review").catch(() => {});
|
||||
lastNotifiedAt.current[session.id] = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
prevLevels.current = nextLevels;
|
||||
isFirstRender.current = false;
|
||||
|
||||
void AsyncStorage.setItem(NOTIFY_STATE_KEY, JSON.stringify(nextLevels));
|
||||
}, [sessions]);
|
||||
}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { AppState, type AppStateStatus } from "react-native";
|
||||
import { useBackend } from "../context/BackendContext";
|
||||
import type { DashboardSession, DashboardStats } from "../types";
|
||||
|
||||
const POLL_INTERVAL = 5_000;
|
||||
|
||||
interface UseSessionsResult {
|
||||
sessions: DashboardSession[];
|
||||
stats: DashboardStats | null;
|
||||
orchestratorId: string | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
export function useSessions(): UseSessionsResult {
|
||||
const { fetchSessions } = useBackend();
|
||||
const [sessions, setSessions] = useState<DashboardSession[]>([]);
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [orchestratorId, setOrchestratorId] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
// Generation counter — incremented on cleanup to invalidate in-flight fetches
|
||||
// from a previous effect run (e.g. when backend URL changes).
|
||||
const fetchGenRef = useRef(0);
|
||||
|
||||
const doFetch = useCallback(async () => {
|
||||
const gen = fetchGenRef.current;
|
||||
try {
|
||||
const data = await fetchSessions();
|
||||
if (gen !== fetchGenRef.current) return;
|
||||
setSessions(data.sessions ?? []);
|
||||
setStats(data.stats ?? null);
|
||||
setOrchestratorId(data.orchestratorId ?? null);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (gen !== fetchGenRef.current) return;
|
||||
setError(err instanceof Error ? err.message : "Failed to load sessions");
|
||||
} finally {
|
||||
if (gen === fetchGenRef.current) setLoading(false);
|
||||
}
|
||||
}, [fetchSessions]);
|
||||
|
||||
const startPolling = useCallback(() => {
|
||||
doFetch();
|
||||
intervalRef.current = setInterval(doFetch, POLL_INTERVAL);
|
||||
}, [doFetch]);
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
startPolling();
|
||||
|
||||
const handleAppState = (nextState: AppStateStatus) => {
|
||||
if (nextState === "active") {
|
||||
// Resumed — refresh immediately and restart polling
|
||||
stopPolling();
|
||||
startPolling();
|
||||
} else {
|
||||
// Backgrounded — stop polling to save battery
|
||||
stopPolling();
|
||||
}
|
||||
};
|
||||
|
||||
const sub = AppState.addEventListener("change", handleAppState);
|
||||
|
||||
return () => {
|
||||
fetchGenRef.current++; // Invalidate in-flight fetches from this effect run
|
||||
stopPolling();
|
||||
sub.remove();
|
||||
};
|
||||
}, [startPolling, stopPolling]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setLoading(true);
|
||||
doFetch();
|
||||
}, [doFetch]);
|
||||
|
||||
return { sessions, stats, orchestratorId, loading, error, refresh };
|
||||
}
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
import React from "react";
|
||||
import { NavigationContainer, DarkTheme, createNavigationContainerRef } from "@react-navigation/native";
|
||||
import { createNativeStackNavigator } from "@react-navigation/native-stack";
|
||||
import HomeScreen from "../screens/HomeScreen";
|
||||
import SessionDetailScreen from "../screens/SessionDetailScreen";
|
||||
import TerminalScreen from "../screens/TerminalScreen";
|
||||
import SettingsScreen from "../screens/SettingsScreen";
|
||||
import SpawnSessionScreen from "../screens/SpawnSessionScreen";
|
||||
import OrchestratorScreen from "../screens/OrchestratorScreen";
|
||||
import CommandsScreen from "../screens/CommandsScreen";
|
||||
|
||||
export type RootStackParamList = {
|
||||
Home: undefined;
|
||||
SessionDetail: { sessionId: string };
|
||||
Terminal: { sessionId: string; terminalWsUrl: string };
|
||||
Settings: undefined;
|
||||
SpawnSession: undefined;
|
||||
Orchestrator: undefined;
|
||||
Commands: undefined;
|
||||
};
|
||||
|
||||
export const navigationRef = createNavigationContainerRef<RootStackParamList>();
|
||||
|
||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||
|
||||
const AoDarkTheme = {
|
||||
...DarkTheme,
|
||||
colors: {
|
||||
...DarkTheme.colors,
|
||||
background: "#0d1117",
|
||||
card: "#161b22",
|
||||
text: "#e6edf3",
|
||||
border: "#30363d",
|
||||
primary: "#58a6ff",
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootNavigator() {
|
||||
return (
|
||||
<NavigationContainer ref={navigationRef} theme={AoDarkTheme}>
|
||||
<Stack.Navigator
|
||||
initialRouteName="Home"
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: "#161b22" },
|
||||
headerTintColor: "#e6edf3",
|
||||
headerTitleStyle: { fontWeight: "600" },
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="Home"
|
||||
component={HomeScreen}
|
||||
options={{ title: "Agent Orchestrator" }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="SessionDetail"
|
||||
component={SessionDetailScreen}
|
||||
options={{ title: "Session" }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Terminal"
|
||||
component={TerminalScreen}
|
||||
options={{
|
||||
title: "Terminal",
|
||||
headerStyle: { backgroundColor: "#0d1117" },
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Settings"
|
||||
component={SettingsScreen}
|
||||
options={{ title: "Settings" }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="SpawnSession"
|
||||
component={SpawnSessionScreen}
|
||||
options={{ title: "New Session" }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Orchestrator"
|
||||
component={OrchestratorScreen}
|
||||
options={{ title: "Orchestrator" }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Commands"
|
||||
component={CommandsScreen}
|
||||
options={{ title: "CLI Commands" }}
|
||||
/>
|
||||
</Stack.Navigator>
|
||||
</NavigationContainer>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
import * as TaskManager from "expo-task-manager";
|
||||
import * as BackgroundTask from "expo-background-task";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { getAttentionLevel, type AttentionLevel, type DashboardSession, type SessionsResponse } from "../types";
|
||||
import { scheduleNotification } from "./index";
|
||||
|
||||
export const TASK_ID = "ao-session-check";
|
||||
const BACKEND_URL_KEY = "@ao_backend_url";
|
||||
export const NOTIFY_STATE_KEY = "@ao_notify_state";
|
||||
const NOTIFY_TIMESTAMPS_KEY = "@ao_notify_timestamps";
|
||||
const COOLDOWN_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
/** Define the background task globally — must be at module top level. */
|
||||
TaskManager.defineTask(TASK_ID, async () => {
|
||||
try {
|
||||
const backendUrl = await AsyncStorage.getItem(BACKEND_URL_KEY);
|
||||
if (!backendUrl) return BackgroundTask.BackgroundTaskResult.Success;
|
||||
|
||||
const res = await fetch(`${backendUrl}/api/sessions`, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
if (!res.ok) return BackgroundTask.BackgroundTaskResult.Failed;
|
||||
|
||||
const data = (await res.json()) as SessionsResponse;
|
||||
const sessions: DashboardSession[] = data.sessions ?? [];
|
||||
|
||||
const [prevRaw, tsRaw] = await Promise.all([
|
||||
AsyncStorage.getItem(NOTIFY_STATE_KEY),
|
||||
AsyncStorage.getItem(NOTIFY_TIMESTAMPS_KEY),
|
||||
]);
|
||||
const prevState: Record<string, AttentionLevel> = prevRaw
|
||||
? (JSON.parse(prevRaw) as Record<string, AttentionLevel>)
|
||||
: {};
|
||||
const timestamps: Record<string, number> = tsRaw
|
||||
? (JSON.parse(tsRaw) as Record<string, number>)
|
||||
: {};
|
||||
|
||||
const nextState: Record<string, AttentionLevel> = {};
|
||||
const now = Date.now();
|
||||
|
||||
for (const session of sessions) {
|
||||
const level = getAttentionLevel(session);
|
||||
nextState[session.id] = level;
|
||||
|
||||
const prev = prevState[session.id];
|
||||
const lastNotified = timestamps[session.id] ?? 0;
|
||||
const cooldownExpired = now - lastNotified > COOLDOWN_MS;
|
||||
|
||||
if (level === "respond" && (prev !== "respond" || cooldownExpired)) {
|
||||
await scheduleNotification(session, "respond");
|
||||
timestamps[session.id] = now;
|
||||
} else if (level === "merge" && (prev !== "merge" || cooldownExpired)) {
|
||||
await scheduleNotification(session, "merge");
|
||||
timestamps[session.id] = now;
|
||||
} else if (level === "review" && (prev !== "review" || cooldownExpired)) {
|
||||
await scheduleNotification(session, "review");
|
||||
timestamps[session.id] = now;
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
AsyncStorage.setItem(NOTIFY_STATE_KEY, JSON.stringify(nextState)),
|
||||
AsyncStorage.setItem(NOTIFY_TIMESTAMPS_KEY, JSON.stringify(timestamps)),
|
||||
]);
|
||||
return BackgroundTask.BackgroundTaskResult.Success;
|
||||
} catch {
|
||||
return BackgroundTask.BackgroundTaskResult.Failed;
|
||||
}
|
||||
});
|
||||
|
||||
/** Register the background task. Safe to call multiple times. */
|
||||
export async function registerBackgroundTask(): Promise<void> {
|
||||
try {
|
||||
const isRegistered = await TaskManager.isTaskRegisteredAsync(TASK_ID);
|
||||
if (isRegistered) return;
|
||||
|
||||
await BackgroundTask.registerTaskAsync(TASK_ID, {
|
||||
minimumInterval: 15 * 60, // 15 minutes (OS minimum)
|
||||
});
|
||||
} catch {
|
||||
// Background tasks are not supported in Expo Go — ignore silently
|
||||
}
|
||||
}
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
import * as Notifications from "expo-notifications";
|
||||
import { Platform } from "react-native";
|
||||
import type { DashboardSession } from "../types";
|
||||
|
||||
const ANDROID_CHANNEL_ID = "ao-respond";
|
||||
|
||||
/** Configure how notifications are presented when the app is in foreground */
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: true,
|
||||
shouldPlaySound: true,
|
||||
shouldSetBadge: false,
|
||||
shouldShowBanner: true,
|
||||
shouldShowList: true,
|
||||
}),
|
||||
});
|
||||
|
||||
/** Create Android channel + request permission. Call once on app startup. */
|
||||
export async function setupNotifications(): Promise<boolean> {
|
||||
// Create Android notification channel
|
||||
if (Platform.OS === "android") {
|
||||
await Notifications.setNotificationChannelAsync(ANDROID_CHANNEL_ID, {
|
||||
name: "Agent Input Required",
|
||||
importance: Notifications.AndroidImportance.HIGH,
|
||||
vibrationPattern: [0, 250, 250, 250],
|
||||
lightColor: "#f85149",
|
||||
});
|
||||
}
|
||||
|
||||
const { status } = await Notifications.requestPermissionsAsync({
|
||||
ios: {
|
||||
allowAlert: true,
|
||||
allowBadge: false,
|
||||
allowSound: true,
|
||||
},
|
||||
});
|
||||
|
||||
return status === "granted";
|
||||
}
|
||||
|
||||
/** Fire an immediate local notification for a session attention transition. */
|
||||
export async function scheduleNotification(
|
||||
session: DashboardSession,
|
||||
level: "respond" | "merge" | "review",
|
||||
): Promise<void> {
|
||||
const sessionLabel =
|
||||
session.issueLabel ??
|
||||
session.id;
|
||||
|
||||
const body =
|
||||
session.issueTitle ??
|
||||
(session.summary && !session.summaryIsFallback ? session.summary : null) ??
|
||||
session.activity ??
|
||||
session.status;
|
||||
|
||||
const titles: Record<typeof level, string> = {
|
||||
respond: "Agent needs your input",
|
||||
merge: "PR ready to merge",
|
||||
review: "Session needs review",
|
||||
};
|
||||
|
||||
const bodies: Record<typeof level, string> = {
|
||||
respond: `${sessionLabel}: ${body}`,
|
||||
merge: `${sessionLabel}${session.pr ? `: PR #${session.pr.number}` : ""}`,
|
||||
review: `${sessionLabel}: ${session.pr?.ciStatus === "failing" ? "CI failing" : session.pr?.reviewDecision === "changes_requested" ? "Changes requested" : body}`,
|
||||
};
|
||||
|
||||
const content: Notifications.NotificationContentInput = {
|
||||
title: titles[level],
|
||||
body: bodies[level],
|
||||
data: { sessionId: session.id },
|
||||
sound: true,
|
||||
...(Platform.OS === "android" && { channelId: ANDROID_CHANNEL_ID }),
|
||||
};
|
||||
|
||||
// Use timeInterval trigger instead of null — trigger: null fails silently on Android in background tasks.
|
||||
// SDK 53 requires explicit `type` field on trigger objects.
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
content,
|
||||
trigger: {
|
||||
type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL,
|
||||
seconds: 1,
|
||||
channelId: Platform.OS === "android" ? ANDROID_CHANNEL_ID : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
import React from "react";
|
||||
import { View, Text, StyleSheet, ScrollView } from "react-native";
|
||||
|
||||
const CLI_COMMANDS = [
|
||||
{ cmd: "ao start [project]", desc: "Start orchestrator + dashboard" },
|
||||
{ cmd: "ao stop [project]", desc: "Stop orchestrator + dashboard" },
|
||||
{ cmd: "ao spawn <project> [issue]", desc: "Spawn a session for an issue" },
|
||||
{ cmd: "ao batch-spawn <project> <issues...>", desc: "Spawn multiple sessions" },
|
||||
{ cmd: "ao session ls [-p <project>]", desc: "List all active sessions" },
|
||||
{ cmd: "ao session kill <session>", desc: "Kill a session" },
|
||||
{ cmd: "ao session restore <session>", desc: "Restore a crashed session" },
|
||||
{ cmd: "ao session cleanup [-p <project>]", desc: "Clean up merged/closed sessions" },
|
||||
{ cmd: "ao send <session> [message]", desc: "Send message to a session" },
|
||||
{ cmd: "ao status [-p <project>]", desc: "Show sessions with PR/CI status" },
|
||||
{ cmd: "ao review-check [project]", desc: "Check PRs and trigger agents" },
|
||||
{ cmd: "ao dashboard [-p <port>]", desc: "Start the web dashboard" },
|
||||
{ cmd: "ao open [target]", desc: "Open session(s) in terminal" },
|
||||
{ cmd: "ao init [project]", desc: "Initialize config file" },
|
||||
];
|
||||
|
||||
export default function CommandsScreen() {
|
||||
return (
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.hint}>Quick reference for managing sessions from terminal.</Text>
|
||||
{CLI_COMMANDS.map((c, i) => (
|
||||
<View key={i} style={styles.cmdRow}>
|
||||
<Text style={styles.cmdText}>{c.cmd}</Text>
|
||||
<Text style={styles.cmdDesc}>{c.desc}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#0d1117",
|
||||
},
|
||||
content: {
|
||||
padding: 14,
|
||||
paddingBottom: 32,
|
||||
},
|
||||
section: {
|
||||
backgroundColor: "#161b22",
|
||||
borderRadius: 10,
|
||||
padding: 16,
|
||||
},
|
||||
hint: {
|
||||
color: "#8b949e",
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
marginBottom: 12,
|
||||
},
|
||||
cmdRow: {
|
||||
backgroundColor: "#0d1117",
|
||||
borderRadius: 6,
|
||||
padding: 10,
|
||||
marginBottom: 6,
|
||||
borderWidth: 1,
|
||||
borderColor: "#30363d",
|
||||
},
|
||||
cmdText: {
|
||||
color: "#58a6ff",
|
||||
fontSize: 12,
|
||||
fontFamily: "monospace",
|
||||
marginBottom: 4,
|
||||
},
|
||||
cmdDesc: {
|
||||
color: "#8b949e",
|
||||
fontSize: 12,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,176 +0,0 @@
|
|||
import React from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
FlatList,
|
||||
StyleSheet,
|
||||
RefreshControl,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
} from "react-native";
|
||||
import type { NativeStackScreenProps } from "@react-navigation/native-stack";
|
||||
import type { RootStackParamList } from "../navigation/RootNavigator";
|
||||
import { useSessions } from "../hooks/useSessions";
|
||||
import { useSessionNotifications } from "../hooks/useSessionNotifications";
|
||||
import SessionCard from "../components/SessionCard";
|
||||
import StatBar from "../components/StatBar";
|
||||
import { getAttentionLevel, type DashboardSession } from "../types";
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParamList, "Home">;
|
||||
|
||||
const ATTENTION_ORDER = ["respond", "merge", "review", "pending", "working", "done"] as const;
|
||||
|
||||
function sortSessions(sessions: DashboardSession[]): DashboardSession[] {
|
||||
return [...sessions].sort((a, b) => {
|
||||
const la = getAttentionLevel(a);
|
||||
const lb = getAttentionLevel(b);
|
||||
const ia = ATTENTION_ORDER.indexOf(la);
|
||||
const ib = ATTENTION_ORDER.indexOf(lb);
|
||||
if (ia !== ib) return ia - ib;
|
||||
return new Date(b.lastActivityAt).getTime() - new Date(a.lastActivityAt).getTime();
|
||||
});
|
||||
}
|
||||
|
||||
export default function HomeScreen({ navigation }: Props) {
|
||||
const { sessions, stats, loading, error, refresh } = useSessions();
|
||||
useSessionNotifications(sessions);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 14 }}>
|
||||
<TouchableOpacity
|
||||
onPress={() => navigation.navigate("SpawnSession")}
|
||||
style={{ flexDirection: "row", alignItems: "center", gap: 2 }}
|
||||
>
|
||||
<Text style={{ color: "#3fb950", fontSize: 18, fontWeight: "700" }}>+</Text>
|
||||
<Text style={{ color: "#3fb950", fontSize: 13, fontWeight: "600" }}>Session</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => navigation.navigate("Orchestrator")}>
|
||||
<Text style={{ fontSize: 20 }}>{"\uD83E\uDD16"}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => navigation.navigate("Settings")}
|
||||
style={{ paddingRight: 4 }}
|
||||
>
|
||||
<Text style={{ fontSize: 20 }}>{"\u2699\uFE0F"}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
),
|
||||
});
|
||||
}, [navigation]);
|
||||
|
||||
const sorted = sortSessions(sessions);
|
||||
|
||||
if (loading && sessions.length === 0) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator color="#58a6ff" size="large" />
|
||||
<Text style={styles.loadingText}>Connecting...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && sessions.length === 0) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
<TouchableOpacity style={styles.retryButton} onPress={refresh}>
|
||||
<Text style={styles.retryText}>Retry</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.retryButton, { marginTop: 8 }]}
|
||||
onPress={() => navigation.navigate("Settings")}
|
||||
>
|
||||
<Text style={styles.retryText}>Configure Backend URL</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{stats && <StatBar stats={stats} />}
|
||||
<FlatList
|
||||
data={sorted}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => (
|
||||
<SessionCard
|
||||
session={item}
|
||||
onPress={() => navigation.navigate("SessionDetail", { sessionId: item.id })}
|
||||
/>
|
||||
)}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={loading}
|
||||
onRefresh={refresh}
|
||||
tintColor="#58a6ff"
|
||||
colors={["#58a6ff"]}
|
||||
/>
|
||||
}
|
||||
contentContainerStyle={
|
||||
sorted.length === 0 ? styles.emptyContainer : styles.listContent
|
||||
}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.emptyText}>No sessions</Text>
|
||||
<Text style={styles.emptySubtext}>Sessions will appear here when agents are running</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#0d1117",
|
||||
},
|
||||
center: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 24,
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
listContent: {
|
||||
paddingVertical: 8,
|
||||
},
|
||||
loadingText: {
|
||||
color: "#8b949e",
|
||||
marginTop: 12,
|
||||
fontSize: 14,
|
||||
},
|
||||
errorText: {
|
||||
color: "#f85149",
|
||||
fontSize: 14,
|
||||
textAlign: "center",
|
||||
marginBottom: 16,
|
||||
},
|
||||
retryButton: {
|
||||
backgroundColor: "#21262d",
|
||||
borderWidth: 1,
|
||||
borderColor: "#30363d",
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
retryText: {
|
||||
color: "#e6edf3",
|
||||
fontSize: 14,
|
||||
},
|
||||
emptyText: {
|
||||
color: "#e6edf3",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
marginBottom: 8,
|
||||
},
|
||||
emptySubtext: {
|
||||
color: "#8b949e",
|
||||
fontSize: 13,
|
||||
textAlign: "center",
|
||||
},
|
||||
});
|
||||
|
|
@ -1,370 +0,0 @@
|
|||
import React, { useState, useCallback } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
TextInput,
|
||||
Alert,
|
||||
} from "react-native";
|
||||
import type { NativeStackScreenProps } from "@react-navigation/native-stack";
|
||||
import type { RootStackParamList } from "../navigation/RootNavigator";
|
||||
import { useSessions } from "../hooks/useSessions";
|
||||
import { useSession } from "../hooks/useSession";
|
||||
import { useBackend } from "../context/BackendContext";
|
||||
import AttentionBadge from "../components/AttentionBadge";
|
||||
import {
|
||||
getAttentionLevel,
|
||||
relativeTime,
|
||||
ATTENTION_COLORS,
|
||||
type DashboardSession,
|
||||
} from "../types";
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParamList, "Orchestrator">;
|
||||
|
||||
function getZoneCounts(sessions: DashboardSession[]) {
|
||||
const counts = { merge: 0, respond: 0, review: 0, pending: 0, working: 0, done: 0 };
|
||||
for (const s of sessions) {
|
||||
const level = getAttentionLevel(s);
|
||||
counts[level]++;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
export default function OrchestratorScreen({ navigation }: Props) {
|
||||
const { sessions, orchestratorId, loading, error, refresh } = useSessions();
|
||||
const { sendMessage, terminalWsUrl } = useBackend();
|
||||
const { session: orchSession } = useSession(orchestratorId ?? "", { enabled: !!orchestratorId });
|
||||
const [message, setMessage] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<TouchableOpacity onPress={() => navigation.navigate("Commands")}>
|
||||
<Text style={{ color: "#58a6ff", fontSize: 14, fontWeight: "600" }}>Commands</Text>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
});
|
||||
}, [navigation]);
|
||||
|
||||
const zones = getZoneCounts(sessions);
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!message.trim() || !orchestratorId) return;
|
||||
setSending(true);
|
||||
try {
|
||||
await sendMessage(orchestratorId, message.trim());
|
||||
setMessage("");
|
||||
} catch (err) {
|
||||
Alert.alert("Error", err instanceof Error ? err.message : "Failed to send message");
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}, [message, sendMessage, orchestratorId]);
|
||||
|
||||
if (loading && sessions.length === 0) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator color="#58a6ff" size="large" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && sessions.length === 0) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
<TouchableOpacity style={styles.retryButton} onPress={refresh}>
|
||||
<Text style={styles.retryText}>Retry</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const orchLevel = orchSession ? getAttentionLevel(orchSession) : null;
|
||||
const orchColor = orchLevel ? ATTENTION_COLORS[orchLevel] : "#8b949e";
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
{/* Orchestrator Session Details */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Orchestrator</Text>
|
||||
{orchestratorId && orchSession ? (
|
||||
<View style={[styles.orchDetailCard, { borderLeftColor: orchColor }]}>
|
||||
<View style={styles.orchHeaderRow}>
|
||||
<View style={styles.orchStatusRow}>
|
||||
<View style={[styles.dot, { backgroundColor: "#3fb950" }]} />
|
||||
<Text style={styles.orchRunning}>Running</Text>
|
||||
</View>
|
||||
<AttentionBadge level={orchLevel!} />
|
||||
</View>
|
||||
<Text style={styles.orchId}>{orchestratorId}</Text>
|
||||
<Text style={styles.orchStatus}>
|
||||
{orchSession.status}
|
||||
{orchSession.activity ? ` · ${orchSession.activity}` : ""}
|
||||
</Text>
|
||||
{orchSession.summary && !orchSession.summaryIsFallback && (
|
||||
<Text style={styles.orchSummary} numberOfLines={3}>{orchSession.summary}</Text>
|
||||
)}
|
||||
<View style={styles.orchTimingRow}>
|
||||
<Text style={styles.orchTiming}>Last activity: {relativeTime(orchSession.lastActivityAt)}</Text>
|
||||
</View>
|
||||
|
||||
{/* Actions */}
|
||||
<View style={styles.orchActions}>
|
||||
<TouchableOpacity
|
||||
style={styles.terminalButton}
|
||||
onPress={() => navigation.navigate("Terminal", { sessionId: orchestratorId, terminalWsUrl })}
|
||||
>
|
||||
<Text style={styles.terminalButtonText}>Open Terminal</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Send message */}
|
||||
<View style={styles.orchMessageRow}>
|
||||
<TextInput
|
||||
style={styles.orchMessageInput}
|
||||
placeholder="Send message to orchestrator..."
|
||||
placeholderTextColor="#8b949e"
|
||||
value={message}
|
||||
onChangeText={setMessage}
|
||||
returnKeyType="send"
|
||||
onSubmitEditing={handleSend}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[styles.sendButton, (!message.trim() || sending) && styles.sendButtonDisabled]}
|
||||
onPress={handleSend}
|
||||
disabled={!message.trim() || sending}
|
||||
>
|
||||
{sending ? (
|
||||
<ActivityIndicator color="#fff" size="small" />
|
||||
) : (
|
||||
<Text style={styles.sendButtonText}>Send</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.orchDetailCard}>
|
||||
<View style={styles.orchStatusRow}>
|
||||
<View style={[styles.dot, { backgroundColor: "#8b949e" }]} />
|
||||
<Text style={[styles.orchRunning, { color: "#8b949e" }]}>Not running</Text>
|
||||
</View>
|
||||
<Text style={styles.orchHint}>Start with: ao start <project></Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Zone Overview */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Session Zones</Text>
|
||||
<View style={styles.zonesGrid}>
|
||||
<ZoneBadge label="Merge" count={zones.merge} color="#3fb950" />
|
||||
<ZoneBadge label="Respond" count={zones.respond} color="#f85149" />
|
||||
<ZoneBadge label="Review" count={zones.review} color="#d29922" />
|
||||
<ZoneBadge label="Pending" count={zones.pending} color="#e3b341" />
|
||||
<ZoneBadge label="Working" count={zones.working} color="#58a6ff" />
|
||||
<ZoneBadge label="Done" count={zones.done} color="#8b949e" />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
function ZoneBadge({ label, count, color }: { label: string; count: number; color: string }) {
|
||||
return (
|
||||
<View style={styles.zoneBadge}>
|
||||
<Text style={[styles.zoneCount, { color }]}>{count}</Text>
|
||||
<Text style={styles.zoneLabel}>{label}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#0d1117",
|
||||
},
|
||||
content: {
|
||||
padding: 14,
|
||||
paddingBottom: 32,
|
||||
gap: 12,
|
||||
},
|
||||
center: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 24,
|
||||
backgroundColor: "#0d1117",
|
||||
},
|
||||
section: {
|
||||
backgroundColor: "#161b22",
|
||||
borderRadius: 10,
|
||||
padding: 16,
|
||||
},
|
||||
sectionTitle: {
|
||||
color: "#8b949e",
|
||||
fontSize: 11,
|
||||
fontWeight: "700",
|
||||
letterSpacing: 0.8,
|
||||
textTransform: "uppercase",
|
||||
marginBottom: 12,
|
||||
},
|
||||
// Orchestrator detail card
|
||||
orchDetailCard: {
|
||||
backgroundColor: "#0d1117",
|
||||
borderWidth: 1,
|
||||
borderColor: "#30363d",
|
||||
borderLeftWidth: 3,
|
||||
borderRadius: 8,
|
||||
padding: 14,
|
||||
},
|
||||
orchHeaderRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 6,
|
||||
},
|
||||
orchStatusRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
},
|
||||
dot: {
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
},
|
||||
orchRunning: {
|
||||
color: "#3fb950",
|
||||
fontSize: 15,
|
||||
fontWeight: "600",
|
||||
},
|
||||
orchId: {
|
||||
color: "#8b949e",
|
||||
fontSize: 12,
|
||||
fontFamily: "monospace",
|
||||
marginBottom: 4,
|
||||
},
|
||||
orchStatus: {
|
||||
color: "#8b949e",
|
||||
fontSize: 13,
|
||||
marginBottom: 4,
|
||||
},
|
||||
orchSummary: {
|
||||
color: "#e6edf3",
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
marginBottom: 4,
|
||||
},
|
||||
orchTimingRow: {
|
||||
marginBottom: 10,
|
||||
},
|
||||
orchTiming: {
|
||||
color: "#6e7681",
|
||||
fontSize: 12,
|
||||
},
|
||||
orchHint: {
|
||||
color: "#6e7681",
|
||||
fontSize: 12,
|
||||
marginTop: 4,
|
||||
},
|
||||
orchActions: {
|
||||
marginBottom: 10,
|
||||
},
|
||||
terminalButton: {
|
||||
backgroundColor: "#21262d",
|
||||
borderWidth: 1,
|
||||
borderColor: "#30363d",
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
alignItems: "center",
|
||||
},
|
||||
terminalButtonText: {
|
||||
color: "#e6edf3",
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
},
|
||||
orchMessageRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
},
|
||||
orchMessageInput: {
|
||||
flex: 1,
|
||||
backgroundColor: "#161b22",
|
||||
borderWidth: 1,
|
||||
borderColor: "#30363d",
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
color: "#e6edf3",
|
||||
fontSize: 14,
|
||||
},
|
||||
sendButton: {
|
||||
backgroundColor: "#238636",
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 14,
|
||||
height: 38,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
sendButtonDisabled: {
|
||||
backgroundColor: "#21262d",
|
||||
},
|
||||
sendButtonText: {
|
||||
color: "#fff",
|
||||
fontSize: 14,
|
||||
fontWeight: "700",
|
||||
},
|
||||
// Zones grid
|
||||
zonesGrid: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: 8,
|
||||
},
|
||||
zoneBadge: {
|
||||
backgroundColor: "#0d1117",
|
||||
borderWidth: 1,
|
||||
borderColor: "#30363d",
|
||||
borderRadius: 8,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 14,
|
||||
alignItems: "center",
|
||||
minWidth: 90,
|
||||
flex: 1,
|
||||
},
|
||||
zoneCount: {
|
||||
fontSize: 22,
|
||||
fontWeight: "700",
|
||||
},
|
||||
zoneLabel: {
|
||||
color: "#8b949e",
|
||||
fontSize: 11,
|
||||
fontWeight: "600",
|
||||
marginTop: 2,
|
||||
},
|
||||
// Error
|
||||
errorText: {
|
||||
color: "#f85149",
|
||||
fontSize: 14,
|
||||
textAlign: "center",
|
||||
marginBottom: 16,
|
||||
},
|
||||
retryButton: {
|
||||
backgroundColor: "#21262d",
|
||||
borderWidth: 1,
|
||||
borderColor: "#30363d",
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
retryText: {
|
||||
color: "#e6edf3",
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,741 +0,0 @@
|
|||
import React, { useState, useCallback, useRef, useEffect } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
Keyboard,
|
||||
Platform,
|
||||
Linking,
|
||||
} from "react-native";
|
||||
import type { NativeStackScreenProps } from "@react-navigation/native-stack";
|
||||
import type { RootStackParamList } from "../navigation/RootNavigator";
|
||||
import { useSession } from "../hooks/useSession";
|
||||
import { useBackend } from "../context/BackendContext";
|
||||
import AttentionBadge from "../components/AttentionBadge";
|
||||
import {
|
||||
getAttentionLevel,
|
||||
relativeTime,
|
||||
isRestorable,
|
||||
isTerminal,
|
||||
isPRRateLimited,
|
||||
ATTENTION_COLORS,
|
||||
type DashboardCICheck,
|
||||
type DashboardUnresolvedComment,
|
||||
type DashboardPR,
|
||||
} from "../types";
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParamList, "SessionDetail">;
|
||||
|
||||
type ActionButtonState = "idle" | "sending" | "sent" | "error";
|
||||
|
||||
export default function SessionDetailScreen({ route, navigation }: Props) {
|
||||
const { sessionId } = route.params;
|
||||
const { session, loading, error, refresh } = useSession(sessionId);
|
||||
const { sendMessage, killSession, restoreSession, mergePR, terminalWsUrl } = useBackend();
|
||||
const [message, setMessage] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [merging, setMerging] = useState(false);
|
||||
const [ciFixState, setCiFixState] = useState<ActionButtonState>("idle");
|
||||
const [commentFixStates, setCommentFixStates] = useState<Record<string, ActionButtonState>>({});
|
||||
const scrollRef = useRef<ScrollView>(null);
|
||||
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const showEvent = Platform.OS === "ios" ? "keyboardWillShow" : "keyboardDidShow";
|
||||
const hideEvent = Platform.OS === "ios" ? "keyboardWillHide" : "keyboardDidHide";
|
||||
|
||||
const showSub = Keyboard.addListener(showEvent, (e) => {
|
||||
setKeyboardHeight(e.endCoordinates.height);
|
||||
});
|
||||
const hideSub = Keyboard.addListener(hideEvent, () => {
|
||||
setKeyboardHeight(0);
|
||||
});
|
||||
|
||||
return () => {
|
||||
showSub.remove();
|
||||
hideSub.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!message.trim()) return;
|
||||
setSending(true);
|
||||
try {
|
||||
await sendMessage(sessionId, message.trim());
|
||||
setMessage("");
|
||||
} catch (err) {
|
||||
Alert.alert("Error", err instanceof Error ? err.message : "Failed to send message");
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}, [message, sendMessage, sessionId]);
|
||||
|
||||
const handleKill = useCallback(() => {
|
||||
Alert.alert(
|
||||
"Kill Session",
|
||||
"This will terminate the agent process. Are you sure?",
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Kill",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
await killSession(sessionId);
|
||||
refresh();
|
||||
} catch (err) {
|
||||
Alert.alert("Error", err instanceof Error ? err.message : "Failed to kill session");
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}, [killSession, sessionId, refresh]);
|
||||
|
||||
const handleRestore = useCallback(() => {
|
||||
Alert.alert(
|
||||
"Restore Session",
|
||||
"This will restore the session and make it active again. Are you sure?",
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Restore",
|
||||
onPress: async () => {
|
||||
try {
|
||||
await restoreSession(sessionId);
|
||||
refresh();
|
||||
} catch (err) {
|
||||
Alert.alert("Error", err instanceof Error ? err.message : "Failed to restore session");
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}, [restoreSession, sessionId, refresh]);
|
||||
|
||||
const handleOpenTerminal = useCallback(() => {
|
||||
navigation.navigate("Terminal", { sessionId, terminalWsUrl });
|
||||
}, [navigation, sessionId, terminalWsUrl]);
|
||||
|
||||
const handleMergePR = useCallback(async (prNumber: number) => {
|
||||
setMerging(true);
|
||||
try {
|
||||
await mergePR(prNumber);
|
||||
Alert.alert("Merged", `PR #${prNumber} merged successfully.`);
|
||||
refresh();
|
||||
} catch (err) {
|
||||
Alert.alert("Merge failed", err instanceof Error ? err.message : "Failed to merge PR");
|
||||
} finally {
|
||||
setMerging(false);
|
||||
}
|
||||
}, [mergePR, refresh]);
|
||||
|
||||
const handleAskFixCI = useCallback(async (pr: DashboardPR) => {
|
||||
setCiFixState("sending");
|
||||
try {
|
||||
await sendMessage(sessionId, `Please fix the failing CI checks on ${pr.url}`);
|
||||
setCiFixState("sent");
|
||||
setTimeout(() => setCiFixState("idle"), 3000);
|
||||
} catch {
|
||||
setCiFixState("error");
|
||||
setTimeout(() => setCiFixState("idle"), 3000);
|
||||
}
|
||||
}, [sendMessage, sessionId]);
|
||||
|
||||
const handleAskFixComment = useCallback(async (comment: DashboardUnresolvedComment) => {
|
||||
const key = comment.url;
|
||||
setCommentFixStates((prev) => ({ ...prev, [key]: "sending" }));
|
||||
try {
|
||||
const msg = `Please address this review comment:\n\nFile: ${comment.path}\nComment: ${comment.body}\n\nComment URL: ${comment.url}\n\nAfter fixing, mark the comment as resolved at ${comment.url}`;
|
||||
await sendMessage(sessionId, msg);
|
||||
setCommentFixStates((prev) => ({ ...prev, [key]: "sent" }));
|
||||
setTimeout(() => setCommentFixStates((prev) => ({ ...prev, [key]: "idle" })), 3000);
|
||||
} catch {
|
||||
setCommentFixStates((prev) => ({ ...prev, [key]: "error" }));
|
||||
setTimeout(() => setCommentFixStates((prev) => ({ ...prev, [key]: "idle" })), 3000);
|
||||
}
|
||||
}, [sendMessage, sessionId]);
|
||||
|
||||
if (loading && !session) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator color="#58a6ff" size="large" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !session) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
<TouchableOpacity style={styles.button} onPress={refresh}>
|
||||
<Text style={styles.buttonText}>Retry</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session) return null;
|
||||
|
||||
const level = getAttentionLevel(session);
|
||||
const color = ATTENTION_COLORS[level];
|
||||
const canRestore = isRestorable(session);
|
||||
const isDone = isTerminal(session);
|
||||
const pr = session.pr;
|
||||
const isReadyToMerge = pr?.mergeability.mergeable && pr.state === "open" && !isPRRateLimited(pr);
|
||||
const failedChecks = pr?.ciChecks.filter((c) => c.status === "failed") ?? [];
|
||||
const unresolvedComments = pr?.unresolvedComments ?? [];
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<ScrollView ref={scrollRef} style={styles.container} contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
|
||||
{/* Header row */}
|
||||
<View style={[styles.headerCard, { borderLeftColor: color }]}>
|
||||
<View style={styles.headerRow}>
|
||||
<Text style={styles.sessionId} numberOfLines={1} ellipsizeMode="middle">
|
||||
{session.id}
|
||||
</Text>
|
||||
<AttentionBadge level={level} />
|
||||
</View>
|
||||
<Text style={styles.status}>
|
||||
{session.status}
|
||||
{session.activity ? ` · ${session.activity}` : ""}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Alerts */}
|
||||
{pr && pr.ciStatus === "failing" && failedChecks.length > 0 && (
|
||||
<View style={styles.alertCard}>
|
||||
<View style={styles.alertRow}>
|
||||
<Text style={styles.alertText}>
|
||||
{failedChecks.length} CI check{failedChecks.length > 1 ? "s" : ""} failing
|
||||
</Text>
|
||||
<ActionButton
|
||||
state={ciFixState}
|
||||
label="Ask to fix"
|
||||
onPress={() => handleAskFixCI(pr)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{pr && !pr.mergeability.noConflicts && (
|
||||
<View style={[styles.alertCard, { borderLeftColor: "#d29922" }]}>
|
||||
<Text style={[styles.alertText, { color: "#d29922" }]}>Merge conflict</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{pr && pr.reviewDecision === "changes_requested" && (
|
||||
<View style={[styles.alertCard, { borderLeftColor: "#d29922" }]}>
|
||||
<Text style={[styles.alertText, { color: "#d29922" }]}>Changes requested</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Issue */}
|
||||
{(session.issueLabel || session.issueTitle) && (
|
||||
<Section title="Issue">
|
||||
<Text style={styles.issueText}>
|
||||
{session.issueLabel ? `${session.issueLabel}: ` : ""}
|
||||
{session.issueTitle ?? ""}
|
||||
</Text>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
{session.summary && !session.summaryIsFallback && (
|
||||
<Section title="Summary">
|
||||
<Text style={styles.bodyText}>{session.summary}</Text>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Branch */}
|
||||
{session.branch && (
|
||||
<Section title="Branch">
|
||||
<Text style={styles.monoText}>{session.branch}</Text>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* PR */}
|
||||
{pr && (
|
||||
<Section title={`PR #${pr.number}`}>
|
||||
<TouchableOpacity onPress={() => Linking.openURL(pr.url)}>
|
||||
<Text style={[styles.issueText, { color: "#58a6ff", marginBottom: 8 }]} numberOfLines={2}>
|
||||
{pr.title}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<InfoRow label="CI" value={pr.ciStatus} valueColor={pr.ciStatus === "passing" ? "#3fb950" : pr.ciStatus === "failing" ? "#f85149" : undefined} />
|
||||
<InfoRow label="Review" value={pr.reviewDecision} valueColor={pr.reviewDecision === "approved" ? "#3fb950" : pr.reviewDecision === "changes_requested" ? "#f85149" : undefined} />
|
||||
<InfoRow label="Mergeable" value={pr.mergeability.mergeable ? "Yes" : "No"} valueColor={pr.mergeability.mergeable ? "#3fb950" : "#f85149"} />
|
||||
<InfoRow label="Changes" value={`+${pr.additions} / -${pr.deletions}`} />
|
||||
{pr.isDraft && <InfoRow label="Draft" value="Yes" />}
|
||||
{pr.mergeability.blockers.length > 0 && (
|
||||
<View style={{ marginTop: 6 }}>
|
||||
<Text style={styles.blockersLabel}>Blockers:</Text>
|
||||
{pr.mergeability.blockers.map((b, i) => (
|
||||
<Text key={i} style={styles.blockerText}>- {b}</Text>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* CI Checks */}
|
||||
{pr && pr.ciChecks.length > 0 && (
|
||||
<Section title="CI Checks">
|
||||
{pr.ciChecks.map((check, i) => (
|
||||
<CICheckRow key={i} check={check} />
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Unresolved Comments */}
|
||||
{unresolvedComments.length > 0 && (
|
||||
<Section title={`Unresolved Comments (${unresolvedComments.length})`}>
|
||||
{unresolvedComments.map((comment, i) => (
|
||||
<View key={i} style={styles.commentCard}>
|
||||
<View style={styles.commentHeader}>
|
||||
<Text style={styles.commentAuthor}>{comment.author}</Text>
|
||||
<Text style={styles.commentPath} numberOfLines={1}>{comment.path}</Text>
|
||||
</View>
|
||||
<Text style={styles.commentBody} numberOfLines={4}>{comment.body}</Text>
|
||||
<View style={styles.commentActions}>
|
||||
<ActionButton
|
||||
state={commentFixStates[comment.url] ?? "idle"}
|
||||
label="Ask Agent to Fix"
|
||||
onPress={() => handleAskFixComment(comment)}
|
||||
/>
|
||||
<TouchableOpacity onPress={() => Linking.openURL(comment.url)}>
|
||||
<Text style={styles.viewLink}>View</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Timestamps */}
|
||||
<Section title="Timing">
|
||||
<InfoRow label="Created" value={relativeTime(session.createdAt)} />
|
||||
<InfoRow label="Last activity" value={relativeTime(session.lastActivityAt)} />
|
||||
</Section>
|
||||
|
||||
{/* Actions */}
|
||||
<View style={styles.actionsSection}>
|
||||
{isReadyToMerge && (
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, styles.mergeButton]}
|
||||
onPress={() => handleMergePR(pr.number)}
|
||||
disabled={merging}
|
||||
>
|
||||
{merging ? (
|
||||
<ActivityIndicator color="#fff" size="small" />
|
||||
) : (
|
||||
<Text style={[styles.actionButtonText, { color: "#fff" }]}>
|
||||
Merge PR #{pr.number}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
<TouchableOpacity style={[styles.actionButton, styles.terminalButton]} onPress={handleOpenTerminal}>
|
||||
<Text style={styles.actionButtonText}>Open Terminal</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{canRestore && (
|
||||
<TouchableOpacity style={[styles.actionButton, styles.restoreButton]} onPress={handleRestore}>
|
||||
<Text style={styles.actionButtonText}>Restore Session</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{!isDone && (
|
||||
<TouchableOpacity style={[styles.actionButton, styles.killButton]} onPress={handleKill}>
|
||||
<Text style={[styles.actionButtonText, { color: "#f85149" }]}>Kill Session</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Message input — only show for active sessions */}
|
||||
{!isDone && (
|
||||
<View style={[styles.messageBar, { paddingBottom: keyboardHeight > 0 ? keyboardHeight + 48 : 10 }]}>
|
||||
<TextInput
|
||||
style={styles.messageInput}
|
||||
placeholder="Send message to agent..."
|
||||
placeholderTextColor="#8b949e"
|
||||
value={message}
|
||||
onChangeText={setMessage}
|
||||
multiline
|
||||
returnKeyType="send"
|
||||
onSubmitEditing={handleSend}
|
||||
blurOnSubmit={false}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[styles.sendButton, (!message.trim() || sending) && styles.sendButtonDisabled]}
|
||||
onPress={handleSend}
|
||||
disabled={!message.trim() || sending}
|
||||
>
|
||||
{sending ? (
|
||||
<ActivityIndicator color="#fff" size="small" />
|
||||
) : (
|
||||
<Text style={styles.sendButtonText}>Send</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{title}</Text>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ label, value, valueColor }: { label: string; value: string; valueColor?: string }) {
|
||||
return (
|
||||
<View style={styles.infoRow}>
|
||||
<Text style={styles.infoLabel}>{label}</Text>
|
||||
<Text style={[styles.infoValue, valueColor ? { color: valueColor } : undefined]}>{value}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const CI_STATUS_ICONS: Record<string, { icon: string; color: string }> = {
|
||||
passed: { icon: "\u2713", color: "#3fb950" },
|
||||
failed: { icon: "\u2717", color: "#f85149" },
|
||||
running: { icon: "\u25CF", color: "#e3b341" },
|
||||
pending: { icon: "\u25CF", color: "#8b949e" },
|
||||
skipped: { icon: "\u2014", color: "#6e7681" },
|
||||
};
|
||||
|
||||
function CICheckRow({ check }: { check: DashboardCICheck }) {
|
||||
const info = CI_STATUS_ICONS[check.status] ?? CI_STATUS_ICONS.pending;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.ciCheckRow}
|
||||
onPress={check.url ? () => Linking.openURL(check.url!) : undefined}
|
||||
disabled={!check.url}
|
||||
>
|
||||
<Text style={[styles.ciCheckIcon, { color: info.color }]}>{info.icon}</Text>
|
||||
<Text style={styles.ciCheckName} numberOfLines={1}>{check.name}</Text>
|
||||
<Text style={[styles.ciCheckStatus, { color: info.color }]}>{check.status}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton({ state, label, onPress }: { state: ActionButtonState; label: string; onPress: () => void }) {
|
||||
const isDisabled = state === "sending" || state === "sent";
|
||||
const bgColor = state === "sent" ? "#1f3a2a" : state === "error" ? "#3d1f20" : "#21262d";
|
||||
const borderColor = state === "sent" ? "#3fb950" : state === "error" ? "#f85149" : "#30363d";
|
||||
const textColor = state === "sent" ? "#3fb950" : state === "error" ? "#f85149" : "#58a6ff";
|
||||
const text = state === "sending" ? "Sending..." : state === "sent" ? "Sent" : state === "error" ? "Failed" : label;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.inlineActionButton, { backgroundColor: bgColor, borderColor }]}
|
||||
onPress={onPress}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{state === "sending" ? (
|
||||
<ActivityIndicator color="#58a6ff" size="small" />
|
||||
) : (
|
||||
<Text style={[styles.inlineActionText, { color: textColor }]}>{text}</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
backgroundColor: "#0d1117",
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
padding: 12,
|
||||
paddingBottom: 24,
|
||||
},
|
||||
center: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 24,
|
||||
backgroundColor: "#0d1117",
|
||||
},
|
||||
headerCard: {
|
||||
backgroundColor: "#161b22",
|
||||
borderLeftWidth: 3,
|
||||
borderRadius: 8,
|
||||
padding: 14,
|
||||
marginBottom: 12,
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 6,
|
||||
},
|
||||
sessionId: {
|
||||
color: "#8b949e",
|
||||
fontSize: 13,
|
||||
fontFamily: "monospace",
|
||||
flex: 1,
|
||||
marginRight: 8,
|
||||
},
|
||||
status: {
|
||||
color: "#8b949e",
|
||||
fontSize: 13,
|
||||
},
|
||||
// Alerts
|
||||
alertCard: {
|
||||
backgroundColor: "#3d1f20",
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: "#f85149",
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
marginBottom: 10,
|
||||
},
|
||||
alertRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
},
|
||||
alertText: {
|
||||
color: "#f85149",
|
||||
fontSize: 13,
|
||||
fontWeight: "600",
|
||||
flex: 1,
|
||||
},
|
||||
// Sections
|
||||
section: {
|
||||
backgroundColor: "#161b22",
|
||||
borderRadius: 8,
|
||||
padding: 14,
|
||||
marginBottom: 10,
|
||||
},
|
||||
sectionTitle: {
|
||||
color: "#8b949e",
|
||||
fontSize: 11,
|
||||
fontWeight: "700",
|
||||
letterSpacing: 0.8,
|
||||
textTransform: "uppercase",
|
||||
marginBottom: 8,
|
||||
},
|
||||
bodyText: {
|
||||
color: "#e6edf3",
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
issueText: {
|
||||
color: "#e6edf3",
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
},
|
||||
monoText: {
|
||||
color: "#58a6ff",
|
||||
fontSize: 13,
|
||||
fontFamily: "monospace",
|
||||
},
|
||||
infoRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: 4,
|
||||
},
|
||||
infoLabel: {
|
||||
color: "#8b949e",
|
||||
fontSize: 13,
|
||||
},
|
||||
infoValue: {
|
||||
color: "#e6edf3",
|
||||
fontSize: 13,
|
||||
fontWeight: "500",
|
||||
},
|
||||
blockersLabel: {
|
||||
color: "#f85149",
|
||||
fontSize: 12,
|
||||
fontWeight: "600",
|
||||
marginBottom: 4,
|
||||
},
|
||||
blockerText: {
|
||||
color: "#f85149",
|
||||
fontSize: 12,
|
||||
marginLeft: 4,
|
||||
marginBottom: 2,
|
||||
},
|
||||
// CI Checks
|
||||
ciCheckRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingVertical: 6,
|
||||
gap: 8,
|
||||
},
|
||||
ciCheckIcon: {
|
||||
fontSize: 14,
|
||||
fontWeight: "700",
|
||||
width: 18,
|
||||
textAlign: "center",
|
||||
},
|
||||
ciCheckName: {
|
||||
color: "#e6edf3",
|
||||
fontSize: 13,
|
||||
flex: 1,
|
||||
},
|
||||
ciCheckStatus: {
|
||||
fontSize: 11,
|
||||
fontWeight: "600",
|
||||
},
|
||||
// Unresolved Comments
|
||||
commentCard: {
|
||||
backgroundColor: "#0d1117",
|
||||
borderRadius: 6,
|
||||
padding: 10,
|
||||
marginBottom: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: "#30363d",
|
||||
},
|
||||
commentHeader: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 6,
|
||||
},
|
||||
commentAuthor: {
|
||||
color: "#e6edf3",
|
||||
fontSize: 12,
|
||||
fontWeight: "600",
|
||||
},
|
||||
commentPath: {
|
||||
color: "#8b949e",
|
||||
fontSize: 11,
|
||||
fontFamily: "monospace",
|
||||
flex: 1,
|
||||
textAlign: "right",
|
||||
marginLeft: 8,
|
||||
},
|
||||
commentBody: {
|
||||
color: "#8b949e",
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
marginBottom: 8,
|
||||
},
|
||||
commentActions: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
},
|
||||
viewLink: {
|
||||
color: "#58a6ff",
|
||||
fontSize: 13,
|
||||
fontWeight: "600",
|
||||
},
|
||||
// Inline action button (for "Ask to fix", etc.)
|
||||
inlineActionButton: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
minWidth: 80,
|
||||
alignItems: "center",
|
||||
},
|
||||
inlineActionText: {
|
||||
fontSize: 12,
|
||||
fontWeight: "600",
|
||||
},
|
||||
// Main actions
|
||||
actionsSection: {
|
||||
gap: 8,
|
||||
marginTop: 4,
|
||||
},
|
||||
actionButton: {
|
||||
borderRadius: 8,
|
||||
padding: 14,
|
||||
alignItems: "center",
|
||||
borderWidth: 1,
|
||||
},
|
||||
mergeButton: {
|
||||
backgroundColor: "#238636",
|
||||
borderColor: "#2ea043",
|
||||
},
|
||||
terminalButton: {
|
||||
backgroundColor: "#21262d",
|
||||
borderColor: "#30363d",
|
||||
},
|
||||
restoreButton: {
|
||||
backgroundColor: "#1f3a2a",
|
||||
borderColor: "#3fb950",
|
||||
},
|
||||
killButton: {
|
||||
backgroundColor: "#21262d",
|
||||
borderColor: "#30363d",
|
||||
},
|
||||
actionButtonText: {
|
||||
color: "#e6edf3",
|
||||
fontSize: 15,
|
||||
fontWeight: "600",
|
||||
},
|
||||
messageBar: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-end",
|
||||
padding: 10,
|
||||
backgroundColor: "#161b22",
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: "#30363d",
|
||||
gap: 8,
|
||||
},
|
||||
messageInput: {
|
||||
flex: 1,
|
||||
backgroundColor: "#0d1117",
|
||||
borderWidth: 1,
|
||||
borderColor: "#30363d",
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
color: "#e6edf3",
|
||||
fontSize: 14,
|
||||
maxHeight: 100,
|
||||
},
|
||||
sendButton: {
|
||||
backgroundColor: "#238636",
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 16,
|
||||
height: 40,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
sendButtonDisabled: {
|
||||
backgroundColor: "#21262d",
|
||||
},
|
||||
sendButtonText: {
|
||||
color: "#fff",
|
||||
fontSize: 14,
|
||||
fontWeight: "700",
|
||||
},
|
||||
button: {
|
||||
backgroundColor: "#21262d",
|
||||
borderWidth: 1,
|
||||
borderColor: "#30363d",
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
buttonText: {
|
||||
color: "#e6edf3",
|
||||
fontSize: 14,
|
||||
},
|
||||
errorText: {
|
||||
color: "#f85149",
|
||||
fontSize: 14,
|
||||
textAlign: "center",
|
||||
marginBottom: 16,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,443 +0,0 @@
|
|||
import React, { useState } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import type { NativeStackScreenProps } from "@react-navigation/native-stack";
|
||||
import type { RootStackParamList } from "../navigation/RootNavigator";
|
||||
import { useBackend } from "../context/BackendContext";
|
||||
import { scheduleNotification } from "../notifications";
|
||||
import * as Notifications from "expo-notifications";
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParamList, "Settings">;
|
||||
|
||||
export default function SettingsScreen({ navigation }: Props) {
|
||||
const { backendUrl, setBackendUrl, terminalWsUrl, terminalWsOverride, setTerminalWsOverride } = useBackend();
|
||||
const [input, setInput] = useState(backendUrl);
|
||||
const [wsInput, setWsInput] = useState(terminalWsOverride);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleTestRespondNotification = async () => {
|
||||
const { status } = await Notifications.getPermissionsAsync();
|
||||
if (status !== "granted") {
|
||||
Alert.alert("Permission denied", "Notification permission is not granted. Enable it in your phone's Settings app.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await scheduleNotification(
|
||||
{
|
||||
id: "ao-test-session",
|
||||
projectId: "test",
|
||||
status: "needs_input",
|
||||
activity: "waiting_input",
|
||||
branch: "feat/test",
|
||||
issueId: null,
|
||||
issueUrl: null,
|
||||
issueLabel: "TEST-1",
|
||||
issueTitle: "Fix the flaky integration test",
|
||||
summary: "Waiting for your decision on the approach",
|
||||
summaryIsFallback: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastActivityAt: new Date().toISOString(),
|
||||
pr: null,
|
||||
metadata: {},
|
||||
},
|
||||
"respond",
|
||||
);
|
||||
Alert.alert("Sent", "A test 'respond' notification was fired. Check your notification shade.");
|
||||
} catch (err) {
|
||||
Alert.alert("Failed", err instanceof Error ? err.message : "Could not schedule notification.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestMergeNotification = async () => {
|
||||
const { status } = await Notifications.getPermissionsAsync();
|
||||
if (status !== "granted") {
|
||||
Alert.alert("Permission denied", "Notification permission is not granted. Enable it in your phone's Settings app.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await scheduleNotification(
|
||||
{
|
||||
id: "ao-test-session",
|
||||
projectId: "test",
|
||||
status: "mergeable",
|
||||
activity: "idle",
|
||||
branch: "feat/test",
|
||||
issueId: null,
|
||||
issueUrl: null,
|
||||
issueLabel: "TEST-1",
|
||||
issueTitle: "Add user authentication flow",
|
||||
summary: null,
|
||||
summaryIsFallback: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastActivityAt: new Date().toISOString(),
|
||||
pr: {
|
||||
number: 42,
|
||||
url: "",
|
||||
title: "Add auth flow",
|
||||
owner: "",
|
||||
repo: "",
|
||||
branch: "feat/test",
|
||||
baseBranch: "main",
|
||||
isDraft: false,
|
||||
state: "open",
|
||||
additions: 120,
|
||||
deletions: 8,
|
||||
ciStatus: "passing",
|
||||
ciChecks: [],
|
||||
reviewDecision: "approved",
|
||||
mergeability: { mergeable: true, ciPassing: true, approved: true, noConflicts: true, blockers: [] },
|
||||
unresolvedThreads: 0,
|
||||
},
|
||||
metadata: {},
|
||||
},
|
||||
"merge",
|
||||
);
|
||||
Alert.alert("Sent", "A test 'merge' notification was fired. Check your notification shade.");
|
||||
} catch (err) {
|
||||
Alert.alert("Failed", err instanceof Error ? err.message : "Could not schedule notification.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestReviewNotification = async () => {
|
||||
const { status } = await Notifications.getPermissionsAsync();
|
||||
if (status !== "granted") {
|
||||
Alert.alert("Permission denied", "Notification permission is not granted. Enable it in your phone's Settings app.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await scheduleNotification(
|
||||
{
|
||||
id: "ao-test-session",
|
||||
projectId: "test",
|
||||
status: "review_pending",
|
||||
activity: "idle",
|
||||
branch: "feat/test",
|
||||
issueId: null,
|
||||
issueUrl: null,
|
||||
issueLabel: "TEST-1",
|
||||
issueTitle: "Refactor database connection pool",
|
||||
summary: "Changes requested on PR",
|
||||
summaryIsFallback: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastActivityAt: new Date().toISOString(),
|
||||
pr: {
|
||||
number: 99,
|
||||
url: "",
|
||||
title: "Refactor DB pool",
|
||||
owner: "",
|
||||
repo: "",
|
||||
branch: "feat/test",
|
||||
baseBranch: "main",
|
||||
isDraft: false,
|
||||
state: "open",
|
||||
additions: 85,
|
||||
deletions: 42,
|
||||
ciStatus: "passing",
|
||||
ciChecks: [],
|
||||
reviewDecision: "changes_requested",
|
||||
mergeability: { mergeable: true, ciPassing: true, approved: false, noConflicts: true, blockers: ["Changes requested"] },
|
||||
unresolvedThreads: 2,
|
||||
},
|
||||
metadata: {},
|
||||
},
|
||||
"review",
|
||||
);
|
||||
Alert.alert("Sent", "A test 'review' notification was fired. Check your notification shade.");
|
||||
} catch (err) {
|
||||
Alert.alert("Failed", err instanceof Error ? err.message : "Could not schedule notification.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
Alert.alert("Invalid URL", "Please enter a valid backend URL.");
|
||||
return;
|
||||
}
|
||||
if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) {
|
||||
Alert.alert("Invalid URL", "URL must start with http:// or https://");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await setBackendUrl(trimmed);
|
||||
await setTerminalWsOverride(wsInput.trim());
|
||||
Alert.alert("Saved", "Settings updated.", [
|
||||
{ text: "OK", onPress: () => navigation.goBack() },
|
||||
]);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.root}
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
>
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Backend URL</Text>
|
||||
<Text style={styles.hint}>
|
||||
Enter the URL where your AO dashboard is running.
|
||||
</Text>
|
||||
<Text style={styles.fieldLabel}>Dashboard API URL</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={input}
|
||||
onChangeText={setInput}
|
||||
placeholder="http://100.x.x.x:3000 or https://abc.ngrok-free.app"
|
||||
placeholderTextColor="#8b949e"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
returnKeyType="next"
|
||||
/>
|
||||
<Text style={styles.fieldLabel}>
|
||||
Terminal WebSocket URL{" "}
|
||||
<Text style={styles.fieldLabelMuted}>(leave blank to auto-derive)</Text>
|
||||
</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={wsInput}
|
||||
onChangeText={setWsInput}
|
||||
placeholder="wss://xyz.ngrok-free.app (only needed for ngrok)"
|
||||
placeholderTextColor="#8b949e"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
returnKeyType="done"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[styles.saveButton, saving && styles.saveButtonDisabled]}
|
||||
onPress={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
<Text style={styles.saveButtonText}>{saving ? "Saving..." : "Save"}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Active URLs</Text>
|
||||
<SettingsInfoRow label="Dashboard" value={backendUrl} />
|
||||
<SettingsInfoRow
|
||||
label="Terminal WS"
|
||||
value={terminalWsUrl}
|
||||
note={terminalWsOverride ? "manual" : "auto"}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{__DEV__ && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Test Notifications</Text>
|
||||
<Text style={styles.hint}>Fire a test notification to verify permissions are working. Tap the notification to navigate to the session.</Text>
|
||||
<TouchableOpacity style={[styles.testButton, { borderColor: "#f85149" }]} onPress={handleTestRespondNotification}>
|
||||
<Text style={[styles.testButtonText, { color: "#f85149" }]}>Test "Agent needs input" (respond)</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={[styles.testButton, { borderColor: "#3fb950", marginTop: 8 }]} onPress={handleTestMergeNotification}>
|
||||
<Text style={[styles.testButtonText, { color: "#3fb950" }]}>Test "PR ready to merge" (merge)</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={[styles.testButton, { borderColor: "#d29922", marginTop: 8 }]} onPress={handleTestReviewNotification}>
|
||||
<Text style={[styles.testButtonText, { color: "#d29922" }]}>Test "Session needs review" (review)</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Setup Guide — Tailscale (Recommended)</Text>
|
||||
<Step n="1" text="Install Tailscale on your Mac and phone (tailscale.com)" />
|
||||
<Step n="2" text="Sign in to the same Tailscale account on both devices" />
|
||||
<Step n="3" text="Find your Mac's Tailscale IP: run 'tailscale ip -4' in terminal (starts with 100.x)" />
|
||||
<Step n="4" text="Make sure the orchestrator is running: pnpm build && pnpm dev" />
|
||||
<Step n="5" text="Enter http://<TAILSCALE_IP>:3000 above and tap Save" />
|
||||
<Text style={styles.sectionDivider}>Alternative: Local Wi-Fi</Text>
|
||||
<Step n="1" text="Your phone must be on the same Wi-Fi as your Mac" />
|
||||
<Step n="2" text="Find your Mac's LAN IP: System Settings > Wi-Fi > Details > IP Address" />
|
||||
<Step n="3" text="Enter http://<LAN_IP>:3000 above and tap Save" />
|
||||
<Text style={styles.sectionDivider}>Alternative: ngrok</Text>
|
||||
<Step n="1" text="Run: ngrok http 3000" />
|
||||
<Step n="2" text="Paste the https:// URL above as Dashboard API URL" />
|
||||
<Step n="3" text="For terminal, run: ngrok http 14801 and paste the wss:// URL in Terminal WebSocket URL" />
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsInfoRow({ label, value, note }: { label: string; value: string; note?: string }) {
|
||||
return (
|
||||
<View style={styles.infoRow}>
|
||||
<View style={styles.infoLabelRow}>
|
||||
<Text style={styles.infoLabel}>{label}</Text>
|
||||
{note ? <Text style={styles.infoNote}>{note}</Text> : null}
|
||||
</View>
|
||||
<Text style={styles.infoValue} numberOfLines={1} ellipsizeMode="middle">
|
||||
{value}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Step({ n, text }: { n: string; text: string }) {
|
||||
return (
|
||||
<View style={styles.step}>
|
||||
<View style={styles.stepBadge}>
|
||||
<Text style={styles.stepN}>{n}</Text>
|
||||
</View>
|
||||
<Text style={styles.stepText}>{text}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
backgroundColor: "#0d1117",
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
padding: 14,
|
||||
paddingBottom: 32,
|
||||
gap: 12,
|
||||
},
|
||||
section: {
|
||||
backgroundColor: "#161b22",
|
||||
borderRadius: 10,
|
||||
padding: 16,
|
||||
},
|
||||
sectionTitle: {
|
||||
color: "#8b949e",
|
||||
fontSize: 11,
|
||||
fontWeight: "700",
|
||||
letterSpacing: 0.8,
|
||||
textTransform: "uppercase",
|
||||
marginBottom: 12,
|
||||
},
|
||||
sectionDivider: {
|
||||
color: "#8b949e",
|
||||
fontSize: 11,
|
||||
fontWeight: "700",
|
||||
letterSpacing: 0.8,
|
||||
textTransform: "uppercase",
|
||||
marginTop: 12,
|
||||
marginBottom: 12,
|
||||
paddingTop: 12,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: "#30363d",
|
||||
},
|
||||
hint: {
|
||||
color: "#8b949e",
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
marginBottom: 12,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: "#0d1117",
|
||||
borderWidth: 1,
|
||||
borderColor: "#30363d",
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
color: "#e6edf3",
|
||||
fontSize: 14,
|
||||
fontFamily: "monospace",
|
||||
marginBottom: 12,
|
||||
},
|
||||
saveButton: {
|
||||
backgroundColor: "#238636",
|
||||
borderRadius: 8,
|
||||
paddingVertical: 12,
|
||||
alignItems: "center",
|
||||
},
|
||||
saveButtonDisabled: {
|
||||
backgroundColor: "#21262d",
|
||||
},
|
||||
saveButtonText: {
|
||||
color: "#fff",
|
||||
fontSize: 15,
|
||||
fontWeight: "600",
|
||||
},
|
||||
testButton: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
paddingVertical: 12,
|
||||
alignItems: "center",
|
||||
},
|
||||
testButtonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
},
|
||||
infoRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: 6,
|
||||
},
|
||||
infoLabel: {
|
||||
color: "#8b949e",
|
||||
fontSize: 13,
|
||||
width: 100,
|
||||
},
|
||||
infoValue: {
|
||||
color: "#58a6ff",
|
||||
fontSize: 13,
|
||||
fontFamily: "monospace",
|
||||
flex: 1,
|
||||
textAlign: "right",
|
||||
},
|
||||
infoLabelRow: {
|
||||
flexDirection: "column",
|
||||
width: 100,
|
||||
},
|
||||
infoNote: {
|
||||
color: "#8b949e",
|
||||
fontSize: 10,
|
||||
marginTop: 1,
|
||||
},
|
||||
fieldLabel: {
|
||||
color: "#8b949e",
|
||||
fontSize: 12,
|
||||
fontWeight: "600",
|
||||
marginBottom: 6,
|
||||
},
|
||||
fieldLabelMuted: {
|
||||
color: "#6e7681",
|
||||
fontWeight: "400",
|
||||
},
|
||||
step: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
marginBottom: 12,
|
||||
gap: 10,
|
||||
},
|
||||
stepBadge: {
|
||||
backgroundColor: "#21262d",
|
||||
borderRadius: 10,
|
||||
width: 20,
|
||||
height: 20,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
stepN: {
|
||||
color: "#58a6ff",
|
||||
fontSize: 11,
|
||||
fontWeight: "700",
|
||||
},
|
||||
stepText: {
|
||||
color: "#8b949e",
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,173 +0,0 @@
|
|||
import React, { useState, useCallback } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
} from "react-native";
|
||||
import type { NativeStackScreenProps } from "@react-navigation/native-stack";
|
||||
import type { RootStackParamList } from "../navigation/RootNavigator";
|
||||
import { useBackend } from "../context/BackendContext";
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParamList, "SpawnSession">;
|
||||
|
||||
export default function SpawnSessionScreen({ navigation }: Props) {
|
||||
const { spawnSession } = useBackend();
|
||||
const [projectId, setProjectId] = useState("");
|
||||
const [issueId, setIssueId] = useState("");
|
||||
const [spawning, setSpawning] = useState(false);
|
||||
|
||||
const handleSpawn = useCallback(async () => {
|
||||
const trimmedProject = projectId.trim();
|
||||
if (!trimmedProject) {
|
||||
Alert.alert("Invalid", "Project ID is required.");
|
||||
return;
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(trimmedProject)) {
|
||||
Alert.alert("Invalid", "Project ID must be alphanumeric (hyphens and underscores allowed).");
|
||||
return;
|
||||
}
|
||||
const trimmedIssue = issueId.trim();
|
||||
if (trimmedIssue && !/^[a-zA-Z0-9_-]+$/.test(trimmedIssue)) {
|
||||
Alert.alert("Invalid", "Issue ID must be alphanumeric (hyphens and underscores allowed).");
|
||||
return;
|
||||
}
|
||||
|
||||
setSpawning(true);
|
||||
try {
|
||||
const session = await spawnSession(trimmedProject, trimmedIssue || undefined);
|
||||
navigation.replace("SessionDetail", { sessionId: session.id });
|
||||
} catch (err) {
|
||||
Alert.alert("Spawn failed", err instanceof Error ? err.message : "Failed to spawn session");
|
||||
} finally {
|
||||
setSpawning(false);
|
||||
}
|
||||
}, [projectId, issueId, spawnSession, navigation]);
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.root}
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
>
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Spawn New Session</Text>
|
||||
<Text style={styles.hint}>
|
||||
Create a new agent session. The orchestrator will assign an agent and workspace.
|
||||
</Text>
|
||||
|
||||
<Text style={styles.fieldLabel}>Project ID *</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={projectId}
|
||||
onChangeText={setProjectId}
|
||||
placeholder="e.g. my-project"
|
||||
placeholderTextColor="#8b949e"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
returnKeyType="next"
|
||||
/>
|
||||
|
||||
<Text style={styles.fieldLabel}>
|
||||
Issue ID{" "}
|
||||
<Text style={styles.fieldLabelMuted}>(optional)</Text>
|
||||
</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={issueId}
|
||||
onChangeText={setIssueId}
|
||||
placeholder="e.g. 42 or PROJ-123"
|
||||
placeholderTextColor="#8b949e"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
returnKeyType="done"
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.spawnButton, spawning && styles.spawnButtonDisabled]}
|
||||
onPress={handleSpawn}
|
||||
disabled={spawning}
|
||||
>
|
||||
<Text style={styles.spawnButtonText}>
|
||||
{spawning ? "Spawning..." : "Spawn Session"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
backgroundColor: "#0d1117",
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
padding: 14,
|
||||
},
|
||||
section: {
|
||||
backgroundColor: "#161b22",
|
||||
borderRadius: 10,
|
||||
padding: 16,
|
||||
},
|
||||
sectionTitle: {
|
||||
color: "#8b949e",
|
||||
fontSize: 11,
|
||||
fontWeight: "700",
|
||||
letterSpacing: 0.8,
|
||||
textTransform: "uppercase",
|
||||
marginBottom: 12,
|
||||
},
|
||||
hint: {
|
||||
color: "#8b949e",
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
marginBottom: 16,
|
||||
},
|
||||
fieldLabel: {
|
||||
color: "#8b949e",
|
||||
fontSize: 12,
|
||||
fontWeight: "600",
|
||||
marginBottom: 6,
|
||||
},
|
||||
fieldLabelMuted: {
|
||||
color: "#6e7681",
|
||||
fontWeight: "400",
|
||||
},
|
||||
input: {
|
||||
backgroundColor: "#0d1117",
|
||||
borderWidth: 1,
|
||||
borderColor: "#30363d",
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
color: "#e6edf3",
|
||||
fontSize: 14,
|
||||
fontFamily: "monospace",
|
||||
marginBottom: 14,
|
||||
},
|
||||
spawnButton: {
|
||||
backgroundColor: "#238636",
|
||||
borderRadius: 8,
|
||||
paddingVertical: 14,
|
||||
alignItems: "center",
|
||||
marginTop: 4,
|
||||
},
|
||||
spawnButtonDisabled: {
|
||||
backgroundColor: "#21262d",
|
||||
},
|
||||
spawnButtonText: {
|
||||
color: "#fff",
|
||||
fontSize: 15,
|
||||
fontWeight: "600",
|
||||
},
|
||||
});
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
import React, { useRef, useCallback, useState } from "react";
|
||||
import { View, StyleSheet, StatusBar, Text } from "react-native";
|
||||
import { WebView, type WebViewMessageEvent } from "react-native-webview";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import type { NativeStackScreenProps } from "@react-navigation/native-stack";
|
||||
import type { RootStackParamList } from "../navigation/RootNavigator";
|
||||
|
||||
import { TERMINAL_HTML } from "../terminal/terminal-html";
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParamList, "Terminal">;
|
||||
|
||||
type ConnectionStatus = "connecting" | "connected" | "error" | "disconnected";
|
||||
|
||||
const STATUS_COLORS: Record<ConnectionStatus, string> = {
|
||||
connecting: "#e3b341",
|
||||
connected: "#3fb950",
|
||||
error: "#f85149",
|
||||
disconnected: "#f85149",
|
||||
};
|
||||
|
||||
export default function TerminalScreen({ route }: Props) {
|
||||
const { sessionId, terminalWsUrl } = route.params;
|
||||
const webViewRef = useRef<WebView>(null);
|
||||
const insets = useSafeAreaInsets();
|
||||
const [status, setStatus] = useState<ConnectionStatus>("connecting");
|
||||
|
||||
// Inject WS URL and session ID BEFORE xterm.js init runs
|
||||
const injectedJS = `
|
||||
window.AO_WS_URL = ${JSON.stringify(terminalWsUrl)};
|
||||
window.AO_SESSION_ID = ${JSON.stringify(sessionId)};
|
||||
true; // required return value
|
||||
`;
|
||||
|
||||
const handleMessage = useCallback((event: WebViewMessageEvent) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.nativeEvent.data) as {
|
||||
type: string;
|
||||
state?: ConnectionStatus;
|
||||
};
|
||||
if (msg.type === "status" && msg.state) {
|
||||
setStatus(msg.state);
|
||||
}
|
||||
} catch {
|
||||
// ignore unparseable messages
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleLoad = useCallback(() => {
|
||||
// Ask xterm to fit to current viewport
|
||||
webViewRef.current?.injectJavaScript(
|
||||
'window.dispatchEvent(new MessageEvent("message", { data: JSON.stringify({ type: "fit" }) })); true;',
|
||||
);
|
||||
}, []);
|
||||
|
||||
const dotColor = STATUS_COLORS[status];
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingBottom: insets.bottom }]}>
|
||||
<StatusBar barStyle="light-content" backgroundColor="#0d1117" />
|
||||
|
||||
{/* Status indicator in top-right */}
|
||||
<View style={styles.statusDot}>
|
||||
<View style={[styles.dot, { backgroundColor: dotColor }]} />
|
||||
<Text style={[styles.statusText, { color: dotColor }]}>
|
||||
{status}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<WebView
|
||||
ref={webViewRef}
|
||||
source={{ html: TERMINAL_HTML }}
|
||||
injectedJavaScriptBeforeContentLoaded={injectedJS}
|
||||
onMessage={handleMessage}
|
||||
onLoad={handleLoad}
|
||||
style={styles.webView}
|
||||
originWhitelist={["*"]}
|
||||
// Required for ws:// from about:blank on Android
|
||||
mixedContentMode="always"
|
||||
allowFileAccess={false}
|
||||
javaScriptEnabled={true}
|
||||
domStorageEnabled={false}
|
||||
scrollEnabled={true}
|
||||
bounces={false}
|
||||
overScrollMode="never"
|
||||
showsVerticalScrollIndicator={false}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
keyboardDisplayRequiresUserAction={false}
|
||||
// Suppress "Can't open file" logs for blob: URLs
|
||||
onError={() => {}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#0d1117",
|
||||
},
|
||||
webView: {
|
||||
flex: 1,
|
||||
backgroundColor: "#0d1117",
|
||||
},
|
||||
statusDot: {
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 12,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
zIndex: 10,
|
||||
gap: 4,
|
||||
},
|
||||
dot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 11,
|
||||
fontWeight: "600",
|
||||
},
|
||||
});
|
||||
|
|
@ -1,178 +0,0 @@
|
|||
/**
|
||||
* Standalone xterm.js terminal page, loaded into WebView.
|
||||
* WS URL and session ID are injected via injectedJavaScriptBeforeContentLoaded
|
||||
* which sets window.AO_WS_URL and window.AO_SESSION_ID before the script runs.
|
||||
*/
|
||||
export const TERMINAL_HTML = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>Terminal</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.js"><\/script>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js"><\/script>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { width: 100%; height: 100%; background: #0d1117; overflow: hidden; }
|
||||
#terminal { width: 100%; height: 100%; }
|
||||
#status {
|
||||
position: fixed;
|
||||
top: 6px;
|
||||
right: 8px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #8b949e;
|
||||
z-index: 10;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
#status.connecting { background: #e3b341; }
|
||||
#status.connected { background: #3fb950; }
|
||||
#status.error { background: #f85149; }
|
||||
<\/style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="status" class="connecting"></div>
|
||||
<div id="terminal"></div>
|
||||
<script>
|
||||
(function () {
|
||||
var WS_BASE = window.AO_WS_URL || 'ws://localhost:3003';
|
||||
var SESSION = window.AO_SESSION_ID || '';
|
||||
|
||||
var statusEl = document.getElementById('status');
|
||||
var term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 13,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
theme: {
|
||||
background: '#0d1117',
|
||||
foreground: '#e6edf3',
|
||||
cursor: '#e6edf3',
|
||||
black: '#0d1117',
|
||||
red: '#f85149',
|
||||
green: '#3fb950',
|
||||
yellow: '#e3b341',
|
||||
blue: '#58a6ff',
|
||||
magenta: '#bc8cff',
|
||||
cyan: '#39c5cf',
|
||||
white: '#b1bac4',
|
||||
brightBlack: '#6e7681',
|
||||
brightRed: '#ff7b72',
|
||||
brightGreen: '#56d364',
|
||||
brightYellow: '#e3b341',
|
||||
brightBlue: '#79c0ff',
|
||||
brightMagenta: '#d2a8ff',
|
||||
brightCyan: '#56d4dd',
|
||||
brightWhite: '#f0f6fc',
|
||||
},
|
||||
allowProposedApi: true,
|
||||
});
|
||||
|
||||
var fitAddon = new FitAddon.FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.open(document.getElementById('terminal'));
|
||||
fitAddon.fit();
|
||||
|
||||
function postToRN(obj) {
|
||||
try {
|
||||
if (window.ReactNativeWebView) {
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify(obj));
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function sendResize() {
|
||||
fitAddon.fit();
|
||||
var cols = term.cols;
|
||||
var rows = term.rows;
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'resize', cols: cols, rows: rows }));
|
||||
}
|
||||
postToRN({ type: 'resize', cols: cols, rows: rows });
|
||||
}
|
||||
|
||||
window.addEventListener('resize', sendResize);
|
||||
|
||||
var ws;
|
||||
var reconnectDelay = 1000;
|
||||
|
||||
function connect() {
|
||||
var url = WS_BASE + '/ws?session=' + encodeURIComponent(SESSION);
|
||||
statusEl.className = 'connecting';
|
||||
postToRN({ type: 'status', state: 'connecting' });
|
||||
|
||||
ws = new WebSocket(url);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.onopen = function () {
|
||||
statusEl.className = 'connected';
|
||||
postToRN({ type: 'status', state: 'connected' });
|
||||
reconnectDelay = 1000;
|
||||
sendResize();
|
||||
};
|
||||
|
||||
ws.onmessage = function (evt) {
|
||||
if (typeof evt.data === 'string') {
|
||||
// Filter out JSON control messages (e.g. resize echoes)
|
||||
try {
|
||||
var msg = JSON.parse(evt.data);
|
||||
if (msg.type === 'resize') return; // echo, ignore
|
||||
} catch (e) { /* not JSON — write as terminal text */ }
|
||||
term.write(evt.data);
|
||||
} else {
|
||||
// Binary (ArrayBuffer)
|
||||
term.write(new Uint8Array(evt.data));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = function () {
|
||||
statusEl.className = 'error';
|
||||
postToRN({ type: 'status', state: 'error' });
|
||||
};
|
||||
|
||||
ws.onclose = function () {
|
||||
statusEl.className = 'error';
|
||||
postToRN({ type: 'status', state: 'disconnected' });
|
||||
setTimeout(function () {
|
||||
reconnectDelay = Math.min(reconnectDelay * 1.5, 10000);
|
||||
connect();
|
||||
}, reconnectDelay);
|
||||
};
|
||||
}
|
||||
|
||||
term.onData(function (data) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(data);
|
||||
}
|
||||
});
|
||||
|
||||
// XDA handler: respond to CSI > q with XTerm identity (enables tmux clipboard)
|
||||
// Must use parser.registerCsiHandler — onData only captures outgoing user input,
|
||||
// not incoming data from the WebSocket. The XDA query arrives via ws → term.write().
|
||||
term.parser.registerCsiHandler({ prefix: '>', final: 'q' }, function () {
|
||||
term.write('\\x1bP>|XTerm(370)\\x1b\\\\');
|
||||
return true;
|
||||
});
|
||||
|
||||
document.addEventListener('message', function (evt) { handleRNMessage(evt.data); });
|
||||
window.addEventListener('message', function (evt) { handleRNMessage(evt.data); });
|
||||
|
||||
function handleRNMessage(raw) {
|
||||
try {
|
||||
var msg = JSON.parse(raw);
|
||||
if (msg.type === 'fit') sendResize();
|
||||
else if (msg.type === 'focus') term.focus();
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
if (SESSION) {
|
||||
connect();
|
||||
} else {
|
||||
term.write('\\x1b[31mError: No session ID provided.\\x1b[0m\\r\\n');
|
||||
statusEl.className = 'error';
|
||||
}
|
||||
})();
|
||||
<\/script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
|
@ -1,222 +0,0 @@
|
|||
/**
|
||||
* Mobile-local types mirroring the web dashboard types.
|
||||
* These must stay in sync with packages/web/src/lib/types.ts.
|
||||
*/
|
||||
|
||||
export type SessionStatus =
|
||||
| "spawning"
|
||||
| "working"
|
||||
| "pr_open"
|
||||
| "ci_failed"
|
||||
| "review_pending"
|
||||
| "changes_requested"
|
||||
| "approved"
|
||||
| "mergeable"
|
||||
| "merged"
|
||||
| "cleanup"
|
||||
| "needs_input"
|
||||
| "stuck"
|
||||
| "errored"
|
||||
| "killed"
|
||||
| "done"
|
||||
| "terminated";
|
||||
|
||||
export type ActivityState =
|
||||
| "active"
|
||||
| "ready"
|
||||
| "idle"
|
||||
| "waiting_input"
|
||||
| "blocked"
|
||||
| "exited";
|
||||
|
||||
export type CIStatus = "none" | "pending" | "passing" | "failing";
|
||||
export type ReviewDecision = "none" | "pending" | "approved" | "changes_requested";
|
||||
|
||||
export type AttentionLevel = "merge" | "respond" | "review" | "pending" | "working" | "done";
|
||||
|
||||
export interface DashboardCICheck {
|
||||
name: string;
|
||||
status: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface DashboardMergeability {
|
||||
mergeable: boolean;
|
||||
ciPassing: boolean;
|
||||
approved: boolean;
|
||||
noConflicts: boolean;
|
||||
blockers: string[];
|
||||
}
|
||||
|
||||
export interface DashboardUnresolvedComment {
|
||||
url: string;
|
||||
path: string;
|
||||
author: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface DashboardPR {
|
||||
number: number;
|
||||
url: string;
|
||||
title: string;
|
||||
owner: string;
|
||||
repo: string;
|
||||
branch: string;
|
||||
baseBranch: string;
|
||||
isDraft: boolean;
|
||||
state: "open" | "merged" | "closed";
|
||||
additions: number;
|
||||
deletions: number;
|
||||
ciStatus: CIStatus;
|
||||
ciChecks: DashboardCICheck[];
|
||||
reviewDecision: ReviewDecision;
|
||||
mergeability: DashboardMergeability;
|
||||
unresolvedThreads: number;
|
||||
unresolvedComments?: DashboardUnresolvedComment[];
|
||||
}
|
||||
|
||||
export interface DashboardSession {
|
||||
id: string;
|
||||
projectId: string;
|
||||
status: SessionStatus;
|
||||
activity: ActivityState | null;
|
||||
branch: string | null;
|
||||
issueId: string | null;
|
||||
issueUrl: string | null;
|
||||
issueLabel: string | null;
|
||||
issueTitle: string | null;
|
||||
summary: string | null;
|
||||
summaryIsFallback: boolean;
|
||||
createdAt: string;
|
||||
lastActivityAt: string;
|
||||
pr: DashboardPR | null;
|
||||
metadata: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
totalSessions: number;
|
||||
workingSessions: number;
|
||||
openPRs: number;
|
||||
needsReview: number;
|
||||
}
|
||||
|
||||
export interface SessionsResponse {
|
||||
sessions: DashboardSession[];
|
||||
stats: DashboardStats;
|
||||
orchestratorId: string | null;
|
||||
}
|
||||
|
||||
/** Attention level colors matching the web dashboard */
|
||||
export const ATTENTION_COLORS: Record<AttentionLevel, string> = {
|
||||
merge: "#3fb950",
|
||||
respond: "#f85149",
|
||||
review: "#d29922",
|
||||
pending: "#e3b341",
|
||||
working: "#58a6ff",
|
||||
done: "#8b949e",
|
||||
};
|
||||
|
||||
/** Statuses that indicate the session is in a terminal (dead) state.
|
||||
* Must stay in sync with packages/core/src/types.ts TERMINAL_STATUSES. */
|
||||
const TERMINAL_STATUSES: SessionStatus[] = ["killed", "terminated", "done", "cleanup", "errored", "merged"];
|
||||
const TERMINAL_ACTIVITIES: ActivityState[] = ["exited"];
|
||||
|
||||
/** Statuses that must never be restored (e.g. already merged).
|
||||
* Must stay in sync with packages/core/src/types.ts NON_RESTORABLE_STATUSES. */
|
||||
const NON_RESTORABLE_STATUSES: SessionStatus[] = ["merged"];
|
||||
|
||||
export function isTerminal(session: DashboardSession): boolean {
|
||||
return (
|
||||
TERMINAL_STATUSES.includes(session.status) ||
|
||||
(session.activity !== null && TERMINAL_ACTIVITIES.includes(session.activity))
|
||||
);
|
||||
}
|
||||
|
||||
export function isRestorable(session: DashboardSession): boolean {
|
||||
return isTerminal(session) && !NON_RESTORABLE_STATUSES.includes(session.status);
|
||||
}
|
||||
|
||||
export function isPRRateLimited(pr: DashboardPR): boolean {
|
||||
return pr.mergeability.blockers.includes("API rate limited or unavailable");
|
||||
}
|
||||
|
||||
/** Determines which attention zone a session belongs to */
|
||||
export function getAttentionLevel(session: DashboardSession): AttentionLevel {
|
||||
// Done: terminal states
|
||||
if (
|
||||
session.status === "merged" ||
|
||||
session.status === "killed" ||
|
||||
session.status === "cleanup" ||
|
||||
session.status === "done" ||
|
||||
session.status === "terminated"
|
||||
) {
|
||||
return "done";
|
||||
}
|
||||
if (session.pr) {
|
||||
if (session.pr.state === "merged" || session.pr.state === "closed") {
|
||||
return "done";
|
||||
}
|
||||
}
|
||||
|
||||
// Merge: PR is ready
|
||||
if (session.status === "mergeable" || session.status === "approved") {
|
||||
return "merge";
|
||||
}
|
||||
if (session.pr?.mergeability.mergeable) {
|
||||
return "merge";
|
||||
}
|
||||
|
||||
// Respond: agent waiting for human input
|
||||
if (session.activity === "waiting_input" || session.activity === "blocked") {
|
||||
return "respond";
|
||||
}
|
||||
if (
|
||||
session.status === "needs_input" ||
|
||||
session.status === "stuck" ||
|
||||
session.status === "errored"
|
||||
) {
|
||||
return "respond";
|
||||
}
|
||||
if (session.activity === "exited") {
|
||||
return "respond";
|
||||
}
|
||||
|
||||
// Review: problems that need investigation
|
||||
if (session.status === "ci_failed" || session.status === "changes_requested") {
|
||||
return "review";
|
||||
}
|
||||
if (session.pr && !isPRRateLimited(session.pr)) {
|
||||
const pr = session.pr;
|
||||
if (pr.ciStatus === "failing") return "review";
|
||||
if (pr.reviewDecision === "changes_requested") return "review";
|
||||
if (!pr.mergeability.noConflicts) return "review";
|
||||
}
|
||||
|
||||
// Pending: waiting on external
|
||||
if (session.status === "review_pending") {
|
||||
return "pending";
|
||||
}
|
||||
if (session.pr && !isPRRateLimited(session.pr)) {
|
||||
const pr = session.pr;
|
||||
if (!pr.isDraft && pr.unresolvedThreads > 0) return "pending";
|
||||
if (!pr.isDraft && (pr.reviewDecision === "pending" || pr.reviewDecision === "none")) {
|
||||
return "pending";
|
||||
}
|
||||
}
|
||||
|
||||
// Working: agents doing their thing
|
||||
return "working";
|
||||
}
|
||||
|
||||
/** Human-readable relative time */
|
||||
export function relativeTime(isoString: string): string {
|
||||
const diff = Date.now() - new Date(isoString).getTime();
|
||||
const minutes = Math.floor(diff / 60_000);
|
||||
const hours = Math.floor(diff / 3_600_000);
|
||||
const days = Math.floor(diff / 86_400_000);
|
||||
|
||||
if (minutes < 1) return "just now";
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return `${days}d ago`;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue