Merge pull request #243 from multica-ai/feature/multi-agent-backend
feat(daemon): unified agent SDK supporting Claude Code and Codex
This commit is contained in:
commit
e3ea7bd02c
44 changed files with 3994 additions and 984 deletions
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -9,6 +9,7 @@ out
|
|||
.turbo
|
||||
build
|
||||
bin
|
||||
dist-electron
|
||||
*.tsbuildinfo
|
||||
|
||||
# env
|
||||
|
|
@ -22,6 +23,9 @@ coverage
|
|||
server/bin/
|
||||
server/tmp/
|
||||
server/migrate
|
||||
server/daemon
|
||||
server/multica-cli
|
||||
server/multica
|
||||
|
||||
# Test artifacts
|
||||
test-results/
|
||||
|
|
@ -30,6 +34,9 @@ apps/web/test-results/
|
|||
# context (agent workspace)
|
||||
.context
|
||||
|
||||
# local settings
|
||||
.claude/
|
||||
|
||||
# platform specific
|
||||
*.dmg
|
||||
*.app
|
||||
|
|
|
|||
114
CLAUDE.md
114
CLAUDE.md
|
|
@ -1,8 +1,8 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file gives coding agents high-signal guidance for this repository.
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## 1. Project Context
|
||||
## Project Context
|
||||
|
||||
Multica is an AI-native task management platform — like Linear, but with AI agents as first-class citizens.
|
||||
|
||||
|
|
@ -10,15 +10,15 @@ Multica is an AI-native task management platform — like Linear, but with AI ag
|
|||
- Supports local (daemon) and cloud agent runtimes
|
||||
- Built for 2-10 person AI-native teams
|
||||
|
||||
## 2. Architecture
|
||||
## Architecture
|
||||
|
||||
**Polyglot monorepo** — Go backend + TypeScript frontend.
|
||||
|
||||
- `server/` — Go backend (Chi + sqlc + gorilla/websocket)
|
||||
- `server/` — Go backend (Chi router, sqlc for DB, gorilla/websocket for real-time)
|
||||
- `apps/web/` — Next.js 16 frontend (App Router)
|
||||
- `packages/` — Shared TypeScript packages (ui, types, sdk, utils)
|
||||
- `packages/` — Shared TypeScript packages (ui, types, sdk, store, hooks, utils)
|
||||
|
||||
### 2.1 Web App Structure (`apps/web/`)
|
||||
### Web App Structure (`apps/web/`)
|
||||
|
||||
The frontend uses a **feature-based architecture** with three layers:
|
||||
|
||||
|
|
@ -42,7 +42,7 @@ apps/web/
|
|||
|
||||
**`shared/`** — Code used across multiple features. Currently only `api.ts` (SDK singleton).
|
||||
|
||||
### 2.2 State Management
|
||||
### State Management
|
||||
|
||||
- **Zustand** for global client state (`features/auth/store.ts`, `features/workspace/store.ts`).
|
||||
- **React Context** only for connection lifecycle (`WSProvider` in `features/realtime/`).
|
||||
|
|
@ -55,7 +55,7 @@ apps/web/
|
|||
- Cross-store reads use `useOtherStore.getState()` inside actions (not hooks).
|
||||
- Dependency direction: `workspace` → `auth`, `realtime` → `auth`, `issues` → `workspace`. Never reverse.
|
||||
|
||||
### 2.3 Import Aliases
|
||||
### Import Aliases
|
||||
|
||||
Use `@/` alias (maps to `apps/web/`):
|
||||
```typescript
|
||||
|
|
@ -68,7 +68,48 @@ import { StatusIcon } from "@/features/issues/components";
|
|||
|
||||
Within a feature, use relative imports. Between features or to shared, use `@/`.
|
||||
|
||||
## 3. Core Workflow Commands
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
Browser → ApiClient (SDK) → REST API (Chi handlers) → sqlc queries → PostgreSQL
|
||||
Browser ← WSClient (SDK) ← WebSocket ← Hub.Broadcast() ← Handlers/TaskService
|
||||
```
|
||||
|
||||
### Backend Structure (`server/`)
|
||||
|
||||
- **Entry points** (`cmd/`): `server` (HTTP API), `daemon` (local agent runtime), `migrate`, `seed`
|
||||
- **Handlers** (`internal/handler/`): One file per domain (issue, comment, agent, auth, daemon, etc.). Each handler holds `Queries`, `DB`, `Hub`, and `TaskService`.
|
||||
- **Real-time** (`internal/realtime/`): Hub manages WebSocket clients. Server broadcasts events; inbound WS message routing is still TODO.
|
||||
- **Auth** (`internal/auth/` + `internal/middleware/`): JWT (HS256). Middleware sets `X-User-ID` and `X-User-Email` headers. Login creates user on-the-fly if not found.
|
||||
- **Task lifecycle** (`internal/service/task.go`): Orchestrates agent work — enqueue → claim → start → complete/fail. Syncs issue status automatically and broadcasts WS events at each transition.
|
||||
- **Database**: sqlc generates Go code from SQL in `pkg/db/queries/` → `pkg/db/generated/`. Migrations in `migrations/`.
|
||||
- **Routes** (`cmd/server/router.go`): Public routes (auth, health, ws) + protected routes (require JWT) + daemon routes (unauthenticated, separate auth model).
|
||||
|
||||
### Frontend Structure (`apps/web/`)
|
||||
|
||||
- **App Router layout groups**: `(auth)/` for login, `(dashboard)/` for protected routes
|
||||
- **Auth context** (`lib/auth-context.tsx`): Global provider for user, workspace, members, agents. Hydrates from localStorage. Provides actor lookup helpers (`getMemberName`, `getAgentName`, `getActorName`).
|
||||
- **WebSocket context** (`lib/ws-context.tsx`): Wraps `WSClient` from SDK. `useWSEvent()` hook auto-subscribes/unsubscribes.
|
||||
- **API client** (`lib/api.ts`): Singleton `ApiClient` from `@multica/sdk`, initialized from localStorage.
|
||||
- **State**: Zustand stores (`@multica/store`) for issues, agents, inbox. WebSocket events keep stores in sync without re-fetching.
|
||||
|
||||
### Key Packages
|
||||
|
||||
- **`@multica/sdk`**: `ApiClient` (REST) and `WSClient` (WebSocket) classes. All backend communication goes through here.
|
||||
- **`@multica/types`**: Shared domain types + WebSocket event types (issue:created/updated/deleted, task:*, agent:status, comment:*, inbox:new, daemon:*).
|
||||
- **`@multica/store`**: Zustand stores — simple arrays with add/update/remove. No persistence; memory only.
|
||||
- **`@multica/ui`**: shadcn/ui component library with Radix primitives, Tailwind CSS 4, Shiki syntax highlighting for markdown.
|
||||
- **`@multica/hooks`**: `useRealtime()` (WS → store sync), `useIssues()`, `useAgents()`, `useInbox()` (fetch + cache).
|
||||
|
||||
### Multi-tenancy
|
||||
|
||||
All queries filter by `workspace_id`. Membership checks gate access. `X-Workspace-ID` header routes requests to the correct workspace.
|
||||
|
||||
### Agent Assignees
|
||||
|
||||
Assignees are polymorphic — can be a member or an agent. `assignee_type` + `assignee_id` on issues. Agents render with distinct styling (purple background, robot icon).
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# One-click setup & run
|
||||
|
|
@ -88,16 +129,34 @@ pnpm test # TS tests (Vitest)
|
|||
make dev # Run Go server (port 8080)
|
||||
make daemon # Run local daemon
|
||||
make test # Go tests
|
||||
make sqlc # Regenerate sqlc code
|
||||
make sqlc # Regenerate sqlc code after editing SQL in server/pkg/db/queries/
|
||||
make migrate-up # Run database migrations
|
||||
make migrate-down # Rollback migrations
|
||||
|
||||
# Run a single Go test
|
||||
cd server && go test ./internal/handler/ -run TestName
|
||||
|
||||
# Run a single TS test
|
||||
pnpm --filter @multica/web exec vitest run src/path/to/file.test.ts
|
||||
|
||||
# Run a single E2E test (requires backend + frontend running)
|
||||
pnpm exec playwright test e2e/tests/specific-test.spec.ts
|
||||
|
||||
# Infrastructure
|
||||
make db-up # Start shared PostgreSQL
|
||||
make db-down # Stop shared PostgreSQL
|
||||
```
|
||||
|
||||
## 4. Coding Rules
|
||||
### Worktree Support
|
||||
|
||||
For isolated feature testing with a separate database:
|
||||
```bash
|
||||
make worktree-env # Generate .env.worktree with unique DB/ports
|
||||
make setup-worktree # Setup using .env.worktree
|
||||
make start-worktree # Start using .env.worktree
|
||||
```
|
||||
|
||||
## Coding Rules
|
||||
|
||||
- TypeScript strict mode is enabled; keep types explicit.
|
||||
- Go code follows standard Go conventions (gofmt, go vet).
|
||||
|
|
@ -108,7 +167,7 @@ make db-down # Stop shared PostgreSQL
|
|||
- Treat compatibility code as a maintenance cost, not a default safety mechanism. Avoid "just in case" branches that make the codebase harder to reason about.
|
||||
- Avoid broad refactors unless required by the task.
|
||||
|
||||
## 5. UI/UX Rules
|
||||
## UI/UX Rules
|
||||
|
||||
- Prefer `packages/ui` shadcn components over custom implementations.
|
||||
- **shadcn official components** → `packages/ui/src/components/ui/` — keep this directory clean; install missing components via `npx shadcn add`, do not mix in business code.
|
||||
|
|
@ -119,12 +178,12 @@ make db-down # Stop shared PostgreSQL
|
|||
- Pay close attention to **overflow** (truncate long text, scrollable containers), **alignment**, and **spacing** consistency.
|
||||
- When unsure about interaction or state design, ask — the user will provide direction.
|
||||
|
||||
## 6. Testing Rules
|
||||
## Testing Rules
|
||||
|
||||
- **TypeScript**: Vitest. Mock external/third-party dependencies only.
|
||||
- **Go**: Standard `go test`. Tests should create their own fixture data in a test database.
|
||||
|
||||
## 7. Commit Rules
|
||||
## Commit Rules
|
||||
|
||||
- Use atomic commits grouped by logical intent.
|
||||
- Conventional format:
|
||||
|
|
@ -135,7 +194,7 @@ make db-down # Stop shared PostgreSQL
|
|||
- `test(scope): ...`
|
||||
- `chore(scope): ...`
|
||||
|
||||
## 8. Verification Commands
|
||||
## Minimum Pre-Push Checks
|
||||
|
||||
```bash
|
||||
make check # Runs all checks: typecheck, unit tests, Go tests, E2E
|
||||
|
|
@ -151,7 +210,30 @@ make test # Go tests only
|
|||
pnpm exec playwright test # E2E only (requires backend + frontend running)
|
||||
```
|
||||
|
||||
## 9. E2E Test Patterns
|
||||
## AI Agent Verification Loop
|
||||
|
||||
After writing or modifying code, always run the full verification pipeline:
|
||||
|
||||
```bash
|
||||
make check
|
||||
```
|
||||
|
||||
This runs all checks in sequence:
|
||||
1. TypeScript typecheck (`pnpm typecheck`)
|
||||
2. TypeScript unit tests (`pnpm test`)
|
||||
3. Go tests (`go test ./...`)
|
||||
4. E2E tests (auto-starts backend + frontend if needed, runs Playwright)
|
||||
|
||||
**Workflow:**
|
||||
- Write code to satisfy the requirement
|
||||
- Run `make check`
|
||||
- If any step fails, read the error output, fix the code, and re-run `make check`
|
||||
- Repeat until all checks pass
|
||||
- Only then consider the task complete
|
||||
|
||||
**Quick iteration:** If you know only TypeScript or Go is affected, run individual checks first for faster feedback, then finish with a full `make check` before marking work complete.
|
||||
|
||||
## E2E Test Patterns
|
||||
|
||||
E2E tests should be self-contained. Use the `TestApiClient` fixture for data setup/teardown:
|
||||
|
||||
|
|
|
|||
12
Makefile
12
Makefile
|
|
@ -1,4 +1,4 @@
|
|||
.PHONY: dev daemon build test migrate-up migrate-down sqlc clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down
|
||||
.PHONY: dev daemon cli build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down
|
||||
|
||||
MAIN_ENV_FILE ?= .env
|
||||
WORKTREE_ENV_FILE ?= .env.worktree
|
||||
|
|
@ -115,11 +115,17 @@ dev:
|
|||
cd server && go run ./cmd/server
|
||||
|
||||
daemon:
|
||||
cd server && MULTICA_CODEX_WORKDIR="${MULTICA_CODEX_WORKDIR:-$(abspath .)}" go run ./cmd/daemon
|
||||
cd server && MULTICA_REPOS_ROOT="${MULTICA_REPOS_ROOT:-$(abspath .)}" go run ./cmd/multica daemon
|
||||
|
||||
cli:
|
||||
cd server && go run ./cmd/multica $(ARGS)
|
||||
|
||||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||
|
||||
build:
|
||||
cd server && go build -o bin/server ./cmd/server
|
||||
cd server && go build -o bin/daemon ./cmd/daemon
|
||||
cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT)" -o bin/multica-cli ./cmd/multica
|
||||
|
||||
test:
|
||||
$(REQUIRE_ENV)
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ export default function SettingsPage() {
|
|||
const [description, setDescription] = useState(
|
||||
workspace?.description ?? "",
|
||||
);
|
||||
const [context, setContext] = useState(workspace?.context ?? "");
|
||||
const [profileName, setProfileName] = useState(user?.name ?? "");
|
||||
const [avatarUrl, setAvatarUrl] = useState(user?.avatar_url ?? "");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
|
@ -124,6 +125,7 @@ export default function SettingsPage() {
|
|||
useEffect(() => {
|
||||
setName(workspace?.name ?? "");
|
||||
setDescription(workspace?.description ?? "");
|
||||
setContext(workspace?.context ?? "");
|
||||
}, [workspace]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -138,7 +140,8 @@ export default function SettingsPage() {
|
|||
try {
|
||||
const updated = await api.updateWorkspace(workspace.id, {
|
||||
name,
|
||||
description: description || undefined,
|
||||
description,
|
||||
context,
|
||||
});
|
||||
updateWorkspace(updated);
|
||||
setSaved(true);
|
||||
|
|
@ -339,6 +342,19 @@ export default function SettingsPage() {
|
|||
placeholder="What does this workspace focus on?"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Context
|
||||
</Label>
|
||||
<textarea
|
||||
value={context}
|
||||
onChange={(e) => setContext(e.target.value)}
|
||||
rows={4}
|
||||
disabled={!canManageWorkspace}
|
||||
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring resize-none"
|
||||
placeholder="Background information and context for AI agents working in this workspace"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Slug
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export const mockWorkspace: Workspace = {
|
|||
name: "Test Workspace",
|
||||
slug: "test-ws",
|
||||
description: "A test workspace",
|
||||
context: null,
|
||||
settings: {},
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
|
|
|
|||
|
|
@ -237,14 +237,14 @@ export class ApiClient {
|
|||
return this.fetch(`/api/workspaces/${id}`);
|
||||
}
|
||||
|
||||
async createWorkspace(data: { name: string; slug: string; description?: string }): Promise<Workspace> {
|
||||
async createWorkspace(data: { name: string; slug: string; description?: string; context?: string }): Promise<Workspace> {
|
||||
return this.fetch("/api/workspaces", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async updateWorkspace(id: string, data: { name?: string; description?: string; settings?: Record<string, unknown> }): Promise<Workspace> {
|
||||
async updateWorkspace(id: string, data: { name?: string; description?: string; context?: string; settings?: Record<string, unknown> }): Promise<Workspace> {
|
||||
return this.fetch(`/api/workspaces/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data),
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export interface Workspace {
|
|||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
context: string | null;
|
||||
settings: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
|
|
|||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
|
|
@ -137,6 +137,15 @@ importers:
|
|||
specifier: ^4.1.0
|
||||
version: 4.1.0(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3))(vite@8.0.1(@types/node@25.5.0)(jiti@2.6.1))
|
||||
|
||||
packages/agent-sdk:
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: 'catalog:'
|
||||
version: 25.5.0
|
||||
typescript:
|
||||
specifier: 'catalog:'
|
||||
version: 5.9.3
|
||||
|
||||
packages/hooks:
|
||||
dependencies:
|
||||
'@multica/sdk':
|
||||
|
|
|
|||
|
|
@ -1,900 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultServerURL = "ws://localhost:8080/ws"
|
||||
defaultDaemonConfigPath = ".multica/daemon.json"
|
||||
defaultPollInterval = 3 * time.Second
|
||||
defaultHeartbeatInterval = 15 * time.Second
|
||||
defaultCodexTimeout = 20 * time.Minute
|
||||
defaultRuntimeName = "Local Codex"
|
||||
defaultCodexPath = "codex"
|
||||
)
|
||||
|
||||
type config struct {
|
||||
ServerBaseURL string
|
||||
ConfigPath string
|
||||
WorkspaceID string
|
||||
WorkspaceFromDisk bool
|
||||
DaemonID string
|
||||
DeviceName string
|
||||
RuntimeName string
|
||||
CodexPath string
|
||||
CodexModel string
|
||||
DefaultWorkdir string
|
||||
PollInterval time.Duration
|
||||
HeartbeatInterval time.Duration
|
||||
CodexTimeout time.Duration
|
||||
}
|
||||
|
||||
type daemon struct {
|
||||
cfg config
|
||||
client *daemonClient
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
type daemonClient struct {
|
||||
baseURL string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
type requestError struct {
|
||||
Method string
|
||||
Path string
|
||||
StatusCode int
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *requestError) Error() string {
|
||||
return fmt.Sprintf("%s %s returned %d: %s", e.Method, e.Path, e.StatusCode, e.Body)
|
||||
}
|
||||
|
||||
type daemonRuntime struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type daemonPairingSession struct {
|
||||
Token string `json:"token"`
|
||||
DaemonID string `json:"daemon_id"`
|
||||
DeviceName string `json:"device_name"`
|
||||
RuntimeName string `json:"runtime_name"`
|
||||
RuntimeType string `json:"runtime_type"`
|
||||
RuntimeVersion string `json:"runtime_version"`
|
||||
WorkspaceID *string `json:"workspace_id"`
|
||||
Status string `json:"status"`
|
||||
ApprovedAt *string `json:"approved_at"`
|
||||
ClaimedAt *string `json:"claimed_at"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
LinkURL *string `json:"link_url"`
|
||||
}
|
||||
|
||||
type daemonPersistedConfig struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
}
|
||||
|
||||
type daemonTask struct {
|
||||
ID string `json:"id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
IssueID string `json:"issue_id"`
|
||||
Context daemonTaskContext `json:"context"`
|
||||
}
|
||||
|
||||
type daemonTaskContext struct {
|
||||
Issue daemonIssueContext `json:"issue"`
|
||||
Agent daemonAgentContext `json:"agent"`
|
||||
Runtime daemonRuntimeContext `json:"runtime"`
|
||||
}
|
||||
|
||||
type daemonIssueContext struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
AcceptanceCriteria []string `json:"acceptance_criteria"`
|
||||
ContextRefs []string `json:"context_refs"`
|
||||
Repository *daemonRepoRef `json:"repository"`
|
||||
}
|
||||
|
||||
type daemonAgentContext struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Skills string `json:"skills"`
|
||||
}
|
||||
|
||||
type daemonRuntimeContext struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
DeviceInfo string `json:"device_info"`
|
||||
}
|
||||
|
||||
type daemonRepoRef struct {
|
||||
URL string `json:"url"`
|
||||
Branch string `json:"branch"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type codexTaskResult struct {
|
||||
Status string `json:"status"`
|
||||
Comment string `json:"comment"`
|
||||
}
|
||||
|
||||
func loadConfig() (config, error) {
|
||||
serverBaseURL, err := normalizeServerBaseURL(envOrDefault("MULTICA_SERVER_URL", defaultServerURL))
|
||||
if err != nil {
|
||||
return config{}, err
|
||||
}
|
||||
|
||||
configPath, err := resolveDaemonConfigPath(strings.TrimSpace(os.Getenv("MULTICA_DAEMON_CONFIG")))
|
||||
if err != nil {
|
||||
return config{}, err
|
||||
}
|
||||
persisted, err := loadPersistedDaemonConfig(configPath)
|
||||
if err != nil {
|
||||
return config{}, err
|
||||
}
|
||||
workspaceID := strings.TrimSpace(os.Getenv("MULTICA_WORKSPACE_ID"))
|
||||
workspaceFromDisk := false
|
||||
if workspaceID == "" {
|
||||
workspaceID = persisted.WorkspaceID
|
||||
workspaceFromDisk = workspaceID != ""
|
||||
}
|
||||
|
||||
codexPath := envOrDefault("MULTICA_CODEX_PATH", defaultCodexPath)
|
||||
if _, err := exec.LookPath(codexPath); err != nil {
|
||||
return config{}, fmt.Errorf("codex executable not found at %q: %w", codexPath, err)
|
||||
}
|
||||
|
||||
host, err := os.Hostname()
|
||||
if err != nil || strings.TrimSpace(host) == "" {
|
||||
host = "local-machine"
|
||||
}
|
||||
|
||||
defaultWorkdir := strings.TrimSpace(os.Getenv("MULTICA_CODEX_WORKDIR"))
|
||||
if defaultWorkdir == "" {
|
||||
defaultWorkdir, err = os.Getwd()
|
||||
if err != nil {
|
||||
return config{}, fmt.Errorf("resolve working directory: %w", err)
|
||||
}
|
||||
}
|
||||
defaultWorkdir, err = filepath.Abs(defaultWorkdir)
|
||||
if err != nil {
|
||||
return config{}, fmt.Errorf("resolve absolute workdir: %w", err)
|
||||
}
|
||||
|
||||
pollInterval, err := durationFromEnv("MULTICA_DAEMON_POLL_INTERVAL", defaultPollInterval)
|
||||
if err != nil {
|
||||
return config{}, err
|
||||
}
|
||||
heartbeatInterval, err := durationFromEnv("MULTICA_DAEMON_HEARTBEAT_INTERVAL", defaultHeartbeatInterval)
|
||||
if err != nil {
|
||||
return config{}, err
|
||||
}
|
||||
codexTimeout, err := durationFromEnv("MULTICA_CODEX_TIMEOUT", defaultCodexTimeout)
|
||||
if err != nil {
|
||||
return config{}, err
|
||||
}
|
||||
|
||||
return config{
|
||||
ServerBaseURL: serverBaseURL,
|
||||
ConfigPath: configPath,
|
||||
WorkspaceID: workspaceID,
|
||||
WorkspaceFromDisk: workspaceFromDisk,
|
||||
DaemonID: envOrDefault("MULTICA_DAEMON_ID", host),
|
||||
DeviceName: envOrDefault("MULTICA_DAEMON_DEVICE_NAME", host),
|
||||
RuntimeName: envOrDefault("MULTICA_CODEX_RUNTIME_NAME", defaultRuntimeName),
|
||||
CodexPath: codexPath,
|
||||
CodexModel: strings.TrimSpace(os.Getenv("MULTICA_CODEX_MODEL")),
|
||||
DefaultWorkdir: defaultWorkdir,
|
||||
PollInterval: pollInterval,
|
||||
HeartbeatInterval: heartbeatInterval,
|
||||
CodexTimeout: codexTimeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newDaemon(cfg config, logger *log.Logger) *daemon {
|
||||
return &daemon{
|
||||
cfg: cfg,
|
||||
client: &daemonClient{baseURL: cfg.ServerBaseURL, client: &http.Client{Timeout: 30 * time.Second}},
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *daemon) run(ctx context.Context) error {
|
||||
d.logger.Printf("starting daemon for workspace=%s server=%s runtime=%s workdir=%s",
|
||||
d.cfg.WorkspaceID, d.cfg.ServerBaseURL, d.cfg.RuntimeName, d.cfg.DefaultWorkdir)
|
||||
|
||||
if strings.TrimSpace(d.cfg.WorkspaceID) == "" {
|
||||
workspaceID, err := d.ensurePaired(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.cfg.WorkspaceID = workspaceID
|
||||
d.logger.Printf("pairing completed for workspace=%s", workspaceID)
|
||||
}
|
||||
|
||||
runtime, err := d.registerRuntimeWithRecovery(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.logger.Printf("registered runtime id=%s provider=%s status=%s", runtime.ID, runtime.Provider, runtime.Status)
|
||||
|
||||
go d.heartbeatLoop(ctx, runtime.ID)
|
||||
return d.pollLoop(ctx, runtime.ID)
|
||||
}
|
||||
|
||||
func (d *daemon) registerRuntime(ctx context.Context) (daemonRuntime, error) {
|
||||
version, err := detectCodexVersion(ctx, d.cfg.CodexPath)
|
||||
if err != nil {
|
||||
return daemonRuntime{}, err
|
||||
}
|
||||
|
||||
req := map[string]any{
|
||||
"workspace_id": d.cfg.WorkspaceID,
|
||||
"daemon_id": d.cfg.DaemonID,
|
||||
"device_name": d.cfg.DeviceName,
|
||||
"runtimes": []map[string]string{
|
||||
{
|
||||
"name": d.cfg.RuntimeName,
|
||||
"type": "codex",
|
||||
"version": version,
|
||||
"status": "online",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Runtimes []daemonRuntime `json:"runtimes"`
|
||||
}
|
||||
if err := d.client.postJSON(ctx, "/api/daemon/register", req, &resp); err != nil {
|
||||
return daemonRuntime{}, fmt.Errorf("register runtime: %w", err)
|
||||
}
|
||||
if len(resp.Runtimes) == 0 {
|
||||
return daemonRuntime{}, fmt.Errorf("register runtime: empty response")
|
||||
}
|
||||
return resp.Runtimes[0], nil
|
||||
}
|
||||
|
||||
func (d *daemon) registerRuntimeWithRecovery(ctx context.Context) (daemonRuntime, error) {
|
||||
runtime, err := d.registerRuntime(ctx)
|
||||
if err == nil {
|
||||
return runtime, nil
|
||||
}
|
||||
if !d.cfg.WorkspaceFromDisk || !isWorkspaceNotFoundError(err) {
|
||||
return daemonRuntime{}, err
|
||||
}
|
||||
|
||||
d.logger.Printf(
|
||||
"persisted workspace=%s is no longer valid; clearing %s and starting a new pairing flow",
|
||||
d.cfg.WorkspaceID,
|
||||
d.cfg.ConfigPath,
|
||||
)
|
||||
if err := clearPersistedDaemonConfig(d.cfg.ConfigPath); err != nil {
|
||||
return daemonRuntime{}, err
|
||||
}
|
||||
|
||||
d.cfg.WorkspaceID = ""
|
||||
d.cfg.WorkspaceFromDisk = false
|
||||
|
||||
workspaceID, err := d.ensurePaired(ctx)
|
||||
if err != nil {
|
||||
return daemonRuntime{}, fmt.Errorf("repair stale workspace binding: %w", err)
|
||||
}
|
||||
d.cfg.WorkspaceID = workspaceID
|
||||
d.logger.Printf("pairing completed for workspace=%s", workspaceID)
|
||||
|
||||
return d.registerRuntime(ctx)
|
||||
}
|
||||
|
||||
func (d *daemon) ensurePaired(ctx context.Context) (string, error) {
|
||||
version, err := detectCodexVersion(ctx, d.cfg.CodexPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
session, err := d.client.createPairingSession(ctx, map[string]string{
|
||||
"daemon_id": d.cfg.DaemonID,
|
||||
"device_name": d.cfg.DeviceName,
|
||||
"runtime_name": d.cfg.RuntimeName,
|
||||
"runtime_type": "codex",
|
||||
"runtime_version": version,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create pairing session: %w", err)
|
||||
}
|
||||
if session.LinkURL != nil {
|
||||
d.logger.Printf("open this link to pair the local Codex runtime: %s", *session.LinkURL)
|
||||
} else {
|
||||
d.logger.Printf("pairing session created: %s", session.Token)
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
current, err := d.client.getPairingSession(ctx, session.Token)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("poll pairing session: %w", err)
|
||||
}
|
||||
|
||||
switch current.Status {
|
||||
case "approved", "claimed":
|
||||
if current.WorkspaceID == nil || strings.TrimSpace(*current.WorkspaceID) == "" {
|
||||
return "", fmt.Errorf("pairing session approved without workspace")
|
||||
}
|
||||
if err := savePersistedDaemonConfig(d.cfg.ConfigPath, daemonPersistedConfig{
|
||||
WorkspaceID: strings.TrimSpace(*current.WorkspaceID),
|
||||
}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if current.Status != "claimed" {
|
||||
if _, err := d.client.claimPairingSession(ctx, current.Token); err != nil {
|
||||
return "", fmt.Errorf("claim pairing session: %w", err)
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(*current.WorkspaceID), nil
|
||||
case "expired":
|
||||
return "", fmt.Errorf("pairing session expired before approval")
|
||||
}
|
||||
|
||||
if err := sleepWithContext(ctx, d.cfg.PollInterval); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *daemon) heartbeatLoop(ctx context.Context, runtimeID string) {
|
||||
ticker := time.NewTicker(d.cfg.HeartbeatInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
err := d.client.postJSON(ctx, "/api/daemon/heartbeat", map[string]string{
|
||||
"runtime_id": runtimeID,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
d.logger.Printf("heartbeat failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *daemon) pollLoop(ctx context.Context, runtimeID string) error {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
task, err := d.client.claimTask(ctx, runtimeID)
|
||||
if err != nil {
|
||||
d.logger.Printf("claim task failed: %v", err)
|
||||
if err := sleepWithContext(ctx, d.cfg.PollInterval); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if task == nil {
|
||||
if err := sleepWithContext(ctx, d.cfg.PollInterval); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
d.handleTask(ctx, *task)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *daemon) handleTask(ctx context.Context, task daemonTask) {
|
||||
d.logger.Printf("picked task=%s issue=%s title=%q", task.ID, task.IssueID, task.Context.Issue.Title)
|
||||
|
||||
if err := d.client.startTask(ctx, task.ID); err != nil {
|
||||
d.logger.Printf("start task %s failed: %v", task.ID, err)
|
||||
return
|
||||
}
|
||||
|
||||
_ = d.client.reportProgress(ctx, task.ID, "Launching Codex", 1, 2)
|
||||
|
||||
result, err := d.runTask(ctx, task)
|
||||
if err != nil {
|
||||
d.logger.Printf("task %s failed: %v", task.ID, err)
|
||||
if failErr := d.client.failTask(ctx, task.ID, err.Error()); failErr != nil {
|
||||
d.logger.Printf("fail task %s callback failed: %v", task.ID, failErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
_ = d.client.reportProgress(ctx, task.ID, "Finishing task", 2, 2)
|
||||
|
||||
switch result.Status {
|
||||
case "blocked":
|
||||
if err := d.client.failTask(ctx, task.ID, result.Comment); err != nil {
|
||||
d.logger.Printf("report blocked task %s failed: %v", task.ID, err)
|
||||
}
|
||||
default:
|
||||
if err := d.client.completeTask(ctx, task.ID, result.Comment); err != nil {
|
||||
d.logger.Printf("complete task %s failed: %v", task.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *daemon) runTask(ctx context.Context, task daemonTask) (codexTaskResult, error) {
|
||||
workdir, err := resolveTaskWorkdir(d.cfg.DefaultWorkdir, task.Context.Issue.Repository)
|
||||
if err != nil {
|
||||
return codexTaskResult{}, err
|
||||
}
|
||||
|
||||
prompt := buildCodexPrompt(task, workdir)
|
||||
runCtx, cancel := context.WithTimeout(ctx, d.cfg.CodexTimeout)
|
||||
defer cancel()
|
||||
|
||||
model := d.cfg.CodexModel
|
||||
if model == "" {
|
||||
model = "default"
|
||||
}
|
||||
|
||||
startedAt := time.Now()
|
||||
d.logger.Printf(
|
||||
"starting codex exec task=%s workdir=%s model=%s timeout=%s",
|
||||
task.ID,
|
||||
workdir,
|
||||
model,
|
||||
d.cfg.CodexTimeout,
|
||||
)
|
||||
|
||||
result, err := runCodexExec(runCtx, d.cfg, workdir, prompt)
|
||||
if err != nil {
|
||||
d.logger.Printf(
|
||||
"codex exec failed task=%s duration=%s err=%v",
|
||||
task.ID,
|
||||
time.Since(startedAt).Round(time.Millisecond),
|
||||
err,
|
||||
)
|
||||
if errors.Is(runCtx.Err(), context.DeadlineExceeded) {
|
||||
return codexTaskResult{}, fmt.Errorf("Codex timed out after %s", d.cfg.CodexTimeout)
|
||||
}
|
||||
return codexTaskResult{}, err
|
||||
}
|
||||
|
||||
d.logger.Printf(
|
||||
"codex exec finished task=%s duration=%s status=%s",
|
||||
task.ID,
|
||||
time.Since(startedAt).Round(time.Millisecond),
|
||||
result.Status,
|
||||
)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func runCodexExec(ctx context.Context, cfg config, workdir, prompt string) (codexTaskResult, error) {
|
||||
outputFile, err := os.CreateTemp("", "multica-codex-output-*.json")
|
||||
if err != nil {
|
||||
return codexTaskResult{}, fmt.Errorf("create codex output file: %w", err)
|
||||
}
|
||||
outputPath := outputFile.Name()
|
||||
outputFile.Close()
|
||||
defer os.Remove(outputPath)
|
||||
|
||||
schemaFile, err := os.CreateTemp("", "multica-codex-schema-*.json")
|
||||
if err != nil {
|
||||
return codexTaskResult{}, fmt.Errorf("create schema file: %w", err)
|
||||
}
|
||||
schemaPath := schemaFile.Name()
|
||||
if _, err := schemaFile.WriteString(codexResultSchema); err != nil {
|
||||
schemaFile.Close()
|
||||
return codexTaskResult{}, fmt.Errorf("write schema file: %w", err)
|
||||
}
|
||||
schemaFile.Close()
|
||||
defer os.Remove(schemaPath)
|
||||
|
||||
args := []string{
|
||||
"-a", "never",
|
||||
"exec",
|
||||
"--skip-git-repo-check",
|
||||
"--sandbox", "workspace-write",
|
||||
"-C", workdir,
|
||||
"--output-schema", schemaPath,
|
||||
"-o", outputPath,
|
||||
prompt,
|
||||
}
|
||||
if cfg.CodexModel != "" {
|
||||
args = append([]string{"-m", cfg.CodexModel}, args...)
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, cfg.CodexPath, args...)
|
||||
var output bytes.Buffer
|
||||
cmd.Stdout = &output
|
||||
cmd.Stderr = &output
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return codexTaskResult{}, fmt.Errorf("codex exec failed: %w\n%s", err, strings.TrimSpace(output.String()))
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(outputPath)
|
||||
if err != nil {
|
||||
return codexTaskResult{}, fmt.Errorf("read codex result: %w", err)
|
||||
}
|
||||
|
||||
var result codexTaskResult
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return codexTaskResult{}, fmt.Errorf("parse codex result: %w", err)
|
||||
}
|
||||
if result.Comment == "" {
|
||||
return codexTaskResult{}, fmt.Errorf("codex returned empty comment")
|
||||
}
|
||||
if result.Status == "" {
|
||||
result.Status = "completed"
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func buildCodexPrompt(task daemonTask, workdir string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("You are running as the local Codex runtime for a Multica agent.\n")
|
||||
b.WriteString("Complete the assigned issue using the local environment.\n")
|
||||
b.WriteString("Return a concise Markdown comment suitable for posting back to the issue.\n")
|
||||
b.WriteString("If you cannot complete the task because context, files, or permissions are missing, return status \"blocked\" and explain the blocker in the comment.\n\n")
|
||||
|
||||
fmt.Fprintf(&b, "Working directory: %s\n", workdir)
|
||||
fmt.Fprintf(&b, "Agent: %s\n", task.Context.Agent.Name)
|
||||
fmt.Fprintf(&b, "Issue title: %s\n\n", task.Context.Issue.Title)
|
||||
|
||||
if task.Context.Issue.Description != "" {
|
||||
b.WriteString("Issue description:\n")
|
||||
b.WriteString(task.Context.Issue.Description)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
if len(task.Context.Issue.AcceptanceCriteria) > 0 {
|
||||
b.WriteString("Acceptance criteria:\n")
|
||||
for _, item := range task.Context.Issue.AcceptanceCriteria {
|
||||
fmt.Fprintf(&b, "- %s\n", item)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
if len(task.Context.Issue.ContextRefs) > 0 {
|
||||
b.WriteString("Context refs:\n")
|
||||
for _, item := range task.Context.Issue.ContextRefs {
|
||||
fmt.Fprintf(&b, "- %s\n", item)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
if repo := task.Context.Issue.Repository; repo != nil {
|
||||
b.WriteString("Repository context:\n")
|
||||
if repo.URL != "" {
|
||||
fmt.Fprintf(&b, "- url: %s\n", repo.URL)
|
||||
}
|
||||
if repo.Branch != "" {
|
||||
fmt.Fprintf(&b, "- branch: %s\n", repo.Branch)
|
||||
}
|
||||
if repo.Path != "" {
|
||||
fmt.Fprintf(&b, "- path: %s\n", repo.Path)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
if task.Context.Agent.Skills != "" {
|
||||
b.WriteString("Agent skills/instructions:\n")
|
||||
b.WriteString(task.Context.Agent.Skills)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
b.WriteString("Comment requirements:\n")
|
||||
b.WriteString("- Lead with the outcome.\n")
|
||||
b.WriteString("- Mention concrete files or commands if you changed anything.\n")
|
||||
b.WriteString("- Mention blockers or follow-up actions if relevant.\n")
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func resolveTaskWorkdir(defaultWorkdir string, repo *daemonRepoRef) (string, error) {
|
||||
base := defaultWorkdir
|
||||
if repo == nil || strings.TrimSpace(repo.Path) == "" {
|
||||
return base, nil
|
||||
}
|
||||
|
||||
path := strings.TrimSpace(repo.Path)
|
||||
if !filepath.IsAbs(path) {
|
||||
path = filepath.Join(base, path)
|
||||
}
|
||||
path = filepath.Clean(path)
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("repository path not found: %s", path)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "", fmt.Errorf("repository path is not a directory: %s", path)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func detectCodexVersion(ctx context.Context, codexPath string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, codexPath, "--version")
|
||||
data, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("detect codex version: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(string(data)), nil
|
||||
}
|
||||
|
||||
func resolveDaemonConfigPath(raw string) (string, error) {
|
||||
if raw != "" {
|
||||
return filepath.Abs(raw)
|
||||
}
|
||||
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve daemon config path: %w", err)
|
||||
}
|
||||
return filepath.Join(home, defaultDaemonConfigPath), nil
|
||||
}
|
||||
|
||||
func loadPersistedDaemonConfig(path string) (daemonPersistedConfig, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return daemonPersistedConfig{}, nil
|
||||
}
|
||||
return daemonPersistedConfig{}, fmt.Errorf("read daemon config: %w", err)
|
||||
}
|
||||
|
||||
var cfg daemonPersistedConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return daemonPersistedConfig{}, fmt.Errorf("parse daemon config: %w", err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func savePersistedDaemonConfig(path string, cfg daemonPersistedConfig) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create daemon config directory: %w", err)
|
||||
}
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode daemon config: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil {
|
||||
return fmt.Errorf("write daemon config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func clearPersistedDaemonConfig(path string) error {
|
||||
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("remove daemon config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isWorkspaceNotFoundError(err error) bool {
|
||||
var reqErr *requestError
|
||||
if !errors.As(err, &reqErr) {
|
||||
return false
|
||||
}
|
||||
if reqErr.StatusCode != http.StatusNotFound {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(strings.ToLower(reqErr.Body), "workspace not found")
|
||||
}
|
||||
|
||||
func normalizeServerBaseURL(raw string) (string, error) {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid MULTICA_SERVER_URL: %w", err)
|
||||
}
|
||||
switch u.Scheme {
|
||||
case "ws":
|
||||
u.Scheme = "http"
|
||||
case "wss":
|
||||
u.Scheme = "https"
|
||||
case "http", "https":
|
||||
default:
|
||||
return "", fmt.Errorf("MULTICA_SERVER_URL must use ws, wss, http, or https")
|
||||
}
|
||||
if u.Path == "/ws" {
|
||||
u.Path = ""
|
||||
}
|
||||
u.RawPath = ""
|
||||
u.RawQuery = ""
|
||||
u.Fragment = ""
|
||||
return strings.TrimRight(u.String(), "/"), nil
|
||||
}
|
||||
|
||||
func durationFromEnv(key string, fallback time.Duration) (time.Duration, error) {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
d, err := time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s: invalid duration %q: %w", key, value, err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func envOrDefault(key, fallback string) string {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func sleepWithContext(ctx context.Context, d time.Duration) error {
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *daemonClient) claimTask(ctx context.Context, runtimeID string) (*daemonTask, error) {
|
||||
var resp struct {
|
||||
Task *daemonTask `json:"task"`
|
||||
}
|
||||
if err := c.postJSON(ctx, fmt.Sprintf("/api/daemon/runtimes/%s/tasks/claim", runtimeID), map[string]any{}, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.Task, nil
|
||||
}
|
||||
|
||||
func (c *daemonClient) createPairingSession(ctx context.Context, req map[string]string) (daemonPairingSession, error) {
|
||||
var resp daemonPairingSession
|
||||
if err := c.postJSON(ctx, "/api/daemon/pairing-sessions", req, &resp); err != nil {
|
||||
return daemonPairingSession{}, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *daemonClient) getPairingSession(ctx context.Context, token string) (daemonPairingSession, error) {
|
||||
var resp daemonPairingSession
|
||||
if err := c.getJSON(ctx, fmt.Sprintf("/api/daemon/pairing-sessions/%s", url.PathEscape(token)), &resp); err != nil {
|
||||
return daemonPairingSession{}, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *daemonClient) claimPairingSession(ctx context.Context, token string) (daemonPairingSession, error) {
|
||||
var resp daemonPairingSession
|
||||
if err := c.postJSON(ctx, fmt.Sprintf("/api/daemon/pairing-sessions/%s/claim", url.PathEscape(token)), map[string]any{}, &resp); err != nil {
|
||||
return daemonPairingSession{}, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *daemonClient) startTask(ctx context.Context, taskID string) error {
|
||||
return c.postJSON(ctx, fmt.Sprintf("/api/daemon/tasks/%s/start", taskID), map[string]any{}, nil)
|
||||
}
|
||||
|
||||
func (c *daemonClient) reportProgress(ctx context.Context, taskID, summary string, step, total int) error {
|
||||
return c.postJSON(ctx, fmt.Sprintf("/api/daemon/tasks/%s/progress", taskID), map[string]any{
|
||||
"summary": summary,
|
||||
"step": step,
|
||||
"total": total,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func (c *daemonClient) completeTask(ctx context.Context, taskID, output string) error {
|
||||
return c.postJSON(ctx, fmt.Sprintf("/api/daemon/tasks/%s/complete", taskID), map[string]any{
|
||||
"output": output,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func (c *daemonClient) failTask(ctx context.Context, taskID, errMsg string) error {
|
||||
return c.postJSON(ctx, fmt.Sprintf("/api/daemon/tasks/%s/fail", taskID), map[string]any{
|
||||
"error": errMsg,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func (c *daemonClient) postJSON(ctx context.Context, path string, reqBody any, respBody any) error {
|
||||
var body io.Reader
|
||||
if reqBody != nil {
|
||||
data, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body = bytes.NewReader(data)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return &requestError{
|
||||
Method: http.MethodPost,
|
||||
Path: path,
|
||||
StatusCode: resp.StatusCode,
|
||||
Body: strings.TrimSpace(string(data)),
|
||||
}
|
||||
}
|
||||
if respBody == nil {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
return nil
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(respBody)
|
||||
}
|
||||
|
||||
func (c *daemonClient) getJSON(ctx context.Context, path string, respBody any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return &requestError{
|
||||
Method: http.MethodGet,
|
||||
Path: path,
|
||||
StatusCode: resp.StatusCode,
|
||||
Body: strings.TrimSpace(string(data)),
|
||||
}
|
||||
}
|
||||
if respBody == nil {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
return nil
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(respBody)
|
||||
}
|
||||
|
||||
const codexResultSchema = `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["completed", "blocked"]
|
||||
},
|
||||
"comment": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["status", "comment"],
|
||||
"additionalProperties": false
|
||||
}`
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
logger := log.New(os.Stdout, "multica-daemon: ", log.LstdFlags)
|
||||
d := newDaemon(cfg, logger)
|
||||
|
||||
if err := d.run(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||
logger.Fatal(err)
|
||||
}
|
||||
}
|
||||
205
server/cmd/multica/cmd_agent.go
Normal file
205
server/cmd/multica/cmd_agent.go
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/cli"
|
||||
)
|
||||
|
||||
var agentCmd = &cobra.Command{
|
||||
Use: "agent",
|
||||
Short: "Manage agents",
|
||||
}
|
||||
|
||||
var agentListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List agents in the workspace",
|
||||
RunE: runAgentList,
|
||||
}
|
||||
|
||||
var agentGetCmd = &cobra.Command{
|
||||
Use: "get <id>",
|
||||
Short: "Get agent details",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runAgentGet,
|
||||
}
|
||||
|
||||
var agentDeleteCmd = &cobra.Command{
|
||||
Use: "delete <id>",
|
||||
Short: "Delete an agent",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runAgentDelete,
|
||||
}
|
||||
|
||||
var agentStopCmd = &cobra.Command{
|
||||
Use: "stop <id>",
|
||||
Short: "Stop an agent (set status to offline)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runAgentStop,
|
||||
}
|
||||
|
||||
func init() {
|
||||
agentCmd.AddCommand(agentListCmd)
|
||||
agentCmd.AddCommand(agentGetCmd)
|
||||
agentCmd.AddCommand(agentDeleteCmd)
|
||||
agentCmd.AddCommand(agentStopCmd)
|
||||
|
||||
agentListCmd.Flags().String("output", "table", "Output format: table or json")
|
||||
agentGetCmd.Flags().String("output", "json", "Output format: table or json")
|
||||
}
|
||||
|
||||
func newAPIClient(cmd *cobra.Command) (*cli.APIClient, error) {
|
||||
serverURL := resolveServerURL(cmd)
|
||||
workspaceID := resolveWorkspaceID(cmd)
|
||||
|
||||
if serverURL == "" {
|
||||
return nil, fmt.Errorf("server URL not set: use --server-url flag, MULTICA_SERVER_URL env, or 'multica-cli config set server_url <url>'")
|
||||
}
|
||||
|
||||
return cli.NewAPIClient(serverURL, workspaceID), nil
|
||||
}
|
||||
|
||||
func resolveServerURL(cmd *cobra.Command) string {
|
||||
val := cli.FlagOrEnv(cmd, "server-url", "MULTICA_SERVER_URL", "")
|
||||
if val != "" {
|
||||
return val
|
||||
}
|
||||
cfg, err := cli.LoadCLIConfig()
|
||||
if err != nil {
|
||||
return "http://localhost:8080"
|
||||
}
|
||||
if cfg.ServerURL != "" {
|
||||
return cfg.ServerURL
|
||||
}
|
||||
return "http://localhost:8080"
|
||||
}
|
||||
|
||||
func resolveWorkspaceID(cmd *cobra.Command) string {
|
||||
val := cli.FlagOrEnv(cmd, "workspace-id", "MULTICA_WORKSPACE_ID", "")
|
||||
if val != "" {
|
||||
return val
|
||||
}
|
||||
cfg, _ := cli.LoadCLIConfig()
|
||||
if cfg.WorkspaceID != "" {
|
||||
return cfg.WorkspaceID
|
||||
}
|
||||
// Fallback: try daemon.json for workspace_id
|
||||
return cli.LoadWorkspaceIDFromDaemonConfig()
|
||||
}
|
||||
|
||||
func runAgentList(cmd *cobra.Command, _ []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var agents []map[string]any
|
||||
path := "/api/agents"
|
||||
if client.WorkspaceID != "" {
|
||||
path += "?" + url.Values{"workspace_id": {client.WorkspaceID}}.Encode()
|
||||
}
|
||||
if err := client.GetJSON(ctx, path, &agents); err != nil {
|
||||
return fmt.Errorf("list agents: %w", err)
|
||||
}
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "json" {
|
||||
return cli.PrintJSON(os.Stdout, agents)
|
||||
}
|
||||
|
||||
headers := []string{"ID", "NAME", "STATUS", "RUNTIME"}
|
||||
rows := make([][]string, 0, len(agents))
|
||||
for _, a := range agents {
|
||||
rows = append(rows, []string{
|
||||
strVal(a, "id"),
|
||||
strVal(a, "name"),
|
||||
strVal(a, "status"),
|
||||
strVal(a, "runtime_mode"),
|
||||
})
|
||||
}
|
||||
cli.PrintTable(os.Stdout, headers, rows)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runAgentGet(cmd *cobra.Command, args []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var agent map[string]any
|
||||
if err := client.GetJSON(ctx, "/api/agents/"+args[0], &agent); err != nil {
|
||||
return fmt.Errorf("get agent: %w", err)
|
||||
}
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "table" {
|
||||
headers := []string{"ID", "NAME", "STATUS", "RUNTIME", "DESCRIPTION"}
|
||||
rows := [][]string{{
|
||||
strVal(agent, "id"),
|
||||
strVal(agent, "name"),
|
||||
strVal(agent, "status"),
|
||||
strVal(agent, "runtime_mode"),
|
||||
strVal(agent, "description"),
|
||||
}}
|
||||
cli.PrintTable(os.Stdout, headers, rows)
|
||||
return nil
|
||||
}
|
||||
|
||||
return cli.PrintJSON(os.Stdout, agent)
|
||||
}
|
||||
|
||||
func runAgentDelete(cmd *cobra.Command, args []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := client.DeleteJSON(ctx, "/api/agents/"+args[0]); err != nil {
|
||||
return fmt.Errorf("delete agent: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Agent %s deleted.\n", args[0])
|
||||
return nil
|
||||
}
|
||||
|
||||
func runAgentStop(cmd *cobra.Command, args []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
body := map[string]any{"status": "offline"}
|
||||
if err := client.PutJSON(ctx, "/api/agents/"+args[0], body, nil); err != nil {
|
||||
return fmt.Errorf("stop agent: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Agent %s stopped.\n", args[0])
|
||||
return nil
|
||||
}
|
||||
|
||||
func strVal(m map[string]any, key string) string {
|
||||
v, ok := m[key]
|
||||
if !ok || v == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
79
server/cmd/multica/cmd_config.go
Normal file
79
server/cmd/multica/cmd_config.go
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/cli"
|
||||
)
|
||||
|
||||
var configCmd = &cobra.Command{
|
||||
Use: "config",
|
||||
Short: "Manage CLI configuration",
|
||||
}
|
||||
|
||||
var configShowCmd = &cobra.Command{
|
||||
Use: "show",
|
||||
Short: "Show current CLI configuration",
|
||||
RunE: runConfigShow,
|
||||
}
|
||||
|
||||
var configSetCmd = &cobra.Command{
|
||||
Use: "set <key> <value>",
|
||||
Short: "Set a CLI configuration value",
|
||||
Long: "Supported keys: server_url, workspace_id",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: runConfigSet,
|
||||
}
|
||||
|
||||
func init() {
|
||||
configCmd.AddCommand(configShowCmd)
|
||||
configCmd.AddCommand(configSetCmd)
|
||||
}
|
||||
|
||||
func runConfigShow(_ *cobra.Command, _ []string) error {
|
||||
cfg, err := cli.LoadCLIConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
path, _ := cli.CLIConfigPath()
|
||||
fmt.Fprintf(os.Stdout, "Config file: %s\n", path)
|
||||
fmt.Fprintf(os.Stdout, "server_url: %s\n", valueOrDefault(cfg.ServerURL, "(not set)"))
|
||||
fmt.Fprintf(os.Stdout, "workspace_id: %s\n", valueOrDefault(cfg.WorkspaceID, "(not set)"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func runConfigSet(_ *cobra.Command, args []string) error {
|
||||
key, value := args[0], args[1]
|
||||
|
||||
cfg, err := cli.LoadCLIConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch key {
|
||||
case "server_url":
|
||||
cfg.ServerURL = value
|
||||
case "workspace_id":
|
||||
cfg.WorkspaceID = value
|
||||
default:
|
||||
return fmt.Errorf("unknown config key %q (supported: server_url, workspace_id)", key)
|
||||
}
|
||||
|
||||
if err := cli.SaveCLIConfig(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Set %s = %s\n", key, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
func valueOrDefault(v, fallback string) string {
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
77
server/cmd/multica/cmd_daemon.go
Normal file
77
server/cmd/multica/cmd_daemon.go
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/cli"
|
||||
"github.com/multica-ai/multica/server/internal/daemon"
|
||||
)
|
||||
|
||||
var daemonCmd = &cobra.Command{
|
||||
Use: "daemon",
|
||||
Short: "Run the local agent runtime daemon",
|
||||
Long: "Start the daemon process that polls for tasks and executes them using local agent CLIs (Claude, Codex).",
|
||||
RunE: runDaemon,
|
||||
}
|
||||
|
||||
func init() {
|
||||
f := daemonCmd.Flags()
|
||||
f.String("repos-root", "", "Base directory for task repositories (env: MULTICA_REPOS_ROOT)")
|
||||
f.String("config-path", "", "Path to daemon config file (env: MULTICA_DAEMON_CONFIG)")
|
||||
f.String("daemon-id", "", "Unique daemon identifier (env: MULTICA_DAEMON_ID)")
|
||||
f.String("device-name", "", "Human-readable device name (env: MULTICA_DAEMON_DEVICE_NAME)")
|
||||
f.String("runtime-name", "", "Runtime display name (env: MULTICA_AGENT_RUNTIME_NAME)")
|
||||
f.Duration("poll-interval", 0, "Task poll interval (env: MULTICA_DAEMON_POLL_INTERVAL)")
|
||||
f.Duration("heartbeat-interval", 0, "Heartbeat interval (env: MULTICA_DAEMON_HEARTBEAT_INTERVAL)")
|
||||
f.Duration("agent-timeout", 0, "Per-task timeout (env: MULTICA_AGENT_TIMEOUT)")
|
||||
}
|
||||
|
||||
func runDaemon(cmd *cobra.Command, _ []string) error {
|
||||
overrides := daemon.Overrides{
|
||||
ServerURL: cli.FlagOrEnv(cmd, "server-url", "MULTICA_SERVER_URL", ""),
|
||||
WorkspaceID: cli.FlagOrEnv(cmd, "workspace-id", "MULTICA_WORKSPACE_ID", ""),
|
||||
ReposRoot: flagString(cmd, "repos-root"),
|
||||
ConfigPath: flagString(cmd, "config-path"),
|
||||
DaemonID: flagString(cmd, "daemon-id"),
|
||||
DeviceName: flagString(cmd, "device-name"),
|
||||
RuntimeName: flagString(cmd, "runtime-name"),
|
||||
}
|
||||
if d, _ := cmd.Flags().GetDuration("poll-interval"); d > 0 {
|
||||
overrides.PollInterval = d
|
||||
}
|
||||
if d, _ := cmd.Flags().GetDuration("heartbeat-interval"); d > 0 {
|
||||
overrides.HeartbeatInterval = d
|
||||
}
|
||||
if d, _ := cmd.Flags().GetDuration("agent-timeout"); d > 0 {
|
||||
overrides.AgentTimeout = d
|
||||
}
|
||||
|
||||
cfg, err := daemon.LoadConfig(overrides)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
logger := log.New(os.Stdout, "multica-daemon: ", log.LstdFlags)
|
||||
d := daemon.New(cfg, logger)
|
||||
|
||||
if err := d.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func flagString(cmd *cobra.Command, name string) string {
|
||||
val, _ := cmd.Flags().GetString(name)
|
||||
return val
|
||||
}
|
||||
|
||||
63
server/cmd/multica/cmd_runtime.go
Normal file
63
server/cmd/multica/cmd_runtime.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/cli"
|
||||
)
|
||||
|
||||
var runtimeCmd = &cobra.Command{
|
||||
Use: "runtime",
|
||||
Short: "Manage agent runtimes",
|
||||
}
|
||||
|
||||
var runtimeListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List agent runtimes",
|
||||
RunE: runRuntimeList,
|
||||
}
|
||||
|
||||
func init() {
|
||||
runtimeCmd.AddCommand(runtimeListCmd)
|
||||
|
||||
runtimeListCmd.Flags().String("output", "table", "Output format: table or json")
|
||||
}
|
||||
|
||||
func runRuntimeList(cmd *cobra.Command, _ []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var runtimes []map[string]any
|
||||
if err := client.GetJSON(ctx, "/api/runtimes", &runtimes); err != nil {
|
||||
return fmt.Errorf("list runtimes: %w", err)
|
||||
}
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "json" {
|
||||
return cli.PrintJSON(os.Stdout, runtimes)
|
||||
}
|
||||
|
||||
headers := []string{"ID", "NAME", "PROVIDER", "STATUS", "DEVICE"}
|
||||
rows := make([][]string, 0, len(runtimes))
|
||||
for _, r := range runtimes {
|
||||
rows = append(rows, []string{
|
||||
strVal(r, "id"),
|
||||
strVal(r, "name"),
|
||||
strVal(r, "provider"),
|
||||
strVal(r, "status"),
|
||||
strVal(r, "device_info"),
|
||||
})
|
||||
}
|
||||
cli.PrintTable(os.Stdout, headers, rows)
|
||||
return nil
|
||||
}
|
||||
36
server/cmd/multica/cmd_status.go
Normal file
36
server/cmd/multica/cmd_status.go
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var statusCmd = &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Check server health",
|
||||
RunE: runStatus,
|
||||
}
|
||||
|
||||
func runStatus(cmd *cobra.Command, _ []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
body, err := client.HealthCheck(ctx)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Server unreachable: %v\n", err)
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stdout, "Server: %s\n", client.BaseURL)
|
||||
fmt.Fprintf(os.Stdout, "Status: %s\n", body)
|
||||
return nil
|
||||
}
|
||||
15
server/cmd/multica/cmd_version.go
Normal file
15
server/cmd/multica/cmd_version.go
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var versionCmd = &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print version information",
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
fmt.Printf("multica-cli %s (commit: %s)\n", version, commit)
|
||||
},
|
||||
}
|
||||
38
server/cmd/multica/main.go
Normal file
38
server/cmd/multica/main.go
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
version = "dev"
|
||||
commit = "unknown"
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "multica-cli",
|
||||
Short: "Multica CLI — local agent runtime and management tool",
|
||||
Long: "multica-cli manages local agent runtimes and provides control commands for the Multica platform.",
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.PersistentFlags().String("server-url", "", "Multica server URL (env: MULTICA_SERVER_URL)")
|
||||
rootCmd.PersistentFlags().String("workspace-id", "", "Workspace ID (env: MULTICA_WORKSPACE_ID)")
|
||||
|
||||
rootCmd.AddCommand(daemonCmd)
|
||||
rootCmd.AddCommand(agentCmd)
|
||||
rootCmd.AddCommand(runtimeCmd)
|
||||
rootCmd.AddCommand(configCmd)
|
||||
rootCmd.AddCommand(statusCmd)
|
||||
rootCmd.AddCommand(versionCmd)
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
|
@ -5,16 +5,18 @@ go 1.26.1
|
|||
require (
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/go-chi/cors v1.2.2
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/jackc/pgx/v5 v5.8.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.8.0 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/crypto v0.49.0 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||
|
|
@ -7,6 +10,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63Y
|
|||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
|
|
@ -15,15 +20,24 @@ github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
|||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
|
|||
129
server/internal/cli/client.go
Normal file
129
server/internal/cli/client.go
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// APIClient is a REST client for the Multica server API.
|
||||
// Used by ctrl subcommands (agent, runtime, status, etc.).
|
||||
type APIClient struct {
|
||||
BaseURL string
|
||||
WorkspaceID string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// NewAPIClient creates a new API client for ctrl commands.
|
||||
func NewAPIClient(baseURL, workspaceID string) *APIClient {
|
||||
return &APIClient{
|
||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||
WorkspaceID: workspaceID,
|
||||
HTTPClient: &http.Client{Timeout: 15 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// GetJSON performs a GET request and decodes the JSON response.
|
||||
func (c *APIClient) GetJSON(ctx context.Context, path string, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c.WorkspaceID != "" {
|
||||
req.Header.Set("X-Workspace-ID", c.WorkspaceID)
|
||||
}
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return fmt.Errorf("GET %s returned %d: %s", path, resp.StatusCode, strings.TrimSpace(string(data)))
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
// DeleteJSON performs a DELETE request.
|
||||
func (c *APIClient) DeleteJSON(ctx context.Context, path string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.BaseURL+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c.WorkspaceID != "" {
|
||||
req.Header.Set("X-Workspace-ID", c.WorkspaceID)
|
||||
}
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return fmt.Errorf("DELETE %s returned %d: %s", path, resp.StatusCode, strings.TrimSpace(string(data)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PutJSON performs a PUT request with a JSON body.
|
||||
func (c *APIClient) PutJSON(ctx context.Context, path string, body any, out any) error {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, c.BaseURL+path, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if c.WorkspaceID != "" {
|
||||
req.Header.Set("X-Workspace-ID", c.WorkspaceID)
|
||||
}
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
respData, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return fmt.Errorf("PUT %s returned %d: %s", path, resp.StatusCode, strings.TrimSpace(string(respData)))
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
// HealthCheck hits the /health endpoint and returns the response body.
|
||||
func (c *APIClient) HealthCheck(ctx context.Context) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/health", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
if resp.StatusCode >= 400 {
|
||||
return "", fmt.Errorf("health check returned %d: %s", resp.StatusCode, strings.TrimSpace(string(data)))
|
||||
}
|
||||
return strings.TrimSpace(string(data)), nil
|
||||
}
|
||||
85
server/internal/cli/config.go
Normal file
85
server/internal/cli/config.go
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
const defaultCLIConfigPath = ".multica/config.json"
|
||||
|
||||
// CLIConfig holds persistent CLI settings.
|
||||
type CLIConfig struct {
|
||||
ServerURL string `json:"server_url,omitempty"`
|
||||
WorkspaceID string `json:"workspace_id,omitempty"`
|
||||
}
|
||||
|
||||
// CLIConfigPath returns the default path for the CLI config file.
|
||||
func CLIConfigPath() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve CLI config path: %w", err)
|
||||
}
|
||||
return filepath.Join(home, defaultCLIConfigPath), nil
|
||||
}
|
||||
|
||||
// LoadCLIConfig reads the CLI config from disk.
|
||||
func LoadCLIConfig() (CLIConfig, error) {
|
||||
path, err := CLIConfigPath()
|
||||
if err != nil {
|
||||
return CLIConfig{}, err
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return CLIConfig{}, nil
|
||||
}
|
||||
return CLIConfig{}, fmt.Errorf("read CLI config: %w", err)
|
||||
}
|
||||
var cfg CLIConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return CLIConfig{}, fmt.Errorf("parse CLI config: %w", err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// LoadWorkspaceIDFromDaemonConfig reads workspace_id from ~/.multica/daemon.json
|
||||
// as a fallback when it's not set in the CLI config.
|
||||
func LoadWorkspaceIDFromDaemonConfig() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(home, ".multica/daemon.json"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var cfg struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
}
|
||||
if json.Unmarshal(data, &cfg) != nil {
|
||||
return ""
|
||||
}
|
||||
return cfg.WorkspaceID
|
||||
}
|
||||
|
||||
// SaveCLIConfig writes the CLI config to disk.
|
||||
func SaveCLIConfig(cfg CLIConfig) error {
|
||||
path, err := CLIConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create CLI config directory: %w", err)
|
||||
}
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode CLI config: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil {
|
||||
return fmt.Errorf("write CLI config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
21
server/internal/cli/flags.go
Normal file
21
server/internal/cli/flags.go
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// FlagOrEnv returns the flag value if set, otherwise the environment variable value,
|
||||
// otherwise the fallback.
|
||||
func FlagOrEnv(cmd *cobra.Command, flagName, envKey, fallback string) string {
|
||||
if cmd.Flags().Changed(flagName) {
|
||||
val, _ := cmd.Flags().GetString(flagName)
|
||||
return val
|
||||
}
|
||||
if v := strings.TrimSpace(os.Getenv(envKey)); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
26
server/internal/cli/output.go
Normal file
26
server/internal/cli/output.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
)
|
||||
|
||||
// PrintTable writes a simple table with headers and rows to w.
|
||||
func PrintTable(w io.Writer, headers []string, rows [][]string) {
|
||||
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, strings.Join(headers, "\t"))
|
||||
for _, row := range rows {
|
||||
fmt.Fprintln(tw, strings.Join(row, "\t"))
|
||||
}
|
||||
tw.Flush()
|
||||
}
|
||||
|
||||
// PrintJSON writes v as indented JSON to w.
|
||||
func PrintJSON(w io.Writer, v any) error {
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(v)
|
||||
}
|
||||
182
server/internal/daemon/client.go
Normal file
182
server/internal/daemon/client.go
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
package daemon
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// requestError is returned by postJSON/getJSON when the server responds with an error status.
|
||||
type requestError struct {
|
||||
Method string
|
||||
Path string
|
||||
StatusCode int
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *requestError) Error() string {
|
||||
return fmt.Sprintf("%s %s returned %d: %s", e.Method, e.Path, e.StatusCode, e.Body)
|
||||
}
|
||||
|
||||
// isWorkspaceNotFoundError returns true if the error is a 404 with "workspace not found" body.
|
||||
func isWorkspaceNotFoundError(err error) bool {
|
||||
var reqErr *requestError
|
||||
if !errors.As(err, &reqErr) {
|
||||
return false
|
||||
}
|
||||
if reqErr.StatusCode != http.StatusNotFound {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(strings.ToLower(reqErr.Body), "workspace not found")
|
||||
}
|
||||
|
||||
// Client handles HTTP communication with the Multica server daemon API.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewClient creates a new daemon API client.
|
||||
func NewClient(baseURL string) *Client {
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) ClaimTask(ctx context.Context, runtimeID string) (*Task, error) {
|
||||
var resp struct {
|
||||
Task *Task `json:"task"`
|
||||
}
|
||||
if err := c.postJSON(ctx, fmt.Sprintf("/api/daemon/runtimes/%s/tasks/claim", runtimeID), map[string]any{}, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.Task, nil
|
||||
}
|
||||
|
||||
func (c *Client) CreatePairingSession(ctx context.Context, req map[string]string) (PairingSession, error) {
|
||||
var resp PairingSession
|
||||
if err := c.postJSON(ctx, "/api/daemon/pairing-sessions", req, &resp); err != nil {
|
||||
return PairingSession{}, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetPairingSession(ctx context.Context, token string) (PairingSession, error) {
|
||||
var resp PairingSession
|
||||
if err := c.getJSON(ctx, fmt.Sprintf("/api/daemon/pairing-sessions/%s", url.PathEscape(token)), &resp); err != nil {
|
||||
return PairingSession{}, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *Client) ClaimPairingSession(ctx context.Context, token string) (PairingSession, error) {
|
||||
var resp PairingSession
|
||||
if err := c.postJSON(ctx, fmt.Sprintf("/api/daemon/pairing-sessions/%s/claim", url.PathEscape(token)), map[string]any{}, &resp); err != nil {
|
||||
return PairingSession{}, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *Client) StartTask(ctx context.Context, taskID string) error {
|
||||
return c.postJSON(ctx, fmt.Sprintf("/api/daemon/tasks/%s/start", taskID), map[string]any{}, nil)
|
||||
}
|
||||
|
||||
func (c *Client) ReportProgress(ctx context.Context, taskID, summary string, step, total int) error {
|
||||
return c.postJSON(ctx, fmt.Sprintf("/api/daemon/tasks/%s/progress", taskID), map[string]any{
|
||||
"summary": summary,
|
||||
"step": step,
|
||||
"total": total,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func (c *Client) CompleteTask(ctx context.Context, taskID, output string) error {
|
||||
return c.postJSON(ctx, fmt.Sprintf("/api/daemon/tasks/%s/complete", taskID), map[string]any{
|
||||
"output": output,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func (c *Client) FailTask(ctx context.Context, taskID, errMsg string) error {
|
||||
return c.postJSON(ctx, fmt.Sprintf("/api/daemon/tasks/%s/fail", taskID), map[string]any{
|
||||
"error": errMsg,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func (c *Client) SendHeartbeat(ctx context.Context, runtimeID string) error {
|
||||
return c.postJSON(ctx, "/api/daemon/heartbeat", map[string]string{
|
||||
"runtime_id": runtimeID,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func (c *Client) Register(ctx context.Context, req map[string]any) ([]Runtime, error) {
|
||||
var resp struct {
|
||||
Runtimes []Runtime `json:"runtimes"`
|
||||
}
|
||||
if err := c.postJSON(ctx, "/api/daemon/register", req, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.Runtimes, nil
|
||||
}
|
||||
|
||||
func (c *Client) postJSON(ctx context.Context, path string, reqBody any, respBody any) error {
|
||||
var body io.Reader
|
||||
if reqBody != nil {
|
||||
data, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body = bytes.NewReader(data)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return &requestError{Method: http.MethodPost, Path: path, StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(data))}
|
||||
}
|
||||
if respBody == nil {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
return nil
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(respBody)
|
||||
}
|
||||
|
||||
func (c *Client) getJSON(ctx context.Context, path string, respBody any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return &requestError{Method: http.MethodGet, Path: path, StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(data))}
|
||||
}
|
||||
if respBody == nil {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
return nil
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(respBody)
|
||||
}
|
||||
254
server/internal/daemon/config.go
Normal file
254
server/internal/daemon/config.go
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
package daemon
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultServerURL = "ws://localhost:8080/ws"
|
||||
DefaultDaemonConfigPath = ".multica/daemon.json"
|
||||
DefaultPollInterval = 3 * time.Second
|
||||
DefaultHeartbeatInterval = 15 * time.Second
|
||||
DefaultAgentTimeout = 20 * time.Minute
|
||||
DefaultRuntimeName = "Local Agent"
|
||||
)
|
||||
|
||||
// Config holds all daemon configuration.
|
||||
type Config struct {
|
||||
ServerBaseURL string
|
||||
ConfigPath string
|
||||
WorkspaceID string
|
||||
DaemonID string
|
||||
DeviceName string
|
||||
RuntimeName string
|
||||
Agents map[string]AgentEntry // "claude" -> entry, "codex" -> entry
|
||||
ReposRoot string // parent directory containing all repos
|
||||
PollInterval time.Duration
|
||||
HeartbeatInterval time.Duration
|
||||
AgentTimeout time.Duration
|
||||
}
|
||||
|
||||
// Overrides allows CLI flags to override environment variables and defaults.
|
||||
// Zero values are ignored and the env/default value is used instead.
|
||||
type Overrides struct {
|
||||
ServerURL string
|
||||
WorkspaceID string
|
||||
ReposRoot string
|
||||
ConfigPath string
|
||||
PollInterval time.Duration
|
||||
HeartbeatInterval time.Duration
|
||||
AgentTimeout time.Duration
|
||||
DaemonID string
|
||||
DeviceName string
|
||||
RuntimeName string
|
||||
}
|
||||
|
||||
// LoadConfig builds the daemon configuration from environment variables,
|
||||
// persisted config, and optional CLI flag overrides.
|
||||
func LoadConfig(overrides Overrides) (Config, error) {
|
||||
// Server URL: override > env > default
|
||||
rawServerURL := envOrDefault("MULTICA_SERVER_URL", DefaultServerURL)
|
||||
if overrides.ServerURL != "" {
|
||||
rawServerURL = overrides.ServerURL
|
||||
}
|
||||
serverBaseURL, err := NormalizeServerBaseURL(rawServerURL)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
// Config path
|
||||
rawConfigPath := strings.TrimSpace(os.Getenv("MULTICA_DAEMON_CONFIG"))
|
||||
if overrides.ConfigPath != "" {
|
||||
rawConfigPath = overrides.ConfigPath
|
||||
}
|
||||
configPath, err := resolveDaemonConfigPath(rawConfigPath)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
// Load persisted config
|
||||
persisted, err := LoadPersistedConfig(configPath)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
// Workspace ID: override > env > persisted
|
||||
workspaceID := strings.TrimSpace(os.Getenv("MULTICA_WORKSPACE_ID"))
|
||||
if workspaceID == "" {
|
||||
workspaceID = persisted.WorkspaceID
|
||||
}
|
||||
if overrides.WorkspaceID != "" {
|
||||
workspaceID = overrides.WorkspaceID
|
||||
}
|
||||
|
||||
// Probe available agent CLIs
|
||||
agents := map[string]AgentEntry{}
|
||||
claudePath := envOrDefault("MULTICA_CLAUDE_PATH", "claude")
|
||||
if _, err := exec.LookPath(claudePath); err == nil {
|
||||
agents["claude"] = AgentEntry{
|
||||
Path: claudePath,
|
||||
Model: strings.TrimSpace(os.Getenv("MULTICA_CLAUDE_MODEL")),
|
||||
}
|
||||
}
|
||||
codexPath := envOrDefault("MULTICA_CODEX_PATH", "codex")
|
||||
if _, err := exec.LookPath(codexPath); err == nil {
|
||||
agents["codex"] = AgentEntry{
|
||||
Path: codexPath,
|
||||
Model: strings.TrimSpace(os.Getenv("MULTICA_CODEX_MODEL")),
|
||||
}
|
||||
}
|
||||
if len(agents) == 0 {
|
||||
return Config{}, fmt.Errorf("no agent CLI found: install claude or codex and ensure it is on PATH")
|
||||
}
|
||||
|
||||
// Host info
|
||||
host, err := os.Hostname()
|
||||
if err != nil || strings.TrimSpace(host) == "" {
|
||||
host = "local-machine"
|
||||
}
|
||||
|
||||
// Repos root: override > env > cwd
|
||||
reposRoot := strings.TrimSpace(os.Getenv("MULTICA_REPOS_ROOT"))
|
||||
if overrides.ReposRoot != "" {
|
||||
reposRoot = overrides.ReposRoot
|
||||
}
|
||||
if reposRoot == "" {
|
||||
reposRoot, err = os.Getwd()
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("resolve working directory: %w", err)
|
||||
}
|
||||
}
|
||||
reposRoot, err = filepath.Abs(reposRoot)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("resolve absolute repos root: %w", err)
|
||||
}
|
||||
|
||||
// Durations: override > env > default
|
||||
pollInterval, err := durationFromEnv("MULTICA_DAEMON_POLL_INTERVAL", DefaultPollInterval)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if overrides.PollInterval > 0 {
|
||||
pollInterval = overrides.PollInterval
|
||||
}
|
||||
|
||||
heartbeatInterval, err := durationFromEnv("MULTICA_DAEMON_HEARTBEAT_INTERVAL", DefaultHeartbeatInterval)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if overrides.HeartbeatInterval > 0 {
|
||||
heartbeatInterval = overrides.HeartbeatInterval
|
||||
}
|
||||
|
||||
agentTimeout, err := durationFromEnv("MULTICA_AGENT_TIMEOUT", DefaultAgentTimeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if overrides.AgentTimeout > 0 {
|
||||
agentTimeout = overrides.AgentTimeout
|
||||
}
|
||||
|
||||
// String overrides
|
||||
daemonID := envOrDefault("MULTICA_DAEMON_ID", host)
|
||||
if overrides.DaemonID != "" {
|
||||
daemonID = overrides.DaemonID
|
||||
}
|
||||
|
||||
deviceName := envOrDefault("MULTICA_DAEMON_DEVICE_NAME", host)
|
||||
if overrides.DeviceName != "" {
|
||||
deviceName = overrides.DeviceName
|
||||
}
|
||||
|
||||
runtimeName := envOrDefault("MULTICA_AGENT_RUNTIME_NAME", DefaultRuntimeName)
|
||||
if overrides.RuntimeName != "" {
|
||||
runtimeName = overrides.RuntimeName
|
||||
}
|
||||
|
||||
return Config{
|
||||
ServerBaseURL: serverBaseURL,
|
||||
ConfigPath: configPath,
|
||||
WorkspaceID: workspaceID,
|
||||
DaemonID: daemonID,
|
||||
DeviceName: deviceName,
|
||||
RuntimeName: runtimeName,
|
||||
Agents: agents,
|
||||
ReposRoot: reposRoot,
|
||||
PollInterval: pollInterval,
|
||||
HeartbeatInterval: heartbeatInterval,
|
||||
AgentTimeout: agentTimeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NormalizeServerBaseURL converts a WebSocket or HTTP URL to a base HTTP URL.
|
||||
func NormalizeServerBaseURL(raw string) (string, error) {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid MULTICA_SERVER_URL: %w", err)
|
||||
}
|
||||
switch u.Scheme {
|
||||
case "ws":
|
||||
u.Scheme = "http"
|
||||
case "wss":
|
||||
u.Scheme = "https"
|
||||
case "http", "https":
|
||||
default:
|
||||
return "", fmt.Errorf("MULTICA_SERVER_URL must use ws, wss, http, or https")
|
||||
}
|
||||
if u.Path == "/ws" {
|
||||
u.Path = ""
|
||||
}
|
||||
u.RawPath = ""
|
||||
u.RawQuery = ""
|
||||
u.Fragment = ""
|
||||
return strings.TrimRight(u.String(), "/"), nil
|
||||
}
|
||||
|
||||
func resolveDaemonConfigPath(raw string) (string, error) {
|
||||
if raw != "" {
|
||||
return filepath.Abs(raw)
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve daemon config path: %w", err)
|
||||
}
|
||||
return filepath.Join(home, DefaultDaemonConfigPath), nil
|
||||
}
|
||||
|
||||
// LoadPersistedConfig reads the daemon config from disk.
|
||||
func LoadPersistedConfig(path string) (PersistedConfig, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return PersistedConfig{}, nil
|
||||
}
|
||||
return PersistedConfig{}, fmt.Errorf("read daemon config: %w", err)
|
||||
}
|
||||
var cfg PersistedConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return PersistedConfig{}, fmt.Errorf("parse daemon config: %w", err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// SavePersistedConfig writes the daemon config to disk.
|
||||
func SavePersistedConfig(path string, cfg PersistedConfig) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create daemon config directory: %w", err)
|
||||
}
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode daemon config: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil {
|
||||
return fmt.Errorf("write daemon config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
328
server/internal/daemon/daemon.go
Normal file
328
server/internal/daemon/daemon.go
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/multica-ai/multica/server/pkg/agent"
|
||||
)
|
||||
|
||||
// Daemon is the local agent runtime that polls for and executes tasks.
|
||||
type Daemon struct {
|
||||
cfg Config
|
||||
client *Client
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
// New creates a new Daemon instance.
|
||||
func New(cfg Config, logger *log.Logger) *Daemon {
|
||||
return &Daemon{
|
||||
cfg: cfg,
|
||||
client: NewClient(cfg.ServerBaseURL),
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Run starts the daemon: pairs if needed, registers runtimes, then polls for tasks.
|
||||
func (d *Daemon) Run(ctx context.Context) error {
|
||||
agentNames := make([]string, 0, len(d.cfg.Agents))
|
||||
for name := range d.cfg.Agents {
|
||||
agentNames = append(agentNames, name)
|
||||
}
|
||||
d.logger.Printf("starting daemon agents=%v workspace=%s server=%s repos_root=%s",
|
||||
agentNames, d.cfg.WorkspaceID, d.cfg.ServerBaseURL, d.cfg.ReposRoot)
|
||||
|
||||
if strings.TrimSpace(d.cfg.WorkspaceID) == "" {
|
||||
workspaceID, err := d.ensurePaired(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.cfg.WorkspaceID = workspaceID
|
||||
d.logger.Printf("pairing completed for workspace=%s", workspaceID)
|
||||
}
|
||||
|
||||
runtimes, err := d.registerRuntimes(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtimeIDs := make([]string, 0, len(runtimes))
|
||||
for _, rt := range runtimes {
|
||||
d.logger.Printf("registered runtime id=%s provider=%s status=%s", rt.ID, rt.Provider, rt.Status)
|
||||
runtimeIDs = append(runtimeIDs, rt.ID)
|
||||
}
|
||||
|
||||
go d.heartbeatLoop(ctx, runtimeIDs)
|
||||
return d.pollLoop(ctx, runtimeIDs)
|
||||
}
|
||||
|
||||
func (d *Daemon) registerRuntimes(ctx context.Context) ([]Runtime, error) {
|
||||
var runtimes []map[string]string
|
||||
for name, entry := range d.cfg.Agents {
|
||||
version, err := agent.DetectVersion(ctx, entry.Path)
|
||||
if err != nil {
|
||||
d.logger.Printf("skip registering %s: %v", name, err)
|
||||
continue
|
||||
}
|
||||
runtimes = append(runtimes, map[string]string{
|
||||
"name": fmt.Sprintf("Local %s", strings.ToUpper(name[:1])+name[1:]),
|
||||
"type": name,
|
||||
"version": version,
|
||||
"status": "online",
|
||||
})
|
||||
}
|
||||
if len(runtimes) == 0 {
|
||||
return nil, fmt.Errorf("no agent runtimes could be registered")
|
||||
}
|
||||
|
||||
req := map[string]any{
|
||||
"workspace_id": d.cfg.WorkspaceID,
|
||||
"daemon_id": d.cfg.DaemonID,
|
||||
"device_name": d.cfg.DeviceName,
|
||||
"runtimes": runtimes,
|
||||
}
|
||||
|
||||
rts, err := d.client.Register(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("register runtimes: %w", err)
|
||||
}
|
||||
if len(rts) == 0 {
|
||||
return nil, fmt.Errorf("register runtimes: empty response")
|
||||
}
|
||||
return rts, nil
|
||||
}
|
||||
|
||||
func (d *Daemon) ensurePaired(ctx context.Context) (string, error) {
|
||||
// Use a deterministic agent for the pairing session metadata (prefer codex for backward compat).
|
||||
var firstName string
|
||||
var firstEntry AgentEntry
|
||||
for _, preferred := range []string{"codex", "claude"} {
|
||||
if entry, ok := d.cfg.Agents[preferred]; ok {
|
||||
firstName = preferred
|
||||
firstEntry = entry
|
||||
break
|
||||
}
|
||||
}
|
||||
version, err := agent.DetectVersion(ctx, firstEntry.Path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
session, err := d.client.CreatePairingSession(ctx, map[string]string{
|
||||
"daemon_id": d.cfg.DaemonID,
|
||||
"device_name": d.cfg.DeviceName,
|
||||
"runtime_name": d.cfg.RuntimeName,
|
||||
"runtime_type": firstName,
|
||||
"runtime_version": version,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create pairing session: %w", err)
|
||||
}
|
||||
if session.LinkURL != nil {
|
||||
d.logger.Printf("open this link to pair the daemon: %s", *session.LinkURL)
|
||||
} else {
|
||||
d.logger.Printf("pairing session created: %s", session.Token)
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
current, err := d.client.GetPairingSession(ctx, session.Token)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("poll pairing session: %w", err)
|
||||
}
|
||||
|
||||
switch current.Status {
|
||||
case "approved", "claimed":
|
||||
if current.WorkspaceID == nil || strings.TrimSpace(*current.WorkspaceID) == "" {
|
||||
return "", fmt.Errorf("pairing session approved without workspace")
|
||||
}
|
||||
if err := SavePersistedConfig(d.cfg.ConfigPath, PersistedConfig{
|
||||
WorkspaceID: strings.TrimSpace(*current.WorkspaceID),
|
||||
}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if current.Status != "claimed" {
|
||||
if _, err := d.client.ClaimPairingSession(ctx, current.Token); err != nil {
|
||||
return "", fmt.Errorf("claim pairing session: %w", err)
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(*current.WorkspaceID), nil
|
||||
case "expired":
|
||||
return "", fmt.Errorf("pairing session expired before approval")
|
||||
}
|
||||
|
||||
if err := sleepWithContext(ctx, d.cfg.PollInterval); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Daemon) heartbeatLoop(ctx context.Context, runtimeIDs []string) {
|
||||
ticker := time.NewTicker(d.cfg.HeartbeatInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
for _, rid := range runtimeIDs {
|
||||
if err := d.client.SendHeartbeat(ctx, rid); err != nil {
|
||||
d.logger.Printf("heartbeat failed for runtime %s: %v", rid, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Daemon) pollLoop(ctx context.Context, runtimeIDs []string) error {
|
||||
pollOffset := 0
|
||||
pollCount := 0
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
claimed := false
|
||||
n := len(runtimeIDs)
|
||||
for i := 0; i < n; i++ {
|
||||
rid := runtimeIDs[(pollOffset+i)%n]
|
||||
task, err := d.client.ClaimTask(ctx, rid)
|
||||
if err != nil {
|
||||
d.logger.Printf("claim task failed for runtime %s: %v", rid, err)
|
||||
continue
|
||||
}
|
||||
if task != nil {
|
||||
d.logger.Printf("poll: got task=%s issue=%s title=%q", task.ID, task.IssueID, task.Context.Issue.Title)
|
||||
d.handleTask(ctx, *task)
|
||||
claimed = true
|
||||
pollOffset = (pollOffset + i + 1) % n
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !claimed {
|
||||
pollCount++
|
||||
if pollCount%20 == 1 {
|
||||
d.logger.Printf("poll: no tasks (runtimes=%v, cycle=%d)", runtimeIDs, pollCount)
|
||||
}
|
||||
pollOffset = (pollOffset + 1) % n
|
||||
if err := sleepWithContext(ctx, d.cfg.PollInterval); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
pollCount = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Daemon) handleTask(ctx context.Context, task Task) {
|
||||
provider := task.Context.Runtime.Provider
|
||||
d.logger.Printf("picked task=%s issue=%s provider=%s title=%q", task.ID, task.IssueID, provider, task.Context.Issue.Title)
|
||||
|
||||
if err := d.client.StartTask(ctx, task.ID); err != nil {
|
||||
d.logger.Printf("start task %s failed: %v", task.ID, err)
|
||||
return
|
||||
}
|
||||
|
||||
_ = d.client.ReportProgress(ctx, task.ID, fmt.Sprintf("Launching %s", provider), 1, 2)
|
||||
|
||||
result, err := d.runTask(ctx, task)
|
||||
if err != nil {
|
||||
d.logger.Printf("task %s failed: %v", task.ID, err)
|
||||
if failErr := d.client.FailTask(ctx, task.ID, err.Error()); failErr != nil {
|
||||
d.logger.Printf("fail task %s callback failed: %v", task.ID, failErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
_ = d.client.ReportProgress(ctx, task.ID, "Finishing task", 2, 2)
|
||||
|
||||
switch result.Status {
|
||||
case "blocked":
|
||||
if err := d.client.FailTask(ctx, task.ID, result.Comment); err != nil {
|
||||
d.logger.Printf("report blocked task %s failed: %v", task.ID, err)
|
||||
}
|
||||
default:
|
||||
d.logger.Printf("task %s completed status=%s", task.ID, result.Status)
|
||||
if err := d.client.CompleteTask(ctx, task.ID, result.Comment); err != nil {
|
||||
d.logger.Printf("complete task %s failed: %v", task.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Daemon) runTask(ctx context.Context, task Task) (TaskResult, error) {
|
||||
provider := task.Context.Runtime.Provider
|
||||
entry, ok := d.cfg.Agents[provider]
|
||||
if !ok {
|
||||
return TaskResult{}, fmt.Errorf("no agent configured for provider %q", provider)
|
||||
}
|
||||
|
||||
workdir, err := ResolveTaskWorkdir(d.cfg.ReposRoot, task.Context.Issue.Repository)
|
||||
if err != nil {
|
||||
return TaskResult{}, err
|
||||
}
|
||||
|
||||
prompt := BuildPrompt(task, workdir)
|
||||
|
||||
backend, err := agent.New(provider, agent.Config{
|
||||
ExecutablePath: entry.Path,
|
||||
Logger: d.logger,
|
||||
})
|
||||
if err != nil {
|
||||
return TaskResult{}, fmt.Errorf("create agent backend: %w", err)
|
||||
}
|
||||
|
||||
d.logger.Printf(
|
||||
"starting %s task=%s workdir=%s model=%s timeout=%s",
|
||||
provider, task.ID, workdir, entry.Model, d.cfg.AgentTimeout,
|
||||
)
|
||||
|
||||
session, err := backend.Execute(ctx, prompt, agent.ExecOptions{
|
||||
Cwd: workdir,
|
||||
Model: entry.Model,
|
||||
Timeout: d.cfg.AgentTimeout,
|
||||
})
|
||||
if err != nil {
|
||||
return TaskResult{}, err
|
||||
}
|
||||
|
||||
// Drain message channel (log tool uses, ignore text since Result has output)
|
||||
go func() {
|
||||
for msg := range session.Messages {
|
||||
switch msg.Type {
|
||||
case agent.MessageToolUse:
|
||||
d.logger.Printf("[%s] tool-use: %s (call=%s)", provider, msg.Tool, msg.CallID)
|
||||
case agent.MessageError:
|
||||
d.logger.Printf("[%s] error: %s", provider, msg.Content)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
result := <-session.Result
|
||||
|
||||
switch result.Status {
|
||||
case "completed":
|
||||
if result.Output == "" {
|
||||
return TaskResult{}, fmt.Errorf("%s returned empty output", provider)
|
||||
}
|
||||
return TaskResult{Status: "completed", Comment: result.Output}, nil
|
||||
case "timeout":
|
||||
return TaskResult{}, fmt.Errorf("%s timed out after %s", provider, d.cfg.AgentTimeout)
|
||||
default:
|
||||
errMsg := result.Error
|
||||
if errMsg == "" {
|
||||
errMsg = fmt.Sprintf("%s execution %s", provider, result.Status)
|
||||
}
|
||||
return TaskResult{Status: "blocked", Comment: errMsg}, nil
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package main
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
|
@ -11,9 +11,9 @@ import (
|
|||
func TestNormalizeServerBaseURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := normalizeServerBaseURL("ws://localhost:8080/ws")
|
||||
got, err := NormalizeServerBaseURL("ws://localhost:8080/ws")
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeServerBaseURL returned error: %v", err)
|
||||
t.Fatalf("NormalizeServerBaseURL returned error: %v", err)
|
||||
}
|
||||
if got != "http://localhost:8080" {
|
||||
t.Fatalf("expected http://localhost:8080, got %s", got)
|
||||
|
|
@ -29,27 +29,27 @@ func TestResolveTaskWorkdirUsesRepoPathWhenPresent(t *testing.T) {
|
|||
t.Fatalf("mkdir repo: %v", err)
|
||||
}
|
||||
|
||||
got, err := resolveTaskWorkdir(root, &daemonRepoRef{Path: "repo"})
|
||||
got, err := ResolveTaskWorkdir(root, &RepoRef{Path: "repo"})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveTaskWorkdir returned error: %v", err)
|
||||
t.Fatalf("ResolveTaskWorkdir returned error: %v", err)
|
||||
}
|
||||
if got != repoPath {
|
||||
t.Fatalf("expected %s, got %s", repoPath, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCodexPromptIncludesIssueAndSkills(t *testing.T) {
|
||||
func TestBuildPromptIncludesIssueAndSkills(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
prompt := buildCodexPrompt(daemonTask{
|
||||
Context: daemonTaskContext{
|
||||
Issue: daemonIssueContext{
|
||||
prompt := BuildPrompt(Task{
|
||||
Context: TaskContext{
|
||||
Issue: IssueContext{
|
||||
Title: "Fix failing test",
|
||||
Description: "Investigate and fix the test failure.",
|
||||
AcceptanceCriteria: []string{"tests pass"},
|
||||
ContextRefs: []string{"log snippet"},
|
||||
},
|
||||
Agent: daemonAgentContext{
|
||||
Agent: AgentContext{
|
||||
Name: "Local Codex",
|
||||
Skills: "Be concise.",
|
||||
},
|
||||
41
server/internal/daemon/helpers.go
Normal file
41
server/internal/daemon/helpers.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func envOrDefault(key, fallback string) string {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func durationFromEnv(key string, fallback time.Duration) (time.Duration, error) {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
d, err := time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s: invalid duration %q: %w", key, value, err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func sleepWithContext(ctx context.Context, d time.Duration) error {
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
99
server/internal/daemon/prompt.go
Normal file
99
server/internal/daemon/prompt.go
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
package daemon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// BuildPrompt constructs the task prompt for an agent CLI.
|
||||
func BuildPrompt(task Task, workdir string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("You are running as a local coding agent for a Multica workspace.\n")
|
||||
b.WriteString("Complete the assigned issue using the local environment.\n")
|
||||
b.WriteString("Return a concise Markdown comment suitable for posting back to the issue.\n")
|
||||
b.WriteString("If you cannot complete the task because context, files, or permissions are missing, return status \"blocked\" and explain the blocker in the comment.\n\n")
|
||||
|
||||
fmt.Fprintf(&b, "Working directory: %s\n", workdir)
|
||||
fmt.Fprintf(&b, "Agent: %s\n", task.Context.Agent.Name)
|
||||
fmt.Fprintf(&b, "Issue title: %s\n\n", task.Context.Issue.Title)
|
||||
|
||||
if task.Context.Issue.Description != "" {
|
||||
b.WriteString("Issue description:\n")
|
||||
b.WriteString(task.Context.Issue.Description)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
if len(task.Context.Issue.AcceptanceCriteria) > 0 {
|
||||
b.WriteString("Acceptance criteria:\n")
|
||||
for _, item := range task.Context.Issue.AcceptanceCriteria {
|
||||
fmt.Fprintf(&b, "- %s\n", item)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
if len(task.Context.Issue.ContextRefs) > 0 {
|
||||
b.WriteString("Context refs:\n")
|
||||
for _, item := range task.Context.Issue.ContextRefs {
|
||||
fmt.Fprintf(&b, "- %s\n", item)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
if repo := task.Context.Issue.Repository; repo != nil {
|
||||
b.WriteString("Repository context:\n")
|
||||
if repo.URL != "" {
|
||||
fmt.Fprintf(&b, "- url: %s\n", repo.URL)
|
||||
}
|
||||
if repo.Branch != "" {
|
||||
fmt.Fprintf(&b, "- branch: %s\n", repo.Branch)
|
||||
}
|
||||
if repo.Path != "" {
|
||||
fmt.Fprintf(&b, "- path: %s\n", repo.Path)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
if task.Context.WorkspaceContext != "" {
|
||||
b.WriteString("Workspace context:\n")
|
||||
b.WriteString(task.Context.WorkspaceContext)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
if task.Context.Agent.Skills != "" {
|
||||
b.WriteString("Agent skills/instructions:\n")
|
||||
b.WriteString(task.Context.Agent.Skills)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
b.WriteString("Comment requirements:\n")
|
||||
b.WriteString("- Lead with the outcome.\n")
|
||||
b.WriteString("- Mention concrete files or commands if you changed anything.\n")
|
||||
b.WriteString("- Mention blockers or follow-up actions if relevant.\n")
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ResolveTaskWorkdir determines the working directory for a task.
|
||||
func ResolveTaskWorkdir(reposRoot string, repo *RepoRef) (string, error) {
|
||||
base := reposRoot
|
||||
if repo == nil || strings.TrimSpace(repo.Path) == "" {
|
||||
return base, nil
|
||||
}
|
||||
|
||||
path := strings.TrimSpace(repo.Path)
|
||||
if !filepath.IsAbs(path) {
|
||||
path = filepath.Join(base, path)
|
||||
}
|
||||
path = filepath.Clean(path)
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("repository path not found: %s", path)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "", fmt.Errorf("repository path is not a directory: %s", path)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
90
server/internal/daemon/types.go
Normal file
90
server/internal/daemon/types.go
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
package daemon
|
||||
|
||||
// AgentEntry describes a single available agent CLI.
|
||||
type AgentEntry struct {
|
||||
Path string // path to CLI binary
|
||||
Model string // model override (optional)
|
||||
}
|
||||
|
||||
// Runtime represents a registered daemon runtime.
|
||||
type Runtime struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// PairingSession represents a daemon pairing session.
|
||||
type PairingSession struct {
|
||||
Token string `json:"token"`
|
||||
DaemonID string `json:"daemon_id"`
|
||||
DeviceName string `json:"device_name"`
|
||||
RuntimeName string `json:"runtime_name"`
|
||||
RuntimeType string `json:"runtime_type"`
|
||||
RuntimeVersion string `json:"runtime_version"`
|
||||
WorkspaceID *string `json:"workspace_id"`
|
||||
Status string `json:"status"`
|
||||
ApprovedAt *string `json:"approved_at"`
|
||||
ClaimedAt *string `json:"claimed_at"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
LinkURL *string `json:"link_url"`
|
||||
}
|
||||
|
||||
// PersistedConfig is the JSON structure saved to ~/.multica/daemon.json.
|
||||
type PersistedConfig struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
}
|
||||
|
||||
// Task represents a claimed task from the server.
|
||||
type Task struct {
|
||||
ID string `json:"id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
IssueID string `json:"issue_id"`
|
||||
Context TaskContext `json:"context"`
|
||||
}
|
||||
|
||||
// TaskContext contains the snapshot context for a task.
|
||||
type TaskContext struct {
|
||||
Issue IssueContext `json:"issue"`
|
||||
Agent AgentContext `json:"agent"`
|
||||
Runtime RuntimeContext `json:"runtime"`
|
||||
WorkspaceContext string `json:"workspace_context,omitempty"`
|
||||
}
|
||||
|
||||
// IssueContext holds issue details for task execution.
|
||||
type IssueContext struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
AcceptanceCriteria []string `json:"acceptance_criteria"`
|
||||
ContextRefs []string `json:"context_refs"`
|
||||
Repository *RepoRef `json:"repository"`
|
||||
}
|
||||
|
||||
// AgentContext holds agent details for task execution.
|
||||
type AgentContext struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Skills string `json:"skills"`
|
||||
}
|
||||
|
||||
// RuntimeContext holds runtime details for task execution.
|
||||
type RuntimeContext struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
DeviceInfo string `json:"device_info"`
|
||||
}
|
||||
|
||||
// RepoRef points to a repository for an issue.
|
||||
type RepoRef struct {
|
||||
URL string `json:"url"`
|
||||
Branch string `json:"branch"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// TaskResult is the outcome of executing a task.
|
||||
type TaskResult struct {
|
||||
Status string `json:"status"`
|
||||
Comment string `json:"comment"`
|
||||
}
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
|
@ -240,9 +243,54 @@ func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
|
|||
agent, _ = h.Queries.GetAgent(r.Context(), agent.ID)
|
||||
}
|
||||
|
||||
// Best-effort: create an initialization issue assigned to the new agent.
|
||||
h.createAgentInitIssue(r.Context(), agent, parseUUID(ownerID))
|
||||
|
||||
writeJSON(w, http.StatusCreated, agentToResponse(agent))
|
||||
}
|
||||
|
||||
// createAgentInitIssue creates an initialization issue assigned to a newly created agent.
|
||||
// It incorporates workspace context so the agent can set up its environment.
|
||||
// Failures are silently ignored — the agent creation itself has already succeeded.
|
||||
func (h *Handler) createAgentInitIssue(ctx context.Context, agent db.Agent, creatorID pgtype.UUID) {
|
||||
ws, err := h.Queries.GetWorkspace(ctx, agent.WorkspaceID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var desc string
|
||||
if ws.Context.Valid && ws.Context.String != "" {
|
||||
desc = fmt.Sprintf("Initialize the development environment for agent **%s**.\n\n## Workspace Context\n\n%s\n\n## Instructions\n\n- Set up the local development environment based on the workspace context above\n- Clone and configure any referenced repositories\n- Verify access to the codebase and tools\n- Report back on what was set up and any issues encountered", agent.Name, ws.Context.String)
|
||||
} else {
|
||||
desc = fmt.Sprintf("Initialize the development environment for agent **%s**.\n\n## Instructions\n\n- Explore the local working directory and understand the project structure\n- Verify access to the codebase and tools\n- Report back on what was found and any issues encountered", agent.Name)
|
||||
}
|
||||
|
||||
issue, err := h.Queries.CreateIssue(ctx, db.CreateIssueParams{
|
||||
WorkspaceID: agent.WorkspaceID,
|
||||
Title: "Initialize environment for " + agent.Name,
|
||||
Description: strToText(desc),
|
||||
Status: "todo",
|
||||
Priority: "medium",
|
||||
AssigneeType: pgtype.Text{String: "agent", Valid: true},
|
||||
AssigneeID: agent.ID,
|
||||
CreatorType: "member",
|
||||
CreatorID: creatorID,
|
||||
AcceptanceCriteria: []byte("[]"),
|
||||
ContextRefs: []byte("[]"),
|
||||
Position: 0,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
h.broadcast("issue:created", map[string]any{"issue": issueToResponse(issue)})
|
||||
|
||||
// Enqueue the task directly — we know the agent is assigned and status is "todo".
|
||||
if _, err := h.TaskService.EnqueueTaskForIssue(ctx, issue); err != nil {
|
||||
log.Printf("createAgentInitIssue: enqueue task failed for issue %s: %v", issue.Title, err)
|
||||
}
|
||||
}
|
||||
|
||||
type UpdateAgentRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
|
|
|
|||
|
|
@ -11,13 +11,14 @@ import (
|
|||
)
|
||||
|
||||
type WorkspaceResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Description *string `json:"description"`
|
||||
Settings any `json:"settings"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Context *string `json:"context"`
|
||||
Settings any `json:"settings"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func workspaceToResponse(w db.Workspace) WorkspaceResponse {
|
||||
|
|
@ -33,6 +34,7 @@ func workspaceToResponse(w db.Workspace) WorkspaceResponse {
|
|||
Name: w.Name,
|
||||
Slug: w.Slug,
|
||||
Description: textToPtr(w.Description),
|
||||
Context: textToPtr(w.Context),
|
||||
Settings: settings,
|
||||
CreatedAt: timestampToString(w.CreatedAt),
|
||||
UpdatedAt: timestampToString(w.UpdatedAt),
|
||||
|
|
@ -95,6 +97,7 @@ type CreateWorkspaceRequest struct {
|
|||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Description *string `json:"description"`
|
||||
Context *string `json:"context"`
|
||||
}
|
||||
|
||||
func (h *Handler) CreateWorkspace(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -128,6 +131,7 @@ func (h *Handler) CreateWorkspace(w http.ResponseWriter, r *http.Request) {
|
|||
Name: req.Name,
|
||||
Slug: req.Slug,
|
||||
Description: ptrToText(req.Description),
|
||||
Context: ptrToText(req.Context),
|
||||
})
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
|
|
@ -159,6 +163,7 @@ func (h *Handler) CreateWorkspace(w http.ResponseWriter, r *http.Request) {
|
|||
type UpdateWorkspaceRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
Context *string `json:"context"`
|
||||
Settings any `json:"settings"`
|
||||
}
|
||||
|
||||
|
|
@ -188,6 +193,9 @@ func (h *Handler) UpdateWorkspace(w http.ResponseWriter, r *http.Request) {
|
|||
if req.Description != nil {
|
||||
params.Description = pgtype.Text{String: *req.Description, Valid: true}
|
||||
}
|
||||
if req.Context != nil {
|
||||
params.Context = pgtype.Text{String: *req.Context, Valid: true}
|
||||
}
|
||||
if req.Settings != nil {
|
||||
s, _ := json.Marshal(req.Settings)
|
||||
params.Settings = s
|
||||
|
|
|
|||
|
|
@ -42,7 +42,13 @@ func (s *TaskService) EnqueueTaskForIssue(ctx context.Context, issue db.Issue) (
|
|||
return db.AgentTaskQueue{}, fmt.Errorf("load runtime: %w", err)
|
||||
}
|
||||
|
||||
snapshot := buildContextSnapshot(issue, agent, runtime)
|
||||
// Include workspace context in the snapshot when available.
|
||||
var workspaceContext string
|
||||
if ws, err := s.Queries.GetWorkspace(ctx, issue.WorkspaceID); err == nil && ws.Context.Valid {
|
||||
workspaceContext = ws.Context.String
|
||||
}
|
||||
|
||||
snapshot := buildContextSnapshot(issue, agent, runtime, workspaceContext)
|
||||
contextJSON, _ := json.Marshal(snapshot)
|
||||
|
||||
task, err := s.Queries.CreateAgentTaskWithContext(ctx, db.CreateAgentTaskWithContextParams{
|
||||
|
|
@ -169,6 +175,7 @@ func (s *TaskService) CompleteTask(ctx context.Context, taskID pgtype.UUID, resu
|
|||
s.createAgentComment(ctx, task.IssueID, task.AgentID, payload.Output, "comment")
|
||||
}
|
||||
}
|
||||
|
||||
if issueErr == nil {
|
||||
s.createInboxForIssueCreator(ctx, issue, "review_requested", "attention", "Review requested: "+issue.Title, "")
|
||||
}
|
||||
|
|
@ -250,7 +257,7 @@ func (s *TaskService) updateAgentStatus(ctx context.Context, agentID pgtype.UUID
|
|||
s.broadcast(protocol.EventAgentStatus, map[string]any{"agent": agentToMap(agent)})
|
||||
}
|
||||
|
||||
func buildContextSnapshot(issue db.Issue, agent db.Agent, runtime db.AgentRuntime) map[string]any {
|
||||
func buildContextSnapshot(issue db.Issue, agent db.Agent, runtime db.AgentRuntime, workspaceContext string) map[string]any {
|
||||
var ac []string
|
||||
if issue.AcceptanceCriteria != nil {
|
||||
json.Unmarshal(issue.AcceptanceCriteria, &ac)
|
||||
|
|
@ -274,7 +281,7 @@ func buildContextSnapshot(issue db.Issue, agent db.Agent, runtime db.AgentRuntim
|
|||
json.Unmarshal(runtime.Metadata, &metadata)
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
m := map[string]any{
|
||||
"issue": map[string]any{
|
||||
"id": util.UUIDToString(issue.ID),
|
||||
"title": issue.Title,
|
||||
|
|
@ -298,6 +305,10 @@ func buildContextSnapshot(issue db.Issue, agent db.Agent, runtime db.AgentRuntim
|
|||
"metadata": metadata,
|
||||
},
|
||||
}
|
||||
if workspaceContext != "" {
|
||||
m["workspace_context"] = workspaceContext
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func priorityToInt(p string) int32 {
|
||||
|
|
|
|||
1
server/migrations/006_workspace_context.down.sql
Normal file
1
server/migrations/006_workspace_context.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE workspace DROP COLUMN IF EXISTS context;
|
||||
1
server/migrations/006_workspace_context.up.sql
Normal file
1
server/migrations/006_workspace_context.up.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE workspace ADD COLUMN context TEXT;
|
||||
99
server/pkg/agent/agent.go
Normal file
99
server/pkg/agent/agent.go
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
// Package agent provides a unified interface for executing prompts via
|
||||
// coding agents (Claude Code, Codex). It mirrors the happy-cli AgentBackend
|
||||
// pattern, translated to idiomatic Go.
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Backend is the unified interface for executing prompts via coding agents.
|
||||
type Backend interface {
|
||||
// Execute runs a prompt and returns a Session for streaming results.
|
||||
// The caller should read from Session.Messages (optional) and wait on
|
||||
// Session.Result for the final outcome.
|
||||
Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error)
|
||||
}
|
||||
|
||||
// ExecOptions configures a single execution.
|
||||
type ExecOptions struct {
|
||||
Cwd string
|
||||
Model string
|
||||
SystemPrompt string
|
||||
MaxTurns int
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// Session represents a running agent execution.
|
||||
type Session struct {
|
||||
// Messages streams events as the agent works. The channel is closed
|
||||
// when the agent finishes (before Result is sent).
|
||||
Messages <-chan Message
|
||||
// Result receives exactly one value — the final outcome — then closes.
|
||||
Result <-chan Result
|
||||
}
|
||||
|
||||
// MessageType identifies the kind of Message.
|
||||
type MessageType string
|
||||
|
||||
const (
|
||||
MessageText MessageType = "text"
|
||||
MessageToolUse MessageType = "tool-use"
|
||||
MessageToolResult MessageType = "tool-result"
|
||||
MessageStatus MessageType = "status"
|
||||
MessageError MessageType = "error"
|
||||
MessageLog MessageType = "log"
|
||||
)
|
||||
|
||||
// Message is a unified event emitted by an agent during execution.
|
||||
type Message struct {
|
||||
Type MessageType
|
||||
Content string // text content (Text, Error, Log)
|
||||
Tool string // tool name (ToolUse, ToolResult)
|
||||
CallID string // tool call ID (ToolUse, ToolResult)
|
||||
Input map[string]any // tool input (ToolUse)
|
||||
Output string // tool output (ToolResult)
|
||||
Status string // agent status string (Status)
|
||||
Level string // log level (Log)
|
||||
}
|
||||
|
||||
// Result is the final outcome after an agent session completes.
|
||||
type Result struct {
|
||||
Status string // "completed", "failed", "aborted", "timeout"
|
||||
Output string // accumulated text output
|
||||
Error string // error message if failed
|
||||
DurationMs int64
|
||||
SessionID string
|
||||
}
|
||||
|
||||
// Config configures a Backend instance.
|
||||
type Config struct {
|
||||
ExecutablePath string // path to CLI binary (claude or codex)
|
||||
Env map[string]string // extra environment variables
|
||||
Logger *log.Logger
|
||||
}
|
||||
|
||||
// New creates a Backend for the given agent type.
|
||||
// Supported types: "claude", "codex".
|
||||
func New(agentType string, cfg Config) (Backend, error) {
|
||||
if cfg.Logger == nil {
|
||||
cfg.Logger = log.Default()
|
||||
}
|
||||
|
||||
switch agentType {
|
||||
case "claude":
|
||||
return &claudeBackend{cfg: cfg}, nil
|
||||
case "codex":
|
||||
return &codexBackend{cfg: cfg}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown agent type: %q (supported: claude, codex)", agentType)
|
||||
}
|
||||
}
|
||||
|
||||
// DetectVersion runs the agent CLI with --version and returns the output.
|
||||
func DetectVersion(ctx context.Context, executablePath string) (string, error) {
|
||||
return detectCLIVersion(ctx, executablePath)
|
||||
}
|
||||
53
server/pkg/agent/agent_test.go
Normal file
53
server/pkg/agent/agent_test.go
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewReturnsClaudeBackend(t *testing.T) {
|
||||
t.Parallel()
|
||||
b, err := New("claude", Config{ExecutablePath: "/nonexistent/claude"})
|
||||
if err != nil {
|
||||
t.Fatalf("New(claude) error: %v", err)
|
||||
}
|
||||
if _, ok := b.(*claudeBackend); !ok {
|
||||
t.Fatalf("expected *claudeBackend, got %T", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewReturnsCodexBackend(t *testing.T) {
|
||||
t.Parallel()
|
||||
b, err := New("codex", Config{ExecutablePath: "/nonexistent/codex"})
|
||||
if err != nil {
|
||||
t.Fatalf("New(codex) error: %v", err)
|
||||
}
|
||||
if _, ok := b.(*codexBackend); !ok {
|
||||
t.Fatalf("expected *codexBackend, got %T", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsUnknownType(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := New("gpt", Config{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown agent type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDefaultsLogger(t *testing.T) {
|
||||
t.Parallel()
|
||||
b, _ := New("claude", Config{})
|
||||
cb := b.(*claudeBackend)
|
||||
if cb.cfg.Logger == nil {
|
||||
t.Fatal("expected non-nil logger")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectVersionFailsForMissingBinary(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := DetectVersion(context.Background(), "/nonexistent/binary")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing binary")
|
||||
}
|
||||
}
|
||||
348
server/pkg/agent/claude.go
Normal file
348
server/pkg/agent/claude.go
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// claudeBackend implements Backend by spawning the Claude Code CLI
|
||||
// with --output-format stream-json.
|
||||
type claudeBackend struct {
|
||||
cfg Config
|
||||
}
|
||||
|
||||
func (b *claudeBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) {
|
||||
execPath := b.cfg.ExecutablePath
|
||||
if execPath == "" {
|
||||
execPath = "claude"
|
||||
}
|
||||
if _, err := exec.LookPath(execPath); err != nil {
|
||||
return nil, fmt.Errorf("claude executable not found at %q: %w", execPath, err)
|
||||
}
|
||||
|
||||
timeout := opts.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = 20 * time.Minute
|
||||
}
|
||||
runCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
|
||||
args := []string{
|
||||
"--output-format", "stream-json",
|
||||
"--verbose",
|
||||
"--permission-mode", "bypassPermissions",
|
||||
}
|
||||
if opts.Model != "" {
|
||||
args = append(args, "--model", opts.Model)
|
||||
}
|
||||
if opts.MaxTurns > 0 {
|
||||
args = append(args, "--max-turns", fmt.Sprintf("%d", opts.MaxTurns))
|
||||
}
|
||||
if opts.SystemPrompt != "" {
|
||||
args = append(args, "--append-system-prompt", opts.SystemPrompt)
|
||||
}
|
||||
args = append(args, "-p", prompt)
|
||||
|
||||
cmd := exec.CommandContext(runCtx, execPath, args...)
|
||||
if opts.Cwd != "" {
|
||||
cmd.Dir = opts.Cwd
|
||||
}
|
||||
cmd.Env = buildEnv(b.cfg.Env)
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("claude stdout pipe: %w", err)
|
||||
}
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("claude stdin pipe: %w", err)
|
||||
}
|
||||
cmd.Stderr = newLogWriter(b.cfg.Logger, "[claude:stderr] ")
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("start claude: %w", err)
|
||||
}
|
||||
|
||||
b.cfg.Logger.Printf("[claude] started pid=%d cwd=%s model=%s", cmd.Process.Pid, opts.Cwd, opts.Model)
|
||||
|
||||
msgCh := make(chan Message, 256)
|
||||
resCh := make(chan Result, 1)
|
||||
|
||||
go func() {
|
||||
defer cancel()
|
||||
defer close(msgCh)
|
||||
defer close(resCh)
|
||||
defer stdin.Close()
|
||||
|
||||
startTime := time.Now()
|
||||
var output strings.Builder
|
||||
var sessionID string
|
||||
finalStatus := "completed"
|
||||
var finalError string
|
||||
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var msg claudeSDKMessage
|
||||
if err := json.Unmarshal([]byte(line), &msg); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
case "assistant":
|
||||
b.handleAssistant(msg, msgCh, &output)
|
||||
case "user":
|
||||
b.handleUser(msg, msgCh)
|
||||
case "system":
|
||||
if msg.SessionID != "" {
|
||||
sessionID = msg.SessionID
|
||||
}
|
||||
trySend(msgCh, Message{Type: MessageStatus, Status: "running"})
|
||||
case "result":
|
||||
sessionID = msg.SessionID
|
||||
if msg.ResultText != "" {
|
||||
output.Reset()
|
||||
output.WriteString(msg.ResultText)
|
||||
}
|
||||
if msg.IsError {
|
||||
finalStatus = "failed"
|
||||
finalError = msg.ResultText
|
||||
}
|
||||
case "log":
|
||||
if msg.Log != nil {
|
||||
trySend(msgCh, Message{
|
||||
Type: MessageLog,
|
||||
Level: msg.Log.Level,
|
||||
Content: msg.Log.Message,
|
||||
})
|
||||
}
|
||||
case "control_request":
|
||||
b.handleControlRequest(msg, stdin)
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for process exit
|
||||
exitErr := cmd.Wait()
|
||||
duration := time.Since(startTime)
|
||||
|
||||
if runCtx.Err() == context.DeadlineExceeded {
|
||||
finalStatus = "timeout"
|
||||
finalError = fmt.Sprintf("claude timed out after %s", timeout)
|
||||
} else if runCtx.Err() == context.Canceled {
|
||||
finalStatus = "aborted"
|
||||
finalError = "execution cancelled"
|
||||
} else if exitErr != nil && finalStatus == "completed" {
|
||||
finalStatus = "failed"
|
||||
finalError = fmt.Sprintf("claude exited with error: %v", exitErr)
|
||||
}
|
||||
|
||||
b.cfg.Logger.Printf("[claude] finished pid=%d status=%s duration=%s",
|
||||
cmd.Process.Pid, finalStatus, duration.Round(time.Millisecond))
|
||||
|
||||
resCh <- Result{
|
||||
Status: finalStatus,
|
||||
Output: output.String(),
|
||||
Error: finalError,
|
||||
DurationMs: duration.Milliseconds(),
|
||||
SessionID: sessionID,
|
||||
}
|
||||
}()
|
||||
|
||||
return &Session{Messages: msgCh, Result: resCh}, nil
|
||||
}
|
||||
|
||||
func (b *claudeBackend) handleAssistant(msg claudeSDKMessage, ch chan<- Message, output *strings.Builder) {
|
||||
var content claudeMessageContent
|
||||
if err := json.Unmarshal(msg.Message, &content); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, block := range content.Content {
|
||||
switch block.Type {
|
||||
case "text":
|
||||
if block.Text != "" {
|
||||
output.WriteString(block.Text)
|
||||
trySend(ch, Message{Type: MessageText, Content: block.Text})
|
||||
}
|
||||
case "tool_use":
|
||||
var input map[string]any
|
||||
if block.Input != nil {
|
||||
_ = json.Unmarshal(block.Input, &input)
|
||||
}
|
||||
trySend(ch, Message{
|
||||
Type: MessageToolUse,
|
||||
Tool: block.Name,
|
||||
CallID: block.ID,
|
||||
Input: input,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *claudeBackend) handleUser(msg claudeSDKMessage, ch chan<- Message) {
|
||||
var content claudeMessageContent
|
||||
if err := json.Unmarshal(msg.Message, &content); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, block := range content.Content {
|
||||
if block.Type == "tool_result" {
|
||||
resultStr := ""
|
||||
if block.Content != nil {
|
||||
resultStr = string(block.Content)
|
||||
}
|
||||
trySend(ch, Message{
|
||||
Type: MessageToolResult,
|
||||
CallID: block.ToolUseID,
|
||||
Output: resultStr,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *claudeBackend) handleControlRequest(msg claudeSDKMessage, stdin interface{ Write([]byte) (int, error) }) {
|
||||
// Auto-approve all tool uses in autonomous/daemon mode.
|
||||
var req claudeControlRequestPayload
|
||||
if err := json.Unmarshal(msg.Request, &req); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var inputMap map[string]any
|
||||
if req.Input != nil {
|
||||
_ = json.Unmarshal(req.Input, &inputMap)
|
||||
}
|
||||
if inputMap == nil {
|
||||
inputMap = map[string]any{}
|
||||
}
|
||||
|
||||
response := map[string]any{
|
||||
"type": "control_response",
|
||||
"response": map[string]any{
|
||||
"subtype": "success",
|
||||
"request_id": msg.RequestID,
|
||||
"response": map[string]any{
|
||||
"behavior": "allow",
|
||||
"updatedInput": inputMap,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
b.cfg.Logger.Printf("[claude] failed to marshal control response: %v", err)
|
||||
return
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if _, err := stdin.Write(data); err != nil {
|
||||
b.cfg.Logger.Printf("[claude] failed to write control response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Claude SDK JSON types ──
|
||||
|
||||
type claudeSDKMessage struct {
|
||||
Type string `json:"type"`
|
||||
Message json.RawMessage `json:"message,omitempty"`
|
||||
Subtype string `json:"subtype,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
|
||||
// result fields
|
||||
ResultText string `json:"result,omitempty"`
|
||||
IsError bool `json:"is_error,omitempty"`
|
||||
DurationMs float64 `json:"duration_ms,omitempty"`
|
||||
NumTurns int `json:"num_turns,omitempty"`
|
||||
|
||||
// log fields
|
||||
Log *claudeLogEntry `json:"log,omitempty"`
|
||||
|
||||
// control request fields
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
Request json.RawMessage `json:"request,omitempty"`
|
||||
}
|
||||
|
||||
type claudeLogEntry struct {
|
||||
Level string `json:"level"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type claudeMessageContent struct {
|
||||
Role string `json:"role"`
|
||||
Content []claudeContentBlock `json:"content"`
|
||||
}
|
||||
|
||||
type claudeContentBlock struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Input json.RawMessage `json:"input,omitempty"`
|
||||
ToolUseID string `json:"tool_use_id,omitempty"`
|
||||
Content json.RawMessage `json:"content,omitempty"`
|
||||
}
|
||||
|
||||
type claudeControlRequestPayload struct {
|
||||
Subtype string `json:"subtype"`
|
||||
ToolName string `json:"tool_name,omitempty"`
|
||||
Input json.RawMessage `json:"input,omitempty"`
|
||||
}
|
||||
|
||||
// ── Shared helpers ──
|
||||
|
||||
func trySend(ch chan<- Message, msg Message) {
|
||||
select {
|
||||
case ch <- msg:
|
||||
default:
|
||||
// Channel full — drop message. Final output is accumulated separately
|
||||
// in Result.Output, so only streaming consumers are affected.
|
||||
}
|
||||
}
|
||||
|
||||
func buildEnv(extra map[string]string) []string {
|
||||
env := os.Environ()
|
||||
for k, v := range extra {
|
||||
env = append(env, k+"="+v)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func detectCLIVersion(ctx context.Context, execPath string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, execPath, "--version")
|
||||
data, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("detect version for %s: %w", execPath, err)
|
||||
}
|
||||
return strings.TrimSpace(string(data)), nil
|
||||
}
|
||||
|
||||
// logWriter adapts a *log.Logger to an io.Writer for capturing stderr.
|
||||
type logWriter struct {
|
||||
logger *log.Logger
|
||||
prefix string
|
||||
}
|
||||
|
||||
func newLogWriter(logger *log.Logger, prefix string) *logWriter {
|
||||
return &logWriter{logger: logger, prefix: prefix}
|
||||
}
|
||||
|
||||
func (w *logWriter) Write(p []byte) (int, error) {
|
||||
text := strings.TrimSpace(string(p))
|
||||
if text != "" {
|
||||
w.logger.Printf("%s%s", w.prefix, text)
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
229
server/pkg/agent/claude_test.go
Normal file
229
server/pkg/agent/claude_test.go
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClaudeHandleAssistantText(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &claudeBackend{cfg: Config{Logger: log.Default()}}
|
||||
ch := make(chan Message, 10)
|
||||
var output strings.Builder
|
||||
|
||||
msg := claudeSDKMessage{
|
||||
Type: "assistant",
|
||||
Message: mustMarshal(t, claudeMessageContent{
|
||||
Role: "assistant",
|
||||
Content: []claudeContentBlock{
|
||||
{Type: "text", Text: "Hello world"},
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
b.handleAssistant(msg, ch, &output)
|
||||
|
||||
if output.String() != "Hello world" {
|
||||
t.Fatalf("expected output 'Hello world', got %q", output.String())
|
||||
}
|
||||
select {
|
||||
case m := <-ch:
|
||||
if m.Type != MessageText || m.Content != "Hello world" {
|
||||
t.Fatalf("unexpected message: %+v", m)
|
||||
}
|
||||
default:
|
||||
t.Fatal("expected message on channel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeHandleAssistantToolUse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &claudeBackend{cfg: Config{Logger: log.Default()}}
|
||||
ch := make(chan Message, 10)
|
||||
var output strings.Builder
|
||||
|
||||
msg := claudeSDKMessage{
|
||||
Type: "assistant",
|
||||
Message: mustMarshal(t, claudeMessageContent{
|
||||
Role: "assistant",
|
||||
Content: []claudeContentBlock{
|
||||
{
|
||||
Type: "tool_use",
|
||||
ID: "call-1",
|
||||
Name: "Read",
|
||||
Input: mustMarshal(t, map[string]any{"path": "/tmp/foo"}),
|
||||
},
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
b.handleAssistant(msg, ch, &output)
|
||||
|
||||
if output.String() != "" {
|
||||
t.Fatalf("tool_use should not add to output, got %q", output.String())
|
||||
}
|
||||
select {
|
||||
case m := <-ch:
|
||||
if m.Type != MessageToolUse || m.Tool != "Read" || m.CallID != "call-1" {
|
||||
t.Fatalf("unexpected message: %+v", m)
|
||||
}
|
||||
if m.Input["path"] != "/tmp/foo" {
|
||||
t.Fatalf("expected input path /tmp/foo, got %v", m.Input["path"])
|
||||
}
|
||||
default:
|
||||
t.Fatal("expected message on channel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeHandleUserToolResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &claudeBackend{cfg: Config{Logger: log.Default()}}
|
||||
ch := make(chan Message, 10)
|
||||
|
||||
msg := claudeSDKMessage{
|
||||
Type: "user",
|
||||
Message: mustMarshal(t, claudeMessageContent{
|
||||
Role: "user",
|
||||
Content: []claudeContentBlock{
|
||||
{
|
||||
Type: "tool_result",
|
||||
ToolUseID: "call-1",
|
||||
Content: mustMarshal(t, "file contents here"),
|
||||
},
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
b.handleUser(msg, ch)
|
||||
|
||||
select {
|
||||
case m := <-ch:
|
||||
if m.Type != MessageToolResult || m.CallID != "call-1" {
|
||||
t.Fatalf("unexpected message: %+v", m)
|
||||
}
|
||||
default:
|
||||
t.Fatal("expected message on channel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeHandleControlRequestAutoApproves(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
b := &claudeBackend{cfg: Config{Logger: log.New(&buf, "", 0)}}
|
||||
|
||||
var written bytes.Buffer
|
||||
|
||||
msg := claudeSDKMessage{
|
||||
Type: "control_request",
|
||||
RequestID: "req-42",
|
||||
Request: mustMarshal(t, claudeControlRequestPayload{
|
||||
Subtype: "tool_use",
|
||||
ToolName: "Bash",
|
||||
Input: mustMarshal(t, map[string]any{"command": "ls"}),
|
||||
}),
|
||||
}
|
||||
|
||||
b.handleControlRequest(msg, &written)
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(bytes.TrimSpace(written.Bytes()), &resp); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
if resp["type"] != "control_response" {
|
||||
t.Fatalf("expected type control_response, got %v", resp["type"])
|
||||
}
|
||||
respInner := resp["response"].(map[string]any)
|
||||
if respInner["request_id"] != "req-42" {
|
||||
t.Fatalf("expected request_id req-42, got %v", respInner["request_id"])
|
||||
}
|
||||
innerResp := respInner["response"].(map[string]any)
|
||||
if innerResp["behavior"] != "allow" {
|
||||
t.Fatalf("expected behavior allow, got %v", innerResp["behavior"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeHandleAssistantInvalidJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &claudeBackend{cfg: Config{Logger: log.Default()}}
|
||||
ch := make(chan Message, 10)
|
||||
var output strings.Builder
|
||||
|
||||
msg := claudeSDKMessage{
|
||||
Type: "assistant",
|
||||
Message: json.RawMessage(`invalid json`),
|
||||
}
|
||||
|
||||
// Should not panic
|
||||
b.handleAssistant(msg, ch, &output)
|
||||
|
||||
if output.String() != "" {
|
||||
t.Fatalf("expected empty output for invalid JSON, got %q", output.String())
|
||||
}
|
||||
select {
|
||||
case m := <-ch:
|
||||
t.Fatalf("expected no message, got %+v", m)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrySendDropsWhenFull(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ch := make(chan Message, 1)
|
||||
// Fill the channel
|
||||
trySend(ch, Message{Type: MessageText, Content: "first"})
|
||||
// This should not block
|
||||
trySend(ch, Message{Type: MessageText, Content: "second"})
|
||||
|
||||
m := <-ch
|
||||
if m.Content != "first" {
|
||||
t.Fatalf("expected 'first', got %q", m.Content)
|
||||
}
|
||||
select {
|
||||
case m := <-ch:
|
||||
t.Fatalf("expected empty channel, got %+v", m)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildEnvAppendsExtras(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := buildEnv(map[string]string{"FOO": "bar", "BAZ": "qux"})
|
||||
found := 0
|
||||
for _, e := range env {
|
||||
if e == "FOO=bar" || e == "BAZ=qux" {
|
||||
found++
|
||||
}
|
||||
}
|
||||
if found != 2 {
|
||||
t.Fatalf("expected 2 extra env vars, found %d", found)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildEnvNilExtras(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := buildEnv(nil)
|
||||
if len(env) == 0 {
|
||||
t.Fatal("expected at least system env vars")
|
||||
}
|
||||
}
|
||||
|
||||
func mustMarshal(t *testing.T, v any) json.RawMessage {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal: %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
654
server/pkg/agent/codex.go
Normal file
654
server/pkg/agent/codex.go
Normal file
|
|
@ -0,0 +1,654 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// codexBackend implements Backend by spawning `codex app-server --listen stdio://`
|
||||
// and communicating via JSON-RPC 2.0 over stdin/stdout.
|
||||
type codexBackend struct {
|
||||
cfg Config
|
||||
}
|
||||
|
||||
func (b *codexBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) {
|
||||
execPath := b.cfg.ExecutablePath
|
||||
if execPath == "" {
|
||||
execPath = "codex"
|
||||
}
|
||||
if _, err := exec.LookPath(execPath); err != nil {
|
||||
return nil, fmt.Errorf("codex executable not found at %q: %w", execPath, err)
|
||||
}
|
||||
|
||||
timeout := opts.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = 20 * time.Minute
|
||||
}
|
||||
runCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
|
||||
cmd := exec.CommandContext(runCtx, execPath, "app-server", "--listen", "stdio://")
|
||||
if opts.Cwd != "" {
|
||||
cmd.Dir = opts.Cwd
|
||||
}
|
||||
cmd.Env = buildEnv(b.cfg.Env)
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("codex stdout pipe: %w", err)
|
||||
}
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("codex stdin pipe: %w", err)
|
||||
}
|
||||
cmd.Stderr = newLogWriter(b.cfg.Logger, "[codex:stderr] ")
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("start codex: %w", err)
|
||||
}
|
||||
|
||||
b.cfg.Logger.Printf("[codex] started app-server pid=%d cwd=%s", cmd.Process.Pid, opts.Cwd)
|
||||
|
||||
msgCh := make(chan Message, 256)
|
||||
resCh := make(chan Result, 1)
|
||||
|
||||
var outputMu sync.Mutex
|
||||
var output strings.Builder
|
||||
|
||||
// turnDone is set before starting the reader goroutine so there is no
|
||||
// race between the lifecycle goroutine writing and the reader reading.
|
||||
turnDone := make(chan bool, 1) // true = aborted
|
||||
|
||||
c := &codexClient{
|
||||
cfg: b.cfg,
|
||||
stdin: stdin,
|
||||
pending: make(map[int]*pendingRPC),
|
||||
notificationProtocol: "unknown",
|
||||
onMessage: func(msg Message) {
|
||||
if msg.Type == MessageText {
|
||||
outputMu.Lock()
|
||||
output.WriteString(msg.Content)
|
||||
outputMu.Unlock()
|
||||
}
|
||||
trySend(msgCh, msg)
|
||||
},
|
||||
onTurnDone: func(aborted bool) {
|
||||
select {
|
||||
case turnDone <- aborted:
|
||||
default:
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// Start reading stdout in background
|
||||
readerDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(readerDone)
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
c.handleLine(line)
|
||||
}
|
||||
c.closeAllPending(fmt.Errorf("codex process exited"))
|
||||
}()
|
||||
|
||||
// Drive the session lifecycle in a goroutine.
|
||||
// Shutdown sequence: lifecycle goroutine closes stdin + cancels context →
|
||||
// codex process exits → reader goroutine's scanner.Scan() returns false →
|
||||
// readerDone closes → lifecycle goroutine collects final output and sends Result.
|
||||
go func() {
|
||||
defer cancel()
|
||||
defer close(msgCh)
|
||||
defer close(resCh)
|
||||
defer func() {
|
||||
stdin.Close()
|
||||
_ = cmd.Wait()
|
||||
}()
|
||||
|
||||
startTime := time.Now()
|
||||
finalStatus := "completed"
|
||||
var finalError string
|
||||
|
||||
// 1. Initialize handshake
|
||||
_, err := c.request(runCtx, "initialize", map[string]any{
|
||||
"clientInfo": map[string]any{
|
||||
"name": "multica-agent-sdk",
|
||||
"title": "Multica Agent SDK",
|
||||
"version": "0.2.0",
|
||||
},
|
||||
"capabilities": map[string]any{
|
||||
"experimentalApi": true,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
finalStatus = "failed"
|
||||
finalError = fmt.Sprintf("codex initialize failed: %v", err)
|
||||
resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()}
|
||||
return
|
||||
}
|
||||
c.notify("initialized")
|
||||
|
||||
// 2. Start thread
|
||||
threadResult, err := c.request(runCtx, "thread/start", map[string]any{
|
||||
"model": nilIfEmpty(opts.Model),
|
||||
"modelProvider": nil,
|
||||
"profile": nil,
|
||||
"cwd": opts.Cwd,
|
||||
"approvalPolicy": nil,
|
||||
"sandbox": "workspace-write",
|
||||
"config": nil,
|
||||
"baseInstructions": nil,
|
||||
"developerInstructions": nilIfEmpty(opts.SystemPrompt),
|
||||
"compactPrompt": nil,
|
||||
"includeApplyPatchTool": nil,
|
||||
"experimentalRawEvents": false,
|
||||
"persistExtendedHistory": true,
|
||||
})
|
||||
if err != nil {
|
||||
finalStatus = "failed"
|
||||
finalError = fmt.Sprintf("codex thread/start failed: %v", err)
|
||||
resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()}
|
||||
return
|
||||
}
|
||||
|
||||
threadID := extractThreadID(threadResult)
|
||||
if threadID == "" {
|
||||
finalStatus = "failed"
|
||||
finalError = "codex thread/start returned no thread ID"
|
||||
resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()}
|
||||
return
|
||||
}
|
||||
c.threadID = threadID
|
||||
b.cfg.Logger.Printf("[codex] thread started: %s", threadID)
|
||||
|
||||
// 3. Send turn and wait for completion
|
||||
_, err = c.request(runCtx, "turn/start", map[string]any{
|
||||
"threadId": threadID,
|
||||
"input": []map[string]any{
|
||||
{"type": "text", "text": prompt},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
finalStatus = "failed"
|
||||
finalError = fmt.Sprintf("codex turn/start failed: %v", err)
|
||||
resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()}
|
||||
return
|
||||
}
|
||||
|
||||
// Wait for turn completion or context cancellation
|
||||
select {
|
||||
case aborted := <-turnDone:
|
||||
if aborted {
|
||||
finalStatus = "aborted"
|
||||
finalError = "turn was aborted"
|
||||
}
|
||||
case <-runCtx.Done():
|
||||
if runCtx.Err() == context.DeadlineExceeded {
|
||||
finalStatus = "timeout"
|
||||
finalError = fmt.Sprintf("codex timed out after %s", timeout)
|
||||
} else {
|
||||
finalStatus = "aborted"
|
||||
finalError = "execution cancelled"
|
||||
}
|
||||
}
|
||||
|
||||
duration := time.Since(startTime)
|
||||
b.cfg.Logger.Printf("[codex] finished pid=%d status=%s duration=%s",
|
||||
cmd.Process.Pid, finalStatus, duration.Round(time.Millisecond))
|
||||
|
||||
// Close stdin and cancel context to signal the app-server to exit.
|
||||
// Without this, the long-running codex process keeps stdout open and
|
||||
// the reader goroutine blocks forever on scanner.Scan().
|
||||
stdin.Close()
|
||||
cancel()
|
||||
|
||||
// Wait for the reader goroutine to finish so all output is accumulated.
|
||||
<-readerDone
|
||||
|
||||
outputMu.Lock()
|
||||
finalOutput := output.String()
|
||||
outputMu.Unlock()
|
||||
|
||||
resCh <- Result{
|
||||
Status: finalStatus,
|
||||
Output: finalOutput,
|
||||
Error: finalError,
|
||||
DurationMs: duration.Milliseconds(),
|
||||
}
|
||||
}()
|
||||
|
||||
return &Session{Messages: msgCh, Result: resCh}, nil
|
||||
}
|
||||
|
||||
// ── codexClient: JSON-RPC 2.0 transport ──
|
||||
|
||||
type codexClient struct {
|
||||
cfg Config
|
||||
stdin interface{ Write([]byte) (int, error) }
|
||||
mu sync.Mutex
|
||||
nextID int
|
||||
pending map[int]*pendingRPC
|
||||
threadID string
|
||||
turnID string
|
||||
onMessage func(Message)
|
||||
onTurnDone func(aborted bool)
|
||||
|
||||
notificationProtocol string // "unknown", "legacy", "raw"
|
||||
turnStarted bool
|
||||
completedTurnIDs map[string]bool
|
||||
}
|
||||
|
||||
type pendingRPC struct {
|
||||
ch chan rpcResult
|
||||
method string
|
||||
}
|
||||
|
||||
type rpcResult struct {
|
||||
result json.RawMessage
|
||||
err error
|
||||
}
|
||||
|
||||
func (c *codexClient) request(ctx context.Context, method string, params any) (json.RawMessage, error) {
|
||||
c.mu.Lock()
|
||||
c.nextID++
|
||||
id := c.nextID
|
||||
pr := &pendingRPC{ch: make(chan rpcResult, 1), method: method}
|
||||
c.pending[id] = pr
|
||||
c.mu.Unlock()
|
||||
|
||||
msg := map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"method": method,
|
||||
"params": params,
|
||||
}
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
c.mu.Lock()
|
||||
delete(c.pending, id)
|
||||
c.mu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if _, err := c.stdin.Write(data); err != nil {
|
||||
c.mu.Lock()
|
||||
delete(c.pending, id)
|
||||
c.mu.Unlock()
|
||||
return nil, fmt.Errorf("write %s: %w", method, err)
|
||||
}
|
||||
|
||||
select {
|
||||
case res := <-pr.ch:
|
||||
return res.result, res.err
|
||||
case <-ctx.Done():
|
||||
c.mu.Lock()
|
||||
delete(c.pending, id)
|
||||
c.mu.Unlock()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *codexClient) notify(method string) {
|
||||
msg := map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"method": method,
|
||||
}
|
||||
data, _ := json.Marshal(msg)
|
||||
data = append(data, '\n')
|
||||
_, _ = c.stdin.Write(data)
|
||||
}
|
||||
|
||||
func (c *codexClient) respond(id int, result any) {
|
||||
msg := map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"result": result,
|
||||
}
|
||||
data, _ := json.Marshal(msg)
|
||||
data = append(data, '\n')
|
||||
_, _ = c.stdin.Write(data)
|
||||
}
|
||||
|
||||
func (c *codexClient) closeAllPending(err error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for id, pr := range c.pending {
|
||||
pr.ch <- rpcResult{err: err}
|
||||
delete(c.pending, id)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *codexClient) handleLine(line string) {
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(line), &raw); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if it's a response to our request
|
||||
if _, hasID := raw["id"]; hasID {
|
||||
if _, hasResult := raw["result"]; hasResult {
|
||||
c.handleResponse(raw)
|
||||
return
|
||||
}
|
||||
if _, hasError := raw["error"]; hasError {
|
||||
c.handleResponse(raw)
|
||||
return
|
||||
}
|
||||
// Server request (has id + method)
|
||||
if _, hasMethod := raw["method"]; hasMethod {
|
||||
c.handleServerRequest(raw)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Notification (no id, has method)
|
||||
if _, hasMethod := raw["method"]; hasMethod {
|
||||
c.handleNotification(raw)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *codexClient) handleResponse(raw map[string]json.RawMessage) {
|
||||
var id int
|
||||
if err := json.Unmarshal(raw["id"], &id); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
pr, ok := c.pending[id]
|
||||
if ok {
|
||||
delete(c.pending, id)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if errData, hasErr := raw["error"]; hasErr {
|
||||
var rpcErr struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
_ = json.Unmarshal(errData, &rpcErr)
|
||||
pr.ch <- rpcResult{err: fmt.Errorf("%s: %s (code=%d)", pr.method, rpcErr.Message, rpcErr.Code)}
|
||||
} else {
|
||||
pr.ch <- rpcResult{result: raw["result"]}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *codexClient) handleServerRequest(raw map[string]json.RawMessage) {
|
||||
var id int
|
||||
_ = json.Unmarshal(raw["id"], &id)
|
||||
|
||||
var method string
|
||||
_ = json.Unmarshal(raw["method"], &method)
|
||||
|
||||
// Auto-approve all exec/patch requests in daemon mode
|
||||
switch method {
|
||||
case "item/commandExecution/requestApproval", "execCommandApproval":
|
||||
c.respond(id, map[string]any{"decision": "accept"})
|
||||
case "item/fileChange/requestApproval", "applyPatchApproval":
|
||||
c.respond(id, map[string]any{"decision": "accept"})
|
||||
default:
|
||||
c.respond(id, map[string]any{})
|
||||
}
|
||||
}
|
||||
|
||||
func (c *codexClient) handleNotification(raw map[string]json.RawMessage) {
|
||||
var method string
|
||||
_ = json.Unmarshal(raw["method"], &method)
|
||||
|
||||
var params map[string]any
|
||||
if p, ok := raw["params"]; ok {
|
||||
_ = json.Unmarshal(p, ¶ms)
|
||||
}
|
||||
|
||||
// Legacy codex/event notifications
|
||||
if method == "codex/event" || strings.HasPrefix(method, "codex/event/") {
|
||||
c.notificationProtocol = "legacy"
|
||||
msgData, ok := params["msg"]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
msgMap, ok := msgData.(map[string]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
c.handleEvent(msgMap)
|
||||
return
|
||||
}
|
||||
|
||||
// Raw v2 notifications
|
||||
if c.notificationProtocol != "legacy" {
|
||||
if c.notificationProtocol == "unknown" &&
|
||||
(method == "turn/started" || method == "turn/completed" ||
|
||||
method == "thread/started" || strings.HasPrefix(method, "item/")) {
|
||||
c.notificationProtocol = "raw"
|
||||
}
|
||||
|
||||
if c.notificationProtocol == "raw" {
|
||||
c.handleRawNotification(method, params)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *codexClient) handleEvent(msg map[string]any) {
|
||||
msgType, _ := msg["type"].(string)
|
||||
|
||||
switch msgType {
|
||||
case "task_started":
|
||||
c.turnStarted = true
|
||||
if c.onMessage != nil {
|
||||
c.onMessage(Message{Type: MessageStatus, Status: "running"})
|
||||
}
|
||||
case "agent_message":
|
||||
text, _ := msg["message"].(string)
|
||||
if text != "" && c.onMessage != nil {
|
||||
c.onMessage(Message{Type: MessageText, Content: text})
|
||||
}
|
||||
case "exec_command_begin":
|
||||
callID, _ := msg["call_id"].(string)
|
||||
command, _ := msg["command"].(string)
|
||||
if c.onMessage != nil {
|
||||
c.onMessage(Message{
|
||||
Type: MessageToolUse,
|
||||
Tool: "exec_command",
|
||||
CallID: callID,
|
||||
Input: map[string]any{"command": command},
|
||||
})
|
||||
}
|
||||
case "exec_command_end":
|
||||
callID, _ := msg["call_id"].(string)
|
||||
output, _ := msg["output"].(string)
|
||||
if c.onMessage != nil {
|
||||
c.onMessage(Message{
|
||||
Type: MessageToolResult,
|
||||
Tool: "exec_command",
|
||||
CallID: callID,
|
||||
Output: output,
|
||||
})
|
||||
}
|
||||
case "patch_apply_begin":
|
||||
callID, _ := msg["call_id"].(string)
|
||||
if c.onMessage != nil {
|
||||
c.onMessage(Message{
|
||||
Type: MessageToolUse,
|
||||
Tool: "patch_apply",
|
||||
CallID: callID,
|
||||
})
|
||||
}
|
||||
case "patch_apply_end":
|
||||
callID, _ := msg["call_id"].(string)
|
||||
if c.onMessage != nil {
|
||||
c.onMessage(Message{
|
||||
Type: MessageToolResult,
|
||||
Tool: "patch_apply",
|
||||
CallID: callID,
|
||||
})
|
||||
}
|
||||
case "task_complete":
|
||||
if c.onTurnDone != nil {
|
||||
c.onTurnDone(false)
|
||||
}
|
||||
case "turn_aborted":
|
||||
if c.onTurnDone != nil {
|
||||
c.onTurnDone(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *codexClient) handleRawNotification(method string, params map[string]any) {
|
||||
switch method {
|
||||
case "turn/started":
|
||||
c.turnStarted = true
|
||||
if turnID := extractNestedString(params, "turn", "id"); turnID != "" {
|
||||
c.turnID = turnID
|
||||
}
|
||||
if c.onMessage != nil {
|
||||
c.onMessage(Message{Type: MessageStatus, Status: "running"})
|
||||
}
|
||||
|
||||
case "turn/completed":
|
||||
turnID := extractNestedString(params, "turn", "id")
|
||||
status := extractNestedString(params, "turn", "status")
|
||||
aborted := status == "cancelled" || status == "canceled" ||
|
||||
status == "aborted" || status == "interrupted"
|
||||
|
||||
if c.completedTurnIDs == nil {
|
||||
c.completedTurnIDs = map[string]bool{}
|
||||
}
|
||||
if turnID != "" {
|
||||
if c.completedTurnIDs[turnID] {
|
||||
return
|
||||
}
|
||||
c.completedTurnIDs[turnID] = true
|
||||
}
|
||||
|
||||
if c.onTurnDone != nil {
|
||||
c.onTurnDone(aborted)
|
||||
}
|
||||
|
||||
case "thread/status/changed":
|
||||
statusType := extractNestedString(params, "status", "type")
|
||||
if statusType == "idle" && c.turnStarted {
|
||||
if c.onTurnDone != nil {
|
||||
c.onTurnDone(false)
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
if strings.HasPrefix(method, "item/") {
|
||||
c.handleItemNotification(method, params)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *codexClient) handleItemNotification(method string, params map[string]any) {
|
||||
item, ok := params["item"].(map[string]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
itemType, _ := item["type"].(string)
|
||||
itemID, _ := item["id"].(string)
|
||||
|
||||
switch {
|
||||
case method == "item/started" && itemType == "commandExecution":
|
||||
command, _ := item["command"].(string)
|
||||
if c.onMessage != nil {
|
||||
c.onMessage(Message{
|
||||
Type: MessageToolUse,
|
||||
Tool: "exec_command",
|
||||
CallID: itemID,
|
||||
Input: map[string]any{"command": command},
|
||||
})
|
||||
}
|
||||
|
||||
case method == "item/completed" && itemType == "commandExecution":
|
||||
output, _ := item["aggregatedOutput"].(string)
|
||||
if c.onMessage != nil {
|
||||
c.onMessage(Message{
|
||||
Type: MessageToolResult,
|
||||
Tool: "exec_command",
|
||||
CallID: itemID,
|
||||
Output: output,
|
||||
})
|
||||
}
|
||||
|
||||
case method == "item/started" && itemType == "fileChange":
|
||||
if c.onMessage != nil {
|
||||
c.onMessage(Message{
|
||||
Type: MessageToolUse,
|
||||
Tool: "patch_apply",
|
||||
CallID: itemID,
|
||||
})
|
||||
}
|
||||
|
||||
case method == "item/completed" && itemType == "fileChange":
|
||||
if c.onMessage != nil {
|
||||
c.onMessage(Message{
|
||||
Type: MessageToolResult,
|
||||
Tool: "patch_apply",
|
||||
CallID: itemID,
|
||||
})
|
||||
}
|
||||
|
||||
case method == "item/completed" && itemType == "agentMessage":
|
||||
text, _ := item["text"].(string)
|
||||
if text != "" && c.onMessage != nil {
|
||||
c.onMessage(Message{Type: MessageText, Content: text})
|
||||
}
|
||||
phase, _ := item["phase"].(string)
|
||||
if phase == "final_answer" && c.turnStarted {
|
||||
if c.onTurnDone != nil {
|
||||
c.onTurnDone(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
func extractThreadID(result json.RawMessage) string {
|
||||
var r struct {
|
||||
Thread struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"thread"`
|
||||
}
|
||||
if err := json.Unmarshal(result, &r); err != nil {
|
||||
return ""
|
||||
}
|
||||
return r.Thread.ID
|
||||
}
|
||||
|
||||
func extractNestedString(m map[string]any, keys ...string) string {
|
||||
current := any(m)
|
||||
for _, key := range keys {
|
||||
obj, ok := current.(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
current = obj[key]
|
||||
}
|
||||
s, _ := current.(string)
|
||||
return s
|
||||
}
|
||||
|
||||
func nilIfEmpty(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
545
server/pkg/agent/codex_test.go
Normal file
545
server/pkg/agent/codex_test.go
Normal file
|
|
@ -0,0 +1,545 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newTestCodexClient(t *testing.T) (*codexClient, *fakeStdin, []Message) {
|
||||
t.Helper()
|
||||
fs := &fakeStdin{}
|
||||
var mu sync.Mutex
|
||||
var messages []Message
|
||||
|
||||
c := &codexClient{
|
||||
cfg: Config{Logger: log.Default()},
|
||||
stdin: fs,
|
||||
pending: make(map[int]*pendingRPC),
|
||||
onMessage: func(msg Message) {
|
||||
mu.Lock()
|
||||
messages = append(messages, msg)
|
||||
mu.Unlock()
|
||||
},
|
||||
onTurnDone: func(aborted bool) {},
|
||||
}
|
||||
return c, fs, messages
|
||||
}
|
||||
|
||||
type fakeStdin struct {
|
||||
mu sync.Mutex
|
||||
data []byte
|
||||
}
|
||||
|
||||
func (f *fakeStdin) Write(p []byte) (int, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.data = append(f.data, p...)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (f *fakeStdin) Lines() []string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var lines []string
|
||||
for _, line := range splitLines(string(f.data)) {
|
||||
if line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func splitLines(s string) []string {
|
||||
var lines []string
|
||||
start := 0
|
||||
for i, c := range s {
|
||||
if c == '\n' {
|
||||
lines = append(lines, s[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if start < len(s) {
|
||||
lines = append(lines, s[start:])
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func TestCodexHandleResponseSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c, _, _ := newTestCodexClient(t)
|
||||
|
||||
// Register a pending request
|
||||
pr := &pendingRPC{ch: make(chan rpcResult, 1), method: "test"}
|
||||
c.mu.Lock()
|
||||
c.pending[1] = pr
|
||||
c.mu.Unlock()
|
||||
|
||||
c.handleLine(`{"jsonrpc":"2.0","id":1,"result":{"ok":true}}`)
|
||||
|
||||
res := <-pr.ch
|
||||
if res.err != nil {
|
||||
t.Fatalf("expected no error, got %v", res.err)
|
||||
}
|
||||
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(res.result, &parsed); err != nil {
|
||||
t.Fatalf("unmarshal result: %v", err)
|
||||
}
|
||||
if parsed["ok"] != true {
|
||||
t.Fatalf("expected ok=true, got %v", parsed["ok"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexHandleResponseError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c, _, _ := newTestCodexClient(t)
|
||||
|
||||
pr := &pendingRPC{ch: make(chan rpcResult, 1), method: "test"}
|
||||
c.mu.Lock()
|
||||
c.pending[1] = pr
|
||||
c.mu.Unlock()
|
||||
|
||||
c.handleLine(`{"jsonrpc":"2.0","id":1,"error":{"code":-32600,"message":"bad request"}}`)
|
||||
|
||||
res := <-pr.ch
|
||||
if res.err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if res.result != nil {
|
||||
t.Fatalf("expected nil result, got %v", res.result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexHandleServerRequestAutoApproves(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c, fs, _ := newTestCodexClient(t)
|
||||
|
||||
// Command execution approval
|
||||
c.handleLine(`{"jsonrpc":"2.0","id":10,"method":"item/commandExecution/requestApproval","params":{}}`)
|
||||
|
||||
lines := fs.Lines()
|
||||
if len(lines) != 1 {
|
||||
t.Fatalf("expected 1 response, got %d", len(lines))
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal([]byte(lines[0]), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if resp["id"] != float64(10) {
|
||||
t.Fatalf("expected id=10, got %v", resp["id"])
|
||||
}
|
||||
result := resp["result"].(map[string]any)
|
||||
if result["decision"] != "accept" {
|
||||
t.Fatalf("expected decision=accept, got %v", result["decision"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexHandleServerRequestFileChangeApproval(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c, fs, _ := newTestCodexClient(t)
|
||||
|
||||
c.handleLine(`{"jsonrpc":"2.0","id":11,"method":"applyPatchApproval","params":{}}`)
|
||||
|
||||
lines := fs.Lines()
|
||||
if len(lines) != 1 {
|
||||
t.Fatalf("expected 1 response, got %d", len(lines))
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal([]byte(lines[0]), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
result := resp["result"].(map[string]any)
|
||||
if result["decision"] != "accept" {
|
||||
t.Fatalf("expected decision=accept, got %v", result["decision"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexLegacyEventTaskStarted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c, _, _ := newTestCodexClient(t)
|
||||
var gotStatus bool
|
||||
c.onMessage = func(msg Message) {
|
||||
if msg.Type == MessageStatus && msg.Status == "running" {
|
||||
gotStatus = true
|
||||
}
|
||||
}
|
||||
|
||||
c.handleLine(`{"jsonrpc":"2.0","method":"codex/event","params":{"msg":{"type":"task_started"}}}`)
|
||||
|
||||
if !gotStatus {
|
||||
t.Fatal("expected status=running message")
|
||||
}
|
||||
if !c.turnStarted {
|
||||
t.Fatal("expected turnStarted=true")
|
||||
}
|
||||
if c.notificationProtocol != "legacy" {
|
||||
t.Fatalf("expected protocol=legacy, got %q", c.notificationProtocol)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexLegacyEventAgentMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c, _, _ := newTestCodexClient(t)
|
||||
var gotText string
|
||||
c.onMessage = func(msg Message) {
|
||||
if msg.Type == MessageText {
|
||||
gotText = msg.Content
|
||||
}
|
||||
}
|
||||
|
||||
c.handleLine(`{"jsonrpc":"2.0","method":"codex/event","params":{"msg":{"type":"agent_message","message":"I found the bug"}}}`)
|
||||
|
||||
if gotText != "I found the bug" {
|
||||
t.Fatalf("expected text 'I found the bug', got %q", gotText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexLegacyEventExecCommand(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c, _, _ := newTestCodexClient(t)
|
||||
var messages []Message
|
||||
c.onMessage = func(msg Message) {
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
|
||||
c.handleLine(`{"jsonrpc":"2.0","method":"codex/event","params":{"msg":{"type":"exec_command_begin","call_id":"c1","command":"ls -la"}}}`)
|
||||
c.handleLine(`{"jsonrpc":"2.0","method":"codex/event","params":{"msg":{"type":"exec_command_end","call_id":"c1","output":"total 42"}}}`)
|
||||
|
||||
if len(messages) != 2 {
|
||||
t.Fatalf("expected 2 messages, got %d", len(messages))
|
||||
}
|
||||
if messages[0].Type != MessageToolUse || messages[0].Tool != "exec_command" || messages[0].CallID != "c1" {
|
||||
t.Fatalf("unexpected begin message: %+v", messages[0])
|
||||
}
|
||||
if messages[1].Type != MessageToolResult || messages[1].CallID != "c1" || messages[1].Output != "total 42" {
|
||||
t.Fatalf("unexpected end message: %+v", messages[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexLegacyEventTaskComplete(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c, _, _ := newTestCodexClient(t)
|
||||
var done bool
|
||||
c.onTurnDone = func(aborted bool) {
|
||||
done = true
|
||||
if aborted {
|
||||
t.Fatal("expected aborted=false")
|
||||
}
|
||||
}
|
||||
|
||||
c.handleLine(`{"jsonrpc":"2.0","method":"codex/event","params":{"msg":{"type":"task_complete"}}}`)
|
||||
|
||||
if !done {
|
||||
t.Fatal("expected onTurnDone to be called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexLegacyEventTurnAborted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c, _, _ := newTestCodexClient(t)
|
||||
var abortedResult bool
|
||||
c.onTurnDone = func(aborted bool) {
|
||||
abortedResult = aborted
|
||||
}
|
||||
|
||||
c.handleLine(`{"jsonrpc":"2.0","method":"codex/event","params":{"msg":{"type":"turn_aborted"}}}`)
|
||||
|
||||
if !abortedResult {
|
||||
t.Fatal("expected aborted=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexRawTurnStarted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c, _, _ := newTestCodexClient(t)
|
||||
// The zero value "" doesn't match "unknown", so protocol auto-detection
|
||||
// won't trigger. Set it explicitly as production code would.
|
||||
c.notificationProtocol = "unknown"
|
||||
|
||||
var gotStatus bool
|
||||
c.onMessage = func(msg Message) {
|
||||
if msg.Type == MessageStatus && msg.Status == "running" {
|
||||
gotStatus = true
|
||||
}
|
||||
}
|
||||
|
||||
c.handleLine(`{"jsonrpc":"2.0","method":"turn/started","params":{"turn":{"id":"turn-1"}}}`)
|
||||
|
||||
if !gotStatus {
|
||||
t.Fatal("expected status=running message")
|
||||
}
|
||||
if c.notificationProtocol != "raw" {
|
||||
t.Fatalf("expected protocol=raw, got %q", c.notificationProtocol)
|
||||
}
|
||||
if c.turnID != "turn-1" {
|
||||
t.Fatalf("expected turnID=turn-1, got %q", c.turnID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexRawTurnCompleted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c, _, _ := newTestCodexClient(t)
|
||||
c.notificationProtocol = "raw"
|
||||
|
||||
var doneCount int
|
||||
c.onTurnDone = func(aborted bool) {
|
||||
doneCount++
|
||||
if aborted {
|
||||
t.Fatal("expected aborted=false")
|
||||
}
|
||||
}
|
||||
|
||||
c.handleLine(`{"jsonrpc":"2.0","method":"turn/completed","params":{"turn":{"id":"turn-1","status":"completed"}}}`)
|
||||
|
||||
if doneCount != 1 {
|
||||
t.Fatalf("expected onTurnDone called once, got %d", doneCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexRawTurnCompletedDeduplication(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c, _, _ := newTestCodexClient(t)
|
||||
c.notificationProtocol = "raw"
|
||||
|
||||
var doneCount int
|
||||
c.onTurnDone = func(aborted bool) {
|
||||
doneCount++
|
||||
}
|
||||
|
||||
c.handleLine(`{"jsonrpc":"2.0","method":"turn/completed","params":{"turn":{"id":"turn-1","status":"completed"}}}`)
|
||||
c.handleLine(`{"jsonrpc":"2.0","method":"turn/completed","params":{"turn":{"id":"turn-1","status":"completed"}}}`)
|
||||
|
||||
if doneCount != 1 {
|
||||
t.Fatalf("expected deduplication, but onTurnDone called %d times", doneCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexRawTurnCompletedAborted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c, _, _ := newTestCodexClient(t)
|
||||
c.notificationProtocol = "raw"
|
||||
|
||||
var wasAborted bool
|
||||
c.onTurnDone = func(aborted bool) {
|
||||
wasAborted = aborted
|
||||
}
|
||||
|
||||
c.handleLine(`{"jsonrpc":"2.0","method":"turn/completed","params":{"turn":{"id":"turn-2","status":"cancelled"}}}`)
|
||||
|
||||
if !wasAborted {
|
||||
t.Fatal("expected aborted=true for cancelled status")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexRawItemCommandExecution(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c, _, _ := newTestCodexClient(t)
|
||||
c.notificationProtocol = "raw"
|
||||
|
||||
var messages []Message
|
||||
c.onMessage = func(msg Message) {
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
|
||||
c.handleLine(`{"jsonrpc":"2.0","method":"item/started","params":{"item":{"type":"commandExecution","id":"item-1","command":"git status"}}}`)
|
||||
c.handleLine(`{"jsonrpc":"2.0","method":"item/completed","params":{"item":{"type":"commandExecution","id":"item-1","aggregatedOutput":"on branch main"}}}`)
|
||||
|
||||
if len(messages) != 2 {
|
||||
t.Fatalf("expected 2 messages, got %d", len(messages))
|
||||
}
|
||||
if messages[0].Type != MessageToolUse || messages[0].Tool != "exec_command" || messages[0].Input["command"] != "git status" {
|
||||
t.Fatalf("unexpected start message: %+v", messages[0])
|
||||
}
|
||||
if messages[1].Type != MessageToolResult || messages[1].Output != "on branch main" {
|
||||
t.Fatalf("unexpected complete message: %+v", messages[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexRawItemAgentMessageFinalAnswer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c, _, _ := newTestCodexClient(t)
|
||||
c.notificationProtocol = "raw"
|
||||
c.turnStarted = true
|
||||
|
||||
var gotText string
|
||||
var turnDone bool
|
||||
c.onMessage = func(msg Message) {
|
||||
if msg.Type == MessageText {
|
||||
gotText = msg.Content
|
||||
}
|
||||
}
|
||||
c.onTurnDone = func(aborted bool) {
|
||||
turnDone = true
|
||||
}
|
||||
|
||||
c.handleLine(`{"jsonrpc":"2.0","method":"item/completed","params":{"item":{"type":"agentMessage","id":"msg-1","text":"Done!","phase":"final_answer"}}}`)
|
||||
|
||||
if gotText != "Done!" {
|
||||
t.Fatalf("expected text 'Done!', got %q", gotText)
|
||||
}
|
||||
if !turnDone {
|
||||
t.Fatal("expected onTurnDone for final_answer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexRawThreadStatusIdle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c, _, _ := newTestCodexClient(t)
|
||||
c.notificationProtocol = "raw"
|
||||
c.turnStarted = true
|
||||
|
||||
var turnDone bool
|
||||
c.onTurnDone = func(aborted bool) {
|
||||
turnDone = true
|
||||
if aborted {
|
||||
t.Fatal("expected aborted=false for idle")
|
||||
}
|
||||
}
|
||||
|
||||
c.handleLine(`{"jsonrpc":"2.0","method":"thread/status/changed","params":{"status":{"type":"idle"}}}`)
|
||||
|
||||
if !turnDone {
|
||||
t.Fatal("expected onTurnDone for idle status")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexCloseAllPending(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c, _, _ := newTestCodexClient(t)
|
||||
|
||||
pr1 := &pendingRPC{ch: make(chan rpcResult, 1), method: "m1"}
|
||||
pr2 := &pendingRPC{ch: make(chan rpcResult, 1), method: "m2"}
|
||||
c.mu.Lock()
|
||||
c.pending[1] = pr1
|
||||
c.pending[2] = pr2
|
||||
c.mu.Unlock()
|
||||
|
||||
c.closeAllPending(fmt.Errorf("test error"))
|
||||
|
||||
r1 := <-pr1.ch
|
||||
if r1.err == nil {
|
||||
t.Fatal("expected error for pending 1")
|
||||
}
|
||||
r2 := <-pr2.ch
|
||||
if r2.err == nil {
|
||||
t.Fatal("expected error for pending 2")
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if len(c.pending) != 0 {
|
||||
t.Fatalf("expected empty pending map, got %d", len(c.pending))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexHandleInvalidJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c, _, _ := newTestCodexClient(t)
|
||||
// Should not panic
|
||||
c.handleLine("not json at all")
|
||||
c.handleLine("")
|
||||
c.handleLine("{}")
|
||||
}
|
||||
|
||||
func TestExtractThreadID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
data := json.RawMessage(`{"thread":{"id":"t-123"}}`)
|
||||
got := extractThreadID(data)
|
||||
if got != "t-123" {
|
||||
t.Fatalf("expected t-123, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractThreadIDMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := extractThreadID(json.RawMessage(`{}`))
|
||||
if got != "" {
|
||||
t.Fatalf("expected empty, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractNestedString(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m := map[string]any{
|
||||
"a": map[string]any{
|
||||
"b": "value",
|
||||
},
|
||||
}
|
||||
got := extractNestedString(m, "a", "b")
|
||||
if got != "value" {
|
||||
t.Fatalf("expected 'value', got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractNestedStringMissingKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m := map[string]any{"a": "flat"}
|
||||
got := extractNestedString(m, "a", "b")
|
||||
if got != "" {
|
||||
t.Fatalf("expected empty, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilIfEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if nilIfEmpty("") != nil {
|
||||
t.Fatal("expected nil for empty string")
|
||||
}
|
||||
if nilIfEmpty("hello") != "hello" {
|
||||
t.Fatal("expected 'hello'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexProtocolDetectionLegacyBlocksRaw(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c, _, _ := newTestCodexClient(t)
|
||||
|
||||
var messages []Message
|
||||
c.onMessage = func(msg Message) {
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
|
||||
// First: receive a legacy event -> locks to "legacy"
|
||||
c.handleLine(`{"jsonrpc":"2.0","method":"codex/event","params":{"msg":{"type":"task_started"}}}`)
|
||||
|
||||
if c.notificationProtocol != "legacy" {
|
||||
t.Fatalf("expected legacy, got %q", c.notificationProtocol)
|
||||
}
|
||||
|
||||
// Now send a raw notification -> should be ignored
|
||||
messagesBefore := len(messages)
|
||||
c.handleLine(`{"jsonrpc":"2.0","method":"turn/started","params":{"turn":{"id":"turn-1"}}}`)
|
||||
|
||||
if len(messages) != messagesBefore {
|
||||
t.Fatal("raw notification should be ignored in legacy mode")
|
||||
}
|
||||
}
|
||||
|
|
@ -92,6 +92,24 @@ type DaemonConnection struct {
|
|||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type DaemonPairingSession struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Token string `json:"token"`
|
||||
DaemonID string `json:"daemon_id"`
|
||||
DeviceName string `json:"device_name"`
|
||||
RuntimeName string `json:"runtime_name"`
|
||||
RuntimeType string `json:"runtime_type"`
|
||||
RuntimeVersion string `json:"runtime_version"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
ApprovedBy pgtype.UUID `json:"approved_by"`
|
||||
Status string `json:"status"`
|
||||
ApprovedAt pgtype.Timestamptz `json:"approved_at"`
|
||||
ClaimedAt pgtype.Timestamptz `json:"claimed_at"`
|
||||
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type InboxItem struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
|
|
@ -172,4 +190,5 @@ type Workspace struct {
|
|||
Settings []byte `json:"settings"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
Context pgtype.Text `json:"context"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,19 +12,25 @@ import (
|
|||
)
|
||||
|
||||
const createWorkspace = `-- name: CreateWorkspace :one
|
||||
INSERT INTO workspace (name, slug, description)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, name, slug, description, settings, created_at, updated_at
|
||||
INSERT INTO workspace (name, slug, description, context)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, name, slug, description, settings, created_at, updated_at, context
|
||||
`
|
||||
|
||||
type CreateWorkspaceParams struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Context pgtype.Text `json:"context"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateWorkspace(ctx context.Context, arg CreateWorkspaceParams) (Workspace, error) {
|
||||
row := q.db.QueryRow(ctx, createWorkspace, arg.Name, arg.Slug, arg.Description)
|
||||
row := q.db.QueryRow(ctx, createWorkspace,
|
||||
arg.Name,
|
||||
arg.Slug,
|
||||
arg.Description,
|
||||
arg.Context,
|
||||
)
|
||||
var i Workspace
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
|
|
@ -34,6 +40,7 @@ func (q *Queries) CreateWorkspace(ctx context.Context, arg CreateWorkspaceParams
|
|||
&i.Settings,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Context,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -48,7 +55,7 @@ func (q *Queries) DeleteWorkspace(ctx context.Context, id pgtype.UUID) error {
|
|||
}
|
||||
|
||||
const getWorkspace = `-- name: GetWorkspace :one
|
||||
SELECT id, name, slug, description, settings, created_at, updated_at FROM workspace
|
||||
SELECT id, name, slug, description, settings, created_at, updated_at, context FROM workspace
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
|
|
@ -63,12 +70,13 @@ func (q *Queries) GetWorkspace(ctx context.Context, id pgtype.UUID) (Workspace,
|
|||
&i.Settings,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Context,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getWorkspaceBySlug = `-- name: GetWorkspaceBySlug :one
|
||||
SELECT id, name, slug, description, settings, created_at, updated_at FROM workspace
|
||||
SELECT id, name, slug, description, settings, created_at, updated_at, context FROM workspace
|
||||
WHERE slug = $1
|
||||
`
|
||||
|
||||
|
|
@ -83,12 +91,13 @@ func (q *Queries) GetWorkspaceBySlug(ctx context.Context, slug string) (Workspac
|
|||
&i.Settings,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Context,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listWorkspaces = `-- name: ListWorkspaces :many
|
||||
SELECT w.id, w.name, w.slug, w.description, w.settings, w.created_at, w.updated_at FROM workspace w
|
||||
SELECT w.id, w.name, w.slug, w.description, w.settings, w.created_at, w.updated_at, w.context FROM workspace w
|
||||
JOIN member m ON m.workspace_id = w.id
|
||||
WHERE m.user_id = $1
|
||||
ORDER BY w.created_at ASC
|
||||
|
|
@ -111,6 +120,7 @@ func (q *Queries) ListWorkspaces(ctx context.Context, userID pgtype.UUID) ([]Wor
|
|||
&i.Settings,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Context,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -126,16 +136,18 @@ const updateWorkspace = `-- name: UpdateWorkspace :one
|
|||
UPDATE workspace SET
|
||||
name = COALESCE($2, name),
|
||||
description = COALESCE($3, description),
|
||||
settings = COALESCE($4, settings),
|
||||
context = COALESCE($4, context),
|
||||
settings = COALESCE($5, settings),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, name, slug, description, settings, created_at, updated_at
|
||||
RETURNING id, name, slug, description, settings, created_at, updated_at, context
|
||||
`
|
||||
|
||||
type UpdateWorkspaceParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name pgtype.Text `json:"name"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Context pgtype.Text `json:"context"`
|
||||
Settings []byte `json:"settings"`
|
||||
}
|
||||
|
||||
|
|
@ -144,6 +156,7 @@ func (q *Queries) UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams
|
|||
arg.ID,
|
||||
arg.Name,
|
||||
arg.Description,
|
||||
arg.Context,
|
||||
arg.Settings,
|
||||
)
|
||||
var i Workspace
|
||||
|
|
@ -155,6 +168,7 @@ func (q *Queries) UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams
|
|||
&i.Settings,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Context,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,14 +13,15 @@ SELECT * FROM workspace
|
|||
WHERE slug = $1;
|
||||
|
||||
-- name: CreateWorkspace :one
|
||||
INSERT INTO workspace (name, slug, description)
|
||||
VALUES ($1, $2, $3)
|
||||
INSERT INTO workspace (name, slug, description, context)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateWorkspace :one
|
||||
UPDATE workspace SET
|
||||
name = COALESCE(sqlc.narg('name'), name),
|
||||
description = COALESCE(sqlc.narg('description'), description),
|
||||
context = COALESCE(sqlc.narg('context'), context),
|
||||
settings = COALESCE(sqlc.narg('settings'), settings),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue