feat: Task decomposition for Tailwind/Radix/shadcn foundation setup
Decomposed "Foundation Setup - Install Dependencies & Configure Tailwind" phase into 6 executable tasks: 1. Install Tailwind CSS, PostCSS & Autoprefixer 2. Map MUI theme to Tailwind design tokens 3. Setup CSS variables for dynamic theming 4. Install Radix UI primitives 5. Initialize shadcn/ui and setup component directory 6. Move MUI to devDependencies and verify setup Tasks follow logical dependency chain with final human verification checkpoint before proceeding with component migration. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
60
CLAUDE.md
60
CLAUDE.md
@@ -1,54 +1,50 @@
|
||||
# Codewalk District
|
||||
|
||||
Multi-agent workspace for orchestrating multiple Claude Code agents.
|
||||
Multi-agent workspace for orchestrating multiple AI coding agents working in parallel on a shared codebase.
|
||||
|
||||
## Database
|
||||
**Architecture**: [docs/architecture.md](docs/architecture.md) — system diagram, module map, entity relationships, tech stack.
|
||||
|
||||
Schema is defined in `src/db/schema.ts` using drizzle-orm. Migrations are managed by drizzle-kit.
|
||||
## Quick Reference
|
||||
|
||||
See [docs/database-migrations.md](docs/database-migrations.md) for the full migration workflow, rules, and commands.
|
||||
| Module | Docs | Path |
|
||||
|--------|------|------|
|
||||
| Agent (lifecycle, providers, accounts) | [docs/agent.md](docs/agent.md) | `src/agent/` |
|
||||
| Database (schema, repositories) | [docs/database.md](docs/database.md) | `src/db/` |
|
||||
| Server & API (tRPC procedures) | [docs/server-api.md](docs/server-api.md) | `src/server/`, `src/trpc/`, `src/coordination/` |
|
||||
| Frontend (React UI) | [docs/frontend.md](docs/frontend.md) | `packages/web/` |
|
||||
| CLI & Config | [docs/cli-config.md](docs/cli-config.md) | `src/cli/`, `src/config/` |
|
||||
| Dispatch & Events | [docs/dispatch-events.md](docs/dispatch-events.md) | `src/dispatch/`, `src/events/` |
|
||||
| Git, Process, Logging | [docs/git-process-logging.md](docs/git-process-logging.md) | `src/git/`, `src/process/`, `src/logger/`, `src/logging/` |
|
||||
| Testing | [docs/testing.md](docs/testing.md) | `src/test/` |
|
||||
| Database Migrations | [docs/database-migrations.md](docs/database-migrations.md) | `drizzle/` |
|
||||
| Logging Guide | [docs/logging.md](docs/logging.md) | `src/logger/` |
|
||||
|
||||
Key rule: **never use raw SQL for schema initialization.** Always use `drizzle-kit generate` and the migration system.
|
||||
Pre-implementation design docs are archived in `docs/archive/`.
|
||||
|
||||
## Logging
|
||||
## Key Rules
|
||||
|
||||
Structured logging via pino. See [docs/logging.md](docs/logging.md) for full details.
|
||||
|
||||
Key rule: use `createModuleLogger()` from `src/logger/index.ts` for backend logging. Keep `console.log` for CLI user-facing output only.
|
||||
- **Database**: Never use raw SQL for schema initialization. Use `drizzle-kit generate` and the migration system. See [docs/database-migrations.md](docs/database-migrations.md).
|
||||
- **Logging**: Use `createModuleLogger()` from `src/logger/index.ts`. Keep `console.log` for CLI user-facing output only.
|
||||
- **Hexagonal architecture**: Repository ports in `src/db/repositories/*.ts`, Drizzle adapters in `src/db/repositories/drizzle/*.ts`. All re-exported from `src/db/index.ts`.
|
||||
- **tRPC context**: Optional repos accessed via `require*Repository()` helpers in `src/trpc/routers/_helpers.ts`.
|
||||
|
||||
## Build
|
||||
|
||||
After completing any change to server-side code (`src/**`), rebuild and re-link the `cw` binary:
|
||||
|
||||
```sh
|
||||
npm run build && npm link
|
||||
```
|
||||
|
||||
Run after any change to server-side code (`src/**`).
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```sh
|
||||
npm test
|
||||
npm test # Unit tests
|
||||
REAL_CLAUDE_TESTS=1 npm test -- src/test/integration/real-providers/ --test-timeout=300000 # Real provider tests (~$0.50)
|
||||
```
|
||||
|
||||
### E2E Tests (Real CLI)
|
||||
See [docs/testing.md](docs/testing.md) for details.
|
||||
|
||||
Real provider integration tests call actual CLI tools and incur API costs. They are **skipped by default**.
|
||||
## Documentation Maintenance
|
||||
|
||||
```sh
|
||||
# Claude tests (~$0.50, ~3 min)
|
||||
REAL_CLAUDE_TESTS=1 npm test -- src/test/integration/real-providers/ --test-timeout=300000
|
||||
|
||||
# Codex tests only
|
||||
REAL_CODEX_TESTS=1 npm test -- src/test/integration/real-providers/codex-manager.test.ts --test-timeout=300000
|
||||
|
||||
# Both providers
|
||||
REAL_CLAUDE_TESTS=1 REAL_CODEX_TESTS=1 npm test -- src/test/integration/real-providers/ --test-timeout=300000
|
||||
```
|
||||
|
||||
Test files in `src/test/integration/real-providers/`:
|
||||
- `claude-manager.test.ts` - Spawn, output parsing, session resume
|
||||
- `schema-retry.test.ts` - Schema validation, JSON extraction, retry logic
|
||||
- `crash-recovery.test.ts` - Server restart simulation
|
||||
- `codex-manager.test.ts` - Codex provider tests
|
||||
**After every code change, update the relevant docs/ file.** Documentation must stay in sync with implementation. When adding a new module, table, tRPC procedure, component, or CLI command, update the corresponding doc. When refactoring, update affected docs and remove stale information.
|
||||
|
||||
103
docs/agent.md
Normal file
103
docs/agent.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Agent Module
|
||||
|
||||
`src/agent/` — Agent lifecycle management, output parsing, multi-provider support, and account failover.
|
||||
|
||||
## File Inventory
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `types.ts` | Core types: `AgentInfo`, `AgentManager` interface, `SpawnOptions`, `StreamEvent` |
|
||||
| `manager.ts` | `MultiProviderAgentManager` — main orchestrator class |
|
||||
| `process-manager.ts` | `AgentProcessManager` — worktree creation, command building, detached spawn |
|
||||
| `output-handler.ts` | `OutputHandler` — JSONL stream parsing, completion detection, proposal creation |
|
||||
| `file-tailer.ts` | `FileTailer` — watches output files, emits line events |
|
||||
| `file-io.ts` | Input/output file I/O: frontmatter writing, signal.json reading, tiptap conversion |
|
||||
| `markdown-to-tiptap.ts` | Markdown to Tiptap JSON conversion using MarkdownManager |
|
||||
| `index.ts` | Public exports, `ClaudeAgentManager` deprecated alias |
|
||||
|
||||
### Sub-modules
|
||||
|
||||
| Directory | Purpose |
|
||||
|-----------|---------|
|
||||
| `providers/` | Provider registry, presets (7 providers), config types |
|
||||
| `providers/parsers/` | Provider-specific output parsers (Claude JSONL, generic line) |
|
||||
| `accounts/` | Account discovery, config dir setup, credential management, usage API |
|
||||
| `credentials/` | `AccountCredentialManager` — credential injection per account |
|
||||
| `lifecycle/` | `LifecycleController` — retry policy, signal recovery, missing signal instructions |
|
||||
| `prompts/` | Mode-specific prompt builders (execute, discuss, breakdown, decompose, refine) |
|
||||
|
||||
## Key Flows
|
||||
|
||||
### Spawning an Agent
|
||||
|
||||
1. **tRPC procedure** calls `agentManager.spawn(options)`
|
||||
2. Manager generates alias (adjective-animal), creates DB record
|
||||
3. `AgentProcessManager.createWorktree()` — creates git worktree at `.cw-worktrees/agent/<alias>/`
|
||||
4. `file-io.writeInputFiles()` — writes `.cw/input/` with initiative, pages, phase, task as frontmatter
|
||||
5. Provider config builds spawn command via `buildSpawnCommand()`
|
||||
6. `spawnDetached()` — launches detached child process with file output redirection
|
||||
7. `FileTailer` watches output file, fires `onEvent` and `onRawContent` callbacks
|
||||
8. `OutputHandler.handleStreamEvent()` processes each JSONL line
|
||||
9. DB record updated with PID, output file path, session ID
|
||||
10. `agent:spawned` event emitted
|
||||
|
||||
### Completion Detection
|
||||
|
||||
1. `FileTailer` detects process exit
|
||||
2. `OutputHandler.handleCompletion()` triggered
|
||||
3. **Primary path**: Reads `.cw/output/signal.json` from agent worktree
|
||||
4. Signal contains `{ status: "done"|"questions"|"error", result?, questions?, error? }`
|
||||
5. Agent DB status updated accordingly (idle, waiting_for_input, crashed)
|
||||
6. For `done`: proposals created from structured output; `agent:stopped` emitted
|
||||
7. For `questions`: parsed and stored as `pendingQuestions`; `agent:waiting` emitted
|
||||
8. **Fallback**: If signal.json missing, lifecycle controller retries with instruction injection
|
||||
|
||||
### Account Failover
|
||||
|
||||
1. On usage-limit error, `markAccountExhausted(id, until)` called
|
||||
2. `findNextAvailable(provider)` returns least-recently-used non-exhausted account
|
||||
3. Agent re-spawned with new account's credentials
|
||||
4. `agent:account_switched` event emitted
|
||||
|
||||
### Resume Flow
|
||||
|
||||
1. tRPC `resumeAgent` called with `answers: Record<string, string>`
|
||||
2. Manager looks up agent's session ID and provider config
|
||||
3. `buildResumeCommand()` creates resume command with session flag
|
||||
4. `formatAnswersAsPrompt(answers)` converts answers to prompt text
|
||||
5. New detached process spawned, same worktree, incremented session number
|
||||
|
||||
## Provider Configuration
|
||||
|
||||
Providers defined in `providers/presets.ts`:
|
||||
|
||||
| Provider | Command | Resume | Prompt Mode |
|
||||
|----------|---------|--------|-------------|
|
||||
| claude | `claude` | `--resume <id>` | native (`-p`) |
|
||||
| claude-code | `claude` | `--resume <id>` | native |
|
||||
| codex | `codex` | none | flag (`--prompt`) |
|
||||
| aider | `aider` | none | flag (`--message`) |
|
||||
| cline | `cline` | none | flag |
|
||||
| continue | `continue` | none | flag |
|
||||
| cursor-agent | `cursor` | none | flag |
|
||||
|
||||
Each provider config specifies: `command`, `args`, `resumeStyle`, `promptMode`, `structuredOutput`, `sessionId` extraction, `nonInteractive` options.
|
||||
|
||||
## Output Parsing
|
||||
|
||||
The `OutputHandler` processes JSONL streams from Claude CLI:
|
||||
|
||||
- `text_delta` events → accumulated as text output, emitted via `agent:output`
|
||||
- `init` event → session ID extracted
|
||||
- `result` event → final result with structured data
|
||||
- Signal file (`signal.json`) → authoritative completion status
|
||||
|
||||
For providers without structured output, the generic line parser accumulates raw text.
|
||||
|
||||
## Log Chunks
|
||||
|
||||
Agent output is persisted to `agent_log_chunks` table:
|
||||
- `onRawContent` callback fires for every output chunk
|
||||
- Fire-and-forget DB insert (no FK to agents — survives deletion)
|
||||
- Session tracking: spawn=1, resume=previousMax+1
|
||||
- Read path concatenates all sessions for full output history
|
||||
109
docs/architecture.md
Normal file
109
docs/architecture.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# Architecture Overview
|
||||
|
||||
Codewalk District is a multi-agent workspace that orchestrates multiple AI coding agents (Claude, Codex, etc.) working in parallel on a shared codebase.
|
||||
|
||||
## System Diagram
|
||||
|
||||
```
|
||||
CLI (cw)
|
||||
└── CoordinationServer
|
||||
├── HTTP Server (node:http, port 3847)
|
||||
│ ├── GET /health
|
||||
│ ├── GET /status
|
||||
│ └── POST /trpc/* → tRPC Router (68+ procedures)
|
||||
├── EventBus (typed pub/sub, 30+ event types)
|
||||
├── MultiProviderAgentManager
|
||||
│ ├── ProcessManager (detached child processes)
|
||||
│ ├── WorktreeManager (git worktrees per agent)
|
||||
│ ├── OutputHandler (JSONL stream parsing)
|
||||
│ ├── AccountManager (round-robin, exhaustion failover)
|
||||
│ └── LifecycleController (retry, signal recovery)
|
||||
├── DispatchManager (task queue, dependency resolution)
|
||||
├── PhaseDispatchManager (phase queue, DAG ordering)
|
||||
└── CoordinationManager (merge queue, conflict resolution)
|
||||
|
||||
Web UI (packages/web/)
|
||||
└── React 19 + TanStack Router + tRPC React Query
|
||||
├── Initiative management (CRUD, projects)
|
||||
├── Page editor (Tiptap rich text)
|
||||
├── Pipeline visualization (phase DAG)
|
||||
├── Execution tab (task dispatch, agent monitoring)
|
||||
└── Review tab (proposal accept/dismiss)
|
||||
```
|
||||
|
||||
## Architectural Patterns
|
||||
|
||||
### Hexagonal Architecture (Ports & Adapters)
|
||||
Every data-access layer follows this pattern:
|
||||
- **Port** (interface): `src/db/repositories/<entity>-repository.ts`
|
||||
- **Adapter** (implementation): `src/db/repositories/drizzle/<entity>-repository.ts`
|
||||
- Re-exported from barrel files so consumers import from `src/db/`
|
||||
|
||||
### Event-Driven Communication
|
||||
All inter-module communication flows through a typed `EventBus`:
|
||||
- Managers emit domain events (agent:spawned, task:completed, etc.)
|
||||
- tRPC subscriptions bridge events to the frontend via SSE
|
||||
- Dispatch/coordination reacts to task/phase lifecycle events
|
||||
|
||||
### Data-Driven Provider Configuration
|
||||
Agent providers (Claude, Codex, etc.) are defined as configuration objects, not code:
|
||||
- `AgentProviderConfig` specifies command, args, resume style, prompt mode
|
||||
- `buildSpawnCommand()` / `buildResumeCommand()` are generic and data-driven
|
||||
|
||||
## Module Map
|
||||
|
||||
| Module | Path | Purpose | Docs |
|
||||
|--------|------|---------|------|
|
||||
| Agent | `src/agent/` | Agent lifecycle, output parsing, accounts | [agent.md](agent.md) |
|
||||
| Database | `src/db/` | Schema, repositories, migrations | [database.md](database.md) |
|
||||
| Server & API | `src/server/`, `src/trpc/` | HTTP server, tRPC procedures | [server-api.md](server-api.md) |
|
||||
| Frontend | `packages/web/` | React UI, components, hooks | [frontend.md](frontend.md) |
|
||||
| CLI & Config | `src/cli/`, `src/config/` | CLI commands, workspace config | [cli-config.md](cli-config.md) |
|
||||
| Dispatch | `src/dispatch/` | Task/phase queue and dispatch | [dispatch-events.md](dispatch-events.md) |
|
||||
| Coordination | `src/coordination/` | Merge queue, conflict resolution | [server-api.md](server-api.md#coordination) |
|
||||
| Git | `src/git/` | Worktree management, project clones | [git-process-logging.md](git-process-logging.md) |
|
||||
| Process | `src/process/` | Child process spawn/track/stop | [git-process-logging.md](git-process-logging.md) |
|
||||
| Logging | `src/logger/`, `src/logging/` | Structured logging, file capture | [git-process-logging.md](git-process-logging.md) |
|
||||
| Events | `src/events/` | EventBus, typed event system | [dispatch-events.md](dispatch-events.md) |
|
||||
| Shared | `packages/shared/` | Types shared between frontend/backend | [frontend.md](frontend.md) |
|
||||
| Tests | `src/test/` | E2E, integration, fixtures | [testing.md](testing.md) |
|
||||
|
||||
## Entity Relationships
|
||||
|
||||
```
|
||||
INITIATIVES (root aggregate)
|
||||
├── N PHASES (with phase_dependencies DAG)
|
||||
│ └── N TASKS
|
||||
├── N TASKS (initiative-level, with task_dependencies DAG)
|
||||
│ └── N TASKS (parentTaskId self-ref for decomposition)
|
||||
├── N PAGES (parentPageId self-ref for hierarchy)
|
||||
├── N INITIATIVE_PROJECTS (junction → PROJECTS M:M)
|
||||
└── N PROPOSALS
|
||||
|
||||
AGENTS (independent aggregate)
|
||||
├── → TASK (optional)
|
||||
├── → INITIATIVE (optional)
|
||||
├── → ACCOUNT (optional)
|
||||
├── N PROPOSALS
|
||||
└── N MESSAGES (sender/recipient)
|
||||
|
||||
ACCOUNTS → N AGENTS
|
||||
AGENT_LOG_CHUNKS (no FK, survives agent deletion)
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| Runtime | Node.js (ESM) |
|
||||
| Language | TypeScript (strict) |
|
||||
| Database | SQLite via better-sqlite3 + Drizzle ORM |
|
||||
| HTTP | Native node:http |
|
||||
| API | tRPC v11 |
|
||||
| Frontend | React 19, TanStack Router, Tailwind CSS, shadcn/ui |
|
||||
| Editor | Tiptap (ProseMirror) |
|
||||
| Git | simple-git |
|
||||
| Process | execa (detached) |
|
||||
| Logging | pino (structured) |
|
||||
| Testing | vitest |
|
||||
| Build | tsup (server), Vite (frontend) |
|
||||
141
docs/cli-config.md
Normal file
141
docs/cli-config.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# CLI & Configuration
|
||||
|
||||
`src/cli/` — CLI commands, `src/config/` — workspace configuration, `src/bin/` — entry point.
|
||||
|
||||
## Entry Point
|
||||
|
||||
`src/bin/cw.ts` — hashbang entry that imports and runs the CLI.
|
||||
|
||||
## CLI Framework
|
||||
|
||||
Uses **Commander.js** for command parsing.
|
||||
|
||||
## Global Flags
|
||||
|
||||
- `-s, --server` — Start coordination server in foreground
|
||||
- `-p, --port <number>` — Server port (default: 3847, env: `CW_PORT`)
|
||||
- `-d, --debug` — Enable debug mode (archive agent workdirs before cleanup)
|
||||
|
||||
## Commands
|
||||
|
||||
### System
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `cw init` | Create `.cwrc` workspace file in current directory |
|
||||
| `cw start` | Start coordination server (see Server Wiring below) |
|
||||
| `cw stop` | Stop running server (reads PID file, sends signal) |
|
||||
| `cw status` | Query server health endpoint |
|
||||
| `cw id [-n count]` | Generate nanoid(s) offline |
|
||||
|
||||
### Agent Management (`cw agent`)
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `spawn <prompt> --task <id> [--name] [--provider] [--initiative] [--cwd]` | Spawn agent |
|
||||
| `stop <name>` | Stop running agent |
|
||||
| `delete <name>` | Delete agent, clean workdir/branches/logs |
|
||||
| `list` | List all agents with status |
|
||||
| `get <name>` | Agent details (ID, task, session, worktree, status) |
|
||||
| `resume <name> <answers>` | Resume with JSON answers or single string |
|
||||
| `result <name>` | Get execution result |
|
||||
|
||||
### Task Management (`cw task`)
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `list --parent\|--phase\|--initiative <id>` | List tasks with counts |
|
||||
| `get <taskId>` | Task details |
|
||||
| `status <taskId> <status>` | Update status |
|
||||
|
||||
### Dispatch (`cw dispatch`)
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `queue <taskId>` | Queue task for dispatch |
|
||||
| `next` | Dispatch next available task |
|
||||
| `status` | Show queue status |
|
||||
| `complete <taskId>` | Mark task complete |
|
||||
|
||||
### Initiative Management (`cw initiative`)
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `create <name> [--project <ids...>]` | Create initiative |
|
||||
| `list [-s status]` | List initiatives |
|
||||
| `get <id>` | Initiative details |
|
||||
| `phases <initiativeId>` | List phases |
|
||||
|
||||
### Architect (`cw architect`)
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `discuss <initiativeId> [-c context]` | Start discussion agent |
|
||||
| `breakdown <initiativeId> [-s summary]` | Start breakdown agent |
|
||||
| `decompose <phaseId> [-t taskName] [-c context]` | Decompose phase into tasks |
|
||||
|
||||
### Phase (`cw phase`)
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `add-dependency --phase <id> --depends-on <id>` | Add dependency edge |
|
||||
| `dependencies <phaseId>` | List dependencies |
|
||||
| `queue <phaseId>` | Queue approved phase |
|
||||
| `dispatch` | Dispatch next phase |
|
||||
| `queue-status` | Show phase queue |
|
||||
|
||||
### Merge & Coordination (`cw merge`, `cw coordinate`)
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `merge queue <taskId>` | Queue task for merge |
|
||||
| `merge status` | Show merge queue |
|
||||
| `merge next` | Show next mergeable task |
|
||||
| `coordinate [-t branch]` | Process all ready merges (default: main) |
|
||||
|
||||
### Messages (`cw message`)
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `list [--agent <id>] [--status <s>]` | List messages |
|
||||
| `read <messageId>` | Read full message |
|
||||
| `respond <messageId> <response>` | Respond to message |
|
||||
|
||||
### Projects (`cw project`)
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `register --name <n> --url <u>` | Register git repo |
|
||||
| `list` | List projects |
|
||||
| `delete <id>` | Delete project |
|
||||
|
||||
### Accounts (`cw account`)
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `add [--provider] [--email]` | Auto-discover or manually register account |
|
||||
| `list` | Show accounts with exhaustion status |
|
||||
| `remove <id>` | Remove account |
|
||||
| `refresh` | Clear expired exhaustion markers |
|
||||
|
||||
## Server Wiring
|
||||
|
||||
The CLI is the composition root. It creates concrete implementations and passes them as `contextDeps`:
|
||||
|
||||
```
|
||||
ServerContextDeps = Omit<TrpcAdapterOptions, 'eventBus' | 'serverStartedAt' | 'processCount'>
|
||||
```
|
||||
|
||||
This includes all repositories, managers, and the credential manager. The server adds `eventBus`, `serverStartedAt`, and `processCount` at startup.
|
||||
|
||||
## Workspace Configuration
|
||||
|
||||
`src/config/` module:
|
||||
|
||||
### .cwrc File
|
||||
JSON file at workspace root that marks a `cw` workspace:
|
||||
```json
|
||||
{ "version": 1 }
|
||||
```
|
||||
|
||||
### findWorkspaceRoot()
|
||||
Walks up directory tree looking for `.cwrc` file. Returns the directory containing it, or throws if not found.
|
||||
|
||||
### Schema
|
||||
Currently `{ version: 1 }` — extend with new top-level keys as needed.
|
||||
|
||||
### Files
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `types.ts` | CwrcConfig type definition |
|
||||
| `cwrc.ts` | findWorkspaceRoot(), readCwrc(), writeCwrc() |
|
||||
| `index.ts` | Public API exports |
|
||||
172
docs/database.md
Normal file
172
docs/database.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# Database Module
|
||||
|
||||
`src/db/` — SQLite database via better-sqlite3 + Drizzle ORM with hexagonal architecture.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Schema**: `src/db/schema.ts` — all tables, columns, relations
|
||||
- **Ports** (interfaces): `src/db/repositories/*.ts` — 10 repository interfaces
|
||||
- **Adapters** (implementations): `src/db/repositories/drizzle/*.ts` — 10 Drizzle adapters
|
||||
- **Barrel exports**: `src/db/index.ts` re-exports everything
|
||||
|
||||
All adapters use nanoid() for IDs, auto-manage timestamps, and use Drizzle's `.returning()` for atomic reads after writes.
|
||||
|
||||
## Tables
|
||||
|
||||
### initiatives
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | text PK | nanoid |
|
||||
| name | text NOT NULL | |
|
||||
| status | text enum | 'active' \| 'completed' \| 'archived', default 'active' |
|
||||
| mergeRequiresApproval | integer/boolean | default true |
|
||||
| mergeTarget | text nullable | target branch for merges |
|
||||
| createdAt, updatedAt | integer/timestamp | |
|
||||
|
||||
### phases
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | text PK | |
|
||||
| initiativeId | text FK → initiatives (cascade) | |
|
||||
| name | text NOT NULL | |
|
||||
| content | text nullable | Tiptap JSON |
|
||||
| status | text enum | 'pending' \| 'approved' \| 'in_progress' \| 'completed' \| 'blocked' |
|
||||
| createdAt, updatedAt | integer/timestamp | |
|
||||
|
||||
### phase_dependencies
|
||||
`phaseId` FK → phases, `dependsOnPhaseId` FK → phases. Both cascade delete.
|
||||
|
||||
### tasks
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | text PK | |
|
||||
| phaseId | text nullable FK → phases (cascade) | |
|
||||
| initiativeId | text nullable FK → initiatives (cascade) | |
|
||||
| parentTaskId | text nullable self-ref FK (cascade) | decomposition hierarchy |
|
||||
| name | text NOT NULL | |
|
||||
| description | text nullable | |
|
||||
| type | text enum | 'auto' \| 'checkpoint:human-verify' \| 'checkpoint:decision' \| 'checkpoint:human-action' |
|
||||
| category | text enum | 'execute' \| 'research' \| 'discuss' \| 'breakdown' \| 'decompose' \| 'refine' \| 'verify' \| 'merge' \| 'review' |
|
||||
| priority | text enum | 'low' \| 'medium' \| 'high' |
|
||||
| status | text enum | 'pending_approval' \| 'pending' \| 'in_progress' \| 'completed' \| 'blocked' |
|
||||
| requiresApproval | integer/boolean nullable | null = inherit from initiative |
|
||||
| order | integer | default 0 |
|
||||
| createdAt, updatedAt | integer/timestamp | |
|
||||
|
||||
### task_dependencies
|
||||
`taskId` FK → tasks, `dependsOnTaskId` FK → tasks. Both cascade delete.
|
||||
|
||||
### agents
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | text PK | |
|
||||
| name | text NOT NULL UNIQUE | human-readable alias (adjective-animal) |
|
||||
| taskId | text nullable FK → tasks (set null) | |
|
||||
| initiativeId | text nullable FK → initiatives (set null) | |
|
||||
| sessionId | text nullable | CLI session ID for resume |
|
||||
| worktreeId | text NOT NULL | path to agent's git worktree |
|
||||
| provider | text NOT NULL | default 'claude' |
|
||||
| accountId | text nullable FK → accounts (set null) | |
|
||||
| status | text enum | 'idle' \| 'running' \| 'waiting_for_input' \| 'stopped' \| 'crashed' |
|
||||
| mode | text enum | 'execute' \| 'discuss' \| 'breakdown' \| 'decompose' \| 'refine' |
|
||||
| pid | integer nullable | OS process ID |
|
||||
| exitCode | integer nullable | |
|
||||
| outputFilePath | text nullable | |
|
||||
| result | text nullable | JSON |
|
||||
| pendingQuestions | text nullable | JSON |
|
||||
| userDismissedAt | integer/timestamp nullable | |
|
||||
| createdAt, updatedAt | integer/timestamp | |
|
||||
|
||||
### accounts
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | text PK | |
|
||||
| email | text NOT NULL | |
|
||||
| provider | text NOT NULL | default 'claude' |
|
||||
| configJson | text nullable | serialized .claude.json |
|
||||
| credentials | text nullable | serialized .credentials.json |
|
||||
| isExhausted | integer/boolean | default false |
|
||||
| exhaustedUntil | integer/timestamp nullable | |
|
||||
| lastUsedAt | integer/timestamp nullable | round-robin scheduling |
|
||||
| sortOrder | integer | |
|
||||
| createdAt, updatedAt | integer/timestamp | |
|
||||
|
||||
### proposals
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | text PK | |
|
||||
| agentId | text FK → agents (cascade) | |
|
||||
| initiativeId | text FK → initiatives (cascade) | |
|
||||
| targetType | text enum | 'page' \| 'phase' \| 'task' |
|
||||
| targetId | text nullable | existing entity ID, null for creates |
|
||||
| title, summary, content | text | markdown body |
|
||||
| metadata | text nullable | JSON |
|
||||
| status | text enum | 'pending' \| 'accepted' \| 'dismissed' |
|
||||
| sortOrder | integer | |
|
||||
| createdAt, updatedAt | integer/timestamp | |
|
||||
|
||||
### pages
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | text PK | |
|
||||
| initiativeId | text FK → initiatives (cascade) | |
|
||||
| parentPageId | text nullable self-ref FK (cascade) | root page has NULL |
|
||||
| title | text NOT NULL | |
|
||||
| content | text nullable | Tiptap JSON |
|
||||
| sortOrder | integer | |
|
||||
| createdAt, updatedAt | integer/timestamp | |
|
||||
|
||||
### projects
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | text PK | |
|
||||
| name | text NOT NULL UNIQUE | |
|
||||
| url | text NOT NULL UNIQUE | git repo URL |
|
||||
| createdAt, updatedAt | integer/timestamp | |
|
||||
|
||||
### initiative_projects (junction)
|
||||
`initiativeId` + `projectId` with unique index. Both FK cascade.
|
||||
|
||||
### messages
|
||||
Self-referencing (parentMessageId) for threading. Sender/recipient types: 'agent' | 'user'.
|
||||
|
||||
### agent_log_chunks
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | text PK | |
|
||||
| agentId | text NOT NULL | **NO FK** — survives agent deletion |
|
||||
| agentName | text NOT NULL | snapshot for display |
|
||||
| sessionNumber | integer | spawn=1, resume=prev+1 |
|
||||
| content | text NOT NULL | raw JSONL chunk |
|
||||
| createdAt | integer/timestamp | |
|
||||
|
||||
Index on `agentId` for fast queries.
|
||||
|
||||
## Repository Interfaces
|
||||
|
||||
10 repositories, each with standard CRUD plus domain-specific methods:
|
||||
|
||||
| Repository | Key Methods |
|
||||
|-----------|-------------|
|
||||
| InitiativeRepository | create, findById, findAll, findByStatus, update, delete |
|
||||
| PhaseRepository | + createDependency, getDependencies, getDependents, findByInitiativeId |
|
||||
| TaskRepository | + findByParentTaskId, findByPhaseId, findPendingApproval, createDependency |
|
||||
| AgentRepository | + findByName, findByTaskId, findBySessionId, findByStatus |
|
||||
| MessageRepository | + findPendingForUser, findRequiringResponse, findReplies |
|
||||
| PageRepository | + findRootPage, getOrCreateRootPage, findByParentPageId |
|
||||
| ProjectRepository | + junction ops: setInitiativeProjects (diff-based), findProjectsByInitiativeId |
|
||||
| AccountRepository | + findNextAvailable (round-robin), markExhausted, clearExpiredExhaustion |
|
||||
| ProposalRepository | + findByAgentIdAndStatus, updateManyByAgentId, countByAgentIdAndStatus |
|
||||
| LogChunkRepository | insertChunk, findByAgentId, deleteByAgentId, getSessionCount |
|
||||
|
||||
## Migrations
|
||||
|
||||
Located in `drizzle/`. Applied via `ensureSchema()` on startup using Drizzle's `migrate()`.
|
||||
|
||||
Key rules:
|
||||
- **Never use raw SQL** for schema initialization
|
||||
- Run `npx drizzle-kit generate` to create migrations
|
||||
- See [database-migrations.md](database-migrations.md) for full workflow
|
||||
- Snapshots stale after 0008; migrations 0008+ are hand-written
|
||||
|
||||
Current migrations: 0000 through 0018 (19 total).
|
||||
92
docs/dispatch-events.md
Normal file
92
docs/dispatch-events.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# Dispatch & Events
|
||||
|
||||
`src/dispatch/` — Task and phase dispatch queues. `src/events/` — Typed event bus.
|
||||
|
||||
## Event Bus
|
||||
|
||||
`src/events/` — Typed pub/sub system for inter-module communication.
|
||||
|
||||
### Architecture
|
||||
- **Port**: `EventBus` interface with `emit(event)` and `on(type, handler)`
|
||||
- **Adapter**: `TypedEventBus` using Node.js `EventEmitter`
|
||||
- All events implement `BaseEvent { type, timestamp, payload }`
|
||||
|
||||
### Event Types (48)
|
||||
|
||||
| Category | Events | Count |
|
||||
|----------|--------|-------|
|
||||
| **Agent** | `agent:spawned`, `agent:stopped`, `agent:crashed`, `agent:resumed`, `agent:account_switched`, `agent:deleted`, `agent:waiting`, `agent:output` | 8 |
|
||||
| **Task** | `task:queued`, `task:dispatched`, `task:completed`, `task:blocked`, `task:pending_approval` | 5 |
|
||||
| **Phase** | `phase:queued`, `phase:started`, `phase:completed`, `phase:blocked` | 4 |
|
||||
| **Merge** | `merge:queued`, `merge:started`, `merge:completed`, `merge:conflicted` | 4 |
|
||||
| **Page** | `page:created`, `page:updated`, `page:deleted` | 3 |
|
||||
| **Process** | `process:spawned`, `process:stopped`, `process:crashed` | 3 |
|
||||
| **Server** | `server:started`, `server:stopped` | 2 |
|
||||
| **Worktree** | `worktree:created`, `worktree:removed`, `worktree:merged`, `worktree:conflict` | 4 |
|
||||
| **Account** | `account:credentials_refreshed`, `account:credentials_expired`, `account:credentials_validated` | 3 |
|
||||
| **Log** | `log:entry` | 1 |
|
||||
|
||||
### Key Event Payloads
|
||||
|
||||
```typescript
|
||||
AgentSpawnedEvent { agentId, name, taskId, worktreeId, provider }
|
||||
AgentStoppedEvent { agentId, name, taskId, reason }
|
||||
// reason: 'user_requested'|'task_complete'|'error'|'waiting_for_input'|
|
||||
// 'context_complete'|'breakdown_complete'|'decompose_complete'|'refine_complete'
|
||||
AgentWaitingEvent { agentId, name, taskId, sessionId, questions[] }
|
||||
AgentOutputEvent { agentId, stream, data }
|
||||
TaskCompletedEvent { taskId, agentId, success, message }
|
||||
TaskQueuedEvent { taskId, priority, dependsOn[] }
|
||||
PhaseStartedEvent { phaseId, initiativeId }
|
||||
MergeConflictedEvent { taskId, agentId, worktreeId, targetBranch, conflictingFiles[] }
|
||||
AccountCredentialsRefreshedEvent { accountId, expiresAt, previousExpiresAt? }
|
||||
```
|
||||
|
||||
## Task Dispatch
|
||||
|
||||
`src/dispatch/` — In-memory queue with dependency-ordered dispatch.
|
||||
|
||||
### Architecture
|
||||
- **Port**: `DispatchManager` interface
|
||||
- **Adapter**: `DefaultDispatchManager`
|
||||
|
||||
### How Task Dispatch Works
|
||||
|
||||
1. **Queue** — `queue(taskId)` fetches task dependencies, adds to internal Map
|
||||
2. **Dispatch** — `dispatchNext()` finds highest-priority task with all deps complete
|
||||
3. **Priority order**: high > medium > low, then oldest first (FIFO within priority)
|
||||
4. **Checkpoint skip** — Tasks with type starting with `checkpoint:` skip auto-dispatch
|
||||
5. **Approval check** — `completeTask()` checks `requiresApproval` (task-level, then initiative-level)
|
||||
6. **Approval flow** — If approval required: status → `pending_approval`, emit `task:pending_approval`
|
||||
|
||||
### DispatchManager Methods
|
||||
|
||||
| Method | Purpose |
|
||||
|--------|---------|
|
||||
| `queue(taskId)` | Add task to dispatch queue |
|
||||
| `dispatchNext()` | Find and dispatch next ready task |
|
||||
| `getNextDispatchable()` | Get next task without dispatching |
|
||||
| `completeTask(taskId, agentId?)` | Complete with approval check |
|
||||
| `approveTask(taskId)` | Approve pending task |
|
||||
| `blockTask(taskId, reason)` | Block task with reason |
|
||||
| `getQueueState()` | Return queued, ready, blocked tasks |
|
||||
|
||||
## Phase Dispatch
|
||||
|
||||
`DefaultPhaseDispatchManager` — Same pattern for phases:
|
||||
|
||||
1. **Queue** — `queuePhase(phaseId)` validates phase is approved, gets dependencies
|
||||
2. **Dispatch** — `dispatchNextPhase()` finds phase with all deps complete
|
||||
3. **Auto-queue tasks** — When phase starts, all pending tasks are queued
|
||||
4. **Events** — `phase:queued`, `phase:started`, `phase:completed`, `phase:blocked`
|
||||
|
||||
### PhaseDispatchManager Methods
|
||||
|
||||
| Method | Purpose |
|
||||
|--------|---------|
|
||||
| `queuePhase(phaseId)` | Queue approved phase |
|
||||
| `dispatchNextPhase()` | Start next ready phase, auto-queue its tasks |
|
||||
| `getNextDispatchablePhase()` | Get next phase without dispatching |
|
||||
| `completePhase(phaseId)` | Mark phase complete |
|
||||
| `blockPhase(phaseId, reason)` | Block phase |
|
||||
| `getPhaseQueueState()` | Return queued, ready, blocked phases |
|
||||
134
docs/frontend.md
Normal file
134
docs/frontend.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# Frontend
|
||||
|
||||
`packages/web/` — React web UI for managing initiatives, agents, and content.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Technology | Purpose |
|
||||
|-----------|---------|
|
||||
| React 19 | UI framework |
|
||||
| TanStack Router | File-based routing |
|
||||
| tRPC React Query | Type-safe API client with caching |
|
||||
| Tailwind CSS | Utility-first styling |
|
||||
| shadcn/ui | Component library (button, card, dialog, dropdown, input, label, textarea, badge, sonner) |
|
||||
| Tiptap | Rich text editor (ProseMirror-based) |
|
||||
| Lucide | Icon library |
|
||||
|
||||
## Path Alias
|
||||
|
||||
`@/*` maps to `./src/*` (configured in `tsconfig.app.json`).
|
||||
|
||||
## Routes
|
||||
|
||||
| Route | Component | Purpose |
|
||||
|-------|-----------|---------|
|
||||
| `/` | `routes/index.tsx` | Dashboard / initiative list |
|
||||
| `/initiatives/$id` | `routes/initiatives/$initiativeId.tsx` | Initiative detail (tabbed) |
|
||||
| `/settings` | `routes/settings/index.tsx` | Settings page |
|
||||
|
||||
## Initiative Detail Tabs
|
||||
|
||||
The initiative detail page has three tabs managed via local state (not URL params):
|
||||
|
||||
1. **Content Tab** — Page tree + Tiptap editor, proposal review
|
||||
2. **Execution Tab** — Pipeline visualization, phase management, task dispatch
|
||||
3. **Review Tab** — Pending proposals from agents
|
||||
|
||||
## Component Inventory (73 components)
|
||||
|
||||
### Core Components (`src/components/`)
|
||||
| Component | Purpose |
|
||||
|-----------|---------|
|
||||
| `InitiativeHeader` | Initiative name, project badges, merge config |
|
||||
| `InitiativeContent` | Content tab with page tree + editor |
|
||||
| `StatusBadge` | Colored status indicator |
|
||||
| `TaskRow` | Task list item with status, priority, category |
|
||||
| `QuestionForm` | Agent question form with options |
|
||||
| `InboxDetailPanel` | Agent message detail + response form |
|
||||
| `ProjectPicker` | Checkbox list for project selection |
|
||||
| `RegisterProjectDialog` | Dialog to register new git project |
|
||||
| `Skeleton` | Loading placeholder |
|
||||
|
||||
### Editor Components (`src/components/editor/`)
|
||||
| Component | Purpose |
|
||||
|-----------|---------|
|
||||
| `TiptapEditor` | Core rich text editor wrapper |
|
||||
| `PageEditor` | Page content editor with auto-save |
|
||||
| `PhaseContentEditor` | Phase content editor |
|
||||
| `ContentProposalReview` | Accept/dismiss proposals from agents |
|
||||
| `SlashCommandMenu` | Slash command popup in editor |
|
||||
|
||||
### Execution Components (`src/components/execution/`)
|
||||
| Component | Purpose |
|
||||
|-----------|---------|
|
||||
| `ExecutionTab` | Main execution view container |
|
||||
| `ExecutionContext` | React context for execution state |
|
||||
| `PhaseDetailPanel` | Phase detail with tasks, dependencies, breakdown |
|
||||
| `PhaseSidebar` | Phase list sidebar |
|
||||
| `TaskDetailPanel` | Task detail with agent status, output |
|
||||
|
||||
### Pipeline Components (`src/components/pipeline/`)
|
||||
| Component | Purpose |
|
||||
|-----------|---------|
|
||||
| `PipelineVisualization` | DAG visualization of phase pipeline |
|
||||
| `PipelineNode` | Individual phase node in pipeline |
|
||||
| `PipelineEdge` | Dependency edge between nodes |
|
||||
|
||||
### Review Components (`src/components/review/`)
|
||||
| Component | Purpose |
|
||||
|-----------|---------|
|
||||
| `ReviewTab` | Review tab container |
|
||||
| `ProposalCard` | Individual proposal display |
|
||||
|
||||
### UI Primitives (`src/components/ui/`)
|
||||
shadcn/ui components: badge, button, card, dialog, dropdown-menu, input, label, sonner, textarea.
|
||||
|
||||
## Custom Hooks (`src/hooks/`)
|
||||
|
||||
| Hook | Purpose |
|
||||
|------|---------|
|
||||
| `useRefineAgent` | Manages refine agent lifecycle for initiative |
|
||||
| `useDecomposeAgent` | Manages decompose agent for phase breakdown |
|
||||
| `useAgentOutput` | Subscribes to live agent output stream |
|
||||
|
||||
## tRPC Client
|
||||
|
||||
Configured in `src/lib/trpc.ts`. Uses `@trpc/react-query` with TanStack Query for caching and optimistic updates.
|
||||
|
||||
## Key User Flows
|
||||
|
||||
### Creating an Initiative
|
||||
1. Dashboard → "New Initiative" → enter name, select projects
|
||||
2. `createInitiative` mutation → auto-creates root page
|
||||
3. Navigate to initiative detail
|
||||
|
||||
### Managing Content (Pages)
|
||||
1. Content tab → page tree sidebar
|
||||
2. Click page → Tiptap editor loads content
|
||||
3. Edit → auto-saves via `updatePage` mutation
|
||||
4. Use slash commands for formatting
|
||||
|
||||
### Refining Content with AI
|
||||
1. Content tab → "Refine" button
|
||||
2. `spawnArchitectRefine` mutation → agent analyzes pages
|
||||
3. Agent creates proposals (page edits, new phases, tasks)
|
||||
4. Proposals appear in review section → accept/dismiss each
|
||||
|
||||
### Pipeline Visualization
|
||||
1. Execution tab → pipeline DAG shows phases as nodes
|
||||
2. Drag to add dependencies between phases
|
||||
3. Approve phases → queue for dispatch
|
||||
4. Tasks auto-queued when phase starts
|
||||
|
||||
### Decomposing Phases
|
||||
1. Select phase → "Breakdown" button
|
||||
2. `spawnArchitectDecompose` mutation → agent creates task proposals
|
||||
3. Accept proposals → tasks created under phase
|
||||
4. View tasks in phase detail panel
|
||||
|
||||
## Shared Package
|
||||
|
||||
`packages/shared/` exports:
|
||||
- `sortByPriorityAndQueueTime()` — priority-based task sorting
|
||||
- `topologicalSort()` / `groupByPipelineColumn()` — phase DAG layout
|
||||
- Shared type re-exports from `packages/shared/src/types.ts`
|
||||
121
docs/git-process-logging.md
Normal file
121
docs/git-process-logging.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Git, Process, and Logging Modules
|
||||
|
||||
Three infrastructure modules supporting agent execution.
|
||||
|
||||
## Git Module (`src/git/`)
|
||||
|
||||
Manages git worktrees for isolated agent workspaces.
|
||||
|
||||
### Architecture
|
||||
- **Port**: `WorktreeManager` interface
|
||||
- **Adapter**: `SimpleGitWorktreeManager` using simple-git library
|
||||
|
||||
### WorktreeManager Methods
|
||||
|
||||
| Method | Purpose |
|
||||
|--------|---------|
|
||||
| `create(id, branch, baseBranch?)` | Create worktree with new branch (default base: 'main') |
|
||||
| `remove(id)` | Clean up worktree directory |
|
||||
| `list()` | All worktrees including main |
|
||||
| `get(id)` | Specific worktree by ID |
|
||||
| `diff(id)` | Changed files vs HEAD |
|
||||
| `merge(id, targetBranch)` | Merge worktree branch into target |
|
||||
|
||||
### Worktree Storage
|
||||
Worktrees stored in `.cw-worktrees/` subdirectory of the repo. Each agent gets a worktree at `.cw-worktrees/agent/<alias>/`.
|
||||
|
||||
### Merge Flow
|
||||
1. Check out target branch
|
||||
2. `git merge <source> --no-edit`
|
||||
3. On success: emit `worktree:merged`
|
||||
4. On conflict: `git merge --abort`, emit `worktree:conflict` with conflicting file list
|
||||
5. Restore original branch
|
||||
|
||||
### Project Clones
|
||||
- `cloneProject(url, destPath)` — Simple git clone wrapper
|
||||
- `ensureProjectClone(project, workspaceRoot)` — Idempotent: checks if clone exists, clones if not
|
||||
- `getProjectCloneDir(name, id)` — Canonical path: `repos/<sanitized-name>-<id>/`
|
||||
|
||||
### Events Emitted
|
||||
`worktree:created`, `worktree:removed`, `worktree:merged`, `worktree:conflict`
|
||||
|
||||
---
|
||||
|
||||
## Process Module (`src/process/`)
|
||||
|
||||
Spawns, tracks, and controls child processes.
|
||||
|
||||
### Classes
|
||||
|
||||
**ProcessRegistry** — In-memory metadata store:
|
||||
- `register(info)`, `unregister(id)`, `get(id)`, `getAll()`, `getByPid(pid)`, `updateStatus(id, status)`
|
||||
|
||||
**ProcessManager** — Lifecycle management:
|
||||
|
||||
| Method | Purpose |
|
||||
|--------|---------|
|
||||
| `spawn(options)` | Spawn detached process (survives parent exit) |
|
||||
| `stop(id)` | SIGTERM → wait 5s → SIGKILL |
|
||||
| `stopAll()` | Stop all running processes in parallel |
|
||||
| `restart(id)` | Stop + re-spawn with same options |
|
||||
| `isRunning(id)` | Check with `process.kill(pid, 0)` |
|
||||
|
||||
### Spawn Details
|
||||
- Uses `execa` with `detached: true`, `stdio: 'ignore'`
|
||||
- Calls `subprocess.unref()` so parent can exit
|
||||
- Exit handler updates registry and emits events
|
||||
|
||||
### Events Emitted
|
||||
`process:spawned`, `process:stopped`, `process:crashed`
|
||||
|
||||
---
|
||||
|
||||
## Logger Module (`src/logger/`)
|
||||
|
||||
Structured logging via **pino**.
|
||||
|
||||
### Usage
|
||||
```typescript
|
||||
import { createModuleLogger } from './logger/index.js';
|
||||
const log = createModuleLogger('my-module');
|
||||
log.info({ key: 'value' }, 'message');
|
||||
```
|
||||
|
||||
### Configuration
|
||||
| Env Var | Effect |
|
||||
|---------|--------|
|
||||
| `CW_LOG_LEVEL` | Override log level |
|
||||
| `CW_LOG_PRETTY` | Set to `'1'` for human-readable output |
|
||||
| `NODE_ENV=development` | Default to 'debug' level |
|
||||
|
||||
### Output
|
||||
- Default: JSON to stderr (fd 2)
|
||||
- Pretty mode: pino-pretty to stdout with colors and timestamps
|
||||
|
||||
---
|
||||
|
||||
## Logging Module (`src/logging/`)
|
||||
|
||||
File-based per-process output capture (separate from pino).
|
||||
|
||||
### Classes
|
||||
|
||||
**LogManager** — Directory management:
|
||||
- Base dir: `~/.cw/logs/`
|
||||
- Structure: `{processId}/stdout.log`, `{processId}/stderr.log`
|
||||
- `cleanOldLogs(retainDays)` — removes old directories by mtime
|
||||
|
||||
**ProcessLogWriter** — File I/O with timestamps:
|
||||
- `open()` — create directories and append-mode WriteStreams
|
||||
- `writeStdout(data)` / `writeStderr(data)` — prefix each line with `[YYYY-MM-DD HH:mm:ss.SSS]`
|
||||
- Handles backpressure (waits for drain event)
|
||||
- Emits `log:entry` event via EventBus
|
||||
|
||||
### Factory
|
||||
```typescript
|
||||
import { createLogger } from './logging/index.js';
|
||||
const writer = createLogger(processId, eventBus);
|
||||
await writer.open();
|
||||
await writer.writeStdout('output data');
|
||||
await writer.close();
|
||||
```
|
||||
200
docs/server-api.md
Normal file
200
docs/server-api.md
Normal file
@@ -0,0 +1,200 @@
|
||||
# Server & API Module
|
||||
|
||||
`src/server/` — HTTP server, `src/trpc/` — tRPC procedures, `src/coordination/` — merge queue.
|
||||
|
||||
## HTTP Server
|
||||
|
||||
**Framework**: Native `node:http` (no Express/Fastify)
|
||||
**Default**: `127.0.0.1:3847`
|
||||
**PID file**: `~/.cw/server.pid`
|
||||
|
||||
### Routes
|
||||
|
||||
| Route | Method | Purpose |
|
||||
|-------|--------|---------|
|
||||
| `/health` | GET | Health check (`{ status, uptime, processCount }`) |
|
||||
| `/status` | GET | Full server status with process list |
|
||||
| `/trpc/*` | POST | All tRPC procedure calls |
|
||||
|
||||
### Lifecycle
|
||||
- `CoordinationServer.start()` — checks PID file, creates HTTP server, emits `server:started`
|
||||
- `CoordinationServer.stop()` — emits `server:stopped`, closes server, removes PID file
|
||||
- `GracefulShutdown` handles SIGTERM/SIGINT/SIGHUP with 10s timeout
|
||||
|
||||
### tRPC Adapter
|
||||
`trpc-adapter.ts` converts `node:http` IncomingMessage/ServerResponse to fetch Request/Response for tRPC. Subscriptions stream via ReadableStream bodies (SSE).
|
||||
|
||||
## tRPC Context
|
||||
|
||||
All procedures share a context with optional dependencies:
|
||||
|
||||
```typescript
|
||||
interface TRPCContext {
|
||||
eventBus: EventBus // always present
|
||||
serverStartedAt: Date | null
|
||||
processCount: number
|
||||
agentManager?: AgentManager // optional
|
||||
taskRepository?: TaskRepository
|
||||
// ... all 10 repositories, 3 managers, credentialManager, workspaceRoot
|
||||
}
|
||||
```
|
||||
|
||||
Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTERNAL_SERVER_ERROR)` if a dependency is missing.
|
||||
|
||||
## Procedure Reference
|
||||
|
||||
### System
|
||||
| Procedure | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| health | query | Health check with uptime |
|
||||
| status | query | Server status with process list |
|
||||
| systemHealthCheck | query | Account, agent, project health |
|
||||
|
||||
### Agents
|
||||
| Procedure | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| spawnAgent | mutation | Spawn new agent (taskId, prompt, provider, mode) |
|
||||
| stopAgent | mutation | Stop agent by name or ID |
|
||||
| deleteAgent | mutation | Delete agent and clean up worktree |
|
||||
| dismissAgent | mutation | Dismiss agent (set userDismissedAt) |
|
||||
| resumeAgent | mutation | Resume with answers |
|
||||
| listAgents | query | All agents |
|
||||
| getAgent | query | Single agent by name or ID |
|
||||
| getAgentResult | query | Execution result |
|
||||
| getAgentQuestions | query | Pending questions |
|
||||
| getAgentOutput | query | Full output (DB chunks or file fallback) |
|
||||
| getActiveRefineAgent | query | Active refine agent for initiative |
|
||||
| listWaitingAgents | query | Agents waiting for input |
|
||||
| onAgentOutput | subscription | Live output stream + buffered history |
|
||||
|
||||
### Tasks
|
||||
| Procedure | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| listTasks | query | Child tasks of parent |
|
||||
| getTask | query | Single task |
|
||||
| updateTaskStatus | mutation | Change task status |
|
||||
| createInitiativeTask | mutation | Create task on initiative |
|
||||
| createPhaseTask | mutation | Create task on phase |
|
||||
| listInitiativeTasks | query | All tasks for initiative |
|
||||
| listPhaseTasks | query | All tasks for phase |
|
||||
| listPendingApprovals | query | Tasks with status=pending_approval |
|
||||
| approveTask | mutation | Approve and complete task |
|
||||
|
||||
### Initiatives
|
||||
| Procedure | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| createInitiative | mutation | Create with optional projectIds, auto-creates root page |
|
||||
| listInitiatives | query | Filter by status |
|
||||
| getInitiative | query | With projects array |
|
||||
| updateInitiative | mutation | Name, status |
|
||||
| updateInitiativeMergeConfig | mutation | mergeRequiresApproval, mergeTarget |
|
||||
|
||||
### Phases
|
||||
| Procedure | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| createPhase | mutation | Create in initiative |
|
||||
| listPhases | query | By initiative |
|
||||
| getPhase | query | Single phase |
|
||||
| updatePhase | mutation | Name, content, status |
|
||||
| approvePhase | mutation | Validate and approve |
|
||||
| deletePhase | mutation | Cascade delete |
|
||||
| createPhasesFromBreakdown | mutation | Bulk create from agent output |
|
||||
| createPhaseDependency | mutation | Add dependency edge |
|
||||
| removePhaseDependency | mutation | Remove dependency edge |
|
||||
| listInitiativePhaseDependencies | query | All dependency edges |
|
||||
| getPhaseDependencies | query | What this phase depends on |
|
||||
| getPhaseDependents | query | What depends on this phase |
|
||||
|
||||
### Phase Dispatch
|
||||
| Procedure | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| queuePhase | mutation | Queue approved phase |
|
||||
| dispatchNextPhase | mutation | Start next ready phase |
|
||||
| getPhaseQueueState | query | Queue state |
|
||||
| createChildTasks | mutation | Create tasks from decompose parent |
|
||||
|
||||
### Architect (High-Level Agent Spawning)
|
||||
| Procedure | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| spawnArchitectDiscuss | mutation | Discussion agent |
|
||||
| spawnArchitectBreakdown | mutation | Breakdown agent (generates phases) |
|
||||
| spawnArchitectRefine | mutation | Refine agent (generates proposals) |
|
||||
| spawnArchitectDecompose | mutation | Decompose agent (generates tasks) |
|
||||
|
||||
### Dispatch
|
||||
| Procedure | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| queueTask | mutation | Add task to dispatch queue |
|
||||
| dispatchNext | mutation | Dispatch next ready task |
|
||||
| getQueueState | query | Queue state |
|
||||
| completeTask | mutation | Complete with approval check |
|
||||
|
||||
### Coordination (Merge Queue)
|
||||
| Procedure | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| queueMerge | mutation | Queue task for merge |
|
||||
| processMerges | mutation | Process merge queue |
|
||||
| getMergeQueueStatus | query | Queue state |
|
||||
| getNextMergeable | query | Next ready-to-merge task |
|
||||
|
||||
### Projects
|
||||
| Procedure | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| registerProject | mutation | Clone git repo, create record |
|
||||
| listProjects | query | All projects |
|
||||
| getProject | query | Single project |
|
||||
| deleteProject | mutation | Delete clone and record |
|
||||
| getInitiativeProjects | query | Projects for initiative |
|
||||
| updateInitiativeProjects | mutation | Sync junction table |
|
||||
|
||||
### Pages
|
||||
| Procedure | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| getRootPage | query | Auto-creates if missing |
|
||||
| getPage | query | Single page |
|
||||
| getPageUpdatedAtMap | query | Bulk updatedAt check |
|
||||
| listPages | query | By initiative |
|
||||
| listChildPages | query | By parent page |
|
||||
| createPage | mutation | Create, emit page:created |
|
||||
| updatePage | mutation | Title/content/sortOrder, emit page:updated |
|
||||
| deletePage | mutation | Delete, emit page:deleted |
|
||||
|
||||
### Accounts
|
||||
| Procedure | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| listAccounts | query | All accounts |
|
||||
| addAccount | mutation | Create account |
|
||||
| removeAccount | mutation | Delete account |
|
||||
| refreshAccounts | mutation | Clear expired exhaustion |
|
||||
| updateAccountAuth | mutation | Update credentials |
|
||||
| markAccountExhausted | mutation | Set exhaustion timer |
|
||||
| listProviderNames | query | Available provider names |
|
||||
|
||||
### Proposals
|
||||
| Procedure | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| listProposals | query | By agent or initiative |
|
||||
| acceptProposal | mutation | Apply side effects, auto-dismiss agent |
|
||||
| dismissProposal | mutation | Dismiss, auto-dismiss agent |
|
||||
| acceptAllProposals | mutation | Bulk accept with error collection |
|
||||
| dismissAllProposals | mutation | Bulk dismiss |
|
||||
|
||||
### Subscriptions (SSE)
|
||||
| Procedure | Type | Events |
|
||||
|-----------|------|--------|
|
||||
| onEvent | subscription | All 30+ event types |
|
||||
| onAgentUpdate | subscription | agent:* events (8 types) |
|
||||
| onTaskUpdate | subscription | task:* + phase:* events (8 types) |
|
||||
| onPageUpdate | subscription | page:created/updated/deleted |
|
||||
|
||||
Subscriptions use `eventBusIterable()` — queue-based async generator, max 1000 events, 30s heartbeat.
|
||||
|
||||
## Coordination Module
|
||||
|
||||
`src/coordination/` manages merge queue:
|
||||
|
||||
- **CoordinationManager** port: `queueMerge`, `getNextMergeable`, `processMerges`, `handleConflict`, `getQueueState`
|
||||
- **DefaultCoordinationManager** adapter: in-memory queue, dependency-ordered processing
|
||||
- **ConflictResolutionService**: creates resolution tasks for merge conflicts
|
||||
- Merge flow: queue → check deps → merge via WorktreeManager → handle conflicts
|
||||
- Events: `merge:queued`, `merge:started`, `merge:completed`, `merge:conflicted`
|
||||
79
docs/testing.md
Normal file
79
docs/testing.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Testing
|
||||
|
||||
`src/test/` — Test infrastructure, fixtures, and test suites.
|
||||
|
||||
## Framework
|
||||
|
||||
**vitest** (Vite-native test runner)
|
||||
|
||||
## Test Categories
|
||||
|
||||
### Unit Tests
|
||||
Located alongside source files (`*.test.ts`):
|
||||
- `src/agent/*.test.ts` — Manager, output handler, completion detection, file I/O, process manager
|
||||
- `src/db/repositories/drizzle/*.test.ts` — Repository adapters
|
||||
- `src/dispatch/*.test.ts` — Dispatch manager
|
||||
- `src/git/manager.test.ts` — Worktree operations
|
||||
- `src/process/*.test.ts` — Process registry and manager
|
||||
- `src/logging/*.test.ts` — Log manager and writer
|
||||
|
||||
### E2E Tests (Mocked Agents)
|
||||
`src/test/e2e/`:
|
||||
| File | Scenarios |
|
||||
|------|-----------|
|
||||
| `happy-path.test.ts` | Single task, parallel, complex flows |
|
||||
| `architect-workflow.test.ts` | Discussion + breakdown agent workflows |
|
||||
| `decompose-workflow.test.ts` | Task decomposition with child tasks |
|
||||
| `phase-dispatch.test.ts` | Phase-level dispatch with dependencies |
|
||||
| `recovery-scenarios.test.ts` | Crash recovery, agent resume |
|
||||
| `edge-cases.test.ts` | Boundary conditions |
|
||||
| `extended-scenarios.test.ts` | Advanced multi-phase workflows |
|
||||
|
||||
### Integration Tests (Real Providers)
|
||||
`src/test/integration/real-providers/` — **skipped by default** (cost real money):
|
||||
| File | Provider | Cost |
|
||||
|------|----------|------|
|
||||
| `claude-manager.test.ts` | Claude CLI | ~$0.10 |
|
||||
| `codex-manager.test.ts` | Codex | varies |
|
||||
| `schema-retry.test.ts` | Claude CLI | ~$0.10 |
|
||||
| `crash-recovery.test.ts` | Claude CLI | ~$0.10 |
|
||||
|
||||
Enable with env vars: `REAL_CLAUDE_TESTS=1`, `REAL_CODEX_TESTS=1`
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
### TestHarness (`src/test/harness.ts`)
|
||||
Central test utility providing:
|
||||
- In-memory SQLite database with schema applied
|
||||
- All 10 repository instances
|
||||
- `MockAgentManager` — simulates agent behavior (done, questions, error)
|
||||
- `MockWorktreeManager` — in-memory worktree simulator
|
||||
- `CapturingEventBus` — captures events for assertions
|
||||
- `DefaultDispatchManager` and `DefaultPhaseDispatchManager`
|
||||
- 25+ helper methods for test scenarios
|
||||
|
||||
### Fixtures (`src/test/fixtures.ts`)
|
||||
Pre-built task hierarchies for testing:
|
||||
| Fixture | Structure |
|
||||
|---------|-----------|
|
||||
| `SIMPLE_FIXTURE` | 1 initiative → 1 phase → 1 group → 3 tasks (A→B, A→C deps) |
|
||||
| `PARALLEL_FIXTURE` | 1 initiative → 1 phase → 2 groups → 4 independent tasks |
|
||||
| `COMPLEX_FIXTURE` | 1 initiative → 2 phases → 4 groups → cross-phase dependencies |
|
||||
|
||||
### Real Provider Harness (`src/test/integration/real-providers/harness.ts`)
|
||||
- Creates real database, real agent manager with real CLI tools
|
||||
- Provides `describeRealClaude()` / `describeRealCodex()` that skip when env var not set
|
||||
- `MINIMAL_PROMPTS` — cheap prompts for testing output parsing
|
||||
|
||||
## Running Tests
|
||||
|
||||
```sh
|
||||
# Unit tests
|
||||
npm test
|
||||
|
||||
# Specific test file
|
||||
npm test -- src/agent/manager.test.ts
|
||||
|
||||
# Real provider tests (costs money!)
|
||||
REAL_CLAUDE_TESTS=1 npm test -- src/test/integration/real-providers/ --test-timeout=300000
|
||||
```
|
||||
1
drizzle/0019_add_execution_mode.sql
Normal file
1
drizzle/0019_add_execution_mode.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE initiatives ADD COLUMN execution_mode TEXT NOT NULL DEFAULT 'review_per_phase';
|
||||
25
drizzle/0020_add_change_sets.sql
Normal file
25
drizzle/0020_add_change_sets.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
-- Add change_sets and change_set_entries tables
|
||||
CREATE TABLE `change_sets` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`agent_id` text REFERENCES `agents`(`id`) ON DELETE SET NULL,
|
||||
`agent_name` text NOT NULL,
|
||||
`initiative_id` text NOT NULL REFERENCES `initiatives`(`id`) ON DELETE CASCADE,
|
||||
`mode` text NOT NULL,
|
||||
`summary` text,
|
||||
`status` text NOT NULL DEFAULT 'applied',
|
||||
`reverted_at` integer,
|
||||
`created_at` integer NOT NULL
|
||||
);--> statement-breakpoint
|
||||
CREATE TABLE `change_set_entries` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`change_set_id` text NOT NULL REFERENCES `change_sets`(`id`) ON DELETE CASCADE,
|
||||
`entity_type` text NOT NULL,
|
||||
`entity_id` text NOT NULL,
|
||||
`action` text NOT NULL,
|
||||
`previous_state` text,
|
||||
`new_state` text,
|
||||
`sort_order` integer NOT NULL DEFAULT 0,
|
||||
`created_at` integer NOT NULL
|
||||
);--> statement-breakpoint
|
||||
CREATE INDEX `change_sets_initiative_id_idx` ON `change_sets`(`initiative_id`);--> statement-breakpoint
|
||||
CREATE INDEX `change_set_entries_change_set_id_idx` ON `change_set_entries`(`change_set_id`);
|
||||
2
drizzle/0021_drop_proposals.sql
Normal file
2
drizzle/0021_drop_proposals.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- Drop proposals table (replaced by change_sets + change_set_entries)
|
||||
DROP TABLE IF EXISTS `proposals`;
|
||||
@@ -134,6 +134,27 @@
|
||||
"when": 1771113600000,
|
||||
"tag": "0018_drop_phase_number",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "6",
|
||||
"when": 1771200000000,
|
||||
"tag": "0019_add_execution_mode",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"version": "6",
|
||||
"when": 1771286400000,
|
||||
"tag": "0020_add_change_sets",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 21,
|
||||
"version": "6",
|
||||
"when": 1771372800000,
|
||||
"tag": "0021_drop_proposals",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
118
package-lock.json
generated
118
package-lock.json
generated
@@ -1297,7 +1297,6 @@
|
||||
"version": "1.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz",
|
||||
"integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.10"
|
||||
@@ -1307,7 +1306,6 @@
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz",
|
||||
"integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.4",
|
||||
@@ -1318,7 +1316,6 @@
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.7.tgz",
|
||||
"integrity": "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.7.5"
|
||||
@@ -1332,7 +1329,6 @@
|
||||
"version": "0.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
|
||||
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@isaacs/balanced-match": {
|
||||
@@ -1477,6 +1473,12 @@
|
||||
"url": "https://opencollective.com/popperjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/number": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
||||
"integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||
@@ -1487,7 +1489,6 @@
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
|
||||
"integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.3"
|
||||
@@ -1511,7 +1512,6 @@
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
||||
"integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
@@ -1538,7 +1538,6 @@
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
@@ -1641,7 +1640,6 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
|
||||
"integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
@@ -1878,7 +1876,6 @@
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
|
||||
"integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react-dom": "^2.0.0",
|
||||
@@ -2028,6 +2025,67 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-select": {
|
||||
"version": "2.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
|
||||
"integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/number": "1.1.1",
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-collection": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||
"@radix-ui/react-focus-guards": "1.1.3",
|
||||
"@radix-ui/react-focus-scope": "1.1.7",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-popper": "1.2.8",
|
||||
"@radix-ui/react-portal": "1.1.9",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1",
|
||||
"@radix-ui/react-use-previous": "1.1.1",
|
||||
"@radix-ui/react-visually-hidden": "1.2.3",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"react-remove-scroll": "^2.6.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
|
||||
@@ -2131,11 +2189,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-previous": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
|
||||
"integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-rect": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
|
||||
"integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/rect": "1.1.1"
|
||||
@@ -2154,7 +2226,6 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
|
||||
"integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
@@ -2169,11 +2240,33 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-visually-hidden": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
|
||||
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/rect": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
|
||||
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@remirror/core-constants": {
|
||||
@@ -8016,6 +8109,7 @@
|
||||
"@codewalk-district/shared": "*",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@tanstack/react-query": "^5.75.0",
|
||||
"@tanstack/react-router": "^1.158.0",
|
||||
"@tiptap/extension-link": "^3.19.0",
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export type { AppRouter } from './trpc.js';
|
||||
export type { Initiative, Phase, Task, Agent, Message, PendingQuestions, QuestionItem, SubscriptionEvent, Project, Proposal } from './types.js';
|
||||
export type { Initiative, Phase, Task, Agent, Message, PendingQuestions, QuestionItem, SubscriptionEvent, Project, ChangeSet, ChangeSetEntry } from './types.js';
|
||||
export { sortByPriorityAndQueueTime, topologicalSortPhases, groupPhasesByDependencyLevel, type SortableItem, type PhaseForSort, type DependencyEdge, type PipelineColumn } from './utils.js';
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
export type { Initiative, Phase, Task, Agent, Message, Page, Project, Account, Proposal } from '../../../src/db/schema.js';
|
||||
export type { Initiative, Phase, Task, Agent, Message, Page, Project, Account, ChangeSet, ChangeSetEntry } from '../../../src/db/schema.js';
|
||||
export type { PendingQuestions, QuestionItem } from '../../../src/agent/types.js';
|
||||
|
||||
export type ExecutionMode = 'yolo' | 'review_per_phase';
|
||||
export type PhaseStatus = 'pending' | 'approved' | 'in_progress' | 'completed' | 'blocked' | 'pending_review';
|
||||
|
||||
/**
|
||||
* Shape of events received from tRPC subscription streams.
|
||||
* Used by the frontend in onData callbacks.
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"@codewalk-district/shared": "*",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@tanstack/react-query": "^5.75.0",
|
||||
"@tanstack/react-router": "^1.158.0",
|
||||
"@tiptap/extension-link": "^3.19.0",
|
||||
|
||||
139
packages/web/src/components/ChangeSetBanner.tsx
Normal file
139
packages/web/src/components/ChangeSetBanner.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { ChevronDown, ChevronRight, Undo2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import type { ChangeSet } from "@codewalk-district/shared";
|
||||
|
||||
interface ChangeSetBannerProps {
|
||||
changeSet: ChangeSet;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
const MODE_LABELS: Record<string, string> = {
|
||||
breakdown: "phases",
|
||||
decompose: "tasks",
|
||||
refine: "pages",
|
||||
};
|
||||
|
||||
export function ChangeSetBanner({ changeSet, onDismiss }: ChangeSetBannerProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [conflicts, setConflicts] = useState<string[] | null>(null);
|
||||
|
||||
const detailQuery = trpc.getChangeSet.useQuery(
|
||||
{ id: changeSet.id },
|
||||
{ enabled: expanded },
|
||||
);
|
||||
|
||||
const revertMutation = trpc.revertChangeSet.useMutation({
|
||||
onSuccess: (result) => {
|
||||
if (!result.success && "conflicts" in result) {
|
||||
setConflicts(result.conflicts);
|
||||
} else {
|
||||
setConflicts(null);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleRevert = useCallback(
|
||||
(force?: boolean) => {
|
||||
revertMutation.mutate({ id: changeSet.id, force });
|
||||
},
|
||||
[changeSet.id, revertMutation],
|
||||
);
|
||||
|
||||
const entries = detailQuery.data?.entries ?? [];
|
||||
const entityLabel = MODE_LABELS[changeSet.mode] ?? "entities";
|
||||
const isReverted = changeSet.status === "reverted";
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card p-3 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<button
|
||||
className="flex items-center gap-1 text-sm font-medium hover:text-foreground/80"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
|
||||
)}
|
||||
{changeSet.summary ??
|
||||
`Agent ${isReverted ? "reverted" : "applied"} ${entityLabel}`}
|
||||
</button>
|
||||
{isReverted && (
|
||||
<span className="text-xs text-muted-foreground italic">
|
||||
(reverted)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1.5 shrink-0">
|
||||
{!isReverted && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleRevert()}
|
||||
disabled={revertMutation.isPending}
|
||||
className="gap-1"
|
||||
>
|
||||
<Undo2 className="h-3 w-3" />
|
||||
{revertMutation.isPending ? "Reverting..." : "Revert"}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={onDismiss}>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{conflicts && (
|
||||
<div className="rounded border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950 p-2 space-y-2">
|
||||
<p className="text-xs font-medium text-amber-800 dark:text-amber-200">
|
||||
Conflicts detected:
|
||||
</p>
|
||||
<ul className="text-xs text-amber-700 dark:text-amber-300 list-disc pl-4 space-y-0.5">
|
||||
{conflicts.map((c, i) => (
|
||||
<li key={i}>{c}</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setConflicts(null);
|
||||
handleRevert(true);
|
||||
}}
|
||||
disabled={revertMutation.isPending}
|
||||
>
|
||||
Force Revert
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expanded && (
|
||||
<div className="pl-5 space-y-1 text-xs text-muted-foreground">
|
||||
{detailQuery.isLoading && <p>Loading entries...</p>}
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.id} className="flex items-center gap-2">
|
||||
<span className="font-mono">
|
||||
{entry.action === "create" ? "+" : entry.action === "delete" ? "-" : "~"}
|
||||
</span>
|
||||
<span>
|
||||
{entry.entityType}
|
||||
{entry.newState && (() => {
|
||||
try {
|
||||
const parsed = JSON.parse(entry.newState);
|
||||
return parsed.name || parsed.title ? `: ${parsed.name || parsed.title}` : "";
|
||||
} catch { return ""; }
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{entries.length === 0 && !detailQuery.isLoading && (
|
||||
<p>No entries</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,13 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { toast } from "sonner";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { ProjectPicker } from "./ProjectPicker";
|
||||
@@ -25,6 +32,8 @@ export function CreateInitiativeDialog({
|
||||
}: CreateInitiativeDialogProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [projectIds, setProjectIds] = useState<string[]>([]);
|
||||
const [executionMode, setExecutionMode] = useState<"yolo" | "review_per_phase">("review_per_phase");
|
||||
const [mergeTarget, setMergeTarget] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
@@ -63,6 +72,8 @@ export function CreateInitiativeDialog({
|
||||
if (open) {
|
||||
setName("");
|
||||
setProjectIds([]);
|
||||
setExecutionMode("review_per_phase");
|
||||
setMergeTarget("");
|
||||
setError(null);
|
||||
}
|
||||
}, [open]);
|
||||
@@ -73,6 +84,8 @@ export function CreateInitiativeDialog({
|
||||
createMutation.mutate({
|
||||
name: name.trim(),
|
||||
projectIds: projectIds.length > 0 ? projectIds : undefined,
|
||||
executionMode,
|
||||
mergeTarget: mergeTarget.trim() || null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -98,6 +111,32 @@ export function CreateInitiativeDialog({
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Execution Mode</Label>
|
||||
<Select value={executionMode} onValueChange={(v) => setExecutionMode(v as "yolo" | "review_per_phase")}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="review_per_phase">Review per Phase</SelectItem>
|
||||
<SelectItem value="yolo">YOLO (auto-merge)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="merge-target">
|
||||
Merge Target Branch{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
(optional)
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="merge-target"
|
||||
placeholder="e.g. feat/auth"
|
||||
value={mergeTarget}
|
||||
onChange={(e) => setMergeTarget(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Projects{" "}
|
||||
|
||||
@@ -22,6 +22,7 @@ interface ExecutionTabProps {
|
||||
phasesLoading: boolean;
|
||||
phasesLoaded: boolean;
|
||||
dependencyEdges: DependencyEdge[];
|
||||
mergeTarget?: string | null;
|
||||
}
|
||||
|
||||
export function ExecutionTab({
|
||||
@@ -30,6 +31,7 @@ export function ExecutionTab({
|
||||
phasesLoading,
|
||||
phasesLoaded,
|
||||
dependencyEdges,
|
||||
mergeTarget,
|
||||
}: ExecutionTabProps) {
|
||||
// Topological sort
|
||||
const sortedPhases = useMemo(
|
||||
@@ -257,6 +259,7 @@ export function ExecutionTab({
|
||||
tasksLoading={allTasksQuery.isLoading}
|
||||
onDelete={() => deletePhase.mutate({ id: activePhase.id })}
|
||||
decomposeAgent={decomposeAgentByPhase.get(activePhase.id) ?? null}
|
||||
mergeTarget={mergeTarget}
|
||||
/>
|
||||
) : (
|
||||
<PhaseDetailEmpty />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronLeft, Pencil, Check } from "lucide-react";
|
||||
import { ChevronLeft, Pencil, Check, GitBranch } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
@@ -12,6 +12,8 @@ export interface InitiativeHeaderProps {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
executionMode?: string;
|
||||
mergeTarget?: string | null;
|
||||
};
|
||||
projects?: Array<{ id: string; name: string; url: string }>;
|
||||
onBack: () => void;
|
||||
@@ -60,6 +62,24 @@ export function InitiativeHeader({
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold">{initiative.name}</h1>
|
||||
<StatusBadge status={initiative.status} />
|
||||
{initiative.executionMode && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
initiative.executionMode === "yolo"
|
||||
? "border-orange-300 text-orange-700 text-[10px]"
|
||||
: "border-blue-300 text-blue-700 text-[10px]"
|
||||
}
|
||||
>
|
||||
{initiative.executionMode === "yolo" ? "YOLO" : "REVIEW"}
|
||||
</Badge>
|
||||
)}
|
||||
{initiative.mergeTarget && (
|
||||
<Badge variant="outline" className="gap-1 text-[10px] font-mono">
|
||||
<GitBranch className="h-3 w-3" />
|
||||
{initiative.mergeTarget}
|
||||
</Badge>
|
||||
)}
|
||||
{!editing && projects && projects.length > 0 && (
|
||||
<>
|
||||
{projects.map((p) => (
|
||||
|
||||
@@ -12,6 +12,8 @@ const statusStyles: Record<string, string> = {
|
||||
approved: "bg-amber-100 text-amber-800 hover:bg-amber-100/80 border-amber-200",
|
||||
in_progress: "bg-blue-100 text-blue-800 hover:bg-blue-100/80 border-blue-200",
|
||||
blocked: "bg-red-100 text-red-800 hover:bg-red-100/80 border-red-200",
|
||||
pending_review: "bg-amber-100 text-amber-800 hover:bg-amber-100/80 border-amber-200",
|
||||
pending_approval: "bg-amber-100 text-amber-800 hover:bg-amber-100/80 border-amber-200",
|
||||
};
|
||||
|
||||
const defaultStyle = "bg-gray-100 text-gray-800 hover:bg-gray-100/80 border-gray-200";
|
||||
|
||||
@@ -1,234 +0,0 @@
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { Check, ChevronDown, ChevronRight, AlertTriangle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import type { Proposal } from "@codewalk-district/shared";
|
||||
|
||||
interface ContentProposalReviewProps {
|
||||
proposals: Proposal[];
|
||||
agentCreatedAt: Date;
|
||||
agentId: string;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export function ContentProposalReview({
|
||||
proposals,
|
||||
agentCreatedAt,
|
||||
agentId,
|
||||
onDismiss,
|
||||
}: ContentProposalReviewProps) {
|
||||
const [accepted, setAccepted] = useState<Set<string>>(new Set());
|
||||
const [acceptError, setAcceptError] = useState<string | null>(null);
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const acceptMutation = trpc.acceptProposal.useMutation({
|
||||
onMutate: async ({ id }) => {
|
||||
await utils.listProposals.cancel({ agentId });
|
||||
const previousProposals = utils.listProposals.getData({ agentId });
|
||||
utils.listProposals.setData({ agentId }, (old = []) =>
|
||||
old.map(p => p.id === id ? { ...p, status: 'accepted' as const } : p)
|
||||
);
|
||||
return { previousProposals };
|
||||
},
|
||||
onSuccess: () => {
|
||||
setAcceptError(null);
|
||||
},
|
||||
onError: (err, _variables, context) => {
|
||||
if (context?.previousProposals) {
|
||||
utils.listProposals.setData({ agentId }, context.previousProposals);
|
||||
}
|
||||
setAcceptError(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
const acceptAllMutation = trpc.acceptAllProposals.useMutation({
|
||||
onSuccess: (result) => {
|
||||
if (result.failed > 0) {
|
||||
setAcceptError(`${result.failed} proposal(s) failed: ${result.errors.join('; ')}`);
|
||||
} else {
|
||||
setAcceptError(null);
|
||||
onDismiss();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const dismissAllMutation = trpc.dismissAllProposals.useMutation({
|
||||
onMutate: async () => {
|
||||
await utils.listProposals.cancel({ agentId });
|
||||
const previousProposals = utils.listProposals.getData({ agentId });
|
||||
utils.listProposals.setData({ agentId }, []);
|
||||
return { previousProposals };
|
||||
},
|
||||
onError: (_err, _variables, context) => {
|
||||
if (context?.previousProposals) {
|
||||
utils.listProposals.setData({ agentId }, context.previousProposals);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleAccept = useCallback(
|
||||
async (proposal: Proposal) => {
|
||||
await acceptMutation.mutateAsync({ id: proposal.id });
|
||||
setAccepted((prev) => new Set(prev).add(proposal.id));
|
||||
},
|
||||
[acceptMutation],
|
||||
);
|
||||
|
||||
const handleAcceptAll = useCallback(async () => {
|
||||
await acceptAllMutation.mutateAsync({ agentId });
|
||||
}, [acceptAllMutation, agentId]);
|
||||
|
||||
const handleDismissAll = useCallback(() => {
|
||||
dismissAllMutation.mutate({ agentId });
|
||||
}, [dismissAllMutation, agentId]);
|
||||
|
||||
// Batch-fetch page updatedAt timestamps for staleness check (eliminates N+1)
|
||||
const pageTargetIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const p of proposals) {
|
||||
if (p.targetType === 'page' && p.targetId) ids.add(p.targetId);
|
||||
}
|
||||
return [...ids];
|
||||
}, [proposals]);
|
||||
|
||||
const pageUpdatedAtMap = trpc.getPageUpdatedAtMap.useQuery(
|
||||
{ ids: pageTargetIds },
|
||||
{ enabled: pageTargetIds.length > 0 },
|
||||
);
|
||||
|
||||
const allAccepted = proposals.every((p) => accepted.has(p.id) || p.status === 'accepted');
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">
|
||||
Agent Proposals ({proposals.length})
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
{!allAccepted && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAcceptAll}
|
||||
disabled={acceptAllMutation.isPending}
|
||||
>
|
||||
Accept All
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDismissAll}
|
||||
disabled={dismissAllMutation.isPending}
|
||||
>
|
||||
{dismissAllMutation.isPending ? "Dismissing..." : "Dismiss"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{acceptError && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-destructive bg-destructive/10 rounded px-2 py-1.5">
|
||||
<AlertTriangle className="h-3 w-3 shrink-0" />
|
||||
<span>{acceptError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{proposals.map((proposal) => (
|
||||
<ProposalCard
|
||||
key={proposal.id}
|
||||
proposal={proposal}
|
||||
isAccepted={accepted.has(proposal.id) || proposal.status === 'accepted'}
|
||||
agentCreatedAt={agentCreatedAt}
|
||||
pageUpdatedAt={proposal.targetType === 'page' && proposal.targetId
|
||||
? pageUpdatedAtMap.data?.[proposal.targetId] ?? null
|
||||
: null}
|
||||
onAccept={() => handleAccept(proposal)}
|
||||
isAccepting={acceptMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProposalCardProps {
|
||||
proposal: Proposal;
|
||||
isAccepted: boolean;
|
||||
agentCreatedAt: Date;
|
||||
pageUpdatedAt: string | null;
|
||||
onAccept: () => void;
|
||||
isAccepting: boolean;
|
||||
}
|
||||
|
||||
function ProposalCard({
|
||||
proposal,
|
||||
isAccepted,
|
||||
agentCreatedAt,
|
||||
pageUpdatedAt,
|
||||
onAccept,
|
||||
isAccepting,
|
||||
}: ProposalCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const isStale =
|
||||
proposal.targetType === 'page' &&
|
||||
pageUpdatedAt && new Date(pageUpdatedAt) > agentCreatedAt;
|
||||
|
||||
return (
|
||||
<div className="rounded border border-border p-3 space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<button
|
||||
className="flex items-center gap-1 text-sm font-medium hover:text-foreground/80"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
|
||||
)}
|
||||
{proposal.title}
|
||||
</button>
|
||||
{proposal.summary && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 pl-5">
|
||||
{proposal.summary}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isAccepted ? (
|
||||
<div className="flex items-center gap-1 text-xs text-green-600 shrink-0">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
Accepted
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onAccept}
|
||||
disabled={isAccepting}
|
||||
className="shrink-0"
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isStale && !isAccepted && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-yellow-600 pl-5">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
Content was modified since agent started
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expanded && proposal.content && (
|
||||
<div className="pl-5 pt-1">
|
||||
<div className="prose prose-sm max-w-none rounded bg-muted/50 p-3 text-xs overflow-auto max-h-64">
|
||||
<pre className="whitespace-pre-wrap text-xs">{proposal.content}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect } from "react";
|
||||
import { Loader2, AlertCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { QuestionForm } from "@/components/QuestionForm";
|
||||
import { ContentProposalReview } from "./ContentProposalReview";
|
||||
import { ChangeSetBanner } from "@/components/ChangeSetBanner";
|
||||
import { RefineSpawnDialog } from "../RefineSpawnDialog";
|
||||
import { useRefineAgent } from "@/hooks";
|
||||
|
||||
@@ -12,7 +12,7 @@ interface RefineAgentPanelProps {
|
||||
|
||||
export function RefineAgentPanel({ initiativeId }: RefineAgentPanelProps) {
|
||||
// All agent logic is now encapsulated in the hook
|
||||
const { state, agent, questions, proposals, spawn, resume, stop, dismiss, refresh } = useRefineAgent(initiativeId);
|
||||
const { state, agent, questions, changeSet, spawn, resume, stop, dismiss, refresh } = useRefineAgent(initiativeId);
|
||||
|
||||
// spawn.mutate and resume.mutate are stable (ref-backed in useRefineAgent),
|
||||
// so these callbacks won't change on every render.
|
||||
@@ -95,26 +95,24 @@ export function RefineAgentPanel({ initiativeId }: RefineAgentPanelProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Completed with proposals
|
||||
if (state === "completed" && proposals && proposals.length > 0) {
|
||||
// Completed with change set
|
||||
if (state === "completed" && changeSet) {
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<ContentProposalReview
|
||||
proposals={proposals}
|
||||
agentCreatedAt={new Date(agent!.createdAt)}
|
||||
agentId={agent!.id}
|
||||
<ChangeSetBanner
|
||||
changeSet={changeSet}
|
||||
onDismiss={handleDismiss}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Completed without proposals (or generic result)
|
||||
// Completed without changes
|
||||
if (state === "completed") {
|
||||
return (
|
||||
<div className="mb-3 flex items-center gap-2 rounded-lg border border-border bg-card px-3 py-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Agent completed — no changes proposed.
|
||||
Agent completed — no changes made.
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" onClick={handleDismiss}>
|
||||
Dismiss
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Loader2, Plus, Sparkles } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { useSpawnMutation } from "@/hooks/useSpawnMutation";
|
||||
import { ContentProposalReview } from "@/components/editor/ContentProposalReview";
|
||||
import { ChangeSetBanner } from "@/components/ChangeSetBanner";
|
||||
|
||||
interface BreakdownSectionProps {
|
||||
initiativeId: string;
|
||||
@@ -38,14 +38,14 @@ export function BreakdownSection({
|
||||
|
||||
const isBreakdownRunning = breakdownAgent?.status === "running";
|
||||
|
||||
// Query proposals when we have a completed breakdown agent
|
||||
const proposalsQuery = trpc.listProposals.useQuery(
|
||||
// Query change sets when we have a completed breakdown agent
|
||||
const changeSetsQuery = trpc.listChangeSets.useQuery(
|
||||
{ agentId: breakdownAgent?.id ?? "" },
|
||||
{ enabled: !!breakdownAgent && breakdownAgent.status === "idle" },
|
||||
);
|
||||
const pendingProposals = useMemo(
|
||||
() => (proposalsQuery.data ?? []).filter((p) => p.status === "pending"),
|
||||
[proposalsQuery.data],
|
||||
const latestChangeSet = useMemo(
|
||||
() => (changeSetsQuery.data ?? []).find((cs) => cs.status === "applied") ?? null,
|
||||
[changeSetsQuery.data],
|
||||
);
|
||||
|
||||
const dismissMutation = trpc.dismissAgent.useMutation();
|
||||
@@ -68,19 +68,17 @@ export function BreakdownSection({
|
||||
return null;
|
||||
}
|
||||
|
||||
// If phases exist and no pending proposals to review, hide section
|
||||
if (phases.length > 0 && pendingProposals.length === 0) {
|
||||
// If phases exist and no change set to show, hide section
|
||||
if (phases.length > 0 && !latestChangeSet) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Show proposal review when breakdown agent completed with pending proposals
|
||||
if (breakdownAgent?.status === "idle" && pendingProposals.length > 0) {
|
||||
// Show change set banner when breakdown agent completed
|
||||
if (breakdownAgent?.status === "idle" && latestChangeSet) {
|
||||
return (
|
||||
<div className="py-4">
|
||||
<ContentProposalReview
|
||||
proposals={pendingProposals}
|
||||
agentCreatedAt={new Date(breakdownAgent.createdAt)}
|
||||
agentId={breakdownAgent.id}
|
||||
<ChangeSetBanner
|
||||
changeSet={latestChangeSet}
|
||||
onDismiss={handleDismiss}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useEffect, useState, useRef, useMemo, useCallback } from "react";
|
||||
import { Loader2, MoreHorizontal, Plus, Sparkles, Trash2, X } from "lucide-react";
|
||||
import { GitBranch, Loader2, MoreHorizontal, Plus, Sparkles, Trash2, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { TaskRow, type SerializedTask } from "@/components/TaskRow";
|
||||
import { PhaseContentEditor } from "@/components/editor/PhaseContentEditor";
|
||||
import { ContentProposalReview } from "@/components/editor/ContentProposalReview";
|
||||
import { ChangeSetBanner } from "@/components/ChangeSetBanner";
|
||||
import { Skeleton } from "@/components/Skeleton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -36,6 +36,7 @@ interface PhaseDetailPanelProps {
|
||||
tasks: SerializedTask[];
|
||||
tasksLoading: boolean;
|
||||
onDelete?: () => void;
|
||||
mergeTarget?: string | null;
|
||||
decomposeAgent: {
|
||||
id: string;
|
||||
status: string;
|
||||
@@ -52,6 +53,7 @@ export function PhaseDetailPanel({
|
||||
tasks,
|
||||
tasksLoading,
|
||||
onDelete,
|
||||
mergeTarget,
|
||||
decomposeAgent,
|
||||
}: PhaseDetailPanelProps) {
|
||||
const { setSelectedTaskId, handleTaskCounts, handleRegisterTasks } =
|
||||
@@ -135,14 +137,14 @@ export function PhaseDetailPanel({
|
||||
handleRegisterTasks(phase.id, entries);
|
||||
}, [tasks, phase.id, displayIndex, phase.name, handleTaskCounts, handleRegisterTasks]);
|
||||
|
||||
// --- Proposals for decompose agent ---
|
||||
const proposalsQuery = trpc.listProposals.useQuery(
|
||||
// --- Change sets for decompose agent ---
|
||||
const changeSetsQuery = trpc.listChangeSets.useQuery(
|
||||
{ agentId: decomposeAgent?.id ?? "" },
|
||||
{ enabled: !!decomposeAgent && decomposeAgent.status === "idle" },
|
||||
);
|
||||
const pendingProposals = useMemo(
|
||||
() => (proposalsQuery.data ?? []).filter((p) => p.status === "pending"),
|
||||
[proposalsQuery.data],
|
||||
const latestChangeSet = useMemo(
|
||||
() => (changeSetsQuery.data ?? []).find((cs) => cs.status === "applied") ?? null,
|
||||
[changeSetsQuery.data],
|
||||
);
|
||||
|
||||
// --- Decompose spawn ---
|
||||
@@ -152,13 +154,20 @@ export function PhaseDetailPanel({
|
||||
decomposeMutation.mutate({ phaseId: phase.id });
|
||||
}, [phase.id, decomposeMutation]);
|
||||
|
||||
// --- Dismiss handler for proposal review ---
|
||||
// --- Dismiss handler for decompose agent ---
|
||||
const dismissMutation = trpc.dismissAgent.useMutation();
|
||||
const handleDismissDecompose = useCallback(() => {
|
||||
if (!decomposeAgent) return;
|
||||
dismissMutation.mutate({ id: decomposeAgent.id });
|
||||
}, [decomposeAgent, dismissMutation]);
|
||||
|
||||
// Compute phase branch name if initiative has a merge target
|
||||
const phaseBranch = mergeTarget
|
||||
? `${mergeTarget}-phase-${phase.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}`
|
||||
: null;
|
||||
|
||||
const isPendingReview = phase.status === "pending_review";
|
||||
|
||||
const sortedTasks = sortByPriorityAndQueueTime(tasks);
|
||||
const hasTasks = tasks.length > 0;
|
||||
const isDecomposeRunning =
|
||||
@@ -166,8 +175,8 @@ export function PhaseDetailPanel({
|
||||
decomposeAgent?.status === "waiting_for_input";
|
||||
const showBreakdownButton =
|
||||
!decomposeAgent && !hasTasks;
|
||||
const showProposals =
|
||||
decomposeAgent?.status === "idle" && pendingProposals.length > 0;
|
||||
const showChangeSet =
|
||||
decomposeAgent?.status === "idle" && !!latestChangeSet;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -198,6 +207,12 @@ export function PhaseDetailPanel({
|
||||
</h3>
|
||||
)}
|
||||
<StatusBadge status={phase.status} />
|
||||
{phaseBranch && ["in_progress", "completed", "pending_review"].includes(phase.status) && (
|
||||
<span className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-0.5 text-[10px] font-mono text-muted-foreground">
|
||||
<GitBranch className="h-3 w-3" />
|
||||
{phaseBranch}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Breakdown button in header */}
|
||||
{showBreakdownButton && (
|
||||
@@ -243,6 +258,16 @@ export function PhaseDetailPanel({
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Pending review banner */}
|
||||
{isPendingReview && (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 px-4 py-3 dark:border-amber-800 dark:bg-amber-950">
|
||||
<p className="text-sm font-medium text-amber-800 dark:text-amber-200">
|
||||
This phase is pending review. Switch to the{" "}
|
||||
<span className="font-semibold">Review</span> tab to view the diff and approve.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tiptap Editor */}
|
||||
<PhaseContentEditor phaseId={phase.id} initiativeId={initiativeId} />
|
||||
|
||||
@@ -317,12 +342,10 @@ export function PhaseDetailPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Decompose proposals */}
|
||||
{showProposals && (
|
||||
<ContentProposalReview
|
||||
proposals={pendingProposals as any}
|
||||
agentCreatedAt={new Date(decomposeAgent!.createdAt)}
|
||||
agentId={decomposeAgent!.id}
|
||||
{/* Decompose change set */}
|
||||
{showChangeSet && (
|
||||
<ChangeSetBanner
|
||||
changeSet={latestChangeSet!}
|
||||
onDismiss={handleDismissDecompose}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { useState, useCallback, useMemo, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DUMMY_REVIEW } from "./dummy-data";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { parseUnifiedDiff } from "./parse-diff";
|
||||
import { DiffViewer } from "./DiffViewer";
|
||||
import { ReviewSidebar } from "./ReviewSidebar";
|
||||
import type { ReviewComment, ReviewStatus, DiffLine } from "./types";
|
||||
@@ -9,11 +10,44 @@ interface ReviewTabProps {
|
||||
initiativeId: string;
|
||||
}
|
||||
|
||||
export function ReviewTab({ initiativeId: _initiativeId }: ReviewTabProps) {
|
||||
const [comments, setComments] = useState<ReviewComment[]>(DUMMY_REVIEW.comments);
|
||||
const [status, setStatus] = useState<ReviewStatus>(DUMMY_REVIEW.status);
|
||||
export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
const [comments, setComments] = useState<ReviewComment[]>([]);
|
||||
const [status, setStatus] = useState<ReviewStatus>("pending");
|
||||
const fileRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
|
||||
// Fetch phases for this initiative
|
||||
const phasesQuery = trpc.listPhases.useQuery({ initiativeId });
|
||||
const pendingReviewPhases = useMemo(
|
||||
() => (phasesQuery.data ?? []).filter((p) => p.status === "pending_review"),
|
||||
[phasesQuery.data],
|
||||
);
|
||||
|
||||
// Select first pending review phase
|
||||
const [selectedPhaseId, setSelectedPhaseId] = useState<string | null>(null);
|
||||
const activePhaseId = selectedPhaseId ?? pendingReviewPhases[0]?.id ?? null;
|
||||
|
||||
// Fetch diff for active phase
|
||||
const diffQuery = trpc.getPhaseReviewDiff.useQuery(
|
||||
{ phaseId: activePhaseId! },
|
||||
{ enabled: !!activePhaseId },
|
||||
);
|
||||
|
||||
const approveMutation = trpc.approvePhaseReview.useMutation({
|
||||
onSuccess: () => {
|
||||
setStatus("approved");
|
||||
toast.success("Phase approved and merged");
|
||||
phasesQuery.refetch();
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
const files = useMemo(() => {
|
||||
if (!diffQuery.data?.rawDiff) return [];
|
||||
return parseUnifiedDiff(diffQuery.data.rawDiff);
|
||||
}, [diffQuery.data?.rawDiff]);
|
||||
|
||||
const handleAddComment = useCallback(
|
||||
(filePath: string, lineNumber: number, lineType: DiffLine["type"], body: string) => {
|
||||
const newComment: ReviewComment = {
|
||||
@@ -45,9 +79,9 @@ export function ReviewTab({ initiativeId: _initiativeId }: ReviewTabProps) {
|
||||
}, []);
|
||||
|
||||
const handleApprove = useCallback(() => {
|
||||
setStatus("approved");
|
||||
toast.success("Review approved");
|
||||
}, []);
|
||||
if (!activePhaseId) return;
|
||||
approveMutation.mutate({ phaseId: activePhaseId });
|
||||
}, [activePhaseId, approveMutation]);
|
||||
|
||||
const handleRequestChanges = useCallback(() => {
|
||||
setStatus("changes_requested");
|
||||
@@ -63,42 +97,85 @@ export function ReviewTab({ initiativeId: _initiativeId }: ReviewTabProps) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_300px]">
|
||||
{/* Left: Diff */}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center justify-between border-b border-border pb-3 mb-4">
|
||||
<h2 className="text-lg font-semibold">Review</h2>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{comments.filter((c) => !c.resolved).length} unresolved thread
|
||||
{comments.filter((c) => !c.resolved).length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<DiffViewer
|
||||
files={DUMMY_REVIEW.files}
|
||||
comments={comments}
|
||||
onAddComment={handleAddComment}
|
||||
onResolveComment={handleResolveComment}
|
||||
onUnresolveComment={handleUnresolveComment}
|
||||
/>
|
||||
if (pendingReviewPhases.length === 0) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
||||
<p>No phases pending review</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Right: Sidebar */}
|
||||
<div className="w-full lg:w-[300px]">
|
||||
<ReviewSidebar
|
||||
title={DUMMY_REVIEW.title}
|
||||
description={DUMMY_REVIEW.description}
|
||||
author={DUMMY_REVIEW.author}
|
||||
status={status}
|
||||
sourceBranch={DUMMY_REVIEW.sourceBranch}
|
||||
targetBranch={DUMMY_REVIEW.targetBranch}
|
||||
files={DUMMY_REVIEW.files}
|
||||
comments={comments}
|
||||
onApprove={handleApprove}
|
||||
onRequestChanges={handleRequestChanges}
|
||||
onFileClick={handleFileClick}
|
||||
/>
|
||||
</div>
|
||||
const activePhaseName = diffQuery.data?.phaseName ?? pendingReviewPhases.find(p => p.id === activePhaseId)?.name ?? "Phase";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Phase selector if multiple pending */}
|
||||
{pendingReviewPhases.length > 1 && (
|
||||
<div className="flex gap-2">
|
||||
{pendingReviewPhases.map((phase) => (
|
||||
<button
|
||||
key={phase.id}
|
||||
onClick={() => setSelectedPhaseId(phase.id)}
|
||||
className={`rounded-md border px-3 py-1.5 text-sm transition-colors ${
|
||||
phase.id === activePhaseId
|
||||
? "border-primary bg-primary/10 font-medium"
|
||||
: "border-border hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{phase.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{diffQuery.isLoading ? (
|
||||
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
||||
Loading diff...
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_300px]">
|
||||
{/* Left: Diff */}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center justify-between border-b border-border pb-3 mb-4">
|
||||
<h2 className="text-lg font-semibold">Review: {activePhaseName}</h2>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{comments.filter((c) => !c.resolved).length} unresolved thread
|
||||
{comments.filter((c) => !c.resolved).length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
{files.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center text-muted-foreground">
|
||||
No changes in this phase
|
||||
</div>
|
||||
) : (
|
||||
<DiffViewer
|
||||
files={files}
|
||||
comments={comments}
|
||||
onAddComment={handleAddComment}
|
||||
onResolveComment={handleResolveComment}
|
||||
onUnresolveComment={handleUnresolveComment}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Sidebar */}
|
||||
<div className="w-full lg:w-[300px]">
|
||||
<ReviewSidebar
|
||||
title={`Phase: ${activePhaseName}`}
|
||||
description={`Review changes from phase "${activePhaseName}" before merging into the initiative branch.`}
|
||||
author="system"
|
||||
status={status}
|
||||
sourceBranch={diffQuery.data?.sourceBranch ?? ""}
|
||||
targetBranch={diffQuery.data?.targetBranch ?? ""}
|
||||
files={files}
|
||||
comments={comments}
|
||||
onApprove={handleApprove}
|
||||
onRequestChanges={handleRequestChanges}
|
||||
onFileClick={handleFileClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
158
packages/web/src/components/ui/select.tsx
Normal file
158
packages/web/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import type { PendingQuestions, Proposal } from '@codewalk-district/shared';
|
||||
import type { PendingQuestions, ChangeSet } from '@codewalk-district/shared';
|
||||
|
||||
export type RefineAgentState = 'none' | 'running' | 'waiting' | 'completed' | 'crashed';
|
||||
|
||||
@@ -18,8 +18,8 @@ export interface UseRefineAgentResult {
|
||||
state: RefineAgentState;
|
||||
/** Questions from the agent (when state is 'waiting') */
|
||||
questions: PendingQuestions | null;
|
||||
/** Proposal rows from the DB (when state is 'completed') */
|
||||
proposals: Proposal[] | null;
|
||||
/** Latest applied change set (when state is 'completed') */
|
||||
changeSet: ChangeSet | null;
|
||||
/** Raw result message (when state is 'completed') */
|
||||
result: string | null;
|
||||
/** Mutation for spawning a new refine agent */
|
||||
@@ -82,8 +82,8 @@ export function useRefineAgent(initiativeId: string): UseRefineAgentResult {
|
||||
{ enabled: state === 'waiting' && !!agent },
|
||||
);
|
||||
|
||||
// Fetch proposals from DB when completed
|
||||
const proposalsQuery = trpc.listProposals.useQuery(
|
||||
// Fetch change sets from DB when completed
|
||||
const changeSetsQuery = trpc.listChangeSets.useQuery(
|
||||
{ agentId: agent?.id ?? '' },
|
||||
{ enabled: state === 'completed' && !!agent },
|
||||
);
|
||||
@@ -94,12 +94,11 @@ export function useRefineAgent(initiativeId: string): UseRefineAgentResult {
|
||||
{ enabled: state === 'completed' && !!agent },
|
||||
);
|
||||
|
||||
// Filter to only pending proposals
|
||||
const proposals = useMemo(() => {
|
||||
if (!proposalsQuery.data || proposalsQuery.data.length === 0) return null;
|
||||
const pending = proposalsQuery.data.filter((p) => p.status === 'pending');
|
||||
return pending.length > 0 ? pending : null;
|
||||
}, [proposalsQuery.data]);
|
||||
// Get latest applied change set
|
||||
const changeSet = useMemo(() => {
|
||||
if (!changeSetsQuery.data || changeSetsQuery.data.length === 0) return null;
|
||||
return changeSetsQuery.data.find((cs) => cs.status === 'applied') ?? null;
|
||||
}, [changeSetsQuery.data]);
|
||||
|
||||
const result = useMemo(() => {
|
||||
if (!resultQuery.data?.success || !resultQuery.data.message) return null;
|
||||
@@ -182,9 +181,7 @@ export function useRefineAgent(initiativeId: string): UseRefineAgentResult {
|
||||
|
||||
return { previousAgents };
|
||||
},
|
||||
onSuccess: () => {
|
||||
void utils.listProposals.invalidate();
|
||||
},
|
||||
onSuccess: () => {},
|
||||
onError: (err, variables, context) => {
|
||||
// Revert optimistic update
|
||||
if (context?.previousAgents) {
|
||||
@@ -256,18 +253,18 @@ export function useRefineAgent(initiativeId: string): UseRefineAgentResult {
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
void utils.getActiveRefineAgent.invalidate({ initiativeId });
|
||||
void utils.listProposals.invalidate();
|
||||
void utils.listChangeSets.invalidate();
|
||||
}, [utils, initiativeId]);
|
||||
|
||||
const isLoading = agentQuery.isLoading ||
|
||||
(state === 'waiting' && questionsQuery.isLoading) ||
|
||||
(state === 'completed' && (resultQuery.isLoading || proposalsQuery.isLoading));
|
||||
(state === 'completed' && (resultQuery.isLoading || changeSetsQuery.isLoading));
|
||||
|
||||
return {
|
||||
agent,
|
||||
state,
|
||||
questions: questionsQuery.data ?? null,
|
||||
proposals,
|
||||
changeSet,
|
||||
result,
|
||||
spawn,
|
||||
resume,
|
||||
|
||||
@@ -35,7 +35,7 @@ const INVALIDATION_MAP: Partial<Record<MutationName, QueryName[]>> = {
|
||||
// --- Agents ---
|
||||
stopAgent: ["listAgents", "listWaitingAgents", "listMessages"],
|
||||
deleteAgent: ["listAgents"],
|
||||
dismissAgent: ["listAgents", "listProposals"],
|
||||
dismissAgent: ["listAgents"],
|
||||
resumeAgent: ["listAgents", "listWaitingAgents", "listMessages"],
|
||||
respondToMessage: ["listWaitingAgents", "listMessages"],
|
||||
|
||||
@@ -66,10 +66,8 @@ const INVALIDATION_MAP: Partial<Record<MutationName, QueryName[]>> = {
|
||||
queueTask: ["listTasks", "listInitiativeTasks", "listPhaseTasks"],
|
||||
approveTask: ["listTasks", "listInitiativeTasks", "listPhaseTasks", "listPendingApprovals"],
|
||||
|
||||
// --- Proposals ---
|
||||
acceptProposal: ["listProposals", "listPages", "getPage", "listAgents", "listPhases", "listTasks"],
|
||||
acceptAllProposals: ["listProposals", "listPages", "getPage", "listAgents", "listPhases", "listTasks"],
|
||||
dismissAllProposals: ["listProposals", "listAgents"],
|
||||
// --- Change Sets ---
|
||||
revertChangeSet: ["listPhases", "listPhaseTasks", "listInitiativeTasks", "listPages", "getPage", "listChangeSets", "getRootPage"],
|
||||
|
||||
// --- Pages ---
|
||||
updatePage: ["listPages", "getPage", "getRootPage"],
|
||||
|
||||
@@ -89,6 +89,8 @@ function InitiativeDetailPage() {
|
||||
id: initiative.id,
|
||||
name: initiative.name,
|
||||
status: initiative.status,
|
||||
executionMode: (initiative as any).executionMode as string | undefined,
|
||||
mergeTarget: (initiative as any).mergeTarget as string | null | undefined,
|
||||
};
|
||||
|
||||
const projects = (initiative as { projects?: Array<{ id: string; name: string; url: string }> }).projects;
|
||||
@@ -135,6 +137,7 @@ function InitiativeDetailPage() {
|
||||
phasesLoading={phasesQuery.isLoading}
|
||||
phasesLoaded={phasesQuery.isSuccess}
|
||||
dependencyEdges={depsQuery.data ?? []}
|
||||
mergeTarget={serializedInitiative.mergeTarget}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "execution" && (
|
||||
|
||||
@@ -9,13 +9,11 @@ import { tmpdir } from 'node:os';
|
||||
import { rmSync } from 'node:fs';
|
||||
import { OutputHandler } from './output-handler.js';
|
||||
import type { AgentRepository } from '../db/repositories/agent-repository.js';
|
||||
import type { ProposalRepository } from '../db/repositories/proposal-repository.js';
|
||||
|
||||
describe('Completion Detection Fix', () => {
|
||||
let tempDir: string;
|
||||
let outputHandler: OutputHandler;
|
||||
let mockAgentRepo: AgentRepository;
|
||||
let mockProposalRepo: ProposalRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'completion-test-'));
|
||||
@@ -26,11 +24,7 @@ describe('Completion Detection Fix', () => {
|
||||
findById: vi.fn().mockResolvedValue({ id: 'test-agent', mode: 'refine' }),
|
||||
} as any;
|
||||
|
||||
mockProposalRepo = {
|
||||
create: vi.fn(),
|
||||
} as any;
|
||||
|
||||
outputHandler = new OutputHandler(mockAgentRepo, undefined, mockProposalRepo);
|
||||
outputHandler = new OutputHandler(mockAgentRepo);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -50,6 +50,7 @@ describe('writeInputFiles', () => {
|
||||
status: 'active',
|
||||
mergeRequiresApproval: true,
|
||||
mergeTarget: 'main',
|
||||
executionMode: 'review_per_phase',
|
||||
createdAt: new Date('2026-01-01'),
|
||||
updatedAt: new Date('2026-01-02'),
|
||||
};
|
||||
|
||||
@@ -21,7 +21,10 @@ import type {
|
||||
import type { AgentRepository } from '../db/repositories/agent-repository.js';
|
||||
import type { AccountRepository } from '../db/repositories/account-repository.js';
|
||||
import type { ProjectRepository } from '../db/repositories/project-repository.js';
|
||||
import type { ProposalRepository } from '../db/repositories/proposal-repository.js';
|
||||
import type { ChangeSetRepository } from '../db/repositories/change-set-repository.js';
|
||||
import type { PhaseRepository } from '../db/repositories/phase-repository.js';
|
||||
import type { TaskRepository } from '../db/repositories/task-repository.js';
|
||||
import type { PageRepository } from '../db/repositories/page-repository.js';
|
||||
import type { LogChunkRepository } from '../db/repositories/log-chunk-repository.js';
|
||||
import { generateUniqueAlias } from './alias.js';
|
||||
import type {
|
||||
@@ -72,14 +75,17 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
private accountRepository?: AccountRepository,
|
||||
private eventBus?: EventBus,
|
||||
private credentialManager?: AccountCredentialManager,
|
||||
private proposalRepository?: ProposalRepository,
|
||||
private changeSetRepository?: ChangeSetRepository,
|
||||
private phaseRepository?: PhaseRepository,
|
||||
private taskRepository?: TaskRepository,
|
||||
private pageRepository?: PageRepository,
|
||||
private logChunkRepository?: LogChunkRepository,
|
||||
private debug: boolean = false,
|
||||
) {
|
||||
this.signalManager = new FileSystemSignalManager();
|
||||
this.processManager = new ProcessManager(workspaceRoot, projectRepository, eventBus);
|
||||
this.credentialHandler = new CredentialHandler(workspaceRoot, accountRepository, credentialManager);
|
||||
this.outputHandler = new OutputHandler(repository, eventBus, proposalRepository, this.signalManager);
|
||||
this.outputHandler = new OutputHandler(repository, eventBus, changeSetRepository, phaseRepository, taskRepository, pageRepository, this.signalManager);
|
||||
this.cleanupManager = new CleanupManager(workspaceRoot, repository, projectRepository, eventBus, debug, this.signalManager);
|
||||
this.lifecycleController = createLifecycleController({
|
||||
repository,
|
||||
@@ -164,9 +170,9 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
* Used by both legacy spawn() and new lifecycle-managed spawn.
|
||||
*/
|
||||
private async spawnInternal(options: SpawnAgentOptions): Promise<AgentInfo> {
|
||||
const { taskId, cwd, mode = 'execute', provider: providerName = 'claude', initiativeId } = options;
|
||||
const { taskId, cwd, mode = 'execute', provider: providerName = 'claude', initiativeId, baseBranch, branchName } = options;
|
||||
let { prompt } = options;
|
||||
log.info({ taskId, provider: providerName, initiativeId, mode }, 'spawn requested');
|
||||
log.info({ taskId, provider: providerName, initiativeId, mode, baseBranch, branchName }, 'spawn requested');
|
||||
|
||||
const provider = getProvider(providerName);
|
||||
if (!provider) {
|
||||
@@ -215,8 +221,8 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
// 2. Create isolated worktrees
|
||||
let agentCwd: string;
|
||||
if (initiativeId) {
|
||||
log.debug({ alias, initiativeId }, 'creating initiative-based worktrees');
|
||||
agentCwd = await this.processManager.createProjectWorktrees(alias, initiativeId);
|
||||
log.debug({ alias, initiativeId, baseBranch, branchName }, 'creating initiative-based worktrees');
|
||||
agentCwd = await this.processManager.createProjectWorktrees(alias, initiativeId, baseBranch ?? 'main', branchName);
|
||||
|
||||
// Log projects linked to the initiative
|
||||
const projects = await this.projectRepository.findProjectsByInitiativeId(initiativeId);
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { OutputHandler } from './output-handler.js';
|
||||
import type { AgentRepository } from '../db/repositories/agent-repository.js';
|
||||
import type { ProposalRepository } from '../db/repositories/proposal-repository.js';
|
||||
import type { EventBus, DomainEvent, AgentWaitingEvent } from '../events/types.js';
|
||||
import { getProvider } from './providers/registry.js';
|
||||
|
||||
@@ -44,19 +43,6 @@ function createMockAgentRepository() {
|
||||
};
|
||||
}
|
||||
|
||||
function createMockProposalRepository() {
|
||||
return {
|
||||
createMany: vi.fn(),
|
||||
findByAgentId: vi.fn(),
|
||||
findByInitiativeId: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
create: vi.fn(),
|
||||
findAll: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
@@ -64,7 +50,6 @@ function createMockProposalRepository() {
|
||||
describe('OutputHandler', () => {
|
||||
let outputHandler: OutputHandler;
|
||||
let mockAgentRepo: ReturnType<typeof createMockAgentRepository>;
|
||||
let mockProposalRepo: ReturnType<typeof createMockProposalRepository>;
|
||||
let eventBus: ReturnType<typeof createMockEventBus>;
|
||||
|
||||
const mockAgent = {
|
||||
@@ -78,13 +63,11 @@ describe('OutputHandler', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
mockAgentRepo = createMockAgentRepository();
|
||||
mockProposalRepo = createMockProposalRepository();
|
||||
eventBus = createMockEventBus();
|
||||
|
||||
outputHandler = new OutputHandler(
|
||||
mockAgentRepo as any,
|
||||
eventBus,
|
||||
mockProposalRepo as any
|
||||
);
|
||||
|
||||
// Setup default mock behavior
|
||||
|
||||
@@ -10,7 +10,10 @@ import { readFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { AgentRepository } from '../db/repositories/agent-repository.js';
|
||||
import type { ProposalRepository, CreateProposalData } from '../db/repositories/proposal-repository.js';
|
||||
import type { ChangeSetRepository, CreateChangeSetEntryData } from '../db/repositories/change-set-repository.js';
|
||||
import type { PhaseRepository } from '../db/repositories/phase-repository.js';
|
||||
import type { TaskRepository } from '../db/repositories/task-repository.js';
|
||||
import type { PageRepository } from '../db/repositories/page-repository.js';
|
||||
import type {
|
||||
EventBus,
|
||||
AgentStoppedEvent,
|
||||
@@ -36,6 +39,7 @@ import {
|
||||
readFrontmatterFile,
|
||||
} from './file-io.js';
|
||||
import { getProvider } from './providers/registry.js';
|
||||
import { markdownToTiptapJson } from './markdown-to-tiptap.js';
|
||||
import type { SignalManager } from './lifecycle/signal-manager.js';
|
||||
import { createModuleLogger } from '../logger/index.js';
|
||||
|
||||
@@ -83,7 +87,10 @@ export class OutputHandler {
|
||||
constructor(
|
||||
private repository: AgentRepository,
|
||||
private eventBus?: EventBus,
|
||||
private proposalRepository?: ProposalRepository,
|
||||
private changeSetRepository?: ChangeSetRepository,
|
||||
private phaseRepository?: PhaseRepository,
|
||||
private taskRepository?: TaskRepository,
|
||||
private pageRepository?: PageRepository,
|
||||
private signalManager?: SignalManager,
|
||||
) {}
|
||||
|
||||
@@ -404,6 +411,7 @@ export class OutputHandler {
|
||||
|
||||
/**
|
||||
* Process output files from agent workdir after successful completion.
|
||||
* Performs direct writes to entities and records change sets.
|
||||
*/
|
||||
private async processOutputFiles(
|
||||
agentId: string,
|
||||
@@ -414,27 +422,82 @@ export class OutputHandler {
|
||||
const agentWorkdir = getAgentWorkdir(agent.worktreeId);
|
||||
const summary = readSummary(agentWorkdir);
|
||||
const initiativeId = agent.initiativeId;
|
||||
const canWriteProposals = this.proposalRepository && initiativeId;
|
||||
const canWriteChangeSets = this.changeSetRepository && initiativeId;
|
||||
|
||||
let resultMessage = summary?.body ?? 'Task completed';
|
||||
switch (mode) {
|
||||
case 'breakdown': {
|
||||
const phases = readPhaseFiles(agentWorkdir);
|
||||
if (canWriteProposals && phases.length > 0) {
|
||||
const proposalData: CreateProposalData[] = phases.map((p, i) => ({
|
||||
agentId,
|
||||
initiativeId,
|
||||
targetType: 'phase' as const,
|
||||
targetId: null,
|
||||
title: p.title,
|
||||
summary: null,
|
||||
content: p.body || null,
|
||||
metadata: JSON.stringify({ fileId: p.id, dependencies: p.dependencies }),
|
||||
status: 'pending' as const,
|
||||
sortOrder: i,
|
||||
}));
|
||||
await this.proposalRepository!.createMany(proposalData);
|
||||
resultMessage = summary?.body ?? `${phases.length} phase proposals created`;
|
||||
if (canWriteChangeSets && this.phaseRepository && phases.length > 0) {
|
||||
const entries: CreateChangeSetEntryData[] = [];
|
||||
|
||||
// First pass: create phases
|
||||
for (const [i, p] of phases.entries()) {
|
||||
try {
|
||||
const tiptapContent = p.body ? JSON.stringify(markdownToTiptapJson(p.body)) : undefined;
|
||||
const created = await this.phaseRepository.create({
|
||||
id: p.id ?? undefined,
|
||||
initiativeId,
|
||||
name: p.title,
|
||||
content: tiptapContent,
|
||||
});
|
||||
entries.push({
|
||||
entityType: 'phase',
|
||||
entityId: created.id,
|
||||
action: 'create',
|
||||
newState: JSON.stringify(created),
|
||||
sortOrder: i,
|
||||
});
|
||||
this.eventBus?.emit({
|
||||
type: 'phase:started' as const,
|
||||
timestamp: new Date(),
|
||||
payload: { phaseId: created.id, initiativeId },
|
||||
});
|
||||
} catch (err) {
|
||||
log.warn({ agentId, phase: p.title, err: err instanceof Error ? err.message : String(err) }, 'failed to create phase');
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: create phase dependencies
|
||||
let depSortOrder = entries.length;
|
||||
for (const p of phases) {
|
||||
const phaseId = p.id;
|
||||
if (!phaseId || !Array.isArray(p.dependencies)) continue;
|
||||
for (const depFileId of p.dependencies) {
|
||||
try {
|
||||
await this.phaseRepository.createDependency(phaseId, depFileId);
|
||||
entries.push({
|
||||
entityType: 'phase_dependency',
|
||||
entityId: `${phaseId}:${depFileId}`,
|
||||
action: 'create',
|
||||
newState: JSON.stringify({ phaseId, dependsOnPhaseId: depFileId }),
|
||||
sortOrder: depSortOrder++,
|
||||
});
|
||||
} catch (err) {
|
||||
log.warn({ agentId, phaseId, depFileId, err: err instanceof Error ? err.message : String(err) }, 'failed to create phase dependency');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (entries.length > 0) {
|
||||
try {
|
||||
const cs = await this.changeSetRepository!.createWithEntries({
|
||||
agentId,
|
||||
agentName: agent.name,
|
||||
initiativeId,
|
||||
mode: 'breakdown',
|
||||
summary: summary?.body ?? `Created ${phases.length} phases`,
|
||||
}, entries);
|
||||
this.eventBus?.emit({
|
||||
type: 'changeset:created' as const,
|
||||
timestamp: new Date(),
|
||||
payload: { changeSetId: cs.id, initiativeId, agentId, mode: 'breakdown', entryCount: entries.length },
|
||||
});
|
||||
} catch (err) {
|
||||
log.warn({ agentId, err: err instanceof Error ? err.message : String(err) }, 'failed to record change set after successful writes');
|
||||
}
|
||||
}
|
||||
resultMessage = summary?.body ?? `${phases.length} phases created`;
|
||||
} else {
|
||||
resultMessage = JSON.stringify({ summary: summary?.body, phases });
|
||||
}
|
||||
@@ -442,32 +505,58 @@ export class OutputHandler {
|
||||
}
|
||||
case 'decompose': {
|
||||
const tasks = readTaskFiles(agentWorkdir);
|
||||
if (canWriteProposals && tasks.length > 0) {
|
||||
// Read phase info from input context if available
|
||||
if (canWriteChangeSets && this.taskRepository && tasks.length > 0) {
|
||||
const phaseInput = readFrontmatterFile(join(agentWorkdir, '.cw', 'input', 'phase.md'));
|
||||
const phaseId = (phaseInput?.data?.id as string) ?? null;
|
||||
const entries: CreateChangeSetEntryData[] = [];
|
||||
|
||||
const proposalData: CreateProposalData[] = tasks.map((t, i) => ({
|
||||
agentId,
|
||||
initiativeId,
|
||||
targetType: 'task' as const,
|
||||
targetId: null,
|
||||
title: t.title,
|
||||
summary: null,
|
||||
content: t.body || null,
|
||||
metadata: JSON.stringify({
|
||||
fileId: t.id,
|
||||
category: t.category,
|
||||
type: t.type,
|
||||
dependencies: t.dependencies,
|
||||
parentTaskId: agent.taskId,
|
||||
phaseId,
|
||||
}),
|
||||
status: 'pending' as const,
|
||||
sortOrder: i,
|
||||
}));
|
||||
await this.proposalRepository!.createMany(proposalData);
|
||||
resultMessage = summary?.body ?? `${tasks.length} task proposals created`;
|
||||
for (const [i, t] of tasks.entries()) {
|
||||
try {
|
||||
const created = await this.taskRepository.create({
|
||||
initiativeId,
|
||||
phaseId,
|
||||
parentTaskId: agent.taskId ?? null,
|
||||
name: t.title,
|
||||
description: t.body ?? undefined,
|
||||
category: (t.category as any) ?? 'execute',
|
||||
type: (t.type as any) ?? 'auto',
|
||||
});
|
||||
entries.push({
|
||||
entityType: 'task',
|
||||
entityId: created.id,
|
||||
action: 'create',
|
||||
newState: JSON.stringify(created),
|
||||
sortOrder: i,
|
||||
});
|
||||
this.eventBus?.emit({
|
||||
type: 'task:completed' as const,
|
||||
timestamp: new Date(),
|
||||
payload: { taskId: created.id, agentId, success: true, message: 'Task created by decompose' },
|
||||
});
|
||||
} catch (err) {
|
||||
log.warn({ agentId, task: t.title, err: err instanceof Error ? err.message : String(err) }, 'failed to create task');
|
||||
}
|
||||
}
|
||||
|
||||
if (entries.length > 0) {
|
||||
try {
|
||||
const cs = await this.changeSetRepository!.createWithEntries({
|
||||
agentId,
|
||||
agentName: agent.name,
|
||||
initiativeId,
|
||||
mode: 'decompose',
|
||||
summary: summary?.body ?? `Created ${tasks.length} tasks`,
|
||||
}, entries);
|
||||
this.eventBus?.emit({
|
||||
type: 'changeset:created' as const,
|
||||
timestamp: new Date(),
|
||||
payload: { changeSetId: cs.id, initiativeId, agentId, mode: 'decompose', entryCount: entries.length },
|
||||
});
|
||||
} catch (err) {
|
||||
log.warn({ agentId, err: err instanceof Error ? err.message : String(err) }, 'failed to record change set after successful writes');
|
||||
}
|
||||
}
|
||||
resultMessage = summary?.body ?? `${tasks.length} tasks created`;
|
||||
} else {
|
||||
resultMessage = JSON.stringify({ summary: summary?.body, tasks });
|
||||
}
|
||||
@@ -480,43 +569,63 @@ export class OutputHandler {
|
||||
}
|
||||
case 'refine': {
|
||||
const pages = readPageFiles(agentWorkdir);
|
||||
if (canWriteProposals) {
|
||||
if (pages.length > 0) {
|
||||
// Create proposals for actual page changes
|
||||
const proposalData: CreateProposalData[] = pages.map((p, i) => ({
|
||||
agentId,
|
||||
initiativeId,
|
||||
targetType: 'page' as const,
|
||||
targetId: p.pageId,
|
||||
title: p.title,
|
||||
summary: p.summary || null,
|
||||
content: p.body || null,
|
||||
metadata: null,
|
||||
status: 'pending' as const,
|
||||
sortOrder: i,
|
||||
}));
|
||||
await this.proposalRepository!.createMany(proposalData);
|
||||
resultMessage = summary?.body ?? `${pages.length} page proposals created`;
|
||||
} else {
|
||||
// Create a synthetic completion proposal when no changes are proposed
|
||||
// This ensures the dismiss flow always goes through proposals domain
|
||||
const completionProposal: CreateProposalData = {
|
||||
agentId,
|
||||
initiativeId,
|
||||
targetType: 'page' as const,
|
||||
targetId: null,
|
||||
title: 'Analysis Complete',
|
||||
summary: 'Agent completed review with no changes proposed',
|
||||
content: summary?.body || 'The agent has finished analyzing the content and determined no changes are needed.',
|
||||
metadata: JSON.stringify({ synthetic: true, reason: 'no_changes' }),
|
||||
status: 'pending' as const,
|
||||
sortOrder: 0,
|
||||
};
|
||||
await this.proposalRepository!.createMany([completionProposal]);
|
||||
resultMessage = summary?.body ?? 'Analysis completed with 1 completion notice';
|
||||
if (canWriteChangeSets && this.pageRepository && pages.length > 0) {
|
||||
const entries: CreateChangeSetEntryData[] = [];
|
||||
|
||||
for (const [i, p] of pages.entries()) {
|
||||
try {
|
||||
if (!p.pageId) continue;
|
||||
const existing = await this.pageRepository.findById(p.pageId);
|
||||
if (!existing) {
|
||||
log.warn({ agentId, pageId: p.pageId }, 'page not found for refine update');
|
||||
continue;
|
||||
}
|
||||
const previousState = JSON.stringify(existing);
|
||||
const tiptapJson = markdownToTiptapJson(p.body || '');
|
||||
await this.pageRepository.update(p.pageId, {
|
||||
content: JSON.stringify(tiptapJson),
|
||||
title: p.title,
|
||||
});
|
||||
const updated = await this.pageRepository.findById(p.pageId);
|
||||
entries.push({
|
||||
entityType: 'page',
|
||||
entityId: p.pageId,
|
||||
action: 'update',
|
||||
previousState,
|
||||
newState: JSON.stringify(updated),
|
||||
sortOrder: i,
|
||||
});
|
||||
this.eventBus?.emit({
|
||||
type: 'page:updated' as const,
|
||||
timestamp: new Date(),
|
||||
payload: { pageId: p.pageId, initiativeId, title: p.title },
|
||||
});
|
||||
} catch (err) {
|
||||
log.warn({ agentId, pageId: p.pageId, err: err instanceof Error ? err.message : String(err) }, 'failed to update page');
|
||||
}
|
||||
}
|
||||
|
||||
if (entries.length > 0) {
|
||||
try {
|
||||
const cs = await this.changeSetRepository!.createWithEntries({
|
||||
agentId,
|
||||
agentName: agent.name,
|
||||
initiativeId,
|
||||
mode: 'refine',
|
||||
summary: summary?.body ?? `Updated ${entries.length} pages`,
|
||||
}, entries);
|
||||
this.eventBus?.emit({
|
||||
type: 'changeset:created' as const,
|
||||
timestamp: new Date(),
|
||||
payload: { changeSetId: cs.id, initiativeId, agentId, mode: 'refine', entryCount: entries.length },
|
||||
});
|
||||
} catch (err) {
|
||||
log.warn({ agentId, err: err instanceof Error ? err.message : String(err) }, 'failed to record change set after successful writes');
|
||||
}
|
||||
}
|
||||
resultMessage = summary?.body ?? `${entries.length} pages updated`;
|
||||
} else {
|
||||
resultMessage = JSON.stringify({ summary: summary?.body, proposals: pages });
|
||||
resultMessage = JSON.stringify({ summary: summary?.body, pages });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ export class ProcessManager {
|
||||
alias: string,
|
||||
initiativeId: string,
|
||||
baseBranch: string = 'main',
|
||||
branchName?: string,
|
||||
): Promise<string> {
|
||||
const projects = await this.projectRepository.findProjectsByInitiativeId(initiativeId);
|
||||
const agentWorkdir = this.getAgentWorkdir(alias);
|
||||
@@ -70,7 +71,7 @@ export class ProcessManager {
|
||||
for (const project of projects) {
|
||||
const clonePath = await ensureProjectClone(project, this.workspaceRoot);
|
||||
const worktreeManager = new SimpleGitWorktreeManager(clonePath, undefined, agentWorkdir);
|
||||
const worktree = await worktreeManager.create(project.name, `agent/${alias}`, baseBranch);
|
||||
const worktree = await worktreeManager.create(project.name, branchName ?? `agent/${alias}`, baseBranch);
|
||||
const worktreePath = worktree.path;
|
||||
const pathExists = existsSync(worktreePath);
|
||||
|
||||
|
||||
@@ -45,6 +45,12 @@ export interface SpawnAgentOptions {
|
||||
provider?: string;
|
||||
/** Initiative ID — when set, worktrees are created for all linked projects */
|
||||
initiativeId?: string;
|
||||
/** Phase ID — used by dispatch for branch-aware spawning */
|
||||
phaseId?: string;
|
||||
/** Base branch for worktree creation (defaults to 'main') */
|
||||
baseBranch?: string;
|
||||
/** Explicit branch name for worktree (overrides 'agent/<alias>') */
|
||||
branchName?: string;
|
||||
/** Context data to write as input files in agent workdir */
|
||||
inputContext?: AgentInputContext;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
DrizzlePageRepository,
|
||||
DrizzleProjectRepository,
|
||||
DrizzleAccountRepository,
|
||||
DrizzleProposalRepository,
|
||||
DrizzleChangeSetRepository,
|
||||
DrizzleLogChunkRepository,
|
||||
} from './db/index.js';
|
||||
import type { InitiativeRepository } from './db/repositories/initiative-repository.js';
|
||||
@@ -28,7 +28,7 @@ import type { AgentRepository } from './db/repositories/agent-repository.js';
|
||||
import type { PageRepository } from './db/repositories/page-repository.js';
|
||||
import type { ProjectRepository } from './db/repositories/project-repository.js';
|
||||
import type { AccountRepository } from './db/repositories/account-repository.js';
|
||||
import type { ProposalRepository } from './db/repositories/proposal-repository.js';
|
||||
import type { ChangeSetRepository } from './db/repositories/change-set-repository.js';
|
||||
import type { LogChunkRepository } from './db/repositories/log-chunk-repository.js';
|
||||
import type { EventBus } from './events/index.js';
|
||||
import { createEventBus } from './events/index.js';
|
||||
@@ -40,6 +40,10 @@ import type { AccountCredentialManager } from './agent/credentials/types.js';
|
||||
import { DefaultDispatchManager } from './dispatch/manager.js';
|
||||
import { DefaultPhaseDispatchManager } from './dispatch/phase-manager.js';
|
||||
import type { DispatchManager, PhaseDispatchManager } from './dispatch/types.js';
|
||||
import { SimpleGitBranchManager } from './git/simple-git-branch-manager.js';
|
||||
import type { BranchManager } from './git/branch-manager.js';
|
||||
import { ExecutionOrchestrator } from './execution/orchestrator.js';
|
||||
import { DefaultConflictResolutionService } from './coordination/conflict-resolution-service.js';
|
||||
import { findWorkspaceRoot } from './config/index.js';
|
||||
import { createModuleLogger } from './logger/index.js';
|
||||
import type { ServerContextDeps } from './server/index.js';
|
||||
@@ -60,7 +64,7 @@ export interface Repositories {
|
||||
pageRepository: PageRepository;
|
||||
projectRepository: ProjectRepository;
|
||||
accountRepository: AccountRepository;
|
||||
proposalRepository: ProposalRepository;
|
||||
changeSetRepository: ChangeSetRepository;
|
||||
logChunkRepository: LogChunkRepository;
|
||||
}
|
||||
|
||||
@@ -78,7 +82,7 @@ export function createRepositories(db: DrizzleDatabase): Repositories {
|
||||
pageRepository: new DrizzlePageRepository(db),
|
||||
projectRepository: new DrizzleProjectRepository(db),
|
||||
accountRepository: new DrizzleAccountRepository(db),
|
||||
proposalRepository: new DrizzleProposalRepository(db),
|
||||
changeSetRepository: new DrizzleChangeSetRepository(db),
|
||||
logChunkRepository: new DrizzleLogChunkRepository(db),
|
||||
};
|
||||
}
|
||||
@@ -100,6 +104,8 @@ export interface Container extends Repositories {
|
||||
agentManager: MultiProviderAgentManager;
|
||||
dispatchManager: DispatchManager;
|
||||
phaseDispatchManager: PhaseDispatchManager;
|
||||
branchManager: BranchManager;
|
||||
executionOrchestrator: ExecutionOrchestrator;
|
||||
|
||||
/** Extract the subset of deps that CoordinationServer needs. */
|
||||
toContextDeps(): ServerContextDeps;
|
||||
@@ -153,7 +159,10 @@ export async function createContainer(options?: ContainerOptions): Promise<Conta
|
||||
repos.accountRepository,
|
||||
eventBus,
|
||||
credentialManager,
|
||||
repos.proposalRepository,
|
||||
repos.changeSetRepository,
|
||||
repos.phaseRepository,
|
||||
repos.taskRepository,
|
||||
repos.pageRepository,
|
||||
repos.logChunkRepository,
|
||||
options?.debug ?? false,
|
||||
);
|
||||
@@ -163,6 +172,10 @@ export async function createContainer(options?: ContainerOptions): Promise<Conta
|
||||
await agentManager.reconcileAfterRestart();
|
||||
log.info('agent reconciliation complete');
|
||||
|
||||
// Branch manager
|
||||
const branchManager = new SimpleGitBranchManager();
|
||||
log.info('branch manager created');
|
||||
|
||||
// Dispatch managers
|
||||
const dispatchManager = new DefaultDispatchManager(
|
||||
repos.taskRepository,
|
||||
@@ -170,15 +183,43 @@ export async function createContainer(options?: ContainerOptions): Promise<Conta
|
||||
agentManager,
|
||||
eventBus,
|
||||
repos.initiativeRepository,
|
||||
repos.phaseRepository,
|
||||
);
|
||||
const phaseDispatchManager = new DefaultPhaseDispatchManager(
|
||||
repos.phaseRepository,
|
||||
repos.taskRepository,
|
||||
dispatchManager,
|
||||
eventBus,
|
||||
repos.initiativeRepository,
|
||||
repos.projectRepository,
|
||||
branchManager,
|
||||
workspaceRoot,
|
||||
);
|
||||
log.info('dispatch managers created');
|
||||
|
||||
// Conflict resolution service (for orchestrator)
|
||||
const conflictResolutionService = new DefaultConflictResolutionService(
|
||||
repos.taskRepository,
|
||||
repos.agentRepository,
|
||||
repos.messageRepository,
|
||||
eventBus,
|
||||
);
|
||||
|
||||
// Execution orchestrator
|
||||
const executionOrchestrator = new ExecutionOrchestrator(
|
||||
branchManager,
|
||||
repos.phaseRepository,
|
||||
repos.taskRepository,
|
||||
repos.initiativeRepository,
|
||||
repos.projectRepository,
|
||||
phaseDispatchManager,
|
||||
conflictResolutionService,
|
||||
eventBus,
|
||||
workspaceRoot,
|
||||
);
|
||||
executionOrchestrator.start();
|
||||
log.info('execution orchestrator started');
|
||||
|
||||
return {
|
||||
db,
|
||||
eventBus,
|
||||
@@ -189,6 +230,8 @@ export async function createContainer(options?: ContainerOptions): Promise<Conta
|
||||
agentManager,
|
||||
dispatchManager,
|
||||
phaseDispatchManager,
|
||||
branchManager,
|
||||
executionOrchestrator,
|
||||
...repos,
|
||||
|
||||
toContextDeps(): ServerContextDeps {
|
||||
@@ -197,6 +240,8 @@ export async function createContainer(options?: ContainerOptions): Promise<Conta
|
||||
credentialManager,
|
||||
dispatchManager,
|
||||
phaseDispatchManager,
|
||||
branchManager,
|
||||
executionOrchestrator,
|
||||
workspaceRoot,
|
||||
...repos,
|
||||
};
|
||||
|
||||
@@ -23,14 +23,23 @@ import type { MessageRepository } from '../db/repositories/message-repository.js
|
||||
* Service interface for handling merge conflicts.
|
||||
* This is the PORT - implementations are ADAPTERS.
|
||||
*/
|
||||
/**
|
||||
* Branch context for merge conflicts from the branch hierarchy.
|
||||
*/
|
||||
export interface MergeContext {
|
||||
sourceBranch: string;
|
||||
targetBranch: string;
|
||||
}
|
||||
|
||||
export interface ConflictResolutionService {
|
||||
/**
|
||||
* Handle a merge conflict by creating resolution task and notifying agent.
|
||||
*
|
||||
* @param taskId - ID of the task that conflicted
|
||||
* @param conflicts - List of conflicting file paths
|
||||
* @param mergeContext - Optional branch context for branch hierarchy merges
|
||||
*/
|
||||
handleConflict(taskId: string, conflicts: string[]): Promise<void>;
|
||||
handleConflict(taskId: string, conflicts: string[], mergeContext?: MergeContext): Promise<void>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -55,7 +64,7 @@ export class DefaultConflictResolutionService implements ConflictResolutionServi
|
||||
* Handle a merge conflict.
|
||||
* Creates a conflict-resolution task and notifies the agent via message.
|
||||
*/
|
||||
async handleConflict(taskId: string, conflicts: string[]): Promise<void> {
|
||||
async handleConflict(taskId: string, conflicts: string[], mergeContext?: MergeContext): Promise<void> {
|
||||
// Get original task for context
|
||||
const originalTask = await this.taskRepository.findById(taskId);
|
||||
if (!originalTask) {
|
||||
@@ -69,15 +78,28 @@ export class DefaultConflictResolutionService implements ConflictResolutionServi
|
||||
}
|
||||
|
||||
// Build conflict description
|
||||
const conflictDescription = [
|
||||
const descriptionLines = [
|
||||
'Merge conflicts detected. Resolve conflicts in the following files:',
|
||||
'',
|
||||
...conflicts.map((f) => `- ${f}`),
|
||||
'',
|
||||
`Original task: ${originalTask.name}`,
|
||||
'',
|
||||
'Instructions: Resolve merge conflicts in the listed files, then mark task complete.',
|
||||
].join('\n');
|
||||
];
|
||||
|
||||
if (mergeContext) {
|
||||
descriptionLines.push(
|
||||
`Resolve merge conflicts between branch "${mergeContext.sourceBranch}" and "${mergeContext.targetBranch}".`,
|
||||
`Run: git merge ${mergeContext.sourceBranch} --no-edit`,
|
||||
'Resolve all conflicts, then: git add . && git commit',
|
||||
);
|
||||
} else {
|
||||
descriptionLines.push(
|
||||
'Instructions: Resolve merge conflicts in the listed files, then mark task complete.',
|
||||
);
|
||||
}
|
||||
|
||||
const conflictDescription = descriptionLines.join('\n');
|
||||
|
||||
// Create new conflict-resolution task
|
||||
const conflictTask = await this.taskRepository.create({
|
||||
@@ -86,8 +108,9 @@ export class DefaultConflictResolutionService implements ConflictResolutionServi
|
||||
initiativeId: originalTask.initiativeId,
|
||||
name: `Resolve conflicts: ${originalTask.name}`,
|
||||
description: conflictDescription,
|
||||
category: mergeContext ? 'merge' : 'execute',
|
||||
type: 'auto',
|
||||
priority: 'high', // Conflicts should be resolved quickly
|
||||
priority: 'high',
|
||||
status: 'pending',
|
||||
order: originalTask.order + 1,
|
||||
});
|
||||
|
||||
36
src/db/repositories/change-set-repository.ts
Normal file
36
src/db/repositories/change-set-repository.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Change Set Repository Port Interface
|
||||
*
|
||||
* Port for ChangeSet aggregate operations.
|
||||
* Implementations (Drizzle, etc.) are adapters.
|
||||
*/
|
||||
|
||||
import type { ChangeSet, ChangeSetEntry } from '../schema.js';
|
||||
|
||||
export type CreateChangeSetData = {
|
||||
agentId: string | null;
|
||||
agentName: string;
|
||||
initiativeId: string;
|
||||
mode: 'breakdown' | 'decompose' | 'refine';
|
||||
summary?: string | null;
|
||||
};
|
||||
|
||||
export type CreateChangeSetEntryData = {
|
||||
entityType: 'page' | 'phase' | 'task' | 'phase_dependency';
|
||||
entityId: string;
|
||||
action: 'create' | 'update' | 'delete';
|
||||
previousState?: string | null;
|
||||
newState?: string | null;
|
||||
sortOrder?: number;
|
||||
};
|
||||
|
||||
export type ChangeSetWithEntries = ChangeSet & { entries: ChangeSetEntry[] };
|
||||
|
||||
export interface ChangeSetRepository {
|
||||
createWithEntries(data: CreateChangeSetData, entries: CreateChangeSetEntryData[]): Promise<ChangeSet>;
|
||||
findById(id: string): Promise<ChangeSet | null>;
|
||||
findByIdWithEntries(id: string): Promise<ChangeSetWithEntries | null>;
|
||||
findByInitiativeId(initiativeId: string): Promise<ChangeSet[]>;
|
||||
findByAgentId(agentId: string): Promise<ChangeSet[]>;
|
||||
markReverted(id: string): Promise<ChangeSet>;
|
||||
}
|
||||
110
src/db/repositories/drizzle/change-set.ts
Normal file
110
src/db/repositories/drizzle/change-set.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Drizzle Change Set Repository Adapter
|
||||
*
|
||||
* Implements ChangeSetRepository interface using Drizzle ORM.
|
||||
*/
|
||||
|
||||
import { eq, desc, asc } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { DrizzleDatabase } from '../../index.js';
|
||||
import { changeSets, changeSetEntries, type ChangeSet } from '../../schema.js';
|
||||
import type {
|
||||
ChangeSetRepository,
|
||||
CreateChangeSetData,
|
||||
CreateChangeSetEntryData,
|
||||
ChangeSetWithEntries,
|
||||
} from '../change-set-repository.js';
|
||||
|
||||
export class DrizzleChangeSetRepository implements ChangeSetRepository {
|
||||
constructor(private db: DrizzleDatabase) {}
|
||||
|
||||
async createWithEntries(data: CreateChangeSetData, entries: CreateChangeSetEntryData[]): Promise<ChangeSet> {
|
||||
const id = nanoid();
|
||||
const now = new Date();
|
||||
|
||||
// Use transaction for atomicity
|
||||
return this.db.transaction(async (tx) => {
|
||||
const [created] = await tx.insert(changeSets).values({
|
||||
id,
|
||||
agentId: data.agentId,
|
||||
agentName: data.agentName,
|
||||
initiativeId: data.initiativeId,
|
||||
mode: data.mode,
|
||||
summary: data.summary ?? null,
|
||||
status: 'applied',
|
||||
createdAt: now,
|
||||
}).returning();
|
||||
|
||||
if (entries.length > 0) {
|
||||
const entryRows = entries.map((e, i) => ({
|
||||
id: nanoid(),
|
||||
changeSetId: id,
|
||||
entityType: e.entityType,
|
||||
entityId: e.entityId,
|
||||
action: e.action,
|
||||
previousState: e.previousState ?? null,
|
||||
newState: e.newState ?? null,
|
||||
sortOrder: e.sortOrder ?? i,
|
||||
createdAt: now,
|
||||
}));
|
||||
|
||||
await tx.insert(changeSetEntries).values(entryRows);
|
||||
}
|
||||
|
||||
return created;
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<ChangeSet | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(changeSets)
|
||||
.where(eq(changeSets.id, id))
|
||||
.limit(1);
|
||||
|
||||
return result[0] ?? null;
|
||||
}
|
||||
|
||||
async findByIdWithEntries(id: string): Promise<ChangeSetWithEntries | null> {
|
||||
const cs = await this.findById(id);
|
||||
if (!cs) return null;
|
||||
|
||||
const entries = await this.db
|
||||
.select()
|
||||
.from(changeSetEntries)
|
||||
.where(eq(changeSetEntries.changeSetId, id))
|
||||
.orderBy(asc(changeSetEntries.sortOrder));
|
||||
|
||||
return { ...cs, entries };
|
||||
}
|
||||
|
||||
async findByInitiativeId(initiativeId: string): Promise<ChangeSet[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(changeSets)
|
||||
.where(eq(changeSets.initiativeId, initiativeId))
|
||||
.orderBy(desc(changeSets.createdAt));
|
||||
}
|
||||
|
||||
async findByAgentId(agentId: string): Promise<ChangeSet[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(changeSets)
|
||||
.where(eq(changeSets.agentId, agentId))
|
||||
.orderBy(desc(changeSets.createdAt));
|
||||
}
|
||||
|
||||
async markReverted(id: string): Promise<ChangeSet> {
|
||||
const [updated] = await this.db
|
||||
.update(changeSets)
|
||||
.set({ status: 'reverted', revertedAt: new Date() })
|
||||
.where(eq(changeSets.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
throw new Error(`ChangeSet not found: ${id}`);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
@@ -13,5 +13,5 @@ export { DrizzleMessageRepository } from './message.js';
|
||||
export { DrizzlePageRepository } from './page.js';
|
||||
export { DrizzleProjectRepository } from './project.js';
|
||||
export { DrizzleAccountRepository } from './account.js';
|
||||
export { DrizzleProposalRepository } from './proposal.js';
|
||||
export { DrizzleChangeSetRepository } from './change-set.js';
|
||||
export { DrizzleLogChunkRepository } from './log-chunk.js';
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
/**
|
||||
* Drizzle Proposal Repository Adapter
|
||||
*
|
||||
* Implements ProposalRepository interface using Drizzle ORM.
|
||||
*/
|
||||
|
||||
import { eq, and, count, asc } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { DrizzleDatabase } from '../../index.js';
|
||||
import { proposals, type Proposal } from '../../schema.js';
|
||||
import type {
|
||||
ProposalRepository,
|
||||
CreateProposalData,
|
||||
UpdateProposalData,
|
||||
} from '../proposal-repository.js';
|
||||
|
||||
export class DrizzleProposalRepository implements ProposalRepository {
|
||||
constructor(private db: DrizzleDatabase) {}
|
||||
|
||||
async create(data: CreateProposalData): Promise<Proposal> {
|
||||
const id = nanoid();
|
||||
const now = new Date();
|
||||
|
||||
const [created] = await this.db.insert(proposals).values({
|
||||
id,
|
||||
...data,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).returning();
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
async createMany(data: CreateProposalData[]): Promise<Proposal[]> {
|
||||
if (data.length === 0) return [];
|
||||
|
||||
const now = new Date();
|
||||
const rows = data.map((d) => ({
|
||||
id: nanoid(),
|
||||
...d,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}));
|
||||
|
||||
return this.db.insert(proposals).values(rows).returning();
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Proposal | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(proposals)
|
||||
.where(eq(proposals.id, id))
|
||||
.limit(1);
|
||||
|
||||
return result[0] ?? null;
|
||||
}
|
||||
|
||||
async findByAgentId(agentId: string): Promise<Proposal[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(proposals)
|
||||
.where(eq(proposals.agentId, agentId))
|
||||
.orderBy(asc(proposals.sortOrder));
|
||||
}
|
||||
|
||||
async findByInitiativeId(initiativeId: string): Promise<Proposal[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(proposals)
|
||||
.where(eq(proposals.initiativeId, initiativeId))
|
||||
.orderBy(asc(proposals.sortOrder));
|
||||
}
|
||||
|
||||
async findByAgentIdAndStatus(agentId: string, status: string): Promise<Proposal[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(proposals)
|
||||
.where(
|
||||
and(
|
||||
eq(proposals.agentId, agentId),
|
||||
eq(proposals.status, status as 'pending' | 'accepted' | 'dismissed'),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(proposals.sortOrder));
|
||||
}
|
||||
|
||||
async update(id: string, data: UpdateProposalData): Promise<Proposal> {
|
||||
const [updated] = await this.db
|
||||
.update(proposals)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(proposals.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
throw new Error(`Proposal not found: ${id}`);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async updateManyByAgentId(agentId: string, data: UpdateProposalData): Promise<void> {
|
||||
await this.db
|
||||
.update(proposals)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(proposals.agentId, agentId));
|
||||
}
|
||||
|
||||
async updateManyByAgentIdAndStatus(agentId: string, currentStatus: string, data: UpdateProposalData): Promise<void> {
|
||||
await this.db
|
||||
.update(proposals)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(proposals.agentId, agentId),
|
||||
eq(proposals.status, currentStatus as 'pending' | 'accepted' | 'dismissed'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async countByAgentIdAndStatus(agentId: string, status: string): Promise<number> {
|
||||
const result = await this.db
|
||||
.select({ count: count() })
|
||||
.from(proposals)
|
||||
.where(
|
||||
and(
|
||||
eq(proposals.agentId, agentId),
|
||||
eq(proposals.status, status as 'pending' | 'accepted' | 'dismissed'),
|
||||
),
|
||||
);
|
||||
|
||||
return result[0]?.count ?? 0;
|
||||
}
|
||||
}
|
||||
@@ -58,10 +58,11 @@ export type {
|
||||
} from './account-repository.js';
|
||||
|
||||
export type {
|
||||
ProposalRepository,
|
||||
CreateProposalData,
|
||||
UpdateProposalData,
|
||||
} from './proposal-repository.js';
|
||||
ChangeSetRepository,
|
||||
CreateChangeSetData,
|
||||
CreateChangeSetEntryData,
|
||||
ChangeSetWithEntries,
|
||||
} from './change-set-repository.js';
|
||||
|
||||
export type {
|
||||
LogChunkRepository,
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* Proposal Repository Port Interface
|
||||
*
|
||||
* Port for Proposal aggregate operations.
|
||||
* Implementations (Drizzle, etc.) are adapters.
|
||||
*/
|
||||
|
||||
import type { Proposal, NewProposal } from '../schema.js';
|
||||
|
||||
/**
|
||||
* Data for creating a new proposal.
|
||||
* Omits system-managed fields (id, createdAt, updatedAt).
|
||||
*/
|
||||
export type CreateProposalData = Omit<NewProposal, 'id' | 'createdAt' | 'updatedAt'>;
|
||||
|
||||
/**
|
||||
* Data for updating a proposal.
|
||||
*/
|
||||
export type UpdateProposalData = Partial<Pick<NewProposal, 'status'>>;
|
||||
|
||||
/**
|
||||
* Proposal Repository Port
|
||||
*/
|
||||
export interface ProposalRepository {
|
||||
create(data: CreateProposalData): Promise<Proposal>;
|
||||
createMany(data: CreateProposalData[]): Promise<Proposal[]>;
|
||||
findById(id: string): Promise<Proposal | null>;
|
||||
findByAgentId(agentId: string): Promise<Proposal[]>;
|
||||
findByInitiativeId(initiativeId: string): Promise<Proposal[]>;
|
||||
findByAgentIdAndStatus(agentId: string, status: string): Promise<Proposal[]>;
|
||||
update(id: string, data: UpdateProposalData): Promise<Proposal>;
|
||||
updateManyByAgentId(agentId: string, data: UpdateProposalData): Promise<void>;
|
||||
updateManyByAgentIdAndStatus(agentId: string, currentStatus: string, data: UpdateProposalData): Promise<void>;
|
||||
countByAgentIdAndStatus(agentId: string, status: string): Promise<number>;
|
||||
}
|
||||
@@ -26,6 +26,9 @@ export const initiatives = sqliteTable('initiatives', {
|
||||
.notNull()
|
||||
.default(true),
|
||||
mergeTarget: text('merge_target'), // Target branch for merges (e.g., 'feature/xyz')
|
||||
executionMode: text('execution_mode', { enum: ['yolo', 'review_per_phase'] })
|
||||
.notNull()
|
||||
.default('review_per_phase'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
});
|
||||
@@ -35,7 +38,7 @@ export const initiativesRelations = relations(initiatives, ({ many }) => ({
|
||||
pages: many(pages),
|
||||
initiativeProjects: many(initiativeProjects),
|
||||
tasks: many(tasks),
|
||||
proposals: many(proposals),
|
||||
changeSets: many(changeSets),
|
||||
}));
|
||||
|
||||
export type Initiative = InferSelectModel<typeof initiatives>;
|
||||
@@ -52,7 +55,7 @@ export const phases = sqliteTable('phases', {
|
||||
.references(() => initiatives.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
content: text('content'),
|
||||
status: text('status', { enum: ['pending', 'approved', 'in_progress', 'completed', 'blocked'] })
|
||||
status: text('status', { enum: ['pending', 'approved', 'in_progress', 'completed', 'blocked', 'pending_review'] })
|
||||
.notNull()
|
||||
.default('pending'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
@@ -286,51 +289,75 @@ export const agentsRelations = relations(agents, ({ one, many }) => ({
|
||||
fields: [agents.accountId],
|
||||
references: [accounts.id],
|
||||
}),
|
||||
proposals: many(proposals),
|
||||
changeSets: many(changeSets),
|
||||
}));
|
||||
|
||||
export type Agent = InferSelectModel<typeof agents>;
|
||||
export type NewAgent = InferInsertModel<typeof agents>;
|
||||
|
||||
// ============================================================================
|
||||
// PROPOSALS
|
||||
// CHANGE SETS
|
||||
// ============================================================================
|
||||
|
||||
export const proposals = sqliteTable('proposals', {
|
||||
export const changeSets = sqliteTable('change_sets', {
|
||||
id: text('id').primaryKey(),
|
||||
agentId: text('agent_id')
|
||||
.notNull()
|
||||
.references(() => agents.id, { onDelete: 'cascade' }),
|
||||
.references(() => agents.id, { onDelete: 'set null' }),
|
||||
agentName: text('agent_name').notNull(),
|
||||
initiativeId: text('initiative_id')
|
||||
.notNull()
|
||||
.references(() => initiatives.id, { onDelete: 'cascade' }),
|
||||
targetType: text('target_type', { enum: ['page', 'phase', 'task'] }).notNull(),
|
||||
targetId: text('target_id'), // existing entity ID (e.g. pageId for updates), null for creates
|
||||
title: text('title').notNull(),
|
||||
mode: text('mode', { enum: ['breakdown', 'decompose', 'refine'] }).notNull(),
|
||||
summary: text('summary'),
|
||||
content: text('content'), // markdown body (pages), description (phases/tasks)
|
||||
metadata: text('metadata'), // JSON: type-specific data (phase number, task category, deps)
|
||||
status: text('status', { enum: ['pending', 'accepted', 'dismissed'] })
|
||||
status: text('status', { enum: ['applied', 'reverted'] })
|
||||
.notNull()
|
||||
.default('pending'),
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
.default('applied'),
|
||||
revertedAt: integer('reverted_at', { mode: 'timestamp' }),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
});
|
||||
}, (table) => [
|
||||
index('change_sets_initiative_id_idx').on(table.initiativeId),
|
||||
]);
|
||||
|
||||
export const proposalsRelations = relations(proposals, ({ one }) => ({
|
||||
export const changeSetsRelations = relations(changeSets, ({ one, many }) => ({
|
||||
agent: one(agents, {
|
||||
fields: [proposals.agentId],
|
||||
fields: [changeSets.agentId],
|
||||
references: [agents.id],
|
||||
}),
|
||||
initiative: one(initiatives, {
|
||||
fields: [proposals.initiativeId],
|
||||
fields: [changeSets.initiativeId],
|
||||
references: [initiatives.id],
|
||||
}),
|
||||
entries: many(changeSetEntries),
|
||||
}));
|
||||
|
||||
export type ChangeSet = InferSelectModel<typeof changeSets>;
|
||||
export type NewChangeSet = InferInsertModel<typeof changeSets>;
|
||||
|
||||
export const changeSetEntries = sqliteTable('change_set_entries', {
|
||||
id: text('id').primaryKey(),
|
||||
changeSetId: text('change_set_id')
|
||||
.notNull()
|
||||
.references(() => changeSets.id, { onDelete: 'cascade' }),
|
||||
entityType: text('entity_type', { enum: ['page', 'phase', 'task', 'phase_dependency'] }).notNull(),
|
||||
entityId: text('entity_id').notNull(),
|
||||
action: text('action', { enum: ['create', 'update', 'delete'] }).notNull(),
|
||||
previousState: text('previous_state'), // JSON snapshot, null for creates
|
||||
newState: text('new_state'), // JSON snapshot, null for deletes
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
}, (table) => [
|
||||
index('change_set_entries_change_set_id_idx').on(table.changeSetId),
|
||||
]);
|
||||
|
||||
export const changeSetEntriesRelations = relations(changeSetEntries, ({ one }) => ({
|
||||
changeSet: one(changeSets, {
|
||||
fields: [changeSetEntries.changeSetId],
|
||||
references: [changeSets.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export type Proposal = InferSelectModel<typeof proposals>;
|
||||
export type NewProposal = InferInsertModel<typeof proposals>;
|
||||
export type ChangeSetEntry = InferSelectModel<typeof changeSetEntries>;
|
||||
export type NewChangeSetEntry = InferInsertModel<typeof changeSetEntries>;
|
||||
|
||||
// ============================================================================
|
||||
// MESSAGES
|
||||
|
||||
@@ -19,8 +19,10 @@ import type { AgentManager } from '../agent/types.js';
|
||||
import type { TaskRepository } from '../db/repositories/task-repository.js';
|
||||
import type { MessageRepository } from '../db/repositories/message-repository.js';
|
||||
import type { InitiativeRepository } from '../db/repositories/initiative-repository.js';
|
||||
import type { PhaseRepository } from '../db/repositories/phase-repository.js';
|
||||
import type { Task } from '../db/schema.js';
|
||||
import type { DispatchManager, QueuedTask, DispatchResult } from './types.js';
|
||||
import { initiativeBranchName, phaseBranchName, taskBranchName } from '../git/branch-naming.js';
|
||||
import { createModuleLogger } from '../logger/index.js';
|
||||
|
||||
const log = createModuleLogger('dispatch');
|
||||
@@ -59,7 +61,8 @@ export class DefaultDispatchManager implements DispatchManager {
|
||||
private messageRepository: MessageRepository,
|
||||
private agentManager: AgentManager,
|
||||
private eventBus: EventBus,
|
||||
private initiativeRepository?: InitiativeRepository
|
||||
private initiativeRepository?: InitiativeRepository,
|
||||
private phaseRepository?: PhaseRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -320,10 +323,39 @@ export class DefaultDispatchManager implements DispatchManager {
|
||||
};
|
||||
}
|
||||
|
||||
// Compute branch info for branch-aware spawning
|
||||
let baseBranch: string | undefined;
|
||||
let branchName: string | undefined;
|
||||
|
||||
if (task.phaseId && task.initiativeId && this.initiativeRepository && this.phaseRepository) {
|
||||
try {
|
||||
const initiative = await this.initiativeRepository.findById(task.initiativeId);
|
||||
const phase = await this.phaseRepository.findById(task.phaseId);
|
||||
if (initiative?.mergeTarget && phase) {
|
||||
const initBranch = initiativeBranchName(initiative.mergeTarget);
|
||||
|
||||
if (task.category === 'merge') {
|
||||
// Merge tasks work directly on the phase branch
|
||||
baseBranch = initBranch;
|
||||
branchName = phaseBranchName(initBranch, phase.name);
|
||||
} else {
|
||||
baseBranch = phaseBranchName(initBranch, phase.name);
|
||||
branchName = taskBranchName(initBranch, task.id);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal: fall back to default branching
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn agent with task (alias auto-generated by agent manager)
|
||||
const agent = await this.agentManager.spawn({
|
||||
taskId: nextTask.taskId,
|
||||
initiativeId: task.initiativeId ?? undefined,
|
||||
phaseId: task.phaseId ?? undefined,
|
||||
prompt: task.description || task.name,
|
||||
baseBranch,
|
||||
branchName,
|
||||
});
|
||||
|
||||
log.info({ taskId: nextTask.taskId, agentId: agent.id }, 'task dispatched');
|
||||
|
||||
@@ -16,7 +16,15 @@ import type {
|
||||
} from '../events/index.js';
|
||||
import type { PhaseRepository } from '../db/repositories/phase-repository.js';
|
||||
import type { TaskRepository } from '../db/repositories/task-repository.js';
|
||||
import type { InitiativeRepository } from '../db/repositories/initiative-repository.js';
|
||||
import type { ProjectRepository } from '../db/repositories/project-repository.js';
|
||||
import type { BranchManager } from '../git/branch-manager.js';
|
||||
import type { PhaseDispatchManager, DispatchManager, QueuedPhase, PhaseDispatchResult } from './types.js';
|
||||
import { initiativeBranchName, phaseBranchName } from '../git/branch-naming.js';
|
||||
import { ensureProjectClone } from '../git/project-clones.js';
|
||||
import { createModuleLogger } from '../logger/index.js';
|
||||
|
||||
const log = createModuleLogger('phase-dispatch');
|
||||
|
||||
// =============================================================================
|
||||
// Internal Types
|
||||
@@ -51,7 +59,11 @@ export class DefaultPhaseDispatchManager implements PhaseDispatchManager {
|
||||
private phaseRepository: PhaseRepository,
|
||||
private taskRepository: TaskRepository,
|
||||
private dispatchManager: DispatchManager,
|
||||
private eventBus: EventBus
|
||||
private eventBus: EventBus,
|
||||
private initiativeRepository?: InitiativeRepository,
|
||||
private projectRepository?: ProjectRepository,
|
||||
private branchManager?: BranchManager,
|
||||
private workspaceRoot?: string,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -156,6 +168,26 @@ export class DefaultPhaseDispatchManager implements PhaseDispatchManager {
|
||||
// Update phase status to 'in_progress'
|
||||
await this.phaseRepository.update(nextPhase.phaseId, { status: 'in_progress' });
|
||||
|
||||
// Create phase branch in all linked project clones
|
||||
if (this.initiativeRepository && this.projectRepository && this.branchManager && this.workspaceRoot) {
|
||||
try {
|
||||
const initiative = await this.initiativeRepository.findById(phase.initiativeId);
|
||||
if (initiative?.mergeTarget) {
|
||||
const initBranch = initiativeBranchName(initiative.mergeTarget);
|
||||
const phBranch = phaseBranchName(initBranch, phase.name);
|
||||
const projects = await this.projectRepository.findProjectsByInitiativeId(phase.initiativeId);
|
||||
for (const project of projects) {
|
||||
const clonePath = await ensureProjectClone(project, this.workspaceRoot);
|
||||
await this.branchManager.ensureBranch(clonePath, initBranch, 'main');
|
||||
await this.branchManager.ensureBranch(clonePath, phBranch, initBranch);
|
||||
}
|
||||
log.info({ phaseId: nextPhase.phaseId, phBranch, initBranch }, 'phase branch created');
|
||||
}
|
||||
} catch (err) {
|
||||
log.error({ phaseId: nextPhase.phaseId, err: err instanceof Error ? err.message : String(err) }, 'failed to create phase branch');
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from queue (now being worked on)
|
||||
this.phaseQueue.delete(nextPhase.phaseId);
|
||||
|
||||
|
||||
@@ -37,6 +37,9 @@ export type {
|
||||
PhaseStartedEvent,
|
||||
PhaseCompletedEvent,
|
||||
PhaseBlockedEvent,
|
||||
PhasePendingReviewEvent,
|
||||
PhaseMergedEvent,
|
||||
TaskMergedEvent,
|
||||
MergeQueuedEvent,
|
||||
MergeStartedEvent,
|
||||
MergeCompletedEvent,
|
||||
|
||||
@@ -320,6 +320,34 @@ export interface PhaseBlockedEvent extends DomainEvent {
|
||||
};
|
||||
}
|
||||
|
||||
export interface PhasePendingReviewEvent extends DomainEvent {
|
||||
type: 'phase:pending_review';
|
||||
payload: {
|
||||
phaseId: string;
|
||||
initiativeId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PhaseMergedEvent extends DomainEvent {
|
||||
type: 'phase:merged';
|
||||
payload: {
|
||||
phaseId: string;
|
||||
initiativeId: string;
|
||||
sourceBranch: string;
|
||||
targetBranch: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TaskMergedEvent extends DomainEvent {
|
||||
type: 'task:merged';
|
||||
payload: {
|
||||
taskId: string;
|
||||
phaseId: string;
|
||||
sourceBranch: string;
|
||||
targetBranch: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge Coordination Events
|
||||
*/
|
||||
@@ -395,6 +423,29 @@ export interface PageDeletedEvent extends DomainEvent {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Change Set Events
|
||||
*/
|
||||
|
||||
export interface ChangeSetCreatedEvent extends DomainEvent {
|
||||
type: 'changeset:created';
|
||||
payload: {
|
||||
changeSetId: string;
|
||||
initiativeId: string;
|
||||
agentId: string;
|
||||
mode: string;
|
||||
entryCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ChangeSetRevertedEvent extends DomainEvent {
|
||||
type: 'changeset:reverted';
|
||||
payload: {
|
||||
changeSetId: string;
|
||||
initiativeId: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Account Credential Events
|
||||
*/
|
||||
@@ -458,6 +509,9 @@ export type DomainEventMap =
|
||||
| PhaseStartedEvent
|
||||
| PhaseCompletedEvent
|
||||
| PhaseBlockedEvent
|
||||
| PhasePendingReviewEvent
|
||||
| PhaseMergedEvent
|
||||
| TaskMergedEvent
|
||||
| MergeQueuedEvent
|
||||
| MergeStartedEvent
|
||||
| MergeCompletedEvent
|
||||
@@ -465,6 +519,8 @@ export type DomainEventMap =
|
||||
| PageCreatedEvent
|
||||
| PageUpdatedEvent
|
||||
| PageDeletedEvent
|
||||
| ChangeSetCreatedEvent
|
||||
| ChangeSetRevertedEvent
|
||||
| AccountCredentialsRefreshedEvent
|
||||
| AccountCredentialsExpiredEvent
|
||||
| AccountCredentialsValidatedEvent;
|
||||
|
||||
226
src/execution/orchestrator.ts
Normal file
226
src/execution/orchestrator.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Execution Orchestrator
|
||||
*
|
||||
* Subscribes to task:completed events and orchestrates the post-completion
|
||||
* merge workflow:
|
||||
* - Task branch → Phase branch (on task completion)
|
||||
* - Phase branch → Initiative branch (when all phase tasks done)
|
||||
*
|
||||
* Supports two execution modes:
|
||||
* - YOLO: auto-merge everything
|
||||
* - Review per-phase: pause after each phase for diff review
|
||||
*/
|
||||
|
||||
import type { EventBus, TaskCompletedEvent, PhasePendingReviewEvent, PhaseMergedEvent, TaskMergedEvent } from '../events/index.js';
|
||||
import type { BranchManager } from '../git/branch-manager.js';
|
||||
import type { PhaseRepository } from '../db/repositories/phase-repository.js';
|
||||
import type { TaskRepository } from '../db/repositories/task-repository.js';
|
||||
import type { InitiativeRepository } from '../db/repositories/initiative-repository.js';
|
||||
import type { ProjectRepository } from '../db/repositories/project-repository.js';
|
||||
import type { PhaseDispatchManager } from '../dispatch/types.js';
|
||||
import type { ConflictResolutionService } from '../coordination/conflict-resolution-service.js';
|
||||
import { initiativeBranchName, phaseBranchName, taskBranchName } from '../git/branch-naming.js';
|
||||
import { ensureProjectClone } from '../git/project-clones.js';
|
||||
import { createModuleLogger } from '../logger/index.js';
|
||||
|
||||
const log = createModuleLogger('execution-orchestrator');
|
||||
|
||||
export class ExecutionOrchestrator {
|
||||
/** Serialize merges per phase to avoid concurrent merge conflicts */
|
||||
private phaseMergeLocks: Map<string, Promise<void>> = new Map();
|
||||
|
||||
constructor(
|
||||
private branchManager: BranchManager,
|
||||
private phaseRepository: PhaseRepository,
|
||||
private taskRepository: TaskRepository,
|
||||
private initiativeRepository: InitiativeRepository,
|
||||
private projectRepository: ProjectRepository,
|
||||
private phaseDispatchManager: PhaseDispatchManager,
|
||||
private conflictResolutionService: ConflictResolutionService,
|
||||
private eventBus: EventBus,
|
||||
private workspaceRoot: string,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Start listening for task completion events.
|
||||
*/
|
||||
start(): void {
|
||||
this.eventBus.on<TaskCompletedEvent>('task:completed', (event) => {
|
||||
this.handleTaskCompleted(event).catch((err) => {
|
||||
log.error({ err: err instanceof Error ? err.message : String(err) }, 'error handling task:completed');
|
||||
});
|
||||
});
|
||||
log.info('execution orchestrator started');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a task:completed event.
|
||||
* Merges the task branch into the phase branch, then checks if all phase tasks are done.
|
||||
*/
|
||||
private async handleTaskCompleted(event: TaskCompletedEvent): Promise<void> {
|
||||
const { taskId } = event.payload;
|
||||
|
||||
const task = await this.taskRepository.findById(taskId);
|
||||
if (!task?.phaseId || !task.initiativeId) return;
|
||||
|
||||
const initiative = await this.initiativeRepository.findById(task.initiativeId);
|
||||
if (!initiative?.mergeTarget) return;
|
||||
|
||||
const phase = await this.phaseRepository.findById(task.phaseId);
|
||||
if (!phase) return;
|
||||
|
||||
// Skip merge tasks — they already work on the phase branch directly
|
||||
if (task.category === 'merge') return;
|
||||
|
||||
const initBranch = initiativeBranchName(initiative.mergeTarget);
|
||||
const phBranch = phaseBranchName(initBranch, phase.name);
|
||||
const tBranch = taskBranchName(initBranch, task.id);
|
||||
|
||||
// Serialize merges per phase
|
||||
const lock = this.phaseMergeLocks.get(task.phaseId) ?? Promise.resolve();
|
||||
const mergeOp = lock.then(async () => {
|
||||
await this.mergeTaskIntoPhase(taskId, task.phaseId!, tBranch, phBranch);
|
||||
});
|
||||
this.phaseMergeLocks.set(task.phaseId, mergeOp.catch(() => {}));
|
||||
await mergeOp;
|
||||
|
||||
// Check if all phase tasks are done
|
||||
const phaseTasks = await this.taskRepository.findByPhaseId(task.phaseId);
|
||||
const allDone = phaseTasks.every((t) => t.status === 'completed');
|
||||
if (allDone) {
|
||||
await this.handlePhaseAllTasksDone(task.phaseId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a task branch into the phase branch for all linked projects.
|
||||
*/
|
||||
private async mergeTaskIntoPhase(
|
||||
taskId: string,
|
||||
phaseId: string,
|
||||
taskBranch: string,
|
||||
phaseBranch: string,
|
||||
): Promise<void> {
|
||||
const phase = await this.phaseRepository.findById(phaseId);
|
||||
if (!phase) return;
|
||||
|
||||
const projects = await this.projectRepository.findProjectsByInitiativeId(phase.initiativeId);
|
||||
|
||||
for (const project of projects) {
|
||||
const clonePath = await ensureProjectClone(project, this.workspaceRoot);
|
||||
|
||||
// Only merge if the task branch actually exists (agents may not have pushed)
|
||||
const exists = await this.branchManager.branchExists(clonePath, taskBranch);
|
||||
if (!exists) {
|
||||
log.debug({ taskId, taskBranch, project: project.name }, 'task branch does not exist, skipping merge');
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = await this.branchManager.mergeBranch(clonePath, taskBranch, phaseBranch);
|
||||
|
||||
if (!result.success && result.conflicts) {
|
||||
log.warn({ taskId, taskBranch, phaseBranch, conflicts: result.conflicts }, 'task merge conflict');
|
||||
await this.conflictResolutionService.handleConflict(taskId, result.conflicts, {
|
||||
sourceBranch: taskBranch,
|
||||
targetBranch: phaseBranch,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
log.info({ taskId, taskBranch, phaseBranch, project: project.name }, 'task branch merged into phase branch');
|
||||
}
|
||||
|
||||
// Emit task:merged event
|
||||
const mergedEvent: TaskMergedEvent = {
|
||||
type: 'task:merged',
|
||||
timestamp: new Date(),
|
||||
payload: { taskId, phaseId, sourceBranch: taskBranch, targetBranch: phaseBranch },
|
||||
};
|
||||
this.eventBus.emit(mergedEvent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle all tasks in a phase being done.
|
||||
* YOLO mode: auto-merge phase → initiative.
|
||||
* Review mode: set phase to pending_review.
|
||||
*/
|
||||
private async handlePhaseAllTasksDone(phaseId: string): Promise<void> {
|
||||
const phase = await this.phaseRepository.findById(phaseId);
|
||||
if (!phase) return;
|
||||
|
||||
const initiative = await this.initiativeRepository.findById(phase.initiativeId);
|
||||
if (!initiative?.mergeTarget) return;
|
||||
|
||||
if (initiative.executionMode === 'yolo') {
|
||||
await this.mergePhaseIntoInitiative(phaseId);
|
||||
await this.phaseDispatchManager.completePhase(phaseId);
|
||||
await this.phaseDispatchManager.dispatchNextPhase();
|
||||
} else {
|
||||
// review_per_phase
|
||||
await this.phaseRepository.update(phaseId, { status: 'pending_review' as any });
|
||||
|
||||
const event: PhasePendingReviewEvent = {
|
||||
type: 'phase:pending_review',
|
||||
timestamp: new Date(),
|
||||
payload: { phaseId, initiativeId: phase.initiativeId },
|
||||
};
|
||||
this.eventBus.emit(event);
|
||||
|
||||
log.info({ phaseId, initiativeId: phase.initiativeId }, 'phase pending review');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge phase branch into initiative branch for all linked projects.
|
||||
*/
|
||||
private async mergePhaseIntoInitiative(phaseId: string): Promise<void> {
|
||||
const phase = await this.phaseRepository.findById(phaseId);
|
||||
if (!phase) return;
|
||||
|
||||
const initiative = await this.initiativeRepository.findById(phase.initiativeId);
|
||||
if (!initiative?.mergeTarget) return;
|
||||
|
||||
const initBranch = initiativeBranchName(initiative.mergeTarget);
|
||||
const phBranch = phaseBranchName(initBranch, phase.name);
|
||||
|
||||
const projects = await this.projectRepository.findProjectsByInitiativeId(phase.initiativeId);
|
||||
|
||||
for (const project of projects) {
|
||||
const clonePath = await ensureProjectClone(project, this.workspaceRoot);
|
||||
const result = await this.branchManager.mergeBranch(clonePath, phBranch, initBranch);
|
||||
|
||||
if (!result.success) {
|
||||
log.error({ phaseId, phBranch, initBranch, conflicts: result.conflicts }, 'phase merge conflict');
|
||||
throw new Error(`Phase merge conflict: ${result.message}`);
|
||||
}
|
||||
|
||||
log.info({ phaseId, phBranch, initBranch, project: project.name }, 'phase branch merged into initiative branch');
|
||||
}
|
||||
|
||||
// Emit phase:merged event
|
||||
const mergedEvent: PhaseMergedEvent = {
|
||||
type: 'phase:merged',
|
||||
timestamp: new Date(),
|
||||
payload: { phaseId, initiativeId: phase.initiativeId, sourceBranch: phBranch, targetBranch: initBranch },
|
||||
};
|
||||
this.eventBus.emit(mergedEvent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve and merge a phase that's pending review.
|
||||
* Called from tRPC when user clicks approve in ReviewTab.
|
||||
*/
|
||||
async approveAndMergePhase(phaseId: string): Promise<void> {
|
||||
const phase = await this.phaseRepository.findById(phaseId);
|
||||
if (!phase) throw new Error(`Phase not found: ${phaseId}`);
|
||||
if (phase.status !== 'pending_review') {
|
||||
throw new Error(`Phase ${phaseId} is not pending review (status: ${phase.status})`);
|
||||
}
|
||||
|
||||
await this.mergePhaseIntoInitiative(phaseId);
|
||||
await this.phaseDispatchManager.completePhase(phaseId);
|
||||
await this.phaseDispatchManager.dispatchNextPhase();
|
||||
|
||||
log.info({ phaseId }, 'phase review approved and merged');
|
||||
}
|
||||
}
|
||||
41
src/git/branch-manager.ts
Normal file
41
src/git/branch-manager.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* BranchManager Port Interface
|
||||
*
|
||||
* Manages branch-level git operations (create, merge, diff, delete)
|
||||
* across project clones. Works directly on branches without requiring
|
||||
* a worktree to be checked out.
|
||||
*/
|
||||
|
||||
import type { MergeResult } from './types.js';
|
||||
|
||||
export interface BranchManager {
|
||||
/**
|
||||
* Ensure a branch exists. Creates it from baseBranch if it doesn't.
|
||||
* Idempotent — no-op if the branch already exists.
|
||||
*/
|
||||
ensureBranch(repoPath: string, branch: string, baseBranch: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Merge sourceBranch into targetBranch.
|
||||
* Uses an ephemeral worktree for merge safety.
|
||||
* Returns conflict info if merge fails.
|
||||
*/
|
||||
mergeBranch(repoPath: string, sourceBranch: string, targetBranch: string): Promise<MergeResult>;
|
||||
|
||||
/**
|
||||
* Get the raw unified diff between two branches.
|
||||
* Uses three-dot diff (baseBranch...headBranch) to show changes
|
||||
* introduced by headBranch since it diverged from baseBranch.
|
||||
*/
|
||||
diffBranches(repoPath: string, baseBranch: string, headBranch: string): Promise<string>;
|
||||
|
||||
/**
|
||||
* Delete a branch. No-op if the branch doesn't exist.
|
||||
*/
|
||||
deleteBranch(repoPath: string, branch: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Check if a branch exists in the repository.
|
||||
*/
|
||||
branchExists(repoPath: string, branch: string): Promise<boolean>;
|
||||
}
|
||||
42
src/git/branch-naming.ts
Normal file
42
src/git/branch-naming.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Branch Naming Utility
|
||||
*
|
||||
* Pure functions for computing deterministic branch names
|
||||
* in the initiative → phase → task branch hierarchy.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Convert a name to a URL/branch-safe slug.
|
||||
* Lowercase, replace non-alphanumeric runs with single hyphens, trim hyphens.
|
||||
*/
|
||||
export function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the initiative branch name.
|
||||
* Returns the mergeTarget as-is (it's already a branch name), or 'main' if unset.
|
||||
*/
|
||||
export function initiativeBranchName(mergeTarget: string | null): string {
|
||||
return mergeTarget ?? 'main';
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a phase branch name.
|
||||
* Format: `<initiativeBranch>-phase-<slugified-phase-name>`
|
||||
*/
|
||||
export function phaseBranchName(initiativeBranch: string, phaseName: string): string {
|
||||
return `${initiativeBranch}-phase-${slugify(phaseName)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a task branch name.
|
||||
* Format: `<initiativeBranch>-task-<taskId>`
|
||||
* Uses the raw task ID (already unique) to avoid collisions.
|
||||
*/
|
||||
export function taskBranchName(initiativeBranch: string, taskId: string): string {
|
||||
return `${initiativeBranch}-task-${taskId}`;
|
||||
}
|
||||
107
src/git/simple-git-branch-manager.ts
Normal file
107
src/git/simple-git-branch-manager.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* SimpleGit BranchManager Adapter
|
||||
*
|
||||
* Implementation of BranchManager port using simple-git.
|
||||
* Performs branch-level operations (create, merge, diff, delete)
|
||||
* on project clones without requiring a worktree.
|
||||
*/
|
||||
|
||||
import { join } from 'node:path';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { simpleGit } from 'simple-git';
|
||||
import type { BranchManager } from './branch-manager.js';
|
||||
import type { MergeResult } from './types.js';
|
||||
import { createModuleLogger } from '../logger/index.js';
|
||||
|
||||
const log = createModuleLogger('branch-manager');
|
||||
|
||||
export class SimpleGitBranchManager implements BranchManager {
|
||||
async ensureBranch(repoPath: string, branch: string, baseBranch: string): Promise<void> {
|
||||
const git = simpleGit(repoPath);
|
||||
|
||||
const exists = await this.branchExists(repoPath, branch);
|
||||
if (exists) {
|
||||
log.debug({ repoPath, branch }, 'branch already exists');
|
||||
return;
|
||||
}
|
||||
|
||||
await git.branch([branch, baseBranch]);
|
||||
log.info({ repoPath, branch, baseBranch }, 'branch created');
|
||||
}
|
||||
|
||||
async mergeBranch(repoPath: string, sourceBranch: string, targetBranch: string): Promise<MergeResult> {
|
||||
// Use an ephemeral worktree for merge safety
|
||||
const tmpPath = mkdtempSync(join(tmpdir(), 'cw-merge-'));
|
||||
const repoGit = simpleGit(repoPath);
|
||||
|
||||
try {
|
||||
// Create ephemeral worktree on target branch
|
||||
await repoGit.raw(['worktree', 'add', tmpPath, targetBranch]);
|
||||
|
||||
const wtGit = simpleGit(tmpPath);
|
||||
|
||||
try {
|
||||
await wtGit.merge([sourceBranch, '--no-edit']);
|
||||
|
||||
log.info({ repoPath, sourceBranch, targetBranch }, 'merge completed cleanly');
|
||||
return { success: true, message: `Merged ${sourceBranch} into ${targetBranch}` };
|
||||
} catch (mergeErr) {
|
||||
// Check for merge conflicts
|
||||
const status = await wtGit.status();
|
||||
const conflicts = status.conflicted;
|
||||
|
||||
if (conflicts.length > 0) {
|
||||
log.warn({ repoPath, sourceBranch, targetBranch, conflicts }, 'merge conflicts detected');
|
||||
await wtGit.merge(['--abort']);
|
||||
return {
|
||||
success: false,
|
||||
conflicts,
|
||||
message: `Merge conflicts in ${conflicts.length} file(s)`,
|
||||
};
|
||||
}
|
||||
|
||||
// Non-conflict merge failure
|
||||
throw mergeErr;
|
||||
}
|
||||
} finally {
|
||||
// Clean up ephemeral worktree
|
||||
try {
|
||||
await repoGit.raw(['worktree', 'remove', tmpPath, '--force']);
|
||||
} catch {
|
||||
// Best-effort cleanup — force-remove the directory if worktree remove fails
|
||||
try { rmSync(tmpPath, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
try { await repoGit.raw(['worktree', 'prune']); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async diffBranches(repoPath: string, baseBranch: string, headBranch: string): Promise<string> {
|
||||
const git = simpleGit(repoPath);
|
||||
const diff = await git.diff([`${baseBranch}...${headBranch}`]);
|
||||
return diff;
|
||||
}
|
||||
|
||||
async deleteBranch(repoPath: string, branch: string): Promise<void> {
|
||||
const git = simpleGit(repoPath);
|
||||
const exists = await this.branchExists(repoPath, branch);
|
||||
if (!exists) return;
|
||||
|
||||
try {
|
||||
await git.deleteLocalBranch(branch, true);
|
||||
log.info({ repoPath, branch }, 'branch deleted');
|
||||
} catch (err) {
|
||||
log.warn({ repoPath, branch, err: err instanceof Error ? err.message : String(err) }, 'failed to delete branch');
|
||||
}
|
||||
}
|
||||
|
||||
async branchExists(repoPath: string, branch: string): Promise<boolean> {
|
||||
const git = simpleGit(repoPath);
|
||||
try {
|
||||
const branches = await git.branchLocal();
|
||||
return branches.all.includes(branch);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,11 +17,13 @@ import type { PhaseRepository } from '../db/repositories/phase-repository.js';
|
||||
import type { PageRepository } from '../db/repositories/page-repository.js';
|
||||
import type { ProjectRepository } from '../db/repositories/project-repository.js';
|
||||
import type { AccountRepository } from '../db/repositories/account-repository.js';
|
||||
import type { ProposalRepository } from '../db/repositories/proposal-repository.js';
|
||||
import type { ChangeSetRepository } from '../db/repositories/change-set-repository.js';
|
||||
import type { LogChunkRepository } from '../db/repositories/log-chunk-repository.js';
|
||||
import type { AccountCredentialManager } from '../agent/credentials/types.js';
|
||||
import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js';
|
||||
import type { CoordinationManager } from '../coordination/types.js';
|
||||
import type { BranchManager } from '../git/branch-manager.js';
|
||||
import type { ExecutionOrchestrator } from '../execution/orchestrator.js';
|
||||
|
||||
/**
|
||||
* Options for creating the tRPC request handler.
|
||||
@@ -55,12 +57,16 @@ export interface TrpcAdapterOptions {
|
||||
projectRepository?: ProjectRepository;
|
||||
/** Account repository for account CRUD and load balancing */
|
||||
accountRepository?: AccountRepository;
|
||||
/** Proposal repository for agent proposal CRUD operations */
|
||||
proposalRepository?: ProposalRepository;
|
||||
/** Change set repository for agent change set operations */
|
||||
changeSetRepository?: ChangeSetRepository;
|
||||
/** Log chunk repository for agent output persistence */
|
||||
logChunkRepository?: LogChunkRepository;
|
||||
/** Credential manager for account OAuth token management */
|
||||
credentialManager?: AccountCredentialManager;
|
||||
/** Branch manager for git branch operations */
|
||||
branchManager?: BranchManager;
|
||||
/** Execution orchestrator for phase merge/review workflow */
|
||||
executionOrchestrator?: ExecutionOrchestrator;
|
||||
/** Absolute path to the workspace root (.cwrc directory) */
|
||||
workspaceRoot?: string;
|
||||
}
|
||||
@@ -135,9 +141,11 @@ export function createTrpcHandler(options: TrpcAdapterOptions) {
|
||||
pageRepository: options.pageRepository,
|
||||
projectRepository: options.projectRepository,
|
||||
accountRepository: options.accountRepository,
|
||||
proposalRepository: options.proposalRepository,
|
||||
changeSetRepository: options.changeSetRepository,
|
||||
logChunkRepository: options.logChunkRepository,
|
||||
credentialManager: options.credentialManager,
|
||||
branchManager: options.branchManager,
|
||||
executionOrchestrator: options.executionOrchestrator,
|
||||
workspaceRoot: options.workspaceRoot,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -12,7 +12,6 @@ import { tmpdir } from 'node:os';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { OutputHandler } from '../../agent/output-handler.js';
|
||||
import type { AgentRepository } from '../../db/repositories/agent-repository.js';
|
||||
import type { ProposalRepository } from '../../db/repositories/proposal-repository.js';
|
||||
|
||||
interface TestAgent {
|
||||
id: string;
|
||||
@@ -40,7 +39,6 @@ describe('Crash marking race condition', () => {
|
||||
let testAgent: TestAgent;
|
||||
let testDir: string;
|
||||
let mockRepo: AgentRepository;
|
||||
let mockProposalRepo: ProposalRepository;
|
||||
|
||||
// Track all repository calls
|
||||
let updateCalls: Array<{ id: string; data: any }> = [];
|
||||
@@ -99,9 +97,7 @@ describe('Crash marking race condition', () => {
|
||||
async delete() { throw new Error('Not implemented'); }
|
||||
};
|
||||
|
||||
mockProposalRepo = {} as any; // Not used in this test
|
||||
|
||||
outputHandler = new OutputHandler(mockRepo, undefined, mockProposalRepo);
|
||||
outputHandler = new OutputHandler(mockRepo);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
||||
@@ -14,11 +14,13 @@ import type { PhaseRepository } from '../db/repositories/phase-repository.js';
|
||||
import type { PageRepository } from '../db/repositories/page-repository.js';
|
||||
import type { ProjectRepository } from '../db/repositories/project-repository.js';
|
||||
import type { AccountRepository } from '../db/repositories/account-repository.js';
|
||||
import type { ProposalRepository } from '../db/repositories/proposal-repository.js';
|
||||
import type { ChangeSetRepository } from '../db/repositories/change-set-repository.js';
|
||||
import type { LogChunkRepository } from '../db/repositories/log-chunk-repository.js';
|
||||
import type { AccountCredentialManager } from '../agent/credentials/types.js';
|
||||
import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js';
|
||||
import type { CoordinationManager } from '../coordination/types.js';
|
||||
import type { BranchManager } from '../git/branch-manager.js';
|
||||
import type { ExecutionOrchestrator } from '../execution/orchestrator.js';
|
||||
|
||||
// Re-export for convenience
|
||||
export type { EventBus, DomainEvent };
|
||||
@@ -55,12 +57,16 @@ export interface TRPCContext {
|
||||
projectRepository?: ProjectRepository;
|
||||
/** Account repository for account CRUD and load balancing */
|
||||
accountRepository?: AccountRepository;
|
||||
/** Proposal repository for agent proposal CRUD operations */
|
||||
proposalRepository?: ProposalRepository;
|
||||
/** Change set repository for agent change set operations */
|
||||
changeSetRepository?: ChangeSetRepository;
|
||||
/** Log chunk repository for agent output persistence */
|
||||
logChunkRepository?: LogChunkRepository;
|
||||
/** Credential manager for account OAuth token management */
|
||||
credentialManager?: AccountCredentialManager;
|
||||
/** Branch manager for git branch operations */
|
||||
branchManager?: BranchManager;
|
||||
/** Execution orchestrator for phase merge/review workflow */
|
||||
executionOrchestrator?: ExecutionOrchestrator;
|
||||
/** Absolute path to the workspace root (.cwrc directory) */
|
||||
workspaceRoot?: string;
|
||||
}
|
||||
@@ -83,9 +89,11 @@ export interface CreateContextOptions {
|
||||
pageRepository?: PageRepository;
|
||||
projectRepository?: ProjectRepository;
|
||||
accountRepository?: AccountRepository;
|
||||
proposalRepository?: ProposalRepository;
|
||||
changeSetRepository?: ChangeSetRepository;
|
||||
logChunkRepository?: LogChunkRepository;
|
||||
credentialManager?: AccountCredentialManager;
|
||||
branchManager?: BranchManager;
|
||||
executionOrchestrator?: ExecutionOrchestrator;
|
||||
workspaceRoot?: string;
|
||||
}
|
||||
|
||||
@@ -111,9 +119,11 @@ export function createContext(options: CreateContextOptions): TRPCContext {
|
||||
pageRepository: options.pageRepository,
|
||||
projectRepository: options.projectRepository,
|
||||
accountRepository: options.accountRepository,
|
||||
proposalRepository: options.proposalRepository,
|
||||
changeSetRepository: options.changeSetRepository,
|
||||
logChunkRepository: options.logChunkRepository,
|
||||
credentialManager: options.credentialManager,
|
||||
branchManager: options.branchManager,
|
||||
executionOrchestrator: options.executionOrchestrator,
|
||||
workspaceRoot: options.workspaceRoot,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import { architectProcedures } from './routers/architect.js';
|
||||
import { projectProcedures } from './routers/project.js';
|
||||
import { pageProcedures } from './routers/page.js';
|
||||
import { accountProcedures } from './routers/account.js';
|
||||
import { proposalProcedures } from './routers/proposal.js';
|
||||
import { changeSetProcedures } from './routers/change-set.js';
|
||||
import { subscriptionProcedures } from './routers/subscription.js';
|
||||
|
||||
// Re-export tRPC primitives (preserves existing import paths)
|
||||
@@ -55,7 +55,7 @@ export const appRouter = router({
|
||||
...projectProcedures(publicProcedure),
|
||||
...pageProcedures(publicProcedure),
|
||||
...accountProcedures(publicProcedure),
|
||||
...proposalProcedures(publicProcedure),
|
||||
...changeSetProcedures(publicProcedure),
|
||||
...subscriptionProcedures(publicProcedure),
|
||||
});
|
||||
|
||||
|
||||
@@ -14,10 +14,12 @@ import type { PhaseRepository } from '../../db/repositories/phase-repository.js'
|
||||
import type { PageRepository } from '../../db/repositories/page-repository.js';
|
||||
import type { ProjectRepository } from '../../db/repositories/project-repository.js';
|
||||
import type { AccountRepository } from '../../db/repositories/account-repository.js';
|
||||
import type { ProposalRepository } from '../../db/repositories/proposal-repository.js';
|
||||
import type { ChangeSetRepository } from '../../db/repositories/change-set-repository.js';
|
||||
import type { LogChunkRepository } from '../../db/repositories/log-chunk-repository.js';
|
||||
import type { DispatchManager, PhaseDispatchManager } from '../../dispatch/types.js';
|
||||
import type { CoordinationManager } from '../../coordination/types.js';
|
||||
import type { BranchManager } from '../../git/branch-manager.js';
|
||||
import type { ExecutionOrchestrator } from '../../execution/orchestrator.js';
|
||||
|
||||
export function requireAgentManager(ctx: TRPCContext) {
|
||||
if (!ctx.agentManager) {
|
||||
@@ -129,14 +131,14 @@ export function requireAccountRepository(ctx: TRPCContext): AccountRepository {
|
||||
return ctx.accountRepository;
|
||||
}
|
||||
|
||||
export function requireProposalRepository(ctx: TRPCContext): ProposalRepository {
|
||||
if (!ctx.proposalRepository) {
|
||||
export function requireChangeSetRepository(ctx: TRPCContext): ChangeSetRepository {
|
||||
if (!ctx.changeSetRepository) {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Proposal repository not available',
|
||||
message: 'Change set repository not available',
|
||||
});
|
||||
}
|
||||
return ctx.proposalRepository;
|
||||
return ctx.changeSetRepository;
|
||||
}
|
||||
|
||||
export function requireLogChunkRepository(ctx: TRPCContext): LogChunkRepository {
|
||||
@@ -148,3 +150,23 @@ export function requireLogChunkRepository(ctx: TRPCContext): LogChunkRepository
|
||||
}
|
||||
return ctx.logChunkRepository;
|
||||
}
|
||||
|
||||
export function requireBranchManager(ctx: TRPCContext): BranchManager {
|
||||
if (!ctx.branchManager) {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Branch manager not available',
|
||||
});
|
||||
}
|
||||
return ctx.branchManager;
|
||||
}
|
||||
|
||||
export function requireExecutionOrchestrator(ctx: TRPCContext): ExecutionOrchestrator {
|
||||
if (!ctx.executionOrchestrator) {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Execution orchestrator not available',
|
||||
});
|
||||
}
|
||||
return ctx.executionOrchestrator;
|
||||
}
|
||||
|
||||
146
src/trpc/routers/change-set.ts
Normal file
146
src/trpc/routers/change-set.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Change Set Router — list, get, revert workflows
|
||||
*/
|
||||
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import type { ProcedureBuilder } from '../trpc.js';
|
||||
import {
|
||||
requireChangeSetRepository,
|
||||
requirePhaseRepository,
|
||||
requireTaskRepository,
|
||||
requirePageRepository,
|
||||
} from './_helpers.js';
|
||||
|
||||
export function changeSetProcedures(publicProcedure: ProcedureBuilder) {
|
||||
return {
|
||||
listChangeSets: publicProcedure
|
||||
.input(z.object({
|
||||
initiativeId: z.string().min(1).optional(),
|
||||
agentId: z.string().min(1).optional(),
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const repo = requireChangeSetRepository(ctx);
|
||||
if (input.agentId) {
|
||||
return repo.findByAgentId(input.agentId);
|
||||
}
|
||||
if (input.initiativeId) {
|
||||
return repo.findByInitiativeId(input.initiativeId);
|
||||
}
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Either agentId or initiativeId is required',
|
||||
});
|
||||
}),
|
||||
|
||||
getChangeSet: publicProcedure
|
||||
.input(z.object({ id: z.string().min(1) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const repo = requireChangeSetRepository(ctx);
|
||||
const cs = await repo.findByIdWithEntries(input.id);
|
||||
if (!cs) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: `ChangeSet '${input.id}' not found` });
|
||||
}
|
||||
return cs;
|
||||
}),
|
||||
|
||||
revertChangeSet: publicProcedure
|
||||
.input(z.object({ id: z.string().min(1), force: z.boolean().optional() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const repo = requireChangeSetRepository(ctx);
|
||||
const cs = await repo.findByIdWithEntries(input.id);
|
||||
if (!cs) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: `ChangeSet '${input.id}' not found` });
|
||||
}
|
||||
if (cs.status === 'reverted') {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'ChangeSet is already reverted' });
|
||||
}
|
||||
|
||||
const phaseRepo = requirePhaseRepository(ctx);
|
||||
const taskRepo = requireTaskRepository(ctx);
|
||||
const pageRepo = requirePageRepository(ctx);
|
||||
|
||||
// Conflict detection (unless force)
|
||||
if (!input.force) {
|
||||
const conflicts: string[] = [];
|
||||
for (const entry of cs.entries) {
|
||||
if (entry.action === 'create') {
|
||||
if (entry.entityType === 'phase') {
|
||||
const phase = await phaseRepo.findById(entry.entityId);
|
||||
if (phase && phase.status === 'in_progress') {
|
||||
conflicts.push(`Phase "${phase.name}" is in progress`);
|
||||
}
|
||||
} else if (entry.entityType === 'task') {
|
||||
const task = await taskRepo.findById(entry.entityId);
|
||||
if (task && task.status === 'in_progress') {
|
||||
conflicts.push(`Task "${task.name}" is in progress`);
|
||||
}
|
||||
}
|
||||
} else if (entry.action === 'update' && entry.entityType === 'page' && entry.newState) {
|
||||
const page = await pageRepo.findById(entry.entityId);
|
||||
if (page) {
|
||||
const expectedContent = JSON.parse(entry.newState).content;
|
||||
if (page.content !== expectedContent) {
|
||||
conflicts.push(`Page "${page.title}" was modified since change set was applied`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (conflicts.length > 0) {
|
||||
return { success: false as const, conflicts };
|
||||
}
|
||||
}
|
||||
|
||||
// Apply reverts in reverse entry order
|
||||
const reversedEntries = [...cs.entries].reverse();
|
||||
for (const entry of reversedEntries) {
|
||||
try {
|
||||
if (entry.action === 'create') {
|
||||
switch (entry.entityType) {
|
||||
case 'phase':
|
||||
try { await phaseRepo.delete(entry.entityId); } catch { /* already deleted */ }
|
||||
break;
|
||||
case 'task':
|
||||
try { await taskRepo.delete(entry.entityId); } catch { /* already deleted */ }
|
||||
break;
|
||||
case 'phase_dependency': {
|
||||
const depData = JSON.parse(entry.newState || '{}');
|
||||
if (depData.phaseId && depData.dependsOnPhaseId) {
|
||||
try { await phaseRepo.removeDependency(depData.phaseId, depData.dependsOnPhaseId); } catch { /* already removed */ }
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (entry.action === 'update' && entry.previousState) {
|
||||
const prev = JSON.parse(entry.previousState);
|
||||
switch (entry.entityType) {
|
||||
case 'page':
|
||||
await pageRepo.update(entry.entityId, {
|
||||
content: prev.content,
|
||||
title: prev.title,
|
||||
});
|
||||
ctx.eventBus.emit({
|
||||
type: 'page:updated',
|
||||
timestamp: new Date(),
|
||||
payload: { pageId: entry.entityId, initiativeId: cs.initiativeId, title: prev.title },
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Log but continue reverting other entries
|
||||
}
|
||||
}
|
||||
|
||||
await repo.markReverted(input.id);
|
||||
|
||||
ctx.eventBus.emit({
|
||||
type: 'changeset:reverted' as const,
|
||||
timestamp: new Date(),
|
||||
payload: { changeSetId: cs.id, initiativeId: cs.initiativeId },
|
||||
});
|
||||
|
||||
return { success: true as const };
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,8 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
|
||||
.input(z.object({
|
||||
name: z.string().min(1),
|
||||
projectIds: z.array(z.string().min(1)).min(1).optional(),
|
||||
executionMode: z.enum(['yolo', 'review_per_phase']).optional(),
|
||||
mergeTarget: z.string().nullable().optional(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const repo = requireInitiativeRepository(ctx);
|
||||
@@ -33,6 +35,8 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
|
||||
const initiative = await repo.create({
|
||||
name: input.name,
|
||||
status: 'active',
|
||||
...(input.executionMode && { executionMode: input.executionMode }),
|
||||
...(input.mergeTarget !== undefined && { mergeTarget: input.mergeTarget }),
|
||||
});
|
||||
|
||||
if (input.projectIds && input.projectIds.length > 0) {
|
||||
@@ -103,6 +107,7 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
|
||||
initiativeId: z.string().min(1),
|
||||
mergeRequiresApproval: z.boolean().optional(),
|
||||
mergeTarget: z.string().nullable().optional(),
|
||||
executionMode: z.enum(['yolo', 'review_per_phase']).optional(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const repo = requireInitiativeRepository(ctx);
|
||||
|
||||
@@ -6,7 +6,9 @@ import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import type { Phase } from '../../db/schema.js';
|
||||
import type { ProcedureBuilder } from '../trpc.js';
|
||||
import { requirePhaseRepository, requireTaskRepository } from './_helpers.js';
|
||||
import { requirePhaseRepository, requireTaskRepository, requireBranchManager, requireInitiativeRepository, requireProjectRepository, requireExecutionOrchestrator } from './_helpers.js';
|
||||
import { initiativeBranchName, phaseBranchName } from '../../git/branch-naming.js';
|
||||
import { ensureProjectClone } from '../../git/project-clones.js';
|
||||
|
||||
export function phaseProcedures(publicProcedure: ProcedureBuilder) {
|
||||
return {
|
||||
@@ -50,7 +52,7 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) {
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1).optional(),
|
||||
content: z.string().nullable().optional(),
|
||||
status: z.enum(['pending', 'approved', 'in_progress', 'completed', 'blocked']).optional(),
|
||||
status: z.enum(['pending', 'approved', 'in_progress', 'completed', 'blocked', 'pending_review']).optional(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const repo = requirePhaseRepository(ctx);
|
||||
@@ -181,5 +183,56 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) {
|
||||
await repo.removeDependency(input.phaseId, input.dependsOnPhaseId);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
getPhaseReviewDiff: publicProcedure
|
||||
.input(z.object({ phaseId: z.string().min(1) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const phaseRepo = requirePhaseRepository(ctx);
|
||||
const initiativeRepo = requireInitiativeRepository(ctx);
|
||||
const projectRepo = requireProjectRepository(ctx);
|
||||
const branchManager = requireBranchManager(ctx);
|
||||
|
||||
const phase = await phaseRepo.findById(input.phaseId);
|
||||
if (!phase) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: `Phase '${input.phaseId}' not found` });
|
||||
}
|
||||
if (phase.status !== 'pending_review') {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `Phase is not pending review (status: ${phase.status})` });
|
||||
}
|
||||
|
||||
const initiative = await initiativeRepo.findById(phase.initiativeId);
|
||||
if (!initiative?.mergeTarget) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Initiative has no merge target' });
|
||||
}
|
||||
|
||||
const initBranch = initiativeBranchName(initiative.mergeTarget);
|
||||
const phBranch = phaseBranchName(initBranch, phase.name);
|
||||
|
||||
const projects = await projectRepo.findProjectsByInitiativeId(phase.initiativeId);
|
||||
let rawDiff = '';
|
||||
|
||||
for (const project of projects) {
|
||||
const clonePath = await ensureProjectClone(project, ctx.workspaceRoot!);
|
||||
const diff = await branchManager.diffBranches(clonePath, initBranch, phBranch);
|
||||
if (diff) {
|
||||
rawDiff += diff + '\n';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
phaseName: phase.name,
|
||||
sourceBranch: phBranch,
|
||||
targetBranch: initBranch,
|
||||
rawDiff,
|
||||
};
|
||||
}),
|
||||
|
||||
approvePhaseReview: publicProcedure
|
||||
.input(z.object({ phaseId: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const orchestrator = requireExecutionOrchestrator(ctx);
|
||||
await orchestrator.approveAndMergePhase(input.phaseId);
|
||||
return { success: true };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
/**
|
||||
* Proposal Router — CRUD + accept/dismiss workflows
|
||||
*/
|
||||
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import type { ProcedureBuilder } from '../trpc.js';
|
||||
import type { TRPCContext } from '../context.js';
|
||||
import type { Proposal } from '../../db/schema.js';
|
||||
import {
|
||||
requireProposalRepository,
|
||||
requirePageRepository,
|
||||
requirePhaseRepository,
|
||||
requireTaskRepository,
|
||||
requireAgentManager,
|
||||
} from './_helpers.js';
|
||||
import { markdownToTiptapJson } from '../../agent/markdown-to-tiptap.js';
|
||||
|
||||
/**
|
||||
* Accept a single proposal: apply side effects based on targetType.
|
||||
*/
|
||||
async function applyProposal(proposal: Proposal, ctx: TRPCContext): Promise<string | void> {
|
||||
switch (proposal.targetType) {
|
||||
case 'page': {
|
||||
if (!proposal.targetId || !proposal.content) break;
|
||||
const pageRepo = requirePageRepository(ctx);
|
||||
const tiptapJson = markdownToTiptapJson(proposal.content);
|
||||
await pageRepo.update(proposal.targetId, {
|
||||
content: JSON.stringify(tiptapJson),
|
||||
title: proposal.title,
|
||||
});
|
||||
ctx.eventBus.emit({
|
||||
type: 'page:updated',
|
||||
timestamp: new Date(),
|
||||
payload: { pageId: proposal.targetId, initiativeId: proposal.initiativeId, title: proposal.title },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'phase': {
|
||||
const phaseRepo = requirePhaseRepository(ctx);
|
||||
const meta = proposal.metadata ? JSON.parse(proposal.metadata) : {};
|
||||
const tiptapContent = proposal.content ? JSON.stringify(markdownToTiptapJson(proposal.content)) : undefined;
|
||||
const created = await phaseRepo.create({
|
||||
id: meta.fileId ?? undefined,
|
||||
initiativeId: proposal.initiativeId,
|
||||
name: proposal.title,
|
||||
content: tiptapContent,
|
||||
});
|
||||
return created.id;
|
||||
}
|
||||
case 'task': {
|
||||
const taskRepo = requireTaskRepository(ctx);
|
||||
const meta = proposal.metadata ? JSON.parse(proposal.metadata) : {};
|
||||
await taskRepo.create({
|
||||
initiativeId: proposal.initiativeId,
|
||||
phaseId: meta.phaseId ?? null,
|
||||
parentTaskId: meta.parentTaskId ?? null,
|
||||
name: proposal.title,
|
||||
description: proposal.content ?? undefined,
|
||||
category: meta.category ?? 'execute',
|
||||
type: meta.type ?? 'auto',
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After every accept/dismiss, check if all proposals for the agent are resolved.
|
||||
* If so, auto-dismiss the agent.
|
||||
*/
|
||||
async function maybeAutoDismiss(agentId: string, ctx: TRPCContext): Promise<void> {
|
||||
const proposalRepo = requireProposalRepository(ctx);
|
||||
const pendingCount = await proposalRepo.countByAgentIdAndStatus(agentId, 'pending');
|
||||
if (pendingCount === 0) {
|
||||
try {
|
||||
const agentManager = requireAgentManager(ctx);
|
||||
await agentManager.dismiss(agentId);
|
||||
} catch {
|
||||
// Agent manager not available or agent already dismissed — not critical
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function proposalProcedures(publicProcedure: ProcedureBuilder) {
|
||||
return {
|
||||
listProposals: publicProcedure
|
||||
.input(z.object({
|
||||
agentId: z.string().min(1).optional(),
|
||||
initiativeId: z.string().min(1).optional(),
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const repo = requireProposalRepository(ctx);
|
||||
if (input.agentId) {
|
||||
return repo.findByAgentId(input.agentId);
|
||||
}
|
||||
if (input.initiativeId) {
|
||||
return repo.findByInitiativeId(input.initiativeId);
|
||||
}
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Either agentId or initiativeId is required',
|
||||
});
|
||||
}),
|
||||
|
||||
acceptProposal: publicProcedure
|
||||
.input(z.object({ id: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const repo = requireProposalRepository(ctx);
|
||||
const proposal = await repo.findById(input.id);
|
||||
if (!proposal) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: `Proposal '${input.id}' not found` });
|
||||
}
|
||||
if (proposal.status !== 'pending') {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `Proposal is already ${proposal.status}` });
|
||||
}
|
||||
await applyProposal(proposal, ctx);
|
||||
const updated = await repo.update(input.id, { status: 'accepted' });
|
||||
await maybeAutoDismiss(proposal.agentId, ctx);
|
||||
return updated;
|
||||
}),
|
||||
|
||||
dismissProposal: publicProcedure
|
||||
.input(z.object({ id: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const repo = requireProposalRepository(ctx);
|
||||
const proposal = await repo.findById(input.id);
|
||||
if (!proposal) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: `Proposal '${input.id}' not found` });
|
||||
}
|
||||
if (proposal.status !== 'pending') {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `Proposal is already ${proposal.status}` });
|
||||
}
|
||||
const updated = await repo.update(input.id, { status: 'dismissed' });
|
||||
await maybeAutoDismiss(proposal.agentId, ctx);
|
||||
return updated;
|
||||
}),
|
||||
|
||||
acceptAllProposals: publicProcedure
|
||||
.input(z.object({ agentId: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const repo = requireProposalRepository(ctx);
|
||||
const pending = await repo.findByAgentIdAndStatus(input.agentId, 'pending');
|
||||
let successCount = 0;
|
||||
let failedCount = 0;
|
||||
const errorMessages: string[] = [];
|
||||
for (const proposal of pending) {
|
||||
try {
|
||||
await applyProposal(proposal, ctx);
|
||||
await repo.update(proposal.id, { status: 'accepted' });
|
||||
successCount++;
|
||||
} catch (err) {
|
||||
failedCount++;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
errorMessages.push(`${proposal.title}: ${message}`);
|
||||
}
|
||||
}
|
||||
// Second pass: create phase dependencies
|
||||
const phaseProposals = pending.filter(p => p.targetType === 'phase');
|
||||
if (phaseProposals.length > 0) {
|
||||
const phaseRepo = requirePhaseRepository(ctx);
|
||||
for (const proposal of phaseProposals) {
|
||||
const meta = proposal.metadata ? JSON.parse(proposal.metadata) : {};
|
||||
const phaseId = meta.fileId;
|
||||
if (!phaseId || !Array.isArray(meta.dependencies)) continue;
|
||||
for (const depFileId of meta.dependencies) {
|
||||
try {
|
||||
await phaseRepo.createDependency(phaseId, depFileId);
|
||||
} catch (err) {
|
||||
errorMessages.push(`Dep ${proposal.title}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await maybeAutoDismiss(input.agentId, ctx);
|
||||
return { accepted: successCount, failed: failedCount, errors: errorMessages };
|
||||
}),
|
||||
|
||||
dismissAllProposals: publicProcedure
|
||||
.input(z.object({ agentId: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const repo = requireProposalRepository(ctx);
|
||||
await repo.updateManyByAgentIdAndStatus(input.agentId, 'pending', { status: 'dismissed' });
|
||||
await maybeAutoDismiss(input.agentId, ctx);
|
||||
return { success: true };
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -49,6 +49,9 @@ export const ALL_EVENT_TYPES: DomainEventType[] = [
|
||||
'phase:started',
|
||||
'phase:completed',
|
||||
'phase:blocked',
|
||||
'phase:pending_review',
|
||||
'phase:merged',
|
||||
'task:merged',
|
||||
'merge:queued',
|
||||
'merge:started',
|
||||
'merge:completed',
|
||||
@@ -56,6 +59,8 @@ export const ALL_EVENT_TYPES: DomainEventType[] = [
|
||||
'page:created',
|
||||
'page:updated',
|
||||
'page:deleted',
|
||||
'changeset:created',
|
||||
'changeset:reverted',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -80,10 +85,13 @@ export const TASK_EVENT_TYPES: DomainEventType[] = [
|
||||
'task:dispatched',
|
||||
'task:completed',
|
||||
'task:blocked',
|
||||
'task:merged',
|
||||
'phase:queued',
|
||||
'phase:started',
|
||||
'phase:completed',
|
||||
'phase:blocked',
|
||||
'phase:pending_review',
|
||||
'phase:merged',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user