chore: merge main into cw/small-change-flow
Integrates main branch changes (headquarters dashboard, task retry count, agent prompt persistence, remote sync improvements) with the initiative's errand agent feature. Both features coexist in the merged result. Key resolutions: - Schema: take main's errands table (nullable projectId, no conflictFiles, with errandsRelations); migrate to 0035_faulty_human_fly - Router: keep both errandProcedures and headquartersProcedures - Errand prompt: take main's simpler version (no question-asking flow) - Manager: take main's status check (running|idle only, no waiting_for_input) - Tests: update to match removed conflictFiles field and undefined vs null
This commit is contained in:
481
README.md
481
README.md
@@ -1,311 +1,236 @@
|
||||
# Codewalkers
|
||||
|
||||
# Project concept
|
||||
Multi-agent workspace for orchestrating multiple AI coding agents working in parallel on a shared codebase.
|
||||
|
||||
Codewalkers is a multi-agent workspace inspired by gastown. It works differently in the following ways:
|
||||
* Subagents (e.g. Workers) that handle tasks run in -p mode and respond with a clear json schema
|
||||
* One cw (codewalk) web server is running that is also managing the agents
|
||||
* There shall be a clear post worktree setup hook that by default copies files (e.g. .env files) prepared inside a dedicated folder in the Project
|
||||
* It shall support multiple claude code accounts (see ccswitch) and switch them as they run into usage limits
|
||||
* It shall have a web dashboard at some point in the project
|
||||
* The project shall start with a file based UI. That is a folder structure representing the data of the project refreshed when saving (fs events) and updated when db data changes (use events as trigger). The fsui shall be started with `cw fsui` which instantiate a bidirectional watcher that subscribes to the events via a websocket
|
||||
* It shall support integration branches that Workers clone their work from and integrate branches into
|
||||
* It shall base all it's larger development work on initiatives. Initiatives describe a larger amount of work. The concept from the user must follow a formal planning process where the work is verified for integration into the existing codebase, a sophisticated technical concept is created. An initiative is only started once approved by a developer. Analysis work is performed by Architects.
|
||||
* The project shall use a global SQlite DB which also manages tasks
|
||||
* It shall have a cli (the cli shall also be the server application only that it only works as a cli when not run with --server). The cli shall be called "cw"
|
||||
* The communication from and between agents shall happen using an STDIO based mcp that is also implemented in the main binary. e.g. cw mcp
|
||||
Codewalkers coordinates agents from different providers (Claude, Codex, Gemini, Cursor, AMP, Auggie, OpenCode) through a unified CLI and web dashboard. It manages the full lifecycle: planning initiatives, decomposing work into phases and tasks, dispatching agents to isolated git worktrees, merging results, and reviewing changes — all from a single `cw` command.
|
||||
|
||||
## Key Features
|
||||
|
||||
---
|
||||
- **Multi-provider agents** — Data-driven provider configs. Add new providers without code changes.
|
||||
- **Initiative workflow** — Three-level hierarchy (Initiative > Phase > Task) with dependency DAGs and topological ordering.
|
||||
- **Architect agents** — AI-driven planning: discuss, plan, detail, and refine modes produce structured proposals you accept or dismiss.
|
||||
- **Git worktree isolation** — Each agent works in its own worktree. No conflicts during parallel execution.
|
||||
- **Account rotation** — Register multiple provider accounts. On usage-limit exhaustion, automatically fails over to the next available account (LRU scheduling).
|
||||
- **Two execution modes** — *YOLO* auto-merges everything. *Review-per-phase* adds manual approval gates with inline code review, threaded comments, and diff viewing.
|
||||
- **Docker preview deployments** — Zero-config preview environments via `.cw-preview.yml`, `docker-compose.yml`, or bare `Dockerfile`. Caddy reverse proxy with health checks.
|
||||
- **Inter-agent communication** — Agents ask each other questions via a conversation system. Idle agents auto-resume when a question arrives.
|
||||
- **Chat sessions** — Persistent iterative refinement loops where you send messages and agents apply changes with revertable changesets.
|
||||
- **Real-time dashboard** — React web UI with live agent output streaming, pipeline visualization, Tiptap page editor, and command palette.
|
||||
- **Event-driven architecture** — 58+ typed domain events drive all coordination. No polling loops.
|
||||
- **Cassette testing** — Record/replay agent interactions for full-pipeline E2E tests at zero API cost.
|
||||
|
||||
# Implementation considerations
|
||||
## Getting Started
|
||||
|
||||
* Typescript as a programming language
|
||||
* Trpc as an API layer
|
||||
* React with shadcn & tanstack router for the frontend running with vite. Tiptap for markdown editor UIs
|
||||
* Simple deployment (one web server serving front and backend in deployed mode - in dev the frontend may use a dev server for hot reloads). The app shall just be startable by installing the cli and then running it with --server. No more setup needed. The local frontend dev server shall be proxied through the backend in the same path as the compiled frontend would be served in production mode
|
||||
* SQLite as a database
|
||||
* Future support for multi user management (for now only one user implement a stub)
|
||||
* Hexagonal architecture
|
||||
* Built as a modular monolith with clear separation between modules incl. event bus (can be process internal with swappable adapter for the future)
|
||||
### Prerequisites
|
||||
|
||||
---
|
||||
- Node.js 20+
|
||||
- Git 2.38+ (for `merge-tree --write-tree`)
|
||||
- Docker (optional, for preview deployments)
|
||||
- At least one supported AI coding CLI installed (e.g., `claude`, `codex`, `gemini`)
|
||||
|
||||
### Install
|
||||
|
||||
# Modules
|
||||
```sh
|
||||
git clone <repo-url> && cd codewalk-district
|
||||
npm install
|
||||
npm run build
|
||||
npm link
|
||||
```
|
||||
|
||||
## Tasks
|
||||
This makes the `cw` CLI available globally.
|
||||
|
||||
Beads-inspired task management for agent coordination. Centralized SQLite storage (not Git-distributed like beads).
|
||||
### Initialize a workspace
|
||||
|
||||
Key features:
|
||||
* **Status workflow**: `open` → `in_progress` → `blocked` | `closed`
|
||||
* **Priority system**: P0 (critical) through P3 (low)
|
||||
* **Dependency graph**: Tasks block other tasks; `ready` query finds actionable work
|
||||
* **Assignment tracking**: Prevents multiple agents claiming same task
|
||||
* **Audit history**: All state changes logged for debugging
|
||||
```sh
|
||||
cd your-project
|
||||
cw init
|
||||
```
|
||||
|
||||
CLI mirrors beads: `cw task ready`, `cw task create`, `cw task close`, etc.
|
||||
Creates a `.cwrc` config file marking the workspace root.
|
||||
|
||||
See [docs/tasks.md](docs/tasks.md) for schema and CLI reference.
|
||||
### Register a project
|
||||
|
||||
## Initiatives
|
||||
```sh
|
||||
cw project register --name my-app --url /path/to/repo
|
||||
```
|
||||
|
||||
Notion-like document hierarchy for planning larger features. SQLite-backed with parent-child relationships for structured queries (e.g., "all subpages of initiative X", "inventory of all documents").
|
||||
### Add a provider account
|
||||
|
||||
Key features:
|
||||
* **Lifecycle**: `draft` → `review` → `approved` → `in_progress` → `completed`
|
||||
* **Nested pages**: User journeys, business rules, technical concepts, architectural changes
|
||||
* **Phased work plans**: Approved initiatives generate tasks grouped into phases
|
||||
* **Rolling approval**: User approves phase plans one-by-one; agents execute approved phases while subsequent phases are reviewed
|
||||
```sh
|
||||
# Auto-extract from current Claude login
|
||||
cw account add
|
||||
|
||||
Workflow: User drafts → Architect iterates (GSD-style questioning) → Approval or draft extension and further iterations with the Architect → Tasks created with `initiative_id` + `phase` → Execute
|
||||
# Or register with a setup token
|
||||
cw account add --token <token> --email user@example.com
|
||||
```
|
||||
|
||||
See [docs/initiatives.md](docs/initiatives.md) for schema and workflow details.
|
||||
### Start the server
|
||||
|
||||
## Domain Layer
|
||||
```sh
|
||||
cw --server
|
||||
```
|
||||
|
||||
DDD-based documentation of the **as-is state** for agent and human consumption. Initiatives reference and modify domain concepts; completed initiatives update the domain layer to reflect the new state.
|
||||
Starts the coordination server on `localhost:3847`. The web dashboard is served at the same address.
|
||||
|
||||
**Scope**: Per-project domains or cross-project domains (features spanning multiple projects).
|
||||
### Create an initiative and start working
|
||||
|
||||
**Core concepts tracked:**
|
||||
* **Bounded Contexts** — scope boundaries defining where a domain model applies
|
||||
* **Aggregates** — consistency boundaries, what changes together
|
||||
* **Domain Events** — events exposed by the project that trigger workflows or side effects
|
||||
* **Business Rules & Invariants** — constraints that must always hold; agents must preserve these
|
||||
* **Ubiquitous Language** — glossary of domain terms to prevent agent misinterpretation
|
||||
* **Context Maps** — relationships between bounded contexts (especially for cross-project domains)
|
||||
* **External Integrations** — systems the domain interacts with but doesn't own
|
||||
```sh
|
||||
cw initiative create "Add user authentication"
|
||||
cw architect discuss <initiative-id>
|
||||
```
|
||||
|
||||
**Codebase mapping**: Each concept links to folder/module paths. Auto-maintained by agents after implementation work.
|
||||
From the web dashboard, accept the architect's proposals, approve phases, and dispatch execution.
|
||||
|
||||
**Storage**: Dual adapter support — SQLite tables (structured queries) or Markdown with YAML frontmatter (human-readable, version-controllable).
|
||||
## Architecture
|
||||
|
||||
## Orchestrator
|
||||
```
|
||||
CLI (cw)
|
||||
+-- CoordinationServer
|
||||
|-- HTTP + tRPC API (70+ procedures)
|
||||
|-- EventBus (58 typed events)
|
||||
|-- MultiProviderAgentManager
|
||||
| |-- ProcessManager (detached child processes)
|
||||
| |-- WorktreeManager (git worktrees per agent)
|
||||
| |-- OutputHandler (JSONL stream parsing)
|
||||
| +-- LifecycleController (retry, signal recovery)
|
||||
|-- DispatchManager (task queue, dependency DAG)
|
||||
|-- PhaseDispatchManager (phase queue, topological sort)
|
||||
|-- ExecutionOrchestrator (end-to-end coordination)
|
||||
|-- PreviewManager (Docker compose, Caddy proxy)
|
||||
+-- 14 Repository ports (SQLite/Drizzle adapters)
|
||||
|
||||
Main orchestrator loop handling coordination across agents. Can be split per project or initiative for load balancing in the future.
|
||||
Web UI (React 19)
|
||||
+-- TanStack Router + tRPC React Query
|
||||
|-- Initiative management & page editor (Tiptap)
|
||||
|-- Pipeline visualization (phase DAG)
|
||||
|-- Execution tab (task dispatch, live agent output)
|
||||
+-- Review tab (diffs, inline comments, approval)
|
||||
```
|
||||
|
||||
## Session State
|
||||
**Monorepo layout:**
|
||||
|
||||
Tracks execution state across agent restarts. Unlike Domain Layer (codebase state), session state tracks position, decisions, and blockers.
|
||||
| Path | Description |
|
||||
|------|-------------|
|
||||
| `apps/server/` | CLI, coordination server, agent management, all backend modules |
|
||||
| `apps/web/` | React dashboard (Vite + Tailwind + shadcn/ui) |
|
||||
| `packages/shared/` | Shared TypeScript types between server and web |
|
||||
|
||||
**STATE.md** maintains:
|
||||
* Current position (phase, plan, task, wave)
|
||||
* Decisions made (locked choices with reasoning)
|
||||
* Active blockers (what's waiting, workarounds)
|
||||
* Session history (who worked on what, when)
|
||||
**Hexagonal architecture:** Repository ports define data access interfaces. Drizzle/SQLite adapters implement them. Swappable without touching business logic.
|
||||
|
||||
See [docs/session-state.md](docs/session-state.md) for session state management.
|
||||
## Supported Providers
|
||||
|
||||
---
|
||||
| Provider | CLI | Resume | Structured Output |
|
||||
|----------|-----|--------|-------------------|
|
||||
| Claude | `claude` | `--resume` | Prompt-based |
|
||||
| Codex | `codex` | `codex resume` | `--output-schema` |
|
||||
| Gemini | `gemini` | `--resume` | `--output-format` |
|
||||
| Cursor | `cursor-agent` | — | `--output-format` |
|
||||
| AMP | `amp` | `--thread` | `--json` |
|
||||
| Auggie | `aug` | — | — |
|
||||
| OpenCode | `opencode` | — | `--format` |
|
||||
|
||||
# Model Profiles
|
||||
|
||||
Different agent roles have different needs. Model selection balances quality, cost, and latency.
|
||||
|
||||
| Profile | Use Case | Cost | Quality |
|
||||
|---------|----------|------|---------|
|
||||
| **quality** | Critical decisions, architecture | Highest | Best |
|
||||
| **balanced** | Default for most work | Medium | Good |
|
||||
| **budget** | High-volume, low-risk tasks | Lowest | Acceptable |
|
||||
|
||||
| Agent | Quality | Balanced (Default) | Budget |
|
||||
|-------|---------|-------------------|--------|
|
||||
| Architect | Opus | Opus | Sonnet |
|
||||
| Worker | Opus | Sonnet | Sonnet |
|
||||
| Verifier | Sonnet | Sonnet | Haiku |
|
||||
| Orchestrator | Sonnet | Sonnet | Haiku |
|
||||
|
||||
See [docs/model-profiles.md](docs/model-profiles.md) for model selection strategy.
|
||||
|
||||
---
|
||||
|
||||
# Notes
|
||||
|
||||
The "reference" folder contains the implementation of Gastown, get-shit-done and ccswitch (a cli tool to use multiple claude code accounts).
|
||||
|
||||
---
|
||||
|
||||
# Core Principles
|
||||
|
||||
## Task Decomposition
|
||||
Breaking large goals into detailed instructions for agents. Supported by Tasks, Jobs, Workflows, and Pipelines. Ensures work is decomposed into trackable, atomic units that agents can execute autonomously.
|
||||
|
||||
See [docs/task-granularity.md](docs/task-granularity.md) for task specification standards.
|
||||
|
||||
## Pull Model
|
||||
"If there is work in your Queue, YOU MUST RUN IT." This principle ensures agents autonomously proceed with available work without waiting for external input. The heartbeat of autonomous operation.
|
||||
|
||||
## Eventual Completion
|
||||
The overarching goal ensuring useful outcomes through orchestration of potentially unreliable processes. Persistent Tasks and oversight agents (Monitor, Supervisor) guarantee eventual workflow completion even when individual operations may fail or produce varying results.
|
||||
|
||||
## Context Engineering
|
||||
Agent output quality degrades predictably as context fills. This is a first-class concern:
|
||||
* **0-30% context**: Peak quality (thorough, comprehensive)
|
||||
* **30-50% context**: Good quality (solid work)
|
||||
* **50-70% context**: Degrading (shortcuts appear)
|
||||
* **70%+ context**: Poor quality (rushed, minimal)
|
||||
|
||||
**Rule: Stay UNDER 50% context.** Plans sized to fit ~50%. Workers get fresh context per task. Orchestrator stays at 30-40% with heavy work in subagent contexts.
|
||||
|
||||
See [docs/context-engineering.md](docs/context-engineering.md) for context management rules.
|
||||
|
||||
## Goal-Backward Verification
|
||||
Task completion ≠ Goal achievement. Verification confirms observable outcomes, not checkbox completion. Each phase ends with goal-backward verification checking observable truths, required artifacts, and required wiring.
|
||||
|
||||
See [docs/verification.md](docs/verification.md) for verification patterns.
|
||||
|
||||
## Deviation Rules
|
||||
Workers encounter unexpected issues during execution. Four rules govern autonomous action:
|
||||
* **Rule 1**: Auto-fix bugs (no permission needed)
|
||||
* **Rule 2**: Auto-add missing critical functionality (no permission needed)
|
||||
* **Rule 3**: Auto-fix blocking issues (no permission needed)
|
||||
* **Rule 4**: ASK about architectural changes (permission required)
|
||||
|
||||
See [docs/deviation-rules.md](docs/deviation-rules.md) for detailed guidance.
|
||||
|
||||
---
|
||||
|
||||
# Environments
|
||||
|
||||
## Workspace
|
||||
The shared environment where all users operate. The Workspace coordinates all agents across multiple Projects and houses workspace-level agents like Orchestrator and Supervisor. It defines the boundaries, infrastructure, and rules of interaction between agents, projects, and resources.
|
||||
|
||||
## Project
|
||||
A self-contained repository under Workspace management. Each Project has its own Workers, Integrator, Monitor, and Team members. Projects define goals, constraints, and context for users working on a specific problem or domain. This is where actual development work happens.
|
||||
|
||||
---
|
||||
|
||||
# Workspace-Level Roles
|
||||
|
||||
## Codewalker
|
||||
A human operator. Users are the primary inhabitants of the Workspace. They control the system and make final decisions.
|
||||
|
||||
## Orchestrator
|
||||
The coordinating authority of the Workspace. Responsible for initiating Jobs, coordinating work distribution, and notifying users of important events. The Orchestrator operates from the workspace level and has visibility across all Projects.
|
||||
|
||||
## Supervisor
|
||||
Daemon process running continuous health check cycles. The Supervisor ensures agent activity, monitors system health, and triggers recovery when agents become unresponsive.
|
||||
|
||||
## Helpers
|
||||
The Supervisor's pool of maintenance agents handling background tasks like cleanup, health checks, and system maintenance.
|
||||
|
||||
## Watchdog
|
||||
A special Helper that checks the Supervisor periodically, ensuring the monitor itself is still running. Creates a chain of accountability.
|
||||
|
||||
---
|
||||
|
||||
# Project-Level Roles
|
||||
|
||||
## Worker
|
||||
An ephemeral agent optimized for execution. Workers are spawned for specific tasks, perform focused work such as coding, analysis, or integration. They work in isolated git worktrees to avoid conflicts, produce Merge Requests, and are cleaned up after completion.
|
||||
|
||||
Workers follow deviation rules and create atomic commits per task. See [docs/agents/worker.md](docs/agents/worker.md) for the full agent prompt.
|
||||
|
||||
## Integrator
|
||||
Manages the Merge Queue for a Project. The Integrator handles merging changes from Workers, resolving conflicts, and ensuring code quality before changes reach the main branch.
|
||||
|
||||
## Monitor
|
||||
Observes execution and lifecycle events within a Project. Monitors detect failures, enforce limits, oversee Workers and the Integrator, and ensure system health. Can trigger recovery actions when needed.
|
||||
|
||||
## Team
|
||||
Long-lived, named agents for persistent collaboration. Unlike ephemeral Workers, Team members maintain context across sessions and are ideal for ongoing work relationships and complex multi-session tasks.
|
||||
|
||||
## Architect
|
||||
Analysis agent for initiative planning. Architects iterate on initiative drafts with the user through structured questioning. They validate integration with existing codebase, refine technical concepts, and produce work plans broken into phases. Architects don't execute—they plan.
|
||||
|
||||
See [docs/agents/architect.md](docs/agents/architect.md) for the full agent prompt and workflow.
|
||||
|
||||
## Verifier
|
||||
Validation agent that confirms goals are achieved, not just tasks completed. Verifiers run goal-backward verification after phase execution, checking observable truths, required artifacts, and required wiring. They identify gaps and create remediation tasks when needed.
|
||||
|
||||
Key responsibilities:
|
||||
* **Goal-backward verification** — Check outcomes, not activities
|
||||
* **Three-level checks** — Existence, substance, wiring
|
||||
* **Anti-pattern scanning** — TODOs, stubs, empty returns
|
||||
* **User acceptance testing** — Walk users through deliverables
|
||||
* **Remediation** — Create targeted fix tasks when gaps found
|
||||
|
||||
See [docs/agents/verifier.md](docs/agents/verifier.md) for the full agent prompt and verification patterns.
|
||||
|
||||
---
|
||||
|
||||
# Work Units
|
||||
|
||||
## Task
|
||||
The atomic unit of work. SQLite-backed work item with dependency tracking. Tasks link actions, state changes, and artifacts across the Workspace with precision and traceability. They can represent issues, tickets, jobs, or any trackable work item.
|
||||
|
||||
## Template
|
||||
A reusable workflow definition. TOML-based source file describing how tasks are structured, sequenced, and executed across agents. Templates define patterns for common operations like health checks, code review, or deployment.
|
||||
|
||||
## Schema
|
||||
A template class for instantiating Pipelines. Schemas define the structure and steps of a workflow without being tied to specific work items.
|
||||
|
||||
## Pipeline
|
||||
Durable chained Task workflows. Pipelines represent multi-step processes where each step is tracked as a Task. They survive agent restarts and ensure complex workflows complete.
|
||||
|
||||
## Ephemeral
|
||||
Temporary Tasks destroyed after runs. Ephemerals are lightweight work items used for transient operations that don't need permanent tracking.
|
||||
|
||||
## Queue
|
||||
A pinned Task list for each agent. The Queue is an agent's primary work source - when work appears in your Queue, the Pull Model dictates you must run it.
|
||||
|
||||
---
|
||||
|
||||
# Workflow Commands
|
||||
|
||||
## Job
|
||||
A coordinated group of tasks executed together. The primary work-order wrapping related Tasks. Jobs allow related work to be dispatched, tracked, and completed as a single operational unit.
|
||||
|
||||
## Assign
|
||||
The act of putting work on an agent's Queue. Assign translates intent into action, sending Workers or Team members into motion.
|
||||
|
||||
## Notify
|
||||
Real-time messaging between agents. Allows immediate communication without going through formal channels. Quick pings and status updates.
|
||||
|
||||
## Handoff
|
||||
Agent session refresh. When context gets full or an agent needs a fresh start, Handoff transfers work state to a new session while preserving critical context.
|
||||
|
||||
## Replay
|
||||
Querying previous sessions for context. Replay allows agents to access their predecessors' decisions and context from earlier work.
|
||||
|
||||
## Poll
|
||||
Ephemeral loop maintaining system heartbeat. Poll cycles (Supervisor, Monitor) continuously run health checks and trigger actions as needed.
|
||||
|
||||
---
|
||||
|
||||
# Storage & Memory
|
||||
|
||||
## Context Store
|
||||
A persistent store of memory, context, and knowledge. Preserves state across executions, enabling agents to remember decisions, history, and learned insights.
|
||||
|
||||
## Audit Log
|
||||
The authoritative record of system state and history. Ensures reproducibility, auditing, and continuity across operations.
|
||||
|
||||
## Sandbox
|
||||
A personal workspace for an agent. Contains tools, local context, and temporary state used during active reasoning and execution.
|
||||
|
||||
## Config
|
||||
The configuration and rule set governing a Project or the Workspace. Defines behavior, permissions, and operational constraints.
|
||||
|
||||
---
|
||||
|
||||
# Documentation Index
|
||||
|
||||
## Modules
|
||||
* [docs/tasks.md](docs/tasks.md) — Task schema, CLI, and workflows
|
||||
* [docs/initiatives.md](docs/initiatives.md) — Initiative lifecycle and phase management
|
||||
|
||||
## Operational Concepts
|
||||
* [docs/context-engineering.md](docs/context-engineering.md) — Context budget rules and quality curve
|
||||
* [docs/verification.md](docs/verification.md) — Goal-backward verification patterns
|
||||
* [docs/deviation-rules.md](docs/deviation-rules.md) — How agents handle unexpected work
|
||||
* [docs/task-granularity.md](docs/task-granularity.md) — Task specification standards
|
||||
* [docs/session-state.md](docs/session-state.md) — Session continuity and handoffs
|
||||
* [docs/execution-artifacts.md](docs/execution-artifacts.md) — PLAN, SUMMARY, VERIFICATION files
|
||||
* [docs/model-profiles.md](docs/model-profiles.md) — Model selection by role
|
||||
|
||||
## Agent Prompts
|
||||
* [docs/agents/architect.md](docs/agents/architect.md) — Planning and decomposition
|
||||
* [docs/agents/worker.md](docs/agents/worker.md) — Task execution
|
||||
* [docs/agents/verifier.md](docs/agents/verifier.md) — Goal-backward verification
|
||||
Providers are configured as data in `apps/server/agent/providers/presets.ts`. Adding a new provider means adding an entry to the presets object.
|
||||
|
||||
## CLI Reference
|
||||
|
||||
```
|
||||
cw --server [-p port] Start coordination server
|
||||
cw init Initialize workspace (.cwrc)
|
||||
cw status Server health check
|
||||
cw id [-n count] Generate nanoid(s) offline
|
||||
|
||||
cw agent spawn <prompt> --task <id> [--provider <name>]
|
||||
cw agent stop|delete|list|get|resume|result <name>
|
||||
|
||||
cw initiative create|list|get|phases <name|id>
|
||||
cw architect discuss|plan|detail|refine <id>
|
||||
|
||||
cw phase add-dependency --phase <id> --depends-on <id>
|
||||
cw phase queue|dispatch|queue-status|dependencies <id>
|
||||
|
||||
cw task list|get|status <id>
|
||||
cw dispatch queue|next|status|complete <id>
|
||||
|
||||
cw project register --name <n> --url <u>
|
||||
cw project list|delete|sync|status [name|id]
|
||||
|
||||
cw account add|list|remove|refresh|extract [id]
|
||||
|
||||
cw preview start|stop|list|status|setup [id]
|
||||
|
||||
cw listen --agent-id <id>
|
||||
cw ask <question> --from <id> --agent-id <target>
|
||||
cw answer <response> --conversation-id <id>
|
||||
```
|
||||
|
||||
## Workflow Overview
|
||||
|
||||
```
|
||||
1. Create initiative cw initiative create "Feature X"
|
||||
2. Plan with architect cw architect discuss <id> --> plan --> detail
|
||||
3. Accept proposals (web UI: review & accept phase/task proposals)
|
||||
4. Approve phases (web UI: approve phases for execution)
|
||||
5. Dispatch (web UI: queue phases, auto-dispatch tasks to agents)
|
||||
6. Agents execute (parallel, isolated worktrees, auto-retry on crash)
|
||||
7. Review (web UI: diff viewer, inline comments, approve/request changes)
|
||||
8. Merge (auto or manual per execution mode)
|
||||
9. Complete (push branch or merge into default branch)
|
||||
```
|
||||
|
||||
**Execution modes:**
|
||||
- **YOLO** — Phases auto-merge on completion, next phase auto-dispatches. No gates.
|
||||
- **Review per phase** (default) — Each completed phase pauses for human review. Approve to merge and continue.
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
npm run dev # Watch mode (server)
|
||||
npm run dev:web # Vite dev server (frontend)
|
||||
npm run build # TypeScript compilation
|
||||
npm link # Link CLI globally after build
|
||||
```
|
||||
|
||||
After any change to server code (`apps/server/**`), run `npm run build && npm link`.
|
||||
|
||||
## Testing
|
||||
|
||||
```sh
|
||||
npm test # Unit + E2E (no API cost)
|
||||
npm test -- <file> # Run specific test file
|
||||
|
||||
# Record cassettes (one-time API cost)
|
||||
CW_CASSETTE_RECORD=1 npm test -- <test-file>
|
||||
|
||||
# Real provider integration tests (~$0.50)
|
||||
REAL_CLAUDE_TESTS=1 npm test -- apps/server/test/integration/real-providers/ --test-timeout=300000
|
||||
```
|
||||
|
||||
The **cassette system** records real agent subprocess interactions and replays them deterministically. Full-pipeline E2E tests run at zero API cost after initial recording. See [docs/testing.md](docs/testing.md).
|
||||
|
||||
## Documentation
|
||||
|
||||
| Topic | Link |
|
||||
|-------|------|
|
||||
| Architecture | [docs/architecture.md](docs/architecture.md) |
|
||||
| Agent lifecycle, providers, accounts | [docs/agent.md](docs/agent.md) |
|
||||
| Database schema & repositories | [docs/database.md](docs/database.md) |
|
||||
| Server & tRPC API (70+ procedures) | [docs/server-api.md](docs/server-api.md) |
|
||||
| Frontend & components | [docs/frontend.md](docs/frontend.md) |
|
||||
| CLI commands & configuration | [docs/cli-config.md](docs/cli-config.md) |
|
||||
| Dispatch & events (58 event types) | [docs/dispatch-events.md](docs/dispatch-events.md) |
|
||||
| Git, process management, logging | [docs/git-process-logging.md](docs/git-process-logging.md) |
|
||||
| Docker preview deployments | [docs/preview.md](docs/preview.md) |
|
||||
| Testing & cassette system | [docs/testing.md](docs/testing.md) |
|
||||
| Database migrations | [docs/database-migrations.md](docs/database-migrations.md) |
|
||||
| Logging guide | [docs/logging.md](docs/logging.md) |
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Runtime:** Node.js (ESM), TypeScript
|
||||
- **Database:** SQLite via better-sqlite3 + Drizzle ORM
|
||||
- **API:** tRPC v11 with SSE subscriptions
|
||||
- **Frontend:** React 19, TanStack Router, Tailwind CSS, shadcn/ui, Tiptap
|
||||
- **Process:** execa (detached child processes)
|
||||
- **Git:** simple-git (worktrees, branches, merges)
|
||||
- **Logging:** pino (structured JSON)
|
||||
- **Testing:** vitest
|
||||
|
||||
@@ -481,36 +481,4 @@ describe('buildErrandPrompt', () => {
|
||||
const result = buildErrandPrompt('some change');
|
||||
expect(result).toContain('"status": "error"');
|
||||
});
|
||||
|
||||
it('includes instructions for asking questions', () => {
|
||||
const result = buildErrandPrompt('some change');
|
||||
expect(result).toMatch(/ask|question/i);
|
||||
expect(result).toMatch(/chat|message|reply/i);
|
||||
});
|
||||
|
||||
it('includes questions signal format for session-ending questions', () => {
|
||||
const result = buildErrandPrompt('some change');
|
||||
expect(result).toContain('"status": "questions"');
|
||||
expect(result).toContain('"questions"');
|
||||
});
|
||||
|
||||
it('explains session ends and resumes with user answers', () => {
|
||||
const result = buildErrandPrompt('some change');
|
||||
expect(result).toMatch(/resume|end.*session|session.*end/i);
|
||||
});
|
||||
|
||||
it('does not present inline asking as an alternative that bypasses signal.json', () => {
|
||||
const result = buildErrandPrompt('some change');
|
||||
// "session stays open" implied agents can skip signal.json — all exits must write it
|
||||
expect(result).not.toMatch(/session stays open/i);
|
||||
expect(result).not.toMatch(/Option A/i);
|
||||
});
|
||||
|
||||
it('requires signal.json for all question-asking paths', () => {
|
||||
const result = buildErrandPrompt('some change');
|
||||
// questions status must be the mechanism for all user-input requests
|
||||
expect(result).toContain('"status": "questions"');
|
||||
// must not describe a path that skips signal.json
|
||||
expect(result).not.toMatch(/session stays open/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface AgentInfo {
|
||||
status: string;
|
||||
initiativeId?: string | null;
|
||||
worktreeId: string;
|
||||
exitCode?: number | null;
|
||||
}
|
||||
|
||||
export interface CleanupStrategy {
|
||||
|
||||
@@ -50,6 +50,7 @@ function makeController(overrides: {
|
||||
cleanupStrategy,
|
||||
overrides.accountRepository as AccountRepository | undefined,
|
||||
false,
|
||||
overrides.eventBus,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { RetryPolicy, AgentError } from './retry-policy.js';
|
||||
import { AgentExhaustedError, AgentFailureError } from './retry-policy.js';
|
||||
import type { AgentErrorAnalyzer } from './error-analyzer.js';
|
||||
import type { CleanupStrategy, AgentInfo } from './cleanup-strategy.js';
|
||||
import type { EventBus, AgentAccountSwitchedEvent } from '../../events/types.js';
|
||||
|
||||
const log = createModuleLogger('lifecycle-controller');
|
||||
|
||||
@@ -48,6 +49,7 @@ export class AgentLifecycleController {
|
||||
private cleanupStrategy: CleanupStrategy,
|
||||
private accountRepository?: AccountRepository,
|
||||
private debug: boolean = false,
|
||||
private eventBus?: EventBus,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -304,7 +306,7 @@ export class AgentLifecycleController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle account exhaustion by marking account as exhausted.
|
||||
* Handle account exhaustion by marking account as exhausted and emitting account_switched event.
|
||||
*/
|
||||
private async handleAccountExhaustion(agentId: string): Promise<void> {
|
||||
if (!this.accountRepository) {
|
||||
@@ -319,15 +321,34 @@ export class AgentLifecycleController {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousAccountId = agent.accountId;
|
||||
|
||||
// Mark account as exhausted for 1 hour
|
||||
const exhaustedUntil = new Date(Date.now() + 60 * 60 * 1000);
|
||||
await this.accountRepository.markExhausted(agent.accountId, exhaustedUntil);
|
||||
await this.accountRepository.markExhausted(previousAccountId, exhaustedUntil);
|
||||
|
||||
log.info({
|
||||
agentId,
|
||||
accountId: agent.accountId,
|
||||
accountId: previousAccountId,
|
||||
exhaustedUntil
|
||||
}, 'marked account as exhausted due to usage limits');
|
||||
|
||||
// Find the next available account and emit account_switched event
|
||||
const newAccount = await this.accountRepository.findNextAvailable(agent.provider ?? 'claude');
|
||||
if (newAccount && this.eventBus) {
|
||||
const event: AgentAccountSwitchedEvent = {
|
||||
type: 'agent:account_switched',
|
||||
timestamp: new Date(),
|
||||
payload: {
|
||||
agentId,
|
||||
name: agent.name,
|
||||
previousAccountId,
|
||||
newAccountId: newAccount.id,
|
||||
reason: 'account_exhausted',
|
||||
},
|
||||
};
|
||||
this.eventBus.emit(event);
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn({
|
||||
agentId,
|
||||
@@ -353,6 +374,7 @@ export class AgentLifecycleController {
|
||||
status: agent.status,
|
||||
initiativeId: agent.initiativeId,
|
||||
worktreeId: agent.worktreeId,
|
||||
exitCode: agent.exitCode ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import type { AgentRepository } from '../../db/repositories/agent-repository.js'
|
||||
import type { AccountRepository } from '../../db/repositories/account-repository.js';
|
||||
import type { ProcessManager } from '../process-manager.js';
|
||||
import type { CleanupManager } from '../cleanup-manager.js';
|
||||
import type { EventBus } from '../../events/types.js';
|
||||
|
||||
export interface LifecycleFactoryOptions {
|
||||
repository: AgentRepository;
|
||||
@@ -21,6 +22,7 @@ export interface LifecycleFactoryOptions {
|
||||
cleanupManager: CleanupManager;
|
||||
accountRepository?: AccountRepository;
|
||||
debug?: boolean;
|
||||
eventBus?: EventBus;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,7 +34,8 @@ export function createLifecycleController(options: LifecycleFactoryOptions): Age
|
||||
processManager,
|
||||
cleanupManager,
|
||||
accountRepository,
|
||||
debug = false
|
||||
debug = false,
|
||||
eventBus,
|
||||
} = options;
|
||||
|
||||
// Create core components
|
||||
@@ -51,7 +54,8 @@ export function createLifecycleController(options: LifecycleFactoryOptions): Age
|
||||
cleanupManager,
|
||||
cleanupStrategy,
|
||||
accountRepository,
|
||||
debug
|
||||
debug,
|
||||
eventBus,
|
||||
);
|
||||
|
||||
return lifecycleController;
|
||||
|
||||
@@ -463,10 +463,10 @@ describe('MultiProviderAgentManager', () => {
|
||||
});
|
||||
|
||||
describe('sendUserMessage', () => {
|
||||
it('resumes errand agent in waiting_for_input status', async () => {
|
||||
it('resumes errand agent in idle status', async () => {
|
||||
mockRepository.findById = vi.fn().mockResolvedValue({
|
||||
...mockAgent,
|
||||
status: 'waiting_for_input',
|
||||
status: 'idle',
|
||||
});
|
||||
|
||||
const mockChild = createMockChildProcess();
|
||||
|
||||
@@ -98,6 +98,7 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
cleanupManager: this.cleanupManager,
|
||||
accountRepository,
|
||||
debug,
|
||||
eventBus,
|
||||
});
|
||||
|
||||
// Listen for process crashed events to handle agents specially
|
||||
@@ -238,8 +239,18 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
log.debug({ alias, initiativeId, baseBranch, branchName }, 'creating initiative-based worktrees');
|
||||
agentCwd = await this.processManager.createProjectWorktrees(alias, initiativeId, baseBranch, branchName);
|
||||
|
||||
// Log projects linked to the initiative
|
||||
// Verify each project worktree subdirectory actually exists
|
||||
const projects = await this.projectRepository.findProjectsByInitiativeId(initiativeId);
|
||||
for (const project of projects) {
|
||||
const projectWorktreePath = join(agentCwd, project.name);
|
||||
if (!existsSync(projectWorktreePath)) {
|
||||
throw new Error(
|
||||
`Worktree subdirectory missing after createProjectWorktrees: ${projectWorktreePath}. ` +
|
||||
`Agent ${alias} cannot run without an isolated worktree.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
log.info({
|
||||
alias,
|
||||
initiativeId,
|
||||
@@ -254,11 +265,12 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
}
|
||||
|
||||
// Verify the final agentCwd exists
|
||||
const cwdVerified = existsSync(agentCwd);
|
||||
if (!existsSync(agentCwd)) {
|
||||
throw new Error(`Agent workdir does not exist after creation: ${agentCwd}`);
|
||||
}
|
||||
log.info({
|
||||
alias,
|
||||
agentCwd,
|
||||
cwdVerified,
|
||||
initiativeBasedAgent: !!initiativeId
|
||||
}, 'agent workdir setup completed');
|
||||
|
||||
@@ -282,14 +294,15 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
});
|
||||
const agentId = agent.id;
|
||||
|
||||
// 3a. Append inter-agent communication instructions with actual agent ID
|
||||
prompt = prompt + buildInterAgentCommunication(agentId, mode);
|
||||
// 3a. Append inter-agent communication + preview instructions (skipped for focused agents)
|
||||
if (!options.skipPromptExtras) {
|
||||
prompt = prompt + buildInterAgentCommunication(agentId, mode);
|
||||
|
||||
// 3b. Append preview deployment instructions if applicable
|
||||
if (['execute', 'refine', 'discuss'].includes(mode) && initiativeId) {
|
||||
const shouldInject = await this.shouldInjectPreviewInstructions(initiativeId);
|
||||
if (shouldInject) {
|
||||
prompt = prompt + buildPreviewInstructions(agentId);
|
||||
if (['execute', 'refine', 'discuss'].includes(mode) && initiativeId) {
|
||||
const shouldInject = await this.shouldInjectPreviewInstructions(initiativeId);
|
||||
if (shouldInject) {
|
||||
prompt = prompt + buildPreviewInstructions(agentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,6 +310,10 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
if (options.inputContext) {
|
||||
await writeInputFiles({ agentWorkdir: agentCwd, ...options.inputContext, agentId, agentName: alias });
|
||||
log.debug({ alias }, 'input files written');
|
||||
} else {
|
||||
// Always create .cw/output/ at the agent workdir root so the agent
|
||||
// writes signal.json here rather than in a project subdirectory.
|
||||
await mkdir(join(agentCwd, '.cw', 'output'), { recursive: true });
|
||||
}
|
||||
|
||||
// 4. Build spawn command
|
||||
@@ -330,7 +347,7 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
this.createLogChunkCallback(agentId, alias, 1),
|
||||
);
|
||||
|
||||
await this.repository.update(agentId, { pid, outputFilePath });
|
||||
await this.repository.update(agentId, { pid, outputFilePath, prompt });
|
||||
|
||||
// Register agent and start polling BEFORE non-critical I/O so that a
|
||||
// diagnostic-write failure can never orphan a running process.
|
||||
@@ -603,6 +620,7 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
this.activeAgents.set(agentId, activeEntry);
|
||||
|
||||
if (this.eventBus) {
|
||||
// verified: payload matches AgentResumedEvent shape (agentId, name, taskId, sessionId)
|
||||
const event: AgentResumedEvent = {
|
||||
type: 'agent:resumed',
|
||||
timestamp: new Date(),
|
||||
@@ -634,7 +652,7 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
const agent = await this.repository.findById(agentId);
|
||||
if (!agent) throw new Error(`Agent not found: ${agentId}`);
|
||||
|
||||
if (agent.status !== 'running' && agent.status !== 'idle' && agent.status !== 'waiting_for_input') {
|
||||
if (agent.status !== 'running' && agent.status !== 'idle') {
|
||||
throw new Error(`Agent is not running (status: ${agent.status})`);
|
||||
}
|
||||
|
||||
@@ -859,6 +877,7 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
log.info({ agentId, pid }, 'resume detached subprocess started');
|
||||
|
||||
if (this.eventBus) {
|
||||
// verified: payload matches AgentResumedEvent shape (agentId, name, taskId, sessionId)
|
||||
const event: AgentResumedEvent = {
|
||||
type: 'agent:resumed',
|
||||
timestamp: new Date(),
|
||||
@@ -1163,6 +1182,8 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
userDismissedAt?: Date | null;
|
||||
exitCode?: number | null;
|
||||
prompt?: string | null;
|
||||
}): AgentInfo {
|
||||
return {
|
||||
id: agent.id,
|
||||
@@ -1178,6 +1199,8 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
createdAt: agent.createdAt,
|
||||
updatedAt: agent.updatedAt,
|
||||
userDismissedAt: agent.userDismissedAt,
|
||||
exitCode: agent.exitCode ?? null,
|
||||
prompt: agent.prompt ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +163,8 @@ export class MockAgentManager implements AgentManager {
|
||||
accountId: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
exitCode: null,
|
||||
prompt: null,
|
||||
};
|
||||
|
||||
const record: MockAgentRecord = {
|
||||
|
||||
@@ -5,9 +5,7 @@
|
||||
|
||||
import {
|
||||
SIGNAL_FORMAT,
|
||||
SESSION_STARTUP,
|
||||
GIT_WORKFLOW,
|
||||
CONTEXT_MANAGEMENT,
|
||||
} from './shared.js';
|
||||
|
||||
export function buildConflictResolutionPrompt(
|
||||
@@ -29,7 +27,12 @@ You are a Conflict Resolution agent. Your job is to merge \`${targetBranch}\` in
|
||||
${conflictList}
|
||||
</conflict_details>
|
||||
${SIGNAL_FORMAT}
|
||||
${SESSION_STARTUP}
|
||||
|
||||
<session_startup>
|
||||
1. \`pwd\` — confirm working directory
|
||||
2. \`git status\` — check branch state
|
||||
3. Read \`CLAUDE.md\` at the repo root (if it exists) — it contains project conventions you must follow.
|
||||
</session_startup>
|
||||
|
||||
<resolution_protocol>
|
||||
Follow these steps in order:
|
||||
@@ -57,7 +60,6 @@ Follow these steps in order:
|
||||
8. **Signal done**: Write signal.json with status "done".
|
||||
</resolution_protocol>
|
||||
${GIT_WORKFLOW}
|
||||
${CONTEXT_MANAGEMENT}
|
||||
|
||||
<important>
|
||||
- You are on a temporary branch created from ${sourceBranch}. You are merging ${targetBranch} INTO this branch — bringing it up to date, NOT the other way around.
|
||||
|
||||
@@ -4,21 +4,6 @@ export function buildErrandPrompt(description: string): string {
|
||||
Description: ${description}
|
||||
|
||||
Work interactively with the user. Make only the changes needed to fulfill the description.
|
||||
|
||||
## Asking questions
|
||||
|
||||
If you need clarification before or during the change, write .cw/output/signal.json with the questions format and end your session:
|
||||
|
||||
{ "status": "questions", "questions": [{ "id": "q1", "question": "What is the target file?" }] }
|
||||
|
||||
The session will end. The user will be shown your questions in the UI or via:
|
||||
|
||||
cw errand chat <id> "<their answer>"
|
||||
|
||||
Your session will then resume with their answers. Be explicit about what you need — don't make assumptions when the task is ambiguous.
|
||||
|
||||
## Finishing
|
||||
|
||||
When you are done, write .cw/output/signal.json:
|
||||
|
||||
{ "status": "done", "result": { "message": "<one-sentence summary of what you changed>" } }
|
||||
|
||||
@@ -81,6 +81,15 @@ Each phase must pass: **"Could a detail agent break this into tasks without clar
|
||||
</examples>
|
||||
</specificity>
|
||||
|
||||
<subagent_usage>
|
||||
Use subagents to parallelize your analysis — don't do everything sequentially:
|
||||
- **Domain decomposition**: Spawn separate subagents to investigate different aspects of the initiative (e.g., one for database/schema concerns, one for API surface, one for frontend components) and synthesize their findings into your phase plan.
|
||||
- **Dependency mapping**: Spawn a subagent to map existing code dependencies and file ownership while you analyze initiative requirements, so you can make informed decisions about phase boundaries and parallelism.
|
||||
- **Pattern discovery**: When the initiative touches multiple subsystems, spawn subagents to search for existing patterns in each subsystem simultaneously rather than exploring them one at a time.
|
||||
|
||||
Don't spawn subagents for trivial initiatives with obvious structure — use judgment.
|
||||
</subagent_usage>
|
||||
|
||||
<existing_context>
|
||||
- Account for existing phases/tasks — don't plan work already covered
|
||||
- Always generate new phase IDs — never reuse existing ones
|
||||
|
||||
@@ -33,6 +33,15 @@ Ignore style, grammar, formatting unless they cause genuine ambiguity. Rough but
|
||||
If all pages are already clear, signal done with no output files.
|
||||
</improvement_priorities>
|
||||
|
||||
<subagent_usage>
|
||||
Use subagents to parallelize your work:
|
||||
- **Parallel page analysis**: Spawn one subagent per page (or group of related pages) to analyze clarity issues simultaneously rather than reviewing pages sequentially.
|
||||
- **Codebase verification**: When checking whether a requirement is feasible or matches existing patterns, spawn a subagent to search the codebase while you continue reviewing other pages.
|
||||
- **Cross-reference validation**: Spawn a subagent to verify that all [[page:$id|title]] cross-references are valid and consistent across pages.
|
||||
|
||||
Don't over-split — if there are only 1-2 short pages, just do the work directly.
|
||||
</subagent_usage>
|
||||
|
||||
<rules>
|
||||
- Ask 2-4 questions if you need clarification
|
||||
- Preserve [[page:\$id|title]] cross-references
|
||||
|
||||
@@ -36,5 +36,7 @@ This is an isolated git worktree. Other agents may be working in parallel on sep
|
||||
The following project directories contain the source code (git worktrees):
|
||||
|
||||
${lines.join('\n')}
|
||||
|
||||
**IMPORTANT**: All \`.cw/output/\` paths (signal.json, progress.md, etc.) are relative to this working directory (\`${agentCwd}\`), NOT to any project subdirectory. Always write to \`${join(agentCwd, '.cw/output/')}\` regardless of your current \`cd\` location.
|
||||
</workspace>`;
|
||||
}
|
||||
|
||||
@@ -61,6 +61,8 @@ export interface SpawnAgentOptions {
|
||||
branchName?: string;
|
||||
/** Context data to write as input files in agent workdir */
|
||||
inputContext?: AgentInputContext;
|
||||
/** Skip inter-agent communication and preview instructions (for focused agents like conflict resolution) */
|
||||
skipPromptExtras?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,6 +95,10 @@ export interface AgentInfo {
|
||||
updatedAt: Date;
|
||||
/** When the user dismissed this agent (null if not dismissed) */
|
||||
userDismissedAt?: Date | null;
|
||||
/** Process exit code — null while running or if not yet exited */
|
||||
exitCode: number | null;
|
||||
/** Full assembled prompt passed to the agent process — null for agents spawned before DB persistence */
|
||||
prompt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -191,10 +191,6 @@ export async function createContainer(options?: ContainerOptions): Promise<Conta
|
||||
);
|
||||
log.info('agent manager created');
|
||||
|
||||
// Reconcile agent state from any previous server session
|
||||
await agentManager.reconcileAfterRestart();
|
||||
log.info('agent reconciliation complete');
|
||||
|
||||
// Branch manager
|
||||
const branchManager = new SimpleGitBranchManager();
|
||||
log.info('branch manager created');
|
||||
@@ -254,10 +250,17 @@ export async function createContainer(options?: ContainerOptions): Promise<Conta
|
||||
conflictResolutionService,
|
||||
eventBus,
|
||||
workspaceRoot,
|
||||
repos.agentRepository,
|
||||
);
|
||||
executionOrchestrator.start();
|
||||
log.info('execution orchestrator started');
|
||||
|
||||
// Reconcile agent state from any previous server session.
|
||||
// Must run AFTER orchestrator.start() so event listeners are registered
|
||||
// and agent:stopped / agent:crashed events are not lost.
|
||||
await agentManager.reconcileAfterRestart();
|
||||
log.info('agent reconciliation complete');
|
||||
|
||||
// Preview manager
|
||||
const previewManager = new PreviewManager(
|
||||
repos.projectRepository,
|
||||
|
||||
@@ -45,6 +45,7 @@ export interface UpdateAgentData {
|
||||
accountId?: string | null;
|
||||
pid?: number | null;
|
||||
exitCode?: number | null;
|
||||
prompt?: string | null;
|
||||
outputFilePath?: string | null;
|
||||
result?: string | null;
|
||||
pendingQuestions?: string | null;
|
||||
|
||||
@@ -1,161 +1,336 @@
|
||||
/**
|
||||
* DrizzleErrandRepository Tests
|
||||
*
|
||||
* Tests for the Errand repository adapter.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { DrizzleErrandRepository } from './errand.js';
|
||||
import { DrizzleProjectRepository } from './project.js';
|
||||
import { createTestDatabase } from './test-helpers.js';
|
||||
import type { DrizzleDatabase } from '../../index.js';
|
||||
import type { Project } from '../../schema.js';
|
||||
import { projects, agents, errands } from '../../schema.js';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
describe('DrizzleErrandRepository', () => {
|
||||
let db: DrizzleDatabase;
|
||||
let repo: DrizzleErrandRepository;
|
||||
let projectRepo: DrizzleProjectRepository;
|
||||
|
||||
const createProject = async (): Promise<Project> => {
|
||||
const suffix = Math.random().toString(36).slice(2, 8);
|
||||
return projectRepo.create({
|
||||
name: `test-project-${suffix}`,
|
||||
url: `https://github.com/test/repo-${suffix}`,
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDatabase();
|
||||
repo = new DrizzleErrandRepository(db);
|
||||
projectRepo = new DrizzleProjectRepository(db);
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('creates an errand with generated id and timestamps', async () => {
|
||||
// Helper: create a project record
|
||||
async function createProject(name = 'Test Project', suffix = '') {
|
||||
const id = nanoid();
|
||||
const now = new Date();
|
||||
const [project] = await db.insert(projects).values({
|
||||
id,
|
||||
name: name + suffix + id,
|
||||
url: `https://github.com/test/${id}`,
|
||||
defaultBranch: 'main',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).returning();
|
||||
return project;
|
||||
}
|
||||
|
||||
// Helper: create an agent record
|
||||
async function createAgent(name?: string) {
|
||||
const id = nanoid();
|
||||
const now = new Date();
|
||||
const agentName = name ?? `agent-${id}`;
|
||||
const [agent] = await db.insert(agents).values({
|
||||
id,
|
||||
name: agentName,
|
||||
worktreeId: `agent-workdirs/${agentName}`,
|
||||
provider: 'claude',
|
||||
status: 'idle',
|
||||
mode: 'execute',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).returning();
|
||||
return agent;
|
||||
}
|
||||
|
||||
// Helper: create an errand
|
||||
async function createErrand(overrides: Partial<{
|
||||
id: string;
|
||||
description: string;
|
||||
branch: string;
|
||||
baseBranch: string;
|
||||
agentId: string | null;
|
||||
projectId: string | null;
|
||||
status: 'active' | 'pending_review' | 'conflict' | 'merged' | 'abandoned';
|
||||
createdAt: Date;
|
||||
}> = {}) {
|
||||
const project = await createProject();
|
||||
const id = overrides.id ?? nanoid();
|
||||
return repo.create({
|
||||
id,
|
||||
description: overrides.description ?? 'Test errand',
|
||||
branch: overrides.branch ?? 'feature/test',
|
||||
baseBranch: overrides.baseBranch ?? 'main',
|
||||
agentId: overrides.agentId !== undefined ? overrides.agentId : null,
|
||||
projectId: overrides.projectId !== undefined ? overrides.projectId : project.id,
|
||||
status: overrides.status ?? 'active',
|
||||
});
|
||||
}
|
||||
|
||||
describe('create + findById', () => {
|
||||
it('should create errand and find by id with all fields', async () => {
|
||||
const project = await createProject();
|
||||
const errand = await repo.create({
|
||||
description: 'fix typo',
|
||||
branch: 'cw/errand/fix-typo-abc12345',
|
||||
const id = nanoid();
|
||||
|
||||
await repo.create({
|
||||
id,
|
||||
description: 'Fix the bug',
|
||||
branch: 'fix/bug-123',
|
||||
baseBranch: 'main',
|
||||
agentId: null,
|
||||
projectId: project.id,
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
expect(errand.id).toBeDefined();
|
||||
expect(errand.id.length).toBeGreaterThan(0);
|
||||
expect(errand.description).toBe('fix typo');
|
||||
expect(errand.branch).toBe('cw/errand/fix-typo-abc12345');
|
||||
expect(errand.baseBranch).toBe('main');
|
||||
expect(errand.agentId).toBeNull();
|
||||
expect(errand.projectId).toBe(project.id);
|
||||
expect(errand.status).toBe('active');
|
||||
expect(errand.conflictFiles).toBeNull();
|
||||
expect(errand.createdAt).toBeInstanceOf(Date);
|
||||
expect(errand.updatedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('returns null for non-existent errand', async () => {
|
||||
const result = await repo.findById('does-not-exist');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns errand with agentAlias null when no agent', async () => {
|
||||
const project = await createProject();
|
||||
const created = await repo.create({
|
||||
description: 'test',
|
||||
branch: 'cw/errand/test-xyz',
|
||||
baseBranch: 'main',
|
||||
agentId: null,
|
||||
projectId: project.id,
|
||||
status: 'active',
|
||||
});
|
||||
const found = await repo.findById(created.id);
|
||||
expect(found).not.toBeNull();
|
||||
const found = await repo.findById(id);
|
||||
expect(found).toBeDefined();
|
||||
expect(found!.id).toBe(id);
|
||||
expect(found!.description).toBe('Fix the bug');
|
||||
expect(found!.branch).toBe('fix/bug-123');
|
||||
expect(found!.baseBranch).toBe('main');
|
||||
expect(found!.status).toBe('active');
|
||||
expect(found!.projectId).toBe(project.id);
|
||||
expect(found!.agentId).toBeNull();
|
||||
expect(found!.agentAlias).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('returns empty array when no errands', async () => {
|
||||
const results = await repo.findAll();
|
||||
expect(results).toEqual([]);
|
||||
it('should return all errands ordered by createdAt desc', async () => {
|
||||
const project = await createProject();
|
||||
const t1 = new Date('2024-01-01T00:00:00Z');
|
||||
const t2 = new Date('2024-01-02T00:00:00Z');
|
||||
const t3 = new Date('2024-01-03T00:00:00Z');
|
||||
|
||||
const id1 = nanoid();
|
||||
const id2 = nanoid();
|
||||
const id3 = nanoid();
|
||||
|
||||
await db.insert(errands).values([
|
||||
{ id: id1, description: 'Errand 1', branch: 'b1', baseBranch: 'main', agentId: null, projectId: project.id, status: 'active', createdAt: t1, updatedAt: t1 },
|
||||
{ id: id2, description: 'Errand 2', branch: 'b2', baseBranch: 'main', agentId: null, projectId: project.id, status: 'active', createdAt: t2, updatedAt: t2 },
|
||||
{ id: id3, description: 'Errand 3', branch: 'b3', baseBranch: 'main', agentId: null, projectId: project.id, status: 'active', createdAt: t3, updatedAt: t3 },
|
||||
]);
|
||||
|
||||
const result = await repo.findAll();
|
||||
expect(result.length).toBeGreaterThanOrEqual(3);
|
||||
// Find our three in the results
|
||||
const ids = result.map((e) => e.id);
|
||||
expect(ids.indexOf(id3)).toBeLessThan(ids.indexOf(id2));
|
||||
expect(ids.indexOf(id2)).toBeLessThan(ids.indexOf(id1));
|
||||
});
|
||||
|
||||
it('filters by projectId', async () => {
|
||||
const projectA = await createProject();
|
||||
const projectB = await createProject();
|
||||
await repo.create({ description: 'a', branch: 'cw/errand/a', baseBranch: 'main', agentId: null, projectId: projectA.id, status: 'active' });
|
||||
await repo.create({ description: 'b', branch: 'cw/errand/b', baseBranch: 'main', agentId: null, projectId: projectB.id, status: 'active' });
|
||||
it('should filter by projectId', async () => {
|
||||
const projectA = await createProject('A');
|
||||
const projectB = await createProject('B');
|
||||
const now = new Date();
|
||||
|
||||
const results = await repo.findAll({ projectId: projectA.id });
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].description).toBe('a');
|
||||
const idA1 = nanoid();
|
||||
const idA2 = nanoid();
|
||||
const idB1 = nanoid();
|
||||
|
||||
await db.insert(errands).values([
|
||||
{ id: idA1, description: 'A1', branch: 'b-a1', baseBranch: 'main', agentId: null, projectId: projectA.id, status: 'active', createdAt: now, updatedAt: now },
|
||||
{ id: idA2, description: 'A2', branch: 'b-a2', baseBranch: 'main', agentId: null, projectId: projectA.id, status: 'active', createdAt: now, updatedAt: now },
|
||||
{ id: idB1, description: 'B1', branch: 'b-b1', baseBranch: 'main', agentId: null, projectId: projectB.id, status: 'active', createdAt: now, updatedAt: now },
|
||||
]);
|
||||
|
||||
const result = await repo.findAll({ projectId: projectA.id });
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((e) => e.id).sort()).toEqual([idA1, idA2].sort());
|
||||
});
|
||||
|
||||
it('should filter by status', async () => {
|
||||
const project = await createProject();
|
||||
const now = new Date();
|
||||
|
||||
const id1 = nanoid();
|
||||
const id2 = nanoid();
|
||||
const id3 = nanoid();
|
||||
|
||||
await db.insert(errands).values([
|
||||
{ id: id1, description: 'E1', branch: 'b1', baseBranch: 'main', agentId: null, projectId: project.id, status: 'active', createdAt: now, updatedAt: now },
|
||||
{ id: id2, description: 'E2', branch: 'b2', baseBranch: 'main', agentId: null, projectId: project.id, status: 'pending_review', createdAt: now, updatedAt: now },
|
||||
{ id: id3, description: 'E3', branch: 'b3', baseBranch: 'main', agentId: null, projectId: project.id, status: 'merged', createdAt: now, updatedAt: now },
|
||||
]);
|
||||
|
||||
const result = await repo.findAll({ status: 'pending_review' });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe(id2);
|
||||
});
|
||||
|
||||
it('should filter by both projectId and status', async () => {
|
||||
const projectA = await createProject('PA');
|
||||
const projectB = await createProject('PB');
|
||||
const now = new Date();
|
||||
|
||||
const idMatch = nanoid();
|
||||
const idOtherStatus = nanoid();
|
||||
const idOtherProject = nanoid();
|
||||
const idNeither = nanoid();
|
||||
|
||||
await db.insert(errands).values([
|
||||
{ id: idMatch, description: 'Match', branch: 'b1', baseBranch: 'main', agentId: null, projectId: projectA.id, status: 'pending_review', createdAt: now, updatedAt: now },
|
||||
{ id: idOtherStatus, description: 'Wrong status', branch: 'b2', baseBranch: 'main', agentId: null, projectId: projectA.id, status: 'active', createdAt: now, updatedAt: now },
|
||||
{ id: idOtherProject, description: 'Wrong project', branch: 'b3', baseBranch: 'main', agentId: null, projectId: projectB.id, status: 'pending_review', createdAt: now, updatedAt: now },
|
||||
{ id: idNeither, description: 'Neither', branch: 'b4', baseBranch: 'main', agentId: null, projectId: projectB.id, status: 'active', createdAt: now, updatedAt: now },
|
||||
]);
|
||||
|
||||
const result = await repo.findAll({ projectId: projectA.id, status: 'pending_review' });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe(idMatch);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('should return agentAlias when agentId is set', async () => {
|
||||
const agent = await createAgent('known-agent');
|
||||
const project = await createProject();
|
||||
const id = nanoid();
|
||||
const now = new Date();
|
||||
|
||||
await db.insert(errands).values({
|
||||
id,
|
||||
description: 'With agent',
|
||||
branch: 'feature/x',
|
||||
baseBranch: 'main',
|
||||
agentId: agent.id,
|
||||
projectId: project.id,
|
||||
status: 'active',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const found = await repo.findById(id);
|
||||
expect(found).toBeDefined();
|
||||
expect(found!.agentAlias).toBe(agent.name);
|
||||
});
|
||||
|
||||
it('should return agentAlias as null when agentId is null', async () => {
|
||||
const project = await createProject();
|
||||
const id = nanoid();
|
||||
const now = new Date();
|
||||
|
||||
await db.insert(errands).values({
|
||||
id,
|
||||
description: 'No agent',
|
||||
branch: 'feature/y',
|
||||
baseBranch: 'main',
|
||||
agentId: null,
|
||||
projectId: project.id,
|
||||
status: 'active',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const found = await repo.findById(id);
|
||||
expect(found).toBeDefined();
|
||||
expect(found!.agentAlias).toBeNull();
|
||||
});
|
||||
|
||||
it('should return undefined for unknown id', async () => {
|
||||
const found = await repo.findById('nonexistent');
|
||||
expect(found).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('updates errand status', async () => {
|
||||
it('should update status and advance updatedAt', async () => {
|
||||
const project = await createProject();
|
||||
const created = await repo.create({
|
||||
description: 'upd test',
|
||||
branch: 'cw/errand/upd',
|
||||
const id = nanoid();
|
||||
const past = new Date('2024-01-01T00:00:00Z');
|
||||
|
||||
await db.insert(errands).values({
|
||||
id,
|
||||
description: 'Errand',
|
||||
branch: 'feature/update',
|
||||
baseBranch: 'main',
|
||||
agentId: null,
|
||||
projectId: project.id,
|
||||
status: 'active',
|
||||
createdAt: past,
|
||||
updatedAt: past,
|
||||
});
|
||||
const updated = await repo.update(created.id, { status: 'pending_review' });
|
||||
expect(updated!.status).toBe('pending_review');
|
||||
|
||||
const updated = await repo.update(id, { status: 'pending_review' });
|
||||
expect(updated.status).toBe('pending_review');
|
||||
expect(updated.updatedAt.getTime()).toBeGreaterThan(past.getTime());
|
||||
});
|
||||
|
||||
it('should throw on unknown id', async () => {
|
||||
await expect(
|
||||
repo.update('nonexistent', { status: 'merged' })
|
||||
).rejects.toThrow('Errand not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('conflictFiles column', () => {
|
||||
it('stores and retrieves conflictFiles via update + findById', async () => {
|
||||
describe('delete', () => {
|
||||
it('should delete errand and findById returns undefined', async () => {
|
||||
const errand = await createErrand();
|
||||
await repo.delete(errand.id);
|
||||
const found = await repo.findById(errand.id);
|
||||
expect(found).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cascade and set null', () => {
|
||||
it('should cascade delete errands when project is deleted', async () => {
|
||||
const project = await createProject();
|
||||
const created = await repo.create({
|
||||
description: 'x',
|
||||
branch: 'cw/errand/x',
|
||||
const id = nanoid();
|
||||
const now = new Date();
|
||||
|
||||
await db.insert(errands).values({
|
||||
id,
|
||||
description: 'Cascade test',
|
||||
branch: 'feature/cascade',
|
||||
baseBranch: 'main',
|
||||
agentId: null,
|
||||
projectId: project.id,
|
||||
status: 'active',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await repo.update(created.id, { status: 'conflict', conflictFiles: '["src/a.ts","src/b.ts"]' });
|
||||
const found = await repo.findById(created.id);
|
||||
expect(found!.conflictFiles).toBe('["src/a.ts","src/b.ts"]');
|
||||
expect(found!.status).toBe('conflict');
|
||||
|
||||
// Delete project — should cascade delete errands
|
||||
await db.delete(projects).where(eq(projects.id, project.id));
|
||||
|
||||
const found = await repo.findById(id);
|
||||
expect(found).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns null conflictFiles for non-conflict errands', async () => {
|
||||
it('should set agentId to null when agent is deleted', async () => {
|
||||
const agent = await createAgent();
|
||||
const project = await createProject();
|
||||
const created = await repo.create({
|
||||
description: 'y',
|
||||
branch: 'cw/errand/y',
|
||||
baseBranch: 'main',
|
||||
agentId: null,
|
||||
projectId: project.id,
|
||||
status: 'active',
|
||||
});
|
||||
const found = await repo.findById(created.id);
|
||||
expect(found!.conflictFiles).toBeNull();
|
||||
});
|
||||
const id = nanoid();
|
||||
const now = new Date();
|
||||
|
||||
it('findAll includes conflictFiles in results', async () => {
|
||||
const project = await createProject();
|
||||
const created = await repo.create({
|
||||
description: 'z',
|
||||
branch: 'cw/errand/z',
|
||||
await db.insert(errands).values({
|
||||
id,
|
||||
description: 'Agent null test',
|
||||
branch: 'feature/agent-null',
|
||||
baseBranch: 'main',
|
||||
agentId: null,
|
||||
agentId: agent.id,
|
||||
projectId: project.id,
|
||||
status: 'active',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await repo.update(created.id, { conflictFiles: '["x.ts"]' });
|
||||
const all = await repo.findAll({ projectId: project.id });
|
||||
expect(all[0].conflictFiles).toBe('["x.ts"]');
|
||||
|
||||
// Delete agent — should set null
|
||||
await db.delete(agents).where(eq(agents.id, agent.id));
|
||||
|
||||
const [errand] = await db.select().from(errands).where(eq(errands.id, id));
|
||||
expect(errand).toBeDefined();
|
||||
expect(errand.agentId).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,41 +4,32 @@
|
||||
* Implements ErrandRepository interface using Drizzle ORM.
|
||||
*/
|
||||
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { eq, desc, and } from 'drizzle-orm';
|
||||
import type { DrizzleDatabase } from '../../index.js';
|
||||
import { errands, agents, type Errand } from '../../schema.js';
|
||||
import { errands, agents } from '../../schema.js';
|
||||
import type {
|
||||
ErrandRepository,
|
||||
ErrandWithAlias,
|
||||
ErrandStatus,
|
||||
CreateErrandData,
|
||||
UpdateErrandData,
|
||||
ErrandWithAlias,
|
||||
FindAllErrandOptions,
|
||||
} from '../errand-repository.js';
|
||||
import type { Errand } from '../../schema.js';
|
||||
|
||||
export class DrizzleErrandRepository implements ErrandRepository {
|
||||
constructor(private db: DrizzleDatabase) {}
|
||||
|
||||
async create(data: CreateErrandData): Promise<Errand> {
|
||||
const now = new Date();
|
||||
const id = nanoid();
|
||||
const [created] = await this.db.insert(errands).values({
|
||||
id,
|
||||
description: data.description,
|
||||
branch: data.branch,
|
||||
baseBranch: data.baseBranch ?? 'main',
|
||||
agentId: data.agentId ?? null,
|
||||
projectId: data.projectId,
|
||||
status: data.status ?? 'active',
|
||||
conflictFiles: data.conflictFiles ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).returning();
|
||||
const [created] = await this.db
|
||||
.insert(errands)
|
||||
.values({ ...data, createdAt: now, updatedAt: now })
|
||||
.returning();
|
||||
return created;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<ErrandWithAlias | null> {
|
||||
const rows = await this.db
|
||||
async findById(id: string): Promise<ErrandWithAlias | undefined> {
|
||||
const result = await this.db
|
||||
.select({
|
||||
id: errands.id,
|
||||
description: errands.description,
|
||||
@@ -47,7 +38,6 @@ export class DrizzleErrandRepository implements ErrandRepository {
|
||||
agentId: errands.agentId,
|
||||
projectId: errands.projectId,
|
||||
status: errands.status,
|
||||
conflictFiles: errands.conflictFiles,
|
||||
createdAt: errands.createdAt,
|
||||
updatedAt: errands.updatedAt,
|
||||
agentAlias: agents.name,
|
||||
@@ -56,16 +46,15 @@ export class DrizzleErrandRepository implements ErrandRepository {
|
||||
.leftJoin(agents, eq(errands.agentId, agents.id))
|
||||
.where(eq(errands.id, id))
|
||||
.limit(1);
|
||||
if (!rows[0]) return null;
|
||||
return rows[0] as ErrandWithAlias;
|
||||
return result[0] ?? undefined;
|
||||
}
|
||||
|
||||
async findAll(options?: FindAllErrandOptions): Promise<ErrandWithAlias[]> {
|
||||
async findAll(opts?: { projectId?: string; status?: ErrandStatus }): Promise<ErrandWithAlias[]> {
|
||||
const conditions = [];
|
||||
if (options?.projectId) conditions.push(eq(errands.projectId, options.projectId));
|
||||
if (options?.status) conditions.push(eq(errands.status, options.status));
|
||||
if (opts?.projectId) conditions.push(eq(errands.projectId, opts.projectId));
|
||||
if (opts?.status) conditions.push(eq(errands.status, opts.status));
|
||||
|
||||
const rows = await this.db
|
||||
return this.db
|
||||
.select({
|
||||
id: errands.id,
|
||||
description: errands.description,
|
||||
@@ -74,7 +63,6 @@ export class DrizzleErrandRepository implements ErrandRepository {
|
||||
agentId: errands.agentId,
|
||||
projectId: errands.projectId,
|
||||
status: errands.status,
|
||||
conflictFiles: errands.conflictFiles,
|
||||
createdAt: errands.createdAt,
|
||||
updatedAt: errands.updatedAt,
|
||||
agentAlias: agents.name,
|
||||
@@ -82,21 +70,17 @@ export class DrizzleErrandRepository implements ErrandRepository {
|
||||
.from(errands)
|
||||
.leftJoin(agents, eq(errands.agentId, agents.id))
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.orderBy(desc(errands.createdAt), desc(errands.id));
|
||||
return rows as ErrandWithAlias[];
|
||||
.orderBy(desc(errands.createdAt));
|
||||
}
|
||||
|
||||
async update(id: string, data: UpdateErrandData): Promise<Errand | null> {
|
||||
await this.db
|
||||
async update(id: string, data: UpdateErrandData): Promise<Errand> {
|
||||
const [updated] = await this.db
|
||||
.update(errands)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(errands.id, id));
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(errands)
|
||||
.where(eq(errands.id, id))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
.returning();
|
||||
if (!updated) throw new Error(`Errand not found: ${id}`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
|
||||
@@ -1,45 +1,15 @@
|
||||
/**
|
||||
* Errand Repository Port Interface
|
||||
*
|
||||
* Port for Errand aggregate operations.
|
||||
* Implementations (Drizzle, etc.) are adapters.
|
||||
*/
|
||||
import type { Errand, NewErrand } from '../schema.js';
|
||||
|
||||
import type { Errand, NewErrand, ErrandStatus } from '../schema.js';
|
||||
export type ErrandStatus = 'active' | 'pending_review' | 'conflict' | 'merged' | 'abandoned';
|
||||
export type ErrandWithAlias = Errand & { agentAlias: string | null };
|
||||
|
||||
/**
|
||||
* Data for creating a new errand.
|
||||
* Omits system-managed fields (id, createdAt, updatedAt).
|
||||
*/
|
||||
export type CreateErrandData = Omit<NewErrand, 'id' | 'createdAt' | 'updatedAt'>;
|
||||
|
||||
/**
|
||||
* Data for updating an errand.
|
||||
*/
|
||||
export type CreateErrandData = Omit<NewErrand, 'createdAt' | 'updatedAt'>;
|
||||
export type UpdateErrandData = Partial<Omit<NewErrand, 'id' | 'createdAt'>>;
|
||||
|
||||
/**
|
||||
* Errand with the agent alias joined in.
|
||||
*/
|
||||
export interface ErrandWithAlias extends Errand {
|
||||
agentAlias: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter options for listing errands.
|
||||
*/
|
||||
export interface FindAllErrandOptions {
|
||||
projectId?: string;
|
||||
status?: ErrandStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Errand Repository Port
|
||||
*/
|
||||
export interface ErrandRepository {
|
||||
create(data: CreateErrandData): Promise<Errand>;
|
||||
findById(id: string): Promise<ErrandWithAlias | null>;
|
||||
findAll(options?: FindAllErrandOptions): Promise<ErrandWithAlias[]>;
|
||||
update(id: string, data: UpdateErrandData): Promise<Errand | null>;
|
||||
findById(id: string): Promise<ErrandWithAlias | undefined>;
|
||||
findAll(opts?: { projectId?: string; status?: ErrandStatus }): Promise<ErrandWithAlias[]>;
|
||||
update(id: string, data: UpdateErrandData): Promise<Errand>;
|
||||
delete(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -85,8 +85,8 @@ export type {
|
||||
|
||||
export type {
|
||||
ErrandRepository,
|
||||
ErrandWithAlias,
|
||||
ErrandStatus,
|
||||
CreateErrandData,
|
||||
UpdateErrandData,
|
||||
ErrandWithAlias,
|
||||
FindAllErrandOptions,
|
||||
} from './errand-repository.js';
|
||||
|
||||
@@ -157,6 +157,7 @@ export const tasks = sqliteTable('tasks', {
|
||||
.default('pending'),
|
||||
order: integer('order').notNull().default(0),
|
||||
summary: text('summary'), // Agent result summary — propagated to dependent tasks as context
|
||||
retryCount: integer('retry_count').notNull().default(0),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
});
|
||||
@@ -266,6 +267,7 @@ export const agents = sqliteTable('agents', {
|
||||
.default('execute'),
|
||||
pid: integer('pid'),
|
||||
exitCode: integer('exit_code'), // Process exit code for debugging crashes
|
||||
prompt: text('prompt'), // Full assembled prompt passed to the agent process (persisted for durability after log cleanup)
|
||||
outputFilePath: text('output_file_path'),
|
||||
result: text('result'),
|
||||
pendingQuestions: text('pending_questions'),
|
||||
@@ -633,28 +635,30 @@ export type NewReviewComment = InferInsertModel<typeof reviewComments>;
|
||||
// ERRANDS
|
||||
// ============================================================================
|
||||
|
||||
export const ERRAND_STATUS_VALUES = ['active', 'pending_review', 'conflict', 'merged', 'abandoned'] as const;
|
||||
export type ErrandStatus = (typeof ERRAND_STATUS_VALUES)[number];
|
||||
|
||||
export const errands = sqliteTable('errands', {
|
||||
id: text('id').primaryKey(),
|
||||
description: text('description').notNull(),
|
||||
branch: text('branch').notNull(),
|
||||
baseBranch: text('base_branch').notNull().default('main'),
|
||||
agentId: text('agent_id').references(() => agents.id, { onDelete: 'set null' }),
|
||||
projectId: text('project_id')
|
||||
.notNull()
|
||||
.references(() => projects.id, { onDelete: 'cascade' }),
|
||||
status: text('status', { enum: ERRAND_STATUS_VALUES })
|
||||
.notNull()
|
||||
.default('active'),
|
||||
projectId: text('project_id').references(() => projects.id, { onDelete: 'cascade' }),
|
||||
status: text('status', {
|
||||
enum: ['active', 'pending_review', 'conflict', 'merged', 'abandoned'],
|
||||
}).notNull().default('active'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
conflictFiles: text('conflict_files'), // JSON-encoded string[] | null; set on merge conflict
|
||||
}, (table) => [
|
||||
index('errands_project_id_idx').on(table.projectId),
|
||||
index('errands_status_idx').on(table.status),
|
||||
]);
|
||||
});
|
||||
|
||||
export const errandsRelations = relations(errands, ({ one }) => ({
|
||||
agent: one(agents, {
|
||||
fields: [errands.agentId],
|
||||
references: [agents.id],
|
||||
}),
|
||||
project: one(projects, {
|
||||
fields: [errands.projectId],
|
||||
references: [projects.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export type Errand = InferSelectModel<typeof errands>;
|
||||
export type NewErrand = InferInsertModel<typeof errands>;
|
||||
|
||||
@@ -70,6 +70,8 @@ function createMockAgentManager(
|
||||
accountId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
exitCode: null,
|
||||
prompt: null,
|
||||
};
|
||||
mockAgents.push(newAgent);
|
||||
return newAgent;
|
||||
@@ -102,6 +104,8 @@ function createIdleAgent(id: string, name: string): AgentInfo {
|
||||
accountId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
exitCode: null,
|
||||
prompt: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -247,8 +247,8 @@ export class DefaultDispatchManager implements DispatchManager {
|
||||
// Clear blocked state
|
||||
this.blockedTasks.delete(taskId);
|
||||
|
||||
// Reset DB status to pending
|
||||
await this.taskRepository.update(taskId, { status: 'pending' });
|
||||
// Reset DB status to pending and clear retry count (manual retry = fresh start)
|
||||
await this.taskRepository.update(taskId, { status: 'pending', retryCount: 0 });
|
||||
|
||||
log.info({ taskId }, 'retrying blocked task');
|
||||
|
||||
@@ -327,8 +327,13 @@ export class DefaultDispatchManager implements DispatchManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal: fall back to default branching
|
||||
} catch (err) {
|
||||
if (!isPlanningCategory(task.category)) {
|
||||
// Execution tasks MUST have correct branches — fail loudly
|
||||
throw new Error(`Failed to compute branches for execution task ${task.id}: ${err}`);
|
||||
}
|
||||
// Planning tasks: non-fatal, fall back to default branching
|
||||
log.debug({ taskId: task.id, err }, 'branch computation skipped for planning task');
|
||||
}
|
||||
|
||||
// Ensure branches exist in project clones before spawning worktrees
|
||||
@@ -350,7 +355,10 @@ export class DefaultDispatchManager implements DispatchManager {
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn({ taskId: task.id, err }, 'failed to ensure branches for task dispatch');
|
||||
if (!isPlanningCategory(task.category)) {
|
||||
throw new Error(`Failed to ensure branches for execution task ${task.id}: ${err}`);
|
||||
}
|
||||
log.warn({ taskId: task.id, err }, 'failed to ensure branches for planning task dispatch');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1
apps/server/drizzle/0034_add_task_retry_count.sql
Normal file
1
apps/server/drizzle/0034_add_task_retry_count.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE tasks ADD COLUMN retry_count integer NOT NULL DEFAULT 0;
|
||||
13
apps/server/drizzle/0035_faulty_human_fly.sql
Normal file
13
apps/server/drizzle/0035_faulty_human_fly.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE `errands` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`description` text NOT NULL,
|
||||
`branch` text NOT NULL,
|
||||
`base_branch` text DEFAULT 'main' NOT NULL,
|
||||
`agent_id` text,
|
||||
`project_id` text,
|
||||
`status` text DEFAULT 'active' NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
FOREIGN KEY (`agent_id`) REFERENCES `agents`(`id`) ON UPDATE no action ON DELETE set null,
|
||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
1
apps/server/drizzle/0036_icy_silvermane.sql
Normal file
1
apps/server/drizzle/0036_icy_silvermane.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `agents` ADD `prompt` text;
|
||||
1974
apps/server/drizzle/meta/0035_snapshot.json
Normal file
1974
apps/server/drizzle/meta/0035_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1159
apps/server/drizzle/meta/0036_snapshot.json
Normal file
1159
apps/server/drizzle/meta/0036_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -243,9 +243,23 @@
|
||||
{
|
||||
"idx": 34,
|
||||
"version": "6",
|
||||
"when": 1772808163349,
|
||||
"tag": "0034_salty_next_avengers",
|
||||
"when": 1772496000000,
|
||||
"tag": "0034_add_task_retry_count",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 35,
|
||||
"version": "6",
|
||||
"when": 1772796561474,
|
||||
"tag": "0035_faulty_human_fly",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 36,
|
||||
"version": "6",
|
||||
"when": 1772798869413,
|
||||
"tag": "0036_icy_silvermane",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ExecutionOrchestrator } from './orchestrator.js';
|
||||
import { ensureProjectClone } from '../git/project-clones.js';
|
||||
import type { BranchManager } from '../git/branch-manager.js';
|
||||
|
||||
vi.mock('../git/project-clones.js', () => ({
|
||||
ensureProjectClone: vi.fn().mockResolvedValue('/tmp/test-workspace/clones/test'),
|
||||
}));
|
||||
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';
|
||||
@@ -39,7 +44,7 @@ function createMockEventBus(): EventBus & { handlers: Map<string, Function[]>; e
|
||||
function createMocks() {
|
||||
const branchManager: BranchManager = {
|
||||
ensureBranch: vi.fn(),
|
||||
mergeBranch: vi.fn().mockResolvedValue({ success: true, message: 'merged' }),
|
||||
mergeBranch: vi.fn().mockResolvedValue({ success: true, message: 'merged', previousRef: 'abc000' }),
|
||||
diffBranches: vi.fn().mockResolvedValue(''),
|
||||
deleteBranch: vi.fn(),
|
||||
branchExists: vi.fn().mockResolvedValue(true),
|
||||
@@ -51,6 +56,7 @@ function createMocks() {
|
||||
checkMergeability: vi.fn().mockResolvedValue({ mergeable: true }),
|
||||
fetchRemote: vi.fn(),
|
||||
fastForwardBranch: vi.fn(),
|
||||
updateRef: vi.fn(),
|
||||
};
|
||||
|
||||
const phaseRepository = {
|
||||
@@ -306,4 +312,58 @@ describe('ExecutionOrchestrator', () => {
|
||||
expect(mocks.phaseDispatchManager.completePhase).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('approveInitiative', () => {
|
||||
function setupApproveTest(mocks: ReturnType<typeof createMocks>) {
|
||||
const initiative = { id: 'init-1', branch: 'cw/test', status: 'pending_review' };
|
||||
const project = { id: 'proj-1', name: 'test', url: 'https://example.com', defaultBranch: 'main' };
|
||||
vi.mocked(mocks.initiativeRepository.findById).mockResolvedValue(initiative as any);
|
||||
vi.mocked(mocks.projectRepository.findProjectsByInitiativeId).mockResolvedValue([project] as any);
|
||||
vi.mocked(mocks.branchManager.branchExists).mockResolvedValue(true);
|
||||
vi.mocked(mocks.branchManager.mergeBranch).mockResolvedValue({ success: true, message: 'ok', previousRef: 'abc000' });
|
||||
return { initiative, project };
|
||||
}
|
||||
|
||||
it('should roll back merge when push fails', async () => {
|
||||
setupApproveTest(mocks);
|
||||
vi.mocked(mocks.branchManager.pushBranch).mockRejectedValue(new Error('non-fast-forward'));
|
||||
|
||||
const orchestrator = createOrchestrator(mocks);
|
||||
|
||||
await expect(orchestrator.approveInitiative('init-1', 'merge_and_push')).rejects.toThrow('non-fast-forward');
|
||||
|
||||
// Should have rolled back the merge by restoring the previous ref
|
||||
expect(mocks.branchManager.updateRef).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'main',
|
||||
'abc000',
|
||||
);
|
||||
|
||||
// Should NOT have marked initiative as completed
|
||||
expect(mocks.initiativeRepository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should complete initiative when push succeeds', async () => {
|
||||
setupApproveTest(mocks);
|
||||
|
||||
const orchestrator = createOrchestrator(mocks);
|
||||
|
||||
await orchestrator.approveInitiative('init-1', 'merge_and_push');
|
||||
|
||||
expect(mocks.branchManager.updateRef).not.toHaveBeenCalled();
|
||||
expect(mocks.initiativeRepository.update).toHaveBeenCalledWith('init-1', { status: 'completed' });
|
||||
});
|
||||
|
||||
it('should not attempt rollback for push_branch strategy', async () => {
|
||||
setupApproveTest(mocks);
|
||||
vi.mocked(mocks.branchManager.pushBranch).mockRejectedValue(new Error('auth failed'));
|
||||
|
||||
const orchestrator = createOrchestrator(mocks);
|
||||
|
||||
await expect(orchestrator.approveInitiative('init-1', 'push_branch')).rejects.toThrow('auth failed');
|
||||
|
||||
// No merge happened, so no rollback needed
|
||||
expect(mocks.branchManager.updateRef).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,12 +11,13 @@
|
||||
* - Review per-phase: pause after each phase for diff review
|
||||
*/
|
||||
|
||||
import type { EventBus, TaskCompletedEvent, PhasePendingReviewEvent, PhaseChangesRequestedEvent, PhaseMergedEvent, TaskMergedEvent, PhaseQueuedEvent, AgentStoppedEvent, InitiativePendingReviewEvent, InitiativeReviewApprovedEvent, InitiativeChangesRequestedEvent } from '../events/index.js';
|
||||
import type { EventBus, TaskCompletedEvent, PhasePendingReviewEvent, PhaseChangesRequestedEvent, PhaseMergedEvent, TaskMergedEvent, PhaseQueuedEvent, AgentStoppedEvent, AgentCrashedEvent, InitiativePendingReviewEvent, InitiativeReviewApprovedEvent, InitiativeChangesRequestedEvent } 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 { AgentRepository } from '../db/repositories/agent-repository.js';
|
||||
import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js';
|
||||
import type { ConflictResolutionService } from '../coordination/conflict-resolution-service.js';
|
||||
import { phaseBranchName, taskBranchName } from '../git/branch-naming.js';
|
||||
@@ -25,6 +26,9 @@ import { createModuleLogger } from '../logger/index.js';
|
||||
|
||||
const log = createModuleLogger('execution-orchestrator');
|
||||
|
||||
/** Maximum number of automatic retries for crashed tasks before blocking */
|
||||
const MAX_TASK_RETRIES = 3;
|
||||
|
||||
export class ExecutionOrchestrator {
|
||||
/** Serialize merges per phase to avoid concurrent merge conflicts */
|
||||
private phaseMergeLocks: Map<string, Promise<void>> = new Map();
|
||||
@@ -44,6 +48,7 @@ export class ExecutionOrchestrator {
|
||||
private conflictResolutionService: ConflictResolutionService,
|
||||
private eventBus: EventBus,
|
||||
private workspaceRoot: string,
|
||||
private agentRepository?: AgentRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -66,6 +71,13 @@ export class ExecutionOrchestrator {
|
||||
});
|
||||
});
|
||||
|
||||
// Auto-retry crashed agent tasks (up to MAX_TASK_RETRIES)
|
||||
this.eventBus.on<AgentCrashedEvent>('agent:crashed', (event) => {
|
||||
this.handleAgentCrashed(event).catch((err) => {
|
||||
log.error({ err: err instanceof Error ? err.message : String(err) }, 'error handling agent:crashed');
|
||||
});
|
||||
});
|
||||
|
||||
// Recover in-memory dispatch queues from DB state (survives server restarts)
|
||||
this.recoverDispatchQueues().catch((err) => {
|
||||
log.error({ err: err instanceof Error ? err.message : String(err) }, 'dispatch queue recovery failed');
|
||||
@@ -111,6 +123,27 @@ export class ExecutionOrchestrator {
|
||||
this.scheduleDispatch();
|
||||
}
|
||||
|
||||
private async handleAgentCrashed(event: AgentCrashedEvent): Promise<void> {
|
||||
const { taskId, agentId, error } = event.payload;
|
||||
if (!taskId) return;
|
||||
|
||||
const task = await this.taskRepository.findById(taskId);
|
||||
if (!task || task.status !== 'in_progress') return;
|
||||
|
||||
const retryCount = (task.retryCount ?? 0) + 1;
|
||||
if (retryCount > MAX_TASK_RETRIES) {
|
||||
log.warn({ taskId, agentId, retryCount, error }, 'task exceeded max retries, leaving in_progress');
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset task for re-dispatch with incremented retry count
|
||||
await this.taskRepository.update(taskId, { status: 'pending', retryCount });
|
||||
await this.dispatchManager.queue(taskId);
|
||||
log.info({ taskId, agentId, retryCount, error }, 'crashed task re-queued for retry');
|
||||
|
||||
this.scheduleDispatch();
|
||||
}
|
||||
|
||||
private async runDispatchCycle(): Promise<void> {
|
||||
this.dispatchRunning = true;
|
||||
try {
|
||||
@@ -560,7 +593,7 @@ export class ExecutionOrchestrator {
|
||||
}
|
||||
}
|
||||
|
||||
// Re-queue pending tasks for in_progress phases into the task dispatch queue
|
||||
// Re-queue pending tasks and recover stuck in_progress tasks for in_progress phases
|
||||
if (phase.status === 'in_progress') {
|
||||
const tasks = await this.taskRepository.findByPhaseId(phase.id);
|
||||
for (const task of tasks) {
|
||||
@@ -571,6 +604,17 @@ export class ExecutionOrchestrator {
|
||||
} catch {
|
||||
// Already queued or task issue
|
||||
}
|
||||
} else if (task.status === 'in_progress' && this.agentRepository) {
|
||||
// Check if the assigned agent is still alive
|
||||
const agent = await this.agentRepository.findByTaskId(task.id);
|
||||
const isAlive = agent && (agent.status === 'running' || agent.status === 'waiting_for_input');
|
||||
if (!isAlive) {
|
||||
// Agent is dead — reset task for re-dispatch
|
||||
await this.taskRepository.update(task.id, { status: 'pending' });
|
||||
await this.dispatchManager.queue(task.id);
|
||||
tasksRecovered++;
|
||||
log.info({ taskId: task.id, agentId: agent?.id }, 'recovered stuck in_progress task (dead agent)');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -651,7 +695,18 @@ export class ExecutionOrchestrator {
|
||||
if (!result.success) {
|
||||
throw new Error(`Failed to merge ${initiative.branch} into ${project.defaultBranch} for project ${project.name}: ${result.message}`);
|
||||
}
|
||||
await this.branchManager.pushBranch(clonePath, project.defaultBranch);
|
||||
try {
|
||||
await this.branchManager.pushBranch(clonePath, project.defaultBranch);
|
||||
} catch (pushErr) {
|
||||
// Roll back the merge so the diff doesn't disappear from the review tab.
|
||||
// Without rollback, defaultBranch includes the initiative changes and the
|
||||
// three-dot diff (defaultBranch...initiativeBranch) becomes empty.
|
||||
if (result.previousRef) {
|
||||
log.warn({ project: project.name, previousRef: result.previousRef }, 'push failed — rolling back merge');
|
||||
await this.branchManager.updateRef(clonePath, project.defaultBranch, result.previousRef);
|
||||
}
|
||||
throw pushErr;
|
||||
}
|
||||
log.info({ initiativeId, project: project.name }, 'initiative branch merged into default and pushed');
|
||||
} else {
|
||||
await this.branchManager.pushBranch(clonePath, initiative.branch);
|
||||
|
||||
@@ -88,4 +88,10 @@ export interface BranchManager {
|
||||
* (i.e. the branches have diverged).
|
||||
*/
|
||||
fastForwardBranch(repoPath: string, branch: string, remote?: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Force-update a branch ref to point at a specific commit.
|
||||
* Used to roll back a merge when a subsequent push fails.
|
||||
*/
|
||||
updateRef(repoPath: string, branch: string, commitHash: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -453,6 +453,58 @@ describe('SimpleGitWorktreeManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Cross-Agent Isolation
|
||||
// ==========================================================================
|
||||
|
||||
describe('cross-agent isolation', () => {
|
||||
it('get() only matches worktrees in its own worktreesDir', async () => {
|
||||
// Simulate two agents with separate worktree base dirs but same repo
|
||||
const agentADir = path.join(repoPath, 'workdirs', 'agent-a');
|
||||
const agentBDir = path.join(repoPath, 'workdirs', 'agent-b');
|
||||
await mkdir(agentADir, { recursive: true });
|
||||
await mkdir(agentBDir, { recursive: true });
|
||||
|
||||
const managerA = new SimpleGitWorktreeManager(repoPath, undefined, agentADir);
|
||||
const managerB = new SimpleGitWorktreeManager(repoPath, undefined, agentBDir);
|
||||
|
||||
// Both create worktrees with the same id (project name)
|
||||
await managerA.create('my-project', 'agent/agent-a');
|
||||
await managerB.create('my-project', 'agent/agent-b');
|
||||
|
||||
// Each manager should only see its own worktree
|
||||
const wtA = await managerA.get('my-project');
|
||||
const wtB = await managerB.get('my-project');
|
||||
|
||||
expect(wtA).not.toBeNull();
|
||||
expect(wtB).not.toBeNull();
|
||||
expect(wtA!.path).toContain('agent-a');
|
||||
expect(wtB!.path).toContain('agent-b');
|
||||
expect(wtA!.path).not.toBe(wtB!.path);
|
||||
});
|
||||
|
||||
it('remove() only removes worktrees in its own worktreesDir', async () => {
|
||||
const agentADir = path.join(repoPath, 'workdirs', 'agent-a');
|
||||
const agentBDir = path.join(repoPath, 'workdirs', 'agent-b');
|
||||
await mkdir(agentADir, { recursive: true });
|
||||
await mkdir(agentBDir, { recursive: true });
|
||||
|
||||
const managerA = new SimpleGitWorktreeManager(repoPath, undefined, agentADir);
|
||||
const managerB = new SimpleGitWorktreeManager(repoPath, undefined, agentBDir);
|
||||
|
||||
await managerA.create('my-project', 'agent/agent-a');
|
||||
await managerB.create('my-project', 'agent/agent-b');
|
||||
|
||||
// Remove agent A's worktree
|
||||
await managerA.remove('my-project');
|
||||
|
||||
// Agent B's worktree should still exist
|
||||
const wtB = await managerB.get('my-project');
|
||||
expect(wtB).not.toBeNull();
|
||||
expect(wtB!.path).toContain('agent-b');
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Edge Cases
|
||||
// ==========================================================================
|
||||
|
||||
@@ -61,11 +61,30 @@ export class SimpleGitWorktreeManager implements WorktreeManager {
|
||||
const worktreePath = path.join(this.worktreesDir, id);
|
||||
log.info({ id, branch, baseBranch }, 'creating worktree');
|
||||
|
||||
// Safety: never force-reset a branch to its own base — this would nuke
|
||||
// shared branches like the initiative branch if passed as both branch and baseBranch.
|
||||
if (branch === baseBranch) {
|
||||
throw new Error(`Worktree branch and baseBranch are the same (${branch}). Use a unique branch name.`);
|
||||
}
|
||||
|
||||
// Create worktree — reuse existing branch or create new one
|
||||
const branchExists = await this.branchExists(branch);
|
||||
if (branchExists) {
|
||||
// Branch exists from a previous run — reset it to baseBranch and check it out
|
||||
await this.git.raw(['branch', '-f', branch, baseBranch]);
|
||||
// Branch exists from a previous run. Check if it has commits beyond baseBranch
|
||||
// before resetting — a previous agent may have done real work on this branch.
|
||||
try {
|
||||
const aheadCount = await this.git.raw(['rev-list', '--count', `${baseBranch}..${branch}`]);
|
||||
if (parseInt(aheadCount.trim(), 10) > 0) {
|
||||
log.warn({ branch, baseBranch, aheadBy: aheadCount.trim() }, 'branch has commits beyond base, preserving');
|
||||
} else {
|
||||
await this.git.raw(['branch', '-f', branch, baseBranch]);
|
||||
}
|
||||
} catch {
|
||||
// If rev-list fails (e.g. baseBranch doesn't exist yet), fall back to reset
|
||||
await this.git.raw(['branch', '-f', branch, baseBranch]);
|
||||
}
|
||||
// Prune stale worktree references before adding new one
|
||||
await this.git.raw(['worktree', 'prune']);
|
||||
await this.git.raw(['worktree', 'add', worktreePath, branch]);
|
||||
} else {
|
||||
// git worktree add -b <branch> <path> <base-branch>
|
||||
@@ -140,8 +159,14 @@ export class SimpleGitWorktreeManager implements WorktreeManager {
|
||||
* Finds worktree by matching path ending with id.
|
||||
*/
|
||||
async get(id: string): Promise<Worktree | null> {
|
||||
const expectedSuffix = path.join(path.basename(this.worktreesDir), id);
|
||||
const worktrees = await this.list();
|
||||
return worktrees.find((wt) => wt.path.endsWith(id)) ?? null;
|
||||
// Match on the worktreesDir + id suffix to avoid cross-agent collisions.
|
||||
// Multiple agents may have worktrees ending with the same project name
|
||||
// (e.g., ".../agent-A/codewalk-district" vs ".../agent-B/codewalk-district").
|
||||
// We match on basename(worktreesDir)/id to handle symlink differences
|
||||
// (e.g., macOS /var → /private/var) while still being unambiguous.
|
||||
return worktrees.find((wt) => wt.path.endsWith(expectedSuffix)) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
172
apps/server/git/remote-sync.test.ts
Normal file
172
apps/server/git/remote-sync.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { ProjectSyncManager, type SyncResult } from './remote-sync.js'
|
||||
import type { ProjectRepository } from '../db/repositories/project-repository.js'
|
||||
|
||||
vi.mock('simple-git', () => ({
|
||||
simpleGit: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./project-clones.js', () => ({
|
||||
ensureProjectClone: vi.fn().mockResolvedValue('/tmp/fake-clone'),
|
||||
}))
|
||||
|
||||
vi.mock('../logger/index.js', () => ({
|
||||
createModuleLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
function makeRepo(overrides: Partial<ProjectRepository> = {}): ProjectRepository {
|
||||
return {
|
||||
findAll: vi.fn().mockResolvedValue([]),
|
||||
findById: vi.fn().mockResolvedValue(null),
|
||||
create: vi.fn(),
|
||||
update: vi.fn().mockResolvedValue({}),
|
||||
delete: vi.fn(),
|
||||
findProjectsByInitiativeId: vi.fn().mockResolvedValue([]),
|
||||
setInitiativeProjects: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
} as unknown as ProjectRepository
|
||||
}
|
||||
|
||||
const project1 = {
|
||||
id: 'proj-1',
|
||||
name: 'alpha',
|
||||
url: 'https://github.com/org/alpha',
|
||||
defaultBranch: 'main',
|
||||
lastFetchedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
const project2 = {
|
||||
id: 'proj-2',
|
||||
name: 'beta',
|
||||
url: 'https://github.com/org/beta',
|
||||
defaultBranch: 'main',
|
||||
lastFetchedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
describe('ProjectSyncManager', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let simpleGitMock: any
|
||||
|
||||
beforeEach(async () => {
|
||||
const mod = await import('simple-git')
|
||||
simpleGitMock = vi.mocked(mod.simpleGit)
|
||||
simpleGitMock.mockReset()
|
||||
})
|
||||
|
||||
describe('syncAllProjects', () => {
|
||||
it('returns empty array when no projects exist', async () => {
|
||||
const repo = makeRepo({ findAll: vi.fn().mockResolvedValue([]) })
|
||||
const manager = new ProjectSyncManager(repo, '/workspace')
|
||||
const results = await manager.syncAllProjects()
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
|
||||
it('returns success result for each project when all succeed', async () => {
|
||||
const mockGit = {
|
||||
fetch: vi.fn().mockResolvedValue({}),
|
||||
raw: vi.fn().mockResolvedValue(''),
|
||||
}
|
||||
simpleGitMock.mockReturnValue(mockGit)
|
||||
|
||||
const repo = makeRepo({
|
||||
findAll: vi.fn().mockResolvedValue([project1, project2]),
|
||||
findById: vi.fn()
|
||||
.mockResolvedValueOnce(project1)
|
||||
.mockResolvedValueOnce(project2),
|
||||
update: vi.fn().mockResolvedValue({}),
|
||||
})
|
||||
const manager = new ProjectSyncManager(repo, '/workspace')
|
||||
const results = await manager.syncAllProjects()
|
||||
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results[0]).toMatchObject({
|
||||
projectId: 'proj-1',
|
||||
projectName: 'alpha',
|
||||
success: true,
|
||||
fetched: true,
|
||||
})
|
||||
expect(results[1]).toMatchObject({
|
||||
projectId: 'proj-2',
|
||||
projectName: 'beta',
|
||||
success: true,
|
||||
fetched: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns partial failure when the second project fetch throws', async () => {
|
||||
const mockGitSuccess = {
|
||||
fetch: vi.fn().mockResolvedValue({}),
|
||||
raw: vi.fn().mockResolvedValue(''),
|
||||
}
|
||||
const mockGitFail = {
|
||||
fetch: vi.fn().mockRejectedValue(new Error('network error')),
|
||||
raw: vi.fn().mockResolvedValue(''),
|
||||
}
|
||||
simpleGitMock
|
||||
.mockReturnValueOnce(mockGitSuccess)
|
||||
.mockReturnValueOnce(mockGitFail)
|
||||
|
||||
const repo = makeRepo({
|
||||
findAll: vi.fn().mockResolvedValue([project1, project2]),
|
||||
findById: vi.fn()
|
||||
.mockResolvedValueOnce(project1)
|
||||
.mockResolvedValueOnce(project2),
|
||||
update: vi.fn().mockResolvedValue({}),
|
||||
})
|
||||
const manager = new ProjectSyncManager(repo, '/workspace')
|
||||
const results = await manager.syncAllProjects()
|
||||
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results[0]).toMatchObject({ projectId: 'proj-1', success: true })
|
||||
expect(results[1]).toMatchObject({
|
||||
projectId: 'proj-2',
|
||||
success: false,
|
||||
error: expect.any(String),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('SyncResult shape', () => {
|
||||
it('result always contains projectId and success fields', async () => {
|
||||
const mockGit = {
|
||||
fetch: vi.fn().mockResolvedValue({}),
|
||||
raw: vi.fn().mockResolvedValue(''),
|
||||
}
|
||||
simpleGitMock.mockReturnValue(mockGit)
|
||||
|
||||
const repo = makeRepo({
|
||||
findAll: vi.fn().mockResolvedValue([project1]),
|
||||
findById: vi.fn().mockResolvedValue(project1),
|
||||
update: vi.fn().mockResolvedValue({}),
|
||||
})
|
||||
const manager = new ProjectSyncManager(repo, '/workspace')
|
||||
const results = await manager.syncAllProjects()
|
||||
|
||||
expect(results[0]).toMatchObject({
|
||||
projectId: expect.any(String),
|
||||
success: expect.any(Boolean),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('failure counting logic', () => {
|
||||
it('counts failures from SyncResult array', () => {
|
||||
const results: Pick<SyncResult, 'success'>[] = [
|
||||
{ success: true },
|
||||
{ success: false },
|
||||
{ success: true },
|
||||
{ success: false },
|
||||
]
|
||||
const failed = results.filter(r => !r.success)
|
||||
expect(failed.length).toBe(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -6,7 +6,7 @@
|
||||
* on project clones without requiring a worktree.
|
||||
*/
|
||||
|
||||
import { join } from 'node:path';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { simpleGit } from 'simple-git';
|
||||
@@ -39,6 +39,9 @@ export class SimpleGitBranchManager implements BranchManager {
|
||||
const tempBranch = `cw-merge-${Date.now()}`;
|
||||
|
||||
try {
|
||||
// Capture the target branch ref before merge so callers can roll back on push failure
|
||||
const previousRef = (await repoGit.raw(['rev-parse', targetBranch])).trim();
|
||||
|
||||
// Create worktree with a temp branch starting at targetBranch's commit
|
||||
await repoGit.raw(['worktree', 'add', '-b', tempBranch, tmpPath, targetBranch]);
|
||||
|
||||
@@ -53,7 +56,7 @@ export class SimpleGitBranchManager implements BranchManager {
|
||||
await repoGit.raw(['update-ref', `refs/heads/${targetBranch}`, mergeCommit]);
|
||||
|
||||
log.info({ repoPath, sourceBranch, targetBranch }, 'merge completed cleanly');
|
||||
return { success: true, message: `Merged ${sourceBranch} into ${targetBranch}` };
|
||||
return { success: true, message: `Merged ${sourceBranch} into ${targetBranch}`, previousRef };
|
||||
} catch (mergeErr) {
|
||||
// Check for merge conflicts
|
||||
const status = await wtGit.status();
|
||||
@@ -161,7 +164,26 @@ export class SimpleGitBranchManager implements BranchManager {
|
||||
|
||||
async pushBranch(repoPath: string, branch: string, remote = 'origin'): Promise<void> {
|
||||
const git = simpleGit(repoPath);
|
||||
await git.push(remote, branch);
|
||||
try {
|
||||
await git.push(remote, branch);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (!msg.includes('branch is currently checked out')) throw err;
|
||||
|
||||
// Local non-bare repo with the branch checked out — temporarily allow it.
|
||||
// receive.denyCurrentBranch=updateInstead updates the remote's working tree
|
||||
// and index to match, or rejects if the working tree is dirty.
|
||||
const remoteUrl = (await git.remote(['get-url', remote]))?.trim();
|
||||
if (!remoteUrl) throw err;
|
||||
const remotePath = resolve(repoPath, remoteUrl);
|
||||
const remoteGit = simpleGit(remotePath);
|
||||
await remoteGit.addConfig('receive.denyCurrentBranch', 'updateInstead');
|
||||
try {
|
||||
await git.push(remote, branch);
|
||||
} finally {
|
||||
await remoteGit.raw(['config', '--unset', 'receive.denyCurrentBranch']);
|
||||
}
|
||||
}
|
||||
log.info({ repoPath, branch, remote }, 'branch pushed to remote');
|
||||
}
|
||||
|
||||
@@ -205,7 +227,24 @@ export class SimpleGitBranchManager implements BranchManager {
|
||||
async fastForwardBranch(repoPath: string, branch: string, remote = 'origin'): Promise<void> {
|
||||
const git = simpleGit(repoPath);
|
||||
const remoteBranch = `${remote}/${branch}`;
|
||||
await git.raw(['merge', '--ff-only', remoteBranch, branch]);
|
||||
|
||||
// Verify it's a genuine fast-forward (branch is ancestor of remote)
|
||||
try {
|
||||
await git.raw(['merge-base', '--is-ancestor', branch, remoteBranch]);
|
||||
} catch {
|
||||
throw new Error(`Cannot fast-forward ${branch}: it has diverged from ${remoteBranch}`);
|
||||
}
|
||||
|
||||
// Use update-ref instead of git merge so dirty working trees don't block it.
|
||||
// The clone may have uncommitted agent work; we only need to advance the ref.
|
||||
const targetCommit = (await git.raw(['rev-parse', remoteBranch])).trim();
|
||||
await git.raw(['update-ref', `refs/heads/${branch}`, targetCommit]);
|
||||
log.info({ repoPath, branch, remoteBranch }, 'fast-forwarded branch');
|
||||
}
|
||||
|
||||
async updateRef(repoPath: string, branch: string, commitHash: string): Promise<void> {
|
||||
const git = simpleGit(repoPath);
|
||||
await git.raw(['update-ref', `refs/heads/${branch}`, commitHash]);
|
||||
log.info({ repoPath, branch, commitHash: commitHash.slice(0, 7) }, 'branch ref updated');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,8 @@ export interface MergeResult {
|
||||
conflicts?: string[];
|
||||
/** Human-readable message describing the result */
|
||||
message: string;
|
||||
/** The target branch's commit hash before the merge (for rollback on push failure) */
|
||||
previousRef?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -32,6 +32,7 @@ interface TestAgent {
|
||||
initiativeId: string | null;
|
||||
userDismissedAt: Date | null;
|
||||
exitCode: number | null;
|
||||
prompt: string | null;
|
||||
}
|
||||
|
||||
describe('Crash marking race condition', () => {
|
||||
@@ -72,7 +73,8 @@ describe('Crash marking race condition', () => {
|
||||
pendingQuestions: null,
|
||||
initiativeId: 'init-1',
|
||||
userDismissedAt: null,
|
||||
exitCode: null
|
||||
exitCode: null,
|
||||
prompt: null,
|
||||
};
|
||||
|
||||
// Mock repository that tracks all update calls
|
||||
|
||||
320
apps/server/test/unit/headquarters.test.ts
Normal file
320
apps/server/test/unit/headquarters.test.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* Unit tests for getHeadquartersDashboard tRPC procedure.
|
||||
*
|
||||
* Uses in-memory Drizzle DB + inline MockAgentManager for isolation.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { router, publicProcedure, createCallerFactory } from '../../trpc/trpc.js';
|
||||
import { headquartersProcedures } from '../../trpc/routers/headquarters.js';
|
||||
import type { TRPCContext } from '../../trpc/context.js';
|
||||
import type { AgentManager, AgentInfo, PendingQuestions } from '../../agent/types.js';
|
||||
import { createTestDatabase } from '../../db/repositories/drizzle/test-helpers.js';
|
||||
import {
|
||||
DrizzleInitiativeRepository,
|
||||
DrizzlePhaseRepository,
|
||||
DrizzleTaskRepository,
|
||||
} from '../../db/repositories/drizzle/index.js';
|
||||
|
||||
// =============================================================================
|
||||
// MockAgentManager
|
||||
// =============================================================================
|
||||
|
||||
class MockAgentManager implements AgentManager {
|
||||
private agents: AgentInfo[] = [];
|
||||
private questions: Map<string, PendingQuestions> = new Map();
|
||||
|
||||
addAgent(info: Partial<AgentInfo> & Pick<AgentInfo, 'id' | 'name' | 'status'>): void {
|
||||
this.agents.push({
|
||||
taskId: null,
|
||||
initiativeId: null,
|
||||
sessionId: null,
|
||||
worktreeId: info.id,
|
||||
mode: 'execute',
|
||||
provider: 'claude',
|
||||
accountId: null,
|
||||
createdAt: new Date('2025-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2025-01-01T00:00:00Z'),
|
||||
userDismissedAt: null,
|
||||
exitCode: null,
|
||||
prompt: null,
|
||||
...info,
|
||||
});
|
||||
}
|
||||
|
||||
setQuestions(agentId: string, questions: PendingQuestions): void {
|
||||
this.questions.set(agentId, questions);
|
||||
}
|
||||
|
||||
async list(): Promise<AgentInfo[]> {
|
||||
return [...this.agents];
|
||||
}
|
||||
|
||||
async getPendingQuestions(agentId: string): Promise<PendingQuestions | null> {
|
||||
return this.questions.get(agentId) ?? null;
|
||||
}
|
||||
|
||||
async spawn(): Promise<AgentInfo> { throw new Error('Not implemented'); }
|
||||
async stop(): Promise<void> { throw new Error('Not implemented'); }
|
||||
async get(): Promise<AgentInfo | null> { return null; }
|
||||
async getByName(): Promise<AgentInfo | null> { return null; }
|
||||
async resume(): Promise<void> { throw new Error('Not implemented'); }
|
||||
async getResult() { return null; }
|
||||
async delete(): Promise<void> { throw new Error('Not implemented'); }
|
||||
async dismiss(): Promise<void> { throw new Error('Not implemented'); }
|
||||
async resumeForConversation(): Promise<boolean> { return false; }
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Test router
|
||||
// =============================================================================
|
||||
|
||||
const testRouter = router({
|
||||
...headquartersProcedures(publicProcedure),
|
||||
});
|
||||
|
||||
const createCaller = createCallerFactory(testRouter);
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
function makeCtx(agentManager: MockAgentManager, overrides?: Partial<TRPCContext>): TRPCContext {
|
||||
const db = createTestDatabase();
|
||||
return {
|
||||
eventBus: {} as TRPCContext['eventBus'],
|
||||
serverStartedAt: null,
|
||||
processCount: 0,
|
||||
agentManager,
|
||||
initiativeRepository: new DrizzleInitiativeRepository(db),
|
||||
phaseRepository: new DrizzlePhaseRepository(db),
|
||||
taskRepository: new DrizzleTaskRepository(db),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
describe('getHeadquartersDashboard', () => {
|
||||
it('empty state — no initiatives, no agents → all arrays empty', async () => {
|
||||
const agents = new MockAgentManager();
|
||||
const caller = createCaller(makeCtx(agents));
|
||||
|
||||
const result = await caller.getHeadquartersDashboard();
|
||||
|
||||
expect(result.waitingForInput).toEqual([]);
|
||||
expect(result.pendingReviewInitiatives).toEqual([]);
|
||||
expect(result.pendingReviewPhases).toEqual([]);
|
||||
expect(result.planningInitiatives).toEqual([]);
|
||||
expect(result.blockedPhases).toEqual([]);
|
||||
});
|
||||
|
||||
it('waitingForInput — agent with waiting_for_input status appears', async () => {
|
||||
const agents = new MockAgentManager();
|
||||
const ctx = makeCtx(agents);
|
||||
const initiativeRepo = ctx.initiativeRepository!;
|
||||
const initiative = await initiativeRepo.create({ name: 'My Initiative', status: 'active' });
|
||||
|
||||
agents.addAgent({
|
||||
id: 'agent-1',
|
||||
name: 'jolly-agent',
|
||||
status: 'waiting_for_input',
|
||||
initiativeId: initiative.id,
|
||||
userDismissedAt: null,
|
||||
updatedAt: new Date('2025-06-01T12:00:00Z'),
|
||||
});
|
||||
agents.setQuestions('agent-1', {
|
||||
questions: [{ id: 'q1', question: 'Which approach?' }],
|
||||
});
|
||||
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getHeadquartersDashboard();
|
||||
|
||||
expect(result.waitingForInput).toHaveLength(1);
|
||||
const item = result.waitingForInput[0];
|
||||
expect(item.agentId).toBe('agent-1');
|
||||
expect(item.agentName).toBe('jolly-agent');
|
||||
expect(item.initiativeId).toBe(initiative.id);
|
||||
expect(item.initiativeName).toBe('My Initiative');
|
||||
expect(item.questionText).toBe('Which approach?');
|
||||
});
|
||||
|
||||
it('waitingForInput — dismissed agent is excluded', async () => {
|
||||
const agents = new MockAgentManager();
|
||||
const ctx = makeCtx(agents);
|
||||
const initiativeRepo = ctx.initiativeRepository!;
|
||||
const initiative = await initiativeRepo.create({ name: 'My Initiative', status: 'active' });
|
||||
|
||||
agents.addAgent({
|
||||
id: 'agent-1',
|
||||
name: 'dismissed-agent',
|
||||
status: 'waiting_for_input',
|
||||
initiativeId: initiative.id,
|
||||
userDismissedAt: new Date(),
|
||||
});
|
||||
agents.setQuestions('agent-1', {
|
||||
questions: [{ id: 'q1', question: 'Which approach?' }],
|
||||
});
|
||||
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getHeadquartersDashboard();
|
||||
|
||||
expect(result.waitingForInput).toEqual([]);
|
||||
});
|
||||
|
||||
it('pendingReviewInitiatives — initiative with pending_review status appears', async () => {
|
||||
const agents = new MockAgentManager();
|
||||
const ctx = makeCtx(agents);
|
||||
const initiativeRepo = ctx.initiativeRepository!;
|
||||
const initiative = await initiativeRepo.create({ name: 'Review Me', status: 'pending_review' });
|
||||
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getHeadquartersDashboard();
|
||||
|
||||
expect(result.pendingReviewInitiatives).toHaveLength(1);
|
||||
expect(result.pendingReviewInitiatives[0].initiativeId).toBe(initiative.id);
|
||||
expect(result.pendingReviewInitiatives[0].initiativeName).toBe('Review Me');
|
||||
});
|
||||
|
||||
it('pendingReviewPhases — phase with pending_review status appears', async () => {
|
||||
const agents = new MockAgentManager();
|
||||
const ctx = makeCtx(agents);
|
||||
const initiativeRepo = ctx.initiativeRepository!;
|
||||
const phaseRepo = ctx.phaseRepository!;
|
||||
const initiative = await initiativeRepo.create({ name: 'My Initiative', status: 'active' });
|
||||
const phase = await phaseRepo.create({
|
||||
initiativeId: initiative.id,
|
||||
name: 'Phase 1',
|
||||
status: 'pending_review',
|
||||
});
|
||||
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getHeadquartersDashboard();
|
||||
|
||||
expect(result.pendingReviewPhases).toHaveLength(1);
|
||||
const item = result.pendingReviewPhases[0];
|
||||
expect(item.initiativeId).toBe(initiative.id);
|
||||
expect(item.initiativeName).toBe('My Initiative');
|
||||
expect(item.phaseId).toBe(phase.id);
|
||||
expect(item.phaseName).toBe('Phase 1');
|
||||
});
|
||||
|
||||
it('planningInitiatives — all phases pending and no running agents', async () => {
|
||||
const agents = new MockAgentManager();
|
||||
const ctx = makeCtx(agents);
|
||||
const initiativeRepo = ctx.initiativeRepository!;
|
||||
const phaseRepo = ctx.phaseRepository!;
|
||||
const initiative = await initiativeRepo.create({ name: 'Planning Init', status: 'active' });
|
||||
const phase1 = await phaseRepo.create({
|
||||
initiativeId: initiative.id,
|
||||
name: 'Phase 1',
|
||||
status: 'pending',
|
||||
});
|
||||
await phaseRepo.create({
|
||||
initiativeId: initiative.id,
|
||||
name: 'Phase 2',
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getHeadquartersDashboard();
|
||||
|
||||
expect(result.planningInitiatives).toHaveLength(1);
|
||||
const item = result.planningInitiatives[0];
|
||||
expect(item.initiativeId).toBe(initiative.id);
|
||||
expect(item.initiativeName).toBe('Planning Init');
|
||||
expect(item.pendingPhaseCount).toBe(2);
|
||||
// since = oldest phase createdAt
|
||||
expect(item.since).toBe(phase1.createdAt.toISOString());
|
||||
});
|
||||
|
||||
it('planningInitiatives — excluded when a running agent exists for the initiative', async () => {
|
||||
const agents = new MockAgentManager();
|
||||
const ctx = makeCtx(agents);
|
||||
const initiativeRepo = ctx.initiativeRepository!;
|
||||
const phaseRepo = ctx.phaseRepository!;
|
||||
const initiative = await initiativeRepo.create({ name: 'Planning Init', status: 'active' });
|
||||
await phaseRepo.create({ initiativeId: initiative.id, name: 'Phase 1', status: 'pending' });
|
||||
|
||||
agents.addAgent({
|
||||
id: 'agent-running',
|
||||
name: 'busy-agent',
|
||||
status: 'running',
|
||||
initiativeId: initiative.id,
|
||||
userDismissedAt: null,
|
||||
});
|
||||
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getHeadquartersDashboard();
|
||||
|
||||
expect(result.planningInitiatives).toEqual([]);
|
||||
});
|
||||
|
||||
it('planningInitiatives — excluded when a phase is not pending', async () => {
|
||||
const agents = new MockAgentManager();
|
||||
const ctx = makeCtx(agents);
|
||||
const initiativeRepo = ctx.initiativeRepository!;
|
||||
const phaseRepo = ctx.phaseRepository!;
|
||||
const initiative = await initiativeRepo.create({ name: 'Mixed Init', status: 'active' });
|
||||
await phaseRepo.create({ initiativeId: initiative.id, name: 'Phase 1', status: 'pending' });
|
||||
await phaseRepo.create({ initiativeId: initiative.id, name: 'Phase 2', status: 'in_progress' });
|
||||
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getHeadquartersDashboard();
|
||||
|
||||
expect(result.planningInitiatives).toEqual([]);
|
||||
});
|
||||
|
||||
it('blockedPhases — phase with blocked status appears', async () => {
|
||||
const agents = new MockAgentManager();
|
||||
const ctx = makeCtx(agents);
|
||||
const initiativeRepo = ctx.initiativeRepository!;
|
||||
const phaseRepo = ctx.phaseRepository!;
|
||||
const initiative = await initiativeRepo.create({ name: 'Blocked Init', status: 'active' });
|
||||
const phase = await phaseRepo.create({
|
||||
initiativeId: initiative.id,
|
||||
name: 'Stuck Phase',
|
||||
status: 'blocked',
|
||||
});
|
||||
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getHeadquartersDashboard();
|
||||
|
||||
expect(result.blockedPhases).toHaveLength(1);
|
||||
const item = result.blockedPhases[0];
|
||||
expect(item.initiativeId).toBe(initiative.id);
|
||||
expect(item.initiativeName).toBe('Blocked Init');
|
||||
expect(item.phaseId).toBe(phase.id);
|
||||
expect(item.phaseName).toBe('Stuck Phase');
|
||||
expect(item.lastMessage).toBeNull();
|
||||
});
|
||||
|
||||
it('ordering — waitingForInput sorted oldest first', async () => {
|
||||
const agents = new MockAgentManager();
|
||||
const ctx = makeCtx(agents);
|
||||
|
||||
agents.addAgent({
|
||||
id: 'agent-newer',
|
||||
name: 'newer-agent',
|
||||
status: 'waiting_for_input',
|
||||
userDismissedAt: null,
|
||||
updatedAt: new Date('2025-06-02T00:00:00Z'),
|
||||
});
|
||||
agents.addAgent({
|
||||
id: 'agent-older',
|
||||
name: 'older-agent',
|
||||
status: 'waiting_for_input',
|
||||
userDismissedAt: null,
|
||||
updatedAt: new Date('2025-06-01T00:00:00Z'),
|
||||
});
|
||||
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getHeadquartersDashboard();
|
||||
|
||||
expect(result.waitingForInput).toHaveLength(2);
|
||||
expect(result.waitingForInput[0].agentId).toBe('agent-older');
|
||||
expect(result.waitingForInput[1].agentId).toBe('agent-newer');
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,7 @@ import { previewProcedures } from './routers/preview.js';
|
||||
import { conversationProcedures } from './routers/conversation.js';
|
||||
import { chatSessionProcedures } from './routers/chat-session.js';
|
||||
import { errandProcedures } from './routers/errand.js';
|
||||
import { headquartersProcedures } from './routers/headquarters.js';
|
||||
|
||||
// Re-export tRPC primitives (preserves existing import paths)
|
||||
export { router, publicProcedure, middleware, createCallerFactory } from './trpc.js';
|
||||
@@ -65,6 +66,7 @@ export const appRouter = router({
|
||||
...conversationProcedures(publicProcedure),
|
||||
...chatSessionProcedures(publicProcedure),
|
||||
...errandProcedures(publicProcedure),
|
||||
...headquartersProcedures(publicProcedure),
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
327
apps/server/trpc/routers/agent.test.ts
Normal file
327
apps/server/trpc/routers/agent.test.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* Agent Router Tests
|
||||
*
|
||||
* Tests for getAgent (exitCode, taskName, initiativeName),
|
||||
* getAgentInputFiles, and getAgentPrompt procedures.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { appRouter, createCallerFactory } from '../index.js';
|
||||
import type { TRPCContext } from '../context.js';
|
||||
import type { EventBus } from '../../events/types.js';
|
||||
|
||||
const createCaller = createCallerFactory(appRouter);
|
||||
|
||||
function createMockEventBus(): EventBus {
|
||||
return {
|
||||
emit: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
once: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createTestContext(overrides: Partial<TRPCContext> = {}): TRPCContext {
|
||||
return {
|
||||
eventBus: createMockEventBus(),
|
||||
serverStartedAt: new Date('2026-01-30T12:00:00Z'),
|
||||
processCount: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Minimal AgentInfo fixture matching the full interface */
|
||||
function makeAgentInfo(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'agent-1',
|
||||
name: 'test-agent',
|
||||
taskId: null,
|
||||
initiativeId: null,
|
||||
sessionId: null,
|
||||
worktreeId: 'test-agent',
|
||||
status: 'stopped' as const,
|
||||
mode: 'execute' as const,
|
||||
provider: 'claude',
|
||||
accountId: null,
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
userDismissedAt: null,
|
||||
exitCode: null,
|
||||
prompt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('getAgent', () => {
|
||||
it('returns exitCode: 1 when agent has exitCode 1', async () => {
|
||||
const mockManager = {
|
||||
get: vi.fn().mockResolvedValue(makeAgentInfo({ exitCode: 1 })),
|
||||
};
|
||||
|
||||
const ctx = createTestContext({ agentManager: mockManager as any });
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getAgent({ id: 'agent-1' });
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('returns exitCode: null when agent has no exitCode', async () => {
|
||||
const mockManager = {
|
||||
get: vi.fn().mockResolvedValue(makeAgentInfo({ exitCode: null })),
|
||||
};
|
||||
|
||||
const ctx = createTestContext({ agentManager: mockManager as any });
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getAgent({ id: 'agent-1' });
|
||||
|
||||
expect(result.exitCode).toBeNull();
|
||||
});
|
||||
|
||||
it('returns taskName and initiativeName from repositories', async () => {
|
||||
const mockManager = {
|
||||
get: vi.fn().mockResolvedValue(makeAgentInfo({ taskId: 'task-1', initiativeId: 'init-1' })),
|
||||
};
|
||||
const mockTaskRepo = {
|
||||
findById: vi.fn().mockResolvedValue({ id: 'task-1', name: 'My Task' }),
|
||||
};
|
||||
const mockInitiativeRepo = {
|
||||
findById: vi.fn().mockResolvedValue({ id: 'init-1', name: 'My Initiative' }),
|
||||
};
|
||||
|
||||
const ctx = createTestContext({
|
||||
agentManager: mockManager as any,
|
||||
taskRepository: mockTaskRepo as any,
|
||||
initiativeRepository: mockInitiativeRepo as any,
|
||||
});
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getAgent({ id: 'agent-1' });
|
||||
|
||||
expect(result.taskName).toBe('My Task');
|
||||
expect(result.initiativeName).toBe('My Initiative');
|
||||
});
|
||||
|
||||
it('returns taskName: null and initiativeName: null when agent has no taskId or initiativeId', async () => {
|
||||
const mockManager = {
|
||||
get: vi.fn().mockResolvedValue(makeAgentInfo({ taskId: null, initiativeId: null })),
|
||||
};
|
||||
|
||||
const ctx = createTestContext({ agentManager: mockManager as any });
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getAgent({ id: 'agent-1' });
|
||||
|
||||
expect(result.taskName).toBeNull();
|
||||
expect(result.initiativeName).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAgentInputFiles', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-test-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function makeAgentManagerWithWorktree(worktreeId = 'test-worktree', agentName = 'test-agent') {
|
||||
return {
|
||||
get: vi.fn().mockResolvedValue(makeAgentInfo({ worktreeId, name: agentName })),
|
||||
};
|
||||
}
|
||||
|
||||
it('returns worktree_missing when worktree dir does not exist', async () => {
|
||||
const nonExistentRoot = path.join(tmpDir, 'no-such-dir');
|
||||
const mockManager = makeAgentManagerWithWorktree('test-worktree');
|
||||
|
||||
const ctx = createTestContext({
|
||||
agentManager: mockManager as any,
|
||||
workspaceRoot: nonExistentRoot,
|
||||
});
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getAgentInputFiles({ id: 'agent-1' });
|
||||
|
||||
expect(result).toEqual({ files: [], reason: 'worktree_missing' });
|
||||
});
|
||||
|
||||
it('returns input_dir_missing when worktree exists but .cw/input does not', async () => {
|
||||
const worktreeId = 'test-worktree';
|
||||
const worktreeRoot = path.join(tmpDir, 'agent-workdirs', worktreeId);
|
||||
await fs.mkdir(worktreeRoot, { recursive: true });
|
||||
|
||||
const mockManager = makeAgentManagerWithWorktree(worktreeId);
|
||||
const ctx = createTestContext({
|
||||
agentManager: mockManager as any,
|
||||
workspaceRoot: tmpDir,
|
||||
});
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getAgentInputFiles({ id: 'agent-1' });
|
||||
|
||||
expect(result).toEqual({ files: [], reason: 'input_dir_missing' });
|
||||
});
|
||||
|
||||
it('returns sorted file list with correct name, content, sizeBytes', async () => {
|
||||
const worktreeId = 'test-worktree';
|
||||
const inputDir = path.join(tmpDir, 'agent-workdirs', worktreeId, '.cw', 'input');
|
||||
await fs.mkdir(inputDir, { recursive: true });
|
||||
await fs.mkdir(path.join(inputDir, 'pages'), { recursive: true });
|
||||
|
||||
const manifestContent = '{"files": ["a"]}';
|
||||
const fooContent = '# Foo\nHello world';
|
||||
await fs.writeFile(path.join(inputDir, 'manifest.json'), manifestContent);
|
||||
await fs.writeFile(path.join(inputDir, 'pages', 'foo.md'), fooContent);
|
||||
|
||||
const mockManager = makeAgentManagerWithWorktree(worktreeId);
|
||||
const ctx = createTestContext({
|
||||
agentManager: mockManager as any,
|
||||
workspaceRoot: tmpDir,
|
||||
});
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getAgentInputFiles({ id: 'agent-1' });
|
||||
|
||||
expect(result.reason).toBeUndefined();
|
||||
expect(result.files).toHaveLength(2);
|
||||
// Sorted alphabetically: manifest.json before pages/foo.md
|
||||
expect(result.files[0].name).toBe('manifest.json');
|
||||
expect(result.files[0].content).toBe(manifestContent);
|
||||
expect(result.files[0].sizeBytes).toBe(Buffer.byteLength(manifestContent));
|
||||
expect(result.files[1].name).toBe(path.join('pages', 'foo.md'));
|
||||
expect(result.files[1].content).toBe(fooContent);
|
||||
expect(result.files[1].sizeBytes).toBe(Buffer.byteLength(fooContent));
|
||||
});
|
||||
|
||||
it('skips binary files (containing null byte)', async () => {
|
||||
const worktreeId = 'test-worktree';
|
||||
const inputDir = path.join(tmpDir, 'agent-workdirs', worktreeId, '.cw', 'input');
|
||||
await fs.mkdir(inputDir, { recursive: true });
|
||||
|
||||
// Binary file with null byte
|
||||
const binaryData = Buffer.from([0x89, 0x50, 0x00, 0x4e, 0x47]);
|
||||
await fs.writeFile(path.join(inputDir, 'image.png'), binaryData);
|
||||
// Text file should still be returned
|
||||
await fs.writeFile(path.join(inputDir, 'text.txt'), 'hello');
|
||||
|
||||
const mockManager = makeAgentManagerWithWorktree(worktreeId);
|
||||
const ctx = createTestContext({
|
||||
agentManager: mockManager as any,
|
||||
workspaceRoot: tmpDir,
|
||||
});
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getAgentInputFiles({ id: 'agent-1' });
|
||||
|
||||
expect(result.files).toHaveLength(1);
|
||||
expect(result.files[0].name).toBe('text.txt');
|
||||
});
|
||||
|
||||
it('truncates files larger than 500 KB and preserves original sizeBytes', async () => {
|
||||
const worktreeId = 'test-worktree';
|
||||
const inputDir = path.join(tmpDir, 'agent-workdirs', worktreeId, '.cw', 'input');
|
||||
await fs.mkdir(inputDir, { recursive: true });
|
||||
|
||||
const MAX_SIZE = 500 * 1024;
|
||||
const largeContent = Buffer.alloc(MAX_SIZE + 100 * 1024, 'a'); // 600 KB
|
||||
await fs.writeFile(path.join(inputDir, 'big.txt'), largeContent);
|
||||
|
||||
const mockManager = makeAgentManagerWithWorktree(worktreeId);
|
||||
const ctx = createTestContext({
|
||||
agentManager: mockManager as any,
|
||||
workspaceRoot: tmpDir,
|
||||
});
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getAgentInputFiles({ id: 'agent-1' });
|
||||
|
||||
expect(result.files).toHaveLength(1);
|
||||
expect(result.files[0].sizeBytes).toBe(largeContent.length);
|
||||
expect(result.files[0].content).toContain('[truncated — file exceeds 500 KB]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAgentPrompt', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-prompt-test-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns prompt_not_written when PROMPT.md does not exist', async () => {
|
||||
const mockManager = {
|
||||
get: vi.fn().mockResolvedValue(makeAgentInfo({ name: 'test-agent' })),
|
||||
};
|
||||
|
||||
const ctx = createTestContext({
|
||||
agentManager: mockManager as any,
|
||||
workspaceRoot: tmpDir,
|
||||
});
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getAgentPrompt({ id: 'agent-1' });
|
||||
|
||||
expect(result).toEqual({ content: null, reason: 'prompt_not_written' });
|
||||
});
|
||||
|
||||
it('returns prompt content when PROMPT.md exists', async () => {
|
||||
const agentName = 'test-agent';
|
||||
const promptDir = path.join(tmpDir, '.cw', 'agent-logs', agentName);
|
||||
await fs.mkdir(promptDir, { recursive: true });
|
||||
const promptContent = '# System\nHello';
|
||||
await fs.writeFile(path.join(promptDir, 'PROMPT.md'), promptContent);
|
||||
|
||||
const mockManager = {
|
||||
get: vi.fn().mockResolvedValue(makeAgentInfo({ name: agentName, prompt: null })),
|
||||
};
|
||||
|
||||
const ctx = createTestContext({
|
||||
agentManager: mockManager as any,
|
||||
workspaceRoot: tmpDir,
|
||||
});
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getAgentPrompt({ id: 'agent-1' });
|
||||
|
||||
expect(result).toEqual({ content: promptContent });
|
||||
});
|
||||
|
||||
it('returns prompt from DB when agent.prompt is set (no file needed)', async () => {
|
||||
const dbPromptContent = '# DB Prompt\nThis is persisted in the database';
|
||||
const mockManager = {
|
||||
get: vi.fn().mockResolvedValue(makeAgentInfo({ name: 'test-agent', prompt: dbPromptContent })),
|
||||
};
|
||||
|
||||
// workspaceRoot has no PROMPT.md — but DB value takes precedence
|
||||
const ctx = createTestContext({
|
||||
agentManager: mockManager as any,
|
||||
workspaceRoot: tmpDir,
|
||||
});
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getAgentPrompt({ id: 'agent-1' });
|
||||
|
||||
expect(result).toEqual({ content: dbPromptContent });
|
||||
});
|
||||
|
||||
it('falls back to PROMPT.md when agent.prompt is null in DB', async () => {
|
||||
const agentName = 'test-agent';
|
||||
const promptDir = path.join(tmpDir, '.cw', 'agent-logs', agentName);
|
||||
await fs.mkdir(promptDir, { recursive: true });
|
||||
const fileContent = '# File Prompt\nThis is from the file (legacy)';
|
||||
await fs.writeFile(path.join(promptDir, 'PROMPT.md'), fileContent);
|
||||
|
||||
const mockManager = {
|
||||
get: vi.fn().mockResolvedValue(makeAgentInfo({ name: agentName, prompt: null })),
|
||||
};
|
||||
|
||||
const ctx = createTestContext({
|
||||
agentManager: mockManager as any,
|
||||
workspaceRoot: tmpDir,
|
||||
});
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getAgentPrompt({ id: 'agent-1' });
|
||||
|
||||
expect(result).toEqual({ content: fileContent });
|
||||
});
|
||||
});
|
||||
@@ -5,11 +5,13 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { tracked, type TrackedEnvelope } from '@trpc/server';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import type { ProcedureBuilder } from '../trpc.js';
|
||||
import type { TRPCContext } from '../context.js';
|
||||
import type { AgentInfo, AgentResult, PendingQuestions } from '../../agent/types.js';
|
||||
import type { AgentOutputEvent } from '../../events/types.js';
|
||||
import { requireAgentManager, requireLogChunkRepository } from './_helpers.js';
|
||||
import { requireAgentManager, requireLogChunkRepository, requireTaskRepository, requireInitiativeRepository } from './_helpers.js';
|
||||
|
||||
export const spawnAgentInputSchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
@@ -120,7 +122,23 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) {
|
||||
getAgent: publicProcedure
|
||||
.input(agentIdentifierSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return resolveAgent(ctx, input);
|
||||
const agent = await resolveAgent(ctx, input);
|
||||
|
||||
let taskName: string | null = null;
|
||||
let initiativeName: string | null = null;
|
||||
|
||||
if (agent.taskId) {
|
||||
const taskRepo = requireTaskRepository(ctx);
|
||||
const task = await taskRepo.findById(agent.taskId);
|
||||
taskName = task?.name ?? null;
|
||||
}
|
||||
if (agent.initiativeId) {
|
||||
const initiativeRepo = requireInitiativeRepository(ctx);
|
||||
const initiative = await initiativeRepo.findById(agent.initiativeId);
|
||||
initiativeName = initiative?.name ?? null;
|
||||
}
|
||||
|
||||
return { ...agent, taskName, initiativeName };
|
||||
}),
|
||||
|
||||
getAgentByName: publicProcedure
|
||||
@@ -184,6 +202,17 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) {
|
||||
return candidates[0] ?? null;
|
||||
}),
|
||||
|
||||
getTaskAgent: publicProcedure
|
||||
.input(z.object({ taskId: z.string().min(1) }))
|
||||
.query(async ({ ctx, input }): Promise<AgentInfo | null> => {
|
||||
const agentManager = requireAgentManager(ctx);
|
||||
const all = await agentManager.list();
|
||||
const matches = all
|
||||
.filter(a => a.taskId === input.taskId)
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
return matches[0] ?? null;
|
||||
}),
|
||||
|
||||
getActiveConflictAgent: publicProcedure
|
||||
.input(z.object({ initiativeId: z.string().min(1) }))
|
||||
.query(async ({ ctx, input }): Promise<AgentInfo | null> => {
|
||||
@@ -207,12 +236,15 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) {
|
||||
|
||||
getAgentOutput: publicProcedure
|
||||
.input(agentIdentifierSchema)
|
||||
.query(async ({ ctx, input }): Promise<string> => {
|
||||
.query(async ({ ctx, input }) => {
|
||||
const agent = await resolveAgent(ctx, input);
|
||||
const logChunkRepo = requireLogChunkRepository(ctx);
|
||||
|
||||
const chunks = await logChunkRepo.findByAgentId(agent.id);
|
||||
return chunks.map(c => c.content).join('');
|
||||
return chunks.map(c => ({
|
||||
content: c.content,
|
||||
createdAt: c.createdAt.toISOString(),
|
||||
}));
|
||||
}),
|
||||
|
||||
onAgentOutput: publicProcedure
|
||||
@@ -267,5 +299,116 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) {
|
||||
cleanup();
|
||||
}
|
||||
}),
|
||||
|
||||
getAgentInputFiles: publicProcedure
|
||||
.input(z.object({ id: z.string().min(1) }))
|
||||
.output(z.object({
|
||||
files: z.array(z.object({
|
||||
name: z.string(),
|
||||
content: z.string(),
|
||||
sizeBytes: z.number(),
|
||||
})),
|
||||
reason: z.enum(['worktree_missing', 'input_dir_missing']).optional(),
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const agent = await resolveAgent(ctx, { id: input.id });
|
||||
|
||||
const worktreeRoot = path.join(ctx.workspaceRoot!, 'agent-workdirs', agent.worktreeId);
|
||||
const inputDir = path.join(worktreeRoot, '.cw', 'input');
|
||||
|
||||
// Check worktree root exists
|
||||
try {
|
||||
await fs.stat(worktreeRoot);
|
||||
} catch {
|
||||
return { files: [], reason: 'worktree_missing' as const };
|
||||
}
|
||||
|
||||
// Check input dir exists
|
||||
try {
|
||||
await fs.stat(inputDir);
|
||||
} catch {
|
||||
return { files: [], reason: 'input_dir_missing' as const };
|
||||
}
|
||||
|
||||
// Walk inputDir recursively
|
||||
const entries = await fs.readdir(inputDir, { recursive: true, withFileTypes: true });
|
||||
const MAX_SIZE = 500 * 1024;
|
||||
const results: Array<{ name: string; content: string; sizeBytes: number }> = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
// entry.parentPath is available in Node 20+
|
||||
const dir = (entry as any).parentPath ?? (entry as any).path;
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
const relativeName = path.relative(inputDir, fullPath);
|
||||
|
||||
try {
|
||||
// Binary detection: read first 512 bytes
|
||||
const fd = await fs.open(fullPath, 'r');
|
||||
const headerBuf = Buffer.alloc(512);
|
||||
const { bytesRead } = await fd.read(headerBuf, 0, 512, 0);
|
||||
await fd.close();
|
||||
if (headerBuf.slice(0, bytesRead).includes(0)) continue; // skip binary
|
||||
|
||||
const raw = await fs.readFile(fullPath);
|
||||
const sizeBytes = raw.length;
|
||||
let content: string;
|
||||
if (sizeBytes > MAX_SIZE) {
|
||||
content = raw.slice(0, MAX_SIZE).toString('utf-8') + '\n\n[truncated — file exceeds 500 KB]';
|
||||
} else {
|
||||
content = raw.toString('utf-8');
|
||||
}
|
||||
results.push({ name: relativeName, content, sizeBytes });
|
||||
} catch {
|
||||
continue; // skip unreadable files
|
||||
}
|
||||
}
|
||||
|
||||
results.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return { files: results };
|
||||
}),
|
||||
|
||||
getAgentPrompt: publicProcedure
|
||||
.input(z.object({ id: z.string().min(1) }))
|
||||
.output(z.object({
|
||||
content: z.string().nullable(),
|
||||
reason: z.enum(['prompt_not_written']).optional(),
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const agent = await resolveAgent(ctx, { id: input.id });
|
||||
|
||||
const MAX_BYTES = 1024 * 1024; // 1 MB
|
||||
|
||||
function truncateIfNeeded(text: string): string {
|
||||
if (Buffer.byteLength(text, 'utf-8') > MAX_BYTES) {
|
||||
const buf = Buffer.from(text, 'utf-8');
|
||||
return buf.slice(0, MAX_BYTES).toString('utf-8') + '\n\n[truncated — prompt exceeds 1 MB]';
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
// Prefer DB-persisted prompt (durable even after log file cleanup)
|
||||
if (agent.prompt !== null) {
|
||||
return { content: truncateIfNeeded(agent.prompt) };
|
||||
}
|
||||
|
||||
// Fall back to filesystem for agents spawned before DB persistence was added
|
||||
const promptPath = path.join(ctx.workspaceRoot!, '.cw', 'agent-logs', agent.name, 'PROMPT.md');
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(promptPath, 'utf-8');
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'ENOENT') {
|
||||
return { content: null, reason: 'prompt_not_written' as const };
|
||||
}
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: `Failed to read prompt file: ${String(err)}`,
|
||||
});
|
||||
}
|
||||
|
||||
return { content: truncateIfNeeded(raw) };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -139,7 +139,6 @@ async function createErrandDirect(
|
||||
agentId: string | null;
|
||||
projectId: string;
|
||||
status: 'active' | 'pending_review' | 'conflict' | 'merged' | 'abandoned';
|
||||
conflictFiles: string | null;
|
||||
}> = {},
|
||||
) {
|
||||
const project = await createProject(repos);
|
||||
@@ -153,13 +152,13 @@ async function createErrandDirect(
|
||||
});
|
||||
|
||||
const errand = await repos.errandRepository.create({
|
||||
id: nanoid(),
|
||||
description: overrides.description ?? 'Fix typo in README',
|
||||
branch: overrides.branch ?? 'cw/errand/fix-typo-abc12345',
|
||||
baseBranch: overrides.baseBranch ?? 'main',
|
||||
agentId: overrides.agentId !== undefined ? overrides.agentId : agent.id,
|
||||
projectId: overrides.projectId ?? project.id,
|
||||
status: overrides.status ?? 'active',
|
||||
conflictFiles: overrides.conflictFiles ?? null,
|
||||
});
|
||||
|
||||
return { errand, project, agent };
|
||||
@@ -356,7 +355,7 @@ describe('errand procedures', () => {
|
||||
const { errand: e1, project } = await createErrandDirect(h.repos, h.agentManager);
|
||||
const project2 = await h.repos.projectRepository.create({ name: 'proj2', url: 'https://github.com/t/p2', defaultBranch: 'main' });
|
||||
const agent2 = await h.agentManager.spawn({ prompt: 'x', mode: 'errand', cwd: '/tmp/x', taskId: null });
|
||||
await h.repos.errandRepository.create({ description: 'Other', branch: 'cw/errand/other-abc12345', baseBranch: 'main', agentId: agent2.id, projectId: project2.id, status: 'active', conflictFiles: null });
|
||||
await h.repos.errandRepository.create({ id: nanoid(), description: 'Other', branch: 'cw/errand/other-abc12345', baseBranch: 'main', agentId: agent2.id, projectId: project2.id, status: 'active' });
|
||||
|
||||
const result = await h.caller.errand.list({ projectId: project.id });
|
||||
expect(result.length).toBe(1);
|
||||
@@ -388,23 +387,13 @@ describe('errand procedures', () => {
|
||||
// errand.get
|
||||
// =========================================================================
|
||||
describe('errand.get', () => {
|
||||
it('returns errand with agentAlias and parsed conflictFiles', async () => {
|
||||
it('returns errand with agentAlias and projectPath', async () => {
|
||||
const { errand } = await createErrandDirect(h.repos, h.agentManager);
|
||||
const result = await h.caller.errand.get({ id: errand.id });
|
||||
|
||||
expect(result.id).toBe(errand.id);
|
||||
expect(result).toHaveProperty('agentAlias');
|
||||
expect(result.conflictFiles).toEqual([]);
|
||||
});
|
||||
|
||||
it('parses conflictFiles JSON when present', async () => {
|
||||
const { errand } = await createErrandDirect(h.repos, h.agentManager, {
|
||||
status: 'conflict',
|
||||
conflictFiles: '["src/a.ts","src/b.ts"]',
|
||||
});
|
||||
|
||||
const result = await h.caller.errand.get({ id: errand.id });
|
||||
expect(result.conflictFiles).toEqual(['src/a.ts', 'src/b.ts']);
|
||||
expect(result).toHaveProperty('projectPath');
|
||||
});
|
||||
|
||||
it('throws NOT_FOUND for unknown id', async () => {
|
||||
@@ -496,7 +485,6 @@ describe('errand procedures', () => {
|
||||
it('merges clean conflict errand (re-merge after resolve)', async () => {
|
||||
const { errand } = await createErrandDirect(h.repos, h.agentManager, {
|
||||
status: 'conflict',
|
||||
conflictFiles: '["src/a.ts"]',
|
||||
});
|
||||
h.branchManager.setMergeResult({ success: true, message: 'Merged' });
|
||||
|
||||
@@ -517,7 +505,7 @@ describe('errand procedures', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('throws BAD_REQUEST and stores conflictFiles on merge conflict', async () => {
|
||||
it('throws BAD_REQUEST and sets status to conflict on merge conflict', async () => {
|
||||
const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'pending_review' });
|
||||
h.branchManager.setMergeResult({
|
||||
success: false,
|
||||
@@ -532,7 +520,6 @@ describe('errand procedures', () => {
|
||||
|
||||
const updated = await h.repos.errandRepository.findById(errand.id);
|
||||
expect(updated!.status).toBe('conflict');
|
||||
expect(JSON.parse(updated!.conflictFiles!)).toEqual(['src/a.ts', 'src/b.ts']);
|
||||
});
|
||||
|
||||
it('throws BAD_REQUEST when status is active', async () => {
|
||||
@@ -570,7 +557,7 @@ describe('errand procedures', () => {
|
||||
expect(h.branchManager.deletedBranches).toContain(errand.branch);
|
||||
|
||||
const deleted = await h.repos.errandRepository.findById(errand.id);
|
||||
expect(deleted).toBeNull();
|
||||
expect(deleted).toBeUndefined();
|
||||
});
|
||||
|
||||
it('deletes non-active errand: skips agent stop', async () => {
|
||||
@@ -583,7 +570,7 @@ describe('errand procedures', () => {
|
||||
expect(stopSpy).not.toHaveBeenCalled();
|
||||
|
||||
const deleted = await h.repos.errandRepository.findById(errand.id);
|
||||
expect(deleted).toBeNull();
|
||||
expect(deleted).toBeUndefined();
|
||||
});
|
||||
|
||||
it('succeeds when worktree already removed (no-op)', async () => {
|
||||
@@ -595,7 +582,7 @@ describe('errand procedures', () => {
|
||||
expect(result).toEqual({ success: true });
|
||||
|
||||
const deleted = await h.repos.errandRepository.findById(errand.id);
|
||||
expect(deleted).toBeNull();
|
||||
expect(deleted).toBeUndefined();
|
||||
});
|
||||
|
||||
it('succeeds when branch already deleted (no-op)', async () => {
|
||||
@@ -692,7 +679,6 @@ describe('errand procedures', () => {
|
||||
it('abandons conflict errand: skips agent stop, removes worktree, deletes branch', async () => {
|
||||
const { errand } = await createErrandDirect(h.repos, h.agentManager, {
|
||||
status: 'conflict',
|
||||
conflictFiles: '["src/a.ts"]',
|
||||
agentId: null,
|
||||
});
|
||||
|
||||
|
||||
@@ -102,6 +102,7 @@ export function errandProcedures(publicProcedure: ProcedureBuilder) {
|
||||
let errand;
|
||||
try {
|
||||
errand = await repo.create({
|
||||
id: nanoid(),
|
||||
description: input.description,
|
||||
branch: branchName,
|
||||
baseBranch,
|
||||
@@ -202,16 +203,6 @@ export function errandProcedures(publicProcedure: ProcedureBuilder) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Errand not found' });
|
||||
}
|
||||
|
||||
// Parse conflictFiles; return [] on null or malformed JSON
|
||||
let conflictFiles: string[] = [];
|
||||
if (errand.conflictFiles) {
|
||||
try {
|
||||
conflictFiles = JSON.parse(errand.conflictFiles) as string[];
|
||||
} catch {
|
||||
conflictFiles = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Compute project clone path for cw errand resolve
|
||||
let projectPath: string | null = null;
|
||||
if (errand.projectId && ctx.workspaceRoot) {
|
||||
@@ -221,7 +212,7 @@ export function errandProcedures(publicProcedure: ProcedureBuilder) {
|
||||
}
|
||||
}
|
||||
|
||||
return { ...errand, conflictFiles, projectPath };
|
||||
return { ...errand, projectPath };
|
||||
}),
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -235,6 +226,9 @@ export function errandProcedures(publicProcedure: ProcedureBuilder) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Errand not found' });
|
||||
}
|
||||
|
||||
if (!errand.projectId) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Errand has no project' });
|
||||
}
|
||||
const project = await requireProjectRepository(ctx).findById(errand.projectId);
|
||||
if (!project) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' });
|
||||
@@ -303,6 +297,9 @@ export function errandProcedures(publicProcedure: ProcedureBuilder) {
|
||||
|
||||
const targetBranch = input.target ?? errand.baseBranch;
|
||||
|
||||
if (!errand.projectId) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Errand has no project' });
|
||||
}
|
||||
const project = await requireProjectRepository(ctx).findById(errand.projectId);
|
||||
if (!project) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' });
|
||||
@@ -319,15 +316,12 @@ export function errandProcedures(publicProcedure: ProcedureBuilder) {
|
||||
// Clean merge — remove worktree and mark merged
|
||||
const worktreeManager = new SimpleGitWorktreeManager(clonePath);
|
||||
try { await worktreeManager.remove(errand.id); } catch { /* no-op */ }
|
||||
await repo.update(input.id, { status: 'merged', conflictFiles: null });
|
||||
await repo.update(input.id, { status: 'merged' });
|
||||
return { status: 'merged' };
|
||||
} else {
|
||||
// Conflict — persist conflict files and throw
|
||||
// Conflict — update status and throw
|
||||
const conflictFilesList = result.conflicts ?? [];
|
||||
await repo.update(input.id, {
|
||||
status: 'conflict',
|
||||
conflictFiles: JSON.stringify(conflictFilesList),
|
||||
});
|
||||
await repo.update(input.id, { status: 'conflict' });
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `Merge conflict in ${conflictFilesList.length} file(s)`,
|
||||
|
||||
214
apps/server/trpc/routers/headquarters.ts
Normal file
214
apps/server/trpc/routers/headquarters.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Headquarters Router
|
||||
*
|
||||
* Provides the composite dashboard query for the Headquarters page,
|
||||
* aggregating all action items that require user intervention.
|
||||
*/
|
||||
|
||||
import type { ProcedureBuilder } from '../trpc.js';
|
||||
import type { Phase } from '../../db/schema.js';
|
||||
import {
|
||||
requireAgentManager,
|
||||
requireInitiativeRepository,
|
||||
requirePhaseRepository,
|
||||
} from './_helpers.js';
|
||||
|
||||
export function headquartersProcedures(publicProcedure: ProcedureBuilder) {
|
||||
return {
|
||||
getHeadquartersDashboard: publicProcedure.query(async ({ ctx }) => {
|
||||
const initiativeRepo = requireInitiativeRepository(ctx);
|
||||
const phaseRepo = requirePhaseRepository(ctx);
|
||||
const agentManager = requireAgentManager(ctx);
|
||||
|
||||
const [allInitiatives, allAgents] = await Promise.all([
|
||||
initiativeRepo.findAll(),
|
||||
agentManager.list(),
|
||||
]);
|
||||
|
||||
// Relevant initiatives: status in ['active', 'pending_review']
|
||||
const relevantInitiatives = allInitiatives.filter(
|
||||
(i) => i.status === 'active' || i.status === 'pending_review',
|
||||
);
|
||||
|
||||
// Non-dismissed agents only
|
||||
const activeAgents = allAgents.filter((a) => !a.userDismissedAt);
|
||||
|
||||
// Fast lookup map: initiative id → initiative
|
||||
const initiativeMap = new Map(relevantInitiatives.map((i) => [i.id, i]));
|
||||
|
||||
// Batch-fetch all phases for relevant initiatives in parallel
|
||||
const phasesByInitiative = new Map<string, Phase[]>();
|
||||
await Promise.all(
|
||||
relevantInitiatives.map(async (init) => {
|
||||
const phases = await phaseRepo.findByInitiativeId(init.id);
|
||||
phasesByInitiative.set(init.id, phases);
|
||||
}),
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Section 1: waitingForInput
|
||||
// -----------------------------------------------------------------------
|
||||
const waitingAgents = activeAgents.filter((a) => a.status === 'waiting_for_input');
|
||||
const pendingQuestionsResults = await Promise.all(
|
||||
waitingAgents.map((a) => agentManager.getPendingQuestions(a.id)),
|
||||
);
|
||||
|
||||
const waitingForInput = waitingAgents
|
||||
.map((agent, i) => {
|
||||
const initiative = agent.initiativeId ? initiativeMap.get(agent.initiativeId) : undefined;
|
||||
return {
|
||||
agentId: agent.id,
|
||||
agentName: agent.name,
|
||||
initiativeId: agent.initiativeId,
|
||||
initiativeName: initiative?.name ?? null,
|
||||
questionText: pendingQuestionsResults[i]?.questions[0]?.question ?? '',
|
||||
waitingSince: agent.updatedAt.toISOString(),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.waitingSince.localeCompare(b.waitingSince));
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Section 2a: pendingReviewInitiatives
|
||||
// -----------------------------------------------------------------------
|
||||
const pendingReviewInitiatives = relevantInitiatives
|
||||
.filter((i) => i.status === 'pending_review')
|
||||
.map((i) => ({
|
||||
initiativeId: i.id,
|
||||
initiativeName: i.name,
|
||||
since: i.updatedAt.toISOString(),
|
||||
}))
|
||||
.sort((a, b) => a.since.localeCompare(b.since));
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Section 2b: pendingReviewPhases
|
||||
// -----------------------------------------------------------------------
|
||||
const pendingReviewPhases: Array<{
|
||||
initiativeId: string;
|
||||
initiativeName: string;
|
||||
phaseId: string;
|
||||
phaseName: string;
|
||||
since: string;
|
||||
}> = [];
|
||||
|
||||
for (const [initiativeId, phases] of phasesByInitiative) {
|
||||
const initiative = initiativeMap.get(initiativeId)!;
|
||||
for (const phase of phases) {
|
||||
if (phase.status === 'pending_review') {
|
||||
pendingReviewPhases.push({
|
||||
initiativeId,
|
||||
initiativeName: initiative.name,
|
||||
phaseId: phase.id,
|
||||
phaseName: phase.name,
|
||||
since: phase.updatedAt.toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
pendingReviewPhases.sort((a, b) => a.since.localeCompare(b.since));
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Section 3: planningInitiatives
|
||||
// -----------------------------------------------------------------------
|
||||
const planningInitiatives: Array<{
|
||||
initiativeId: string;
|
||||
initiativeName: string;
|
||||
pendingPhaseCount: number;
|
||||
since: string;
|
||||
}> = [];
|
||||
|
||||
for (const initiative of relevantInitiatives) {
|
||||
if (initiative.status !== 'active') continue;
|
||||
const phases = phasesByInitiative.get(initiative.id) ?? [];
|
||||
if (phases.length === 0) continue;
|
||||
|
||||
const allPending = phases.every((p) => p.status === 'pending');
|
||||
if (!allPending) continue;
|
||||
|
||||
const hasActiveAgent = activeAgents.some(
|
||||
(a) =>
|
||||
a.initiativeId === initiative.id &&
|
||||
(a.status === 'running' || a.status === 'waiting_for_input'),
|
||||
);
|
||||
if (hasActiveAgent) continue;
|
||||
|
||||
const sortedByCreatedAt = [...phases].sort(
|
||||
(a, b) => a.createdAt.getTime() - b.createdAt.getTime(),
|
||||
);
|
||||
|
||||
planningInitiatives.push({
|
||||
initiativeId: initiative.id,
|
||||
initiativeName: initiative.name,
|
||||
pendingPhaseCount: phases.length,
|
||||
since: sortedByCreatedAt[0].createdAt.toISOString(),
|
||||
});
|
||||
}
|
||||
planningInitiatives.sort((a, b) => a.since.localeCompare(b.since));
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Section 4: blockedPhases
|
||||
// -----------------------------------------------------------------------
|
||||
const blockedPhases: Array<{
|
||||
initiativeId: string;
|
||||
initiativeName: string;
|
||||
phaseId: string;
|
||||
phaseName: string;
|
||||
lastMessage: string | null;
|
||||
since: string;
|
||||
}> = [];
|
||||
|
||||
for (const initiative of relevantInitiatives) {
|
||||
if (initiative.status !== 'active') continue;
|
||||
const phases = phasesByInitiative.get(initiative.id) ?? [];
|
||||
|
||||
for (const phase of phases) {
|
||||
if (phase.status !== 'blocked') continue;
|
||||
|
||||
let lastMessage: string | null = null;
|
||||
try {
|
||||
if (ctx.taskRepository && ctx.messageRepository) {
|
||||
const taskRepo = ctx.taskRepository;
|
||||
const messageRepo = ctx.messageRepository;
|
||||
const tasks = await taskRepo.findByPhaseId(phase.id);
|
||||
const phaseAgentIds = allAgents
|
||||
.filter((a) => tasks.some((t) => t.id === a.taskId))
|
||||
.map((a) => a.id);
|
||||
|
||||
if (phaseAgentIds.length > 0) {
|
||||
const messageLists = await Promise.all(
|
||||
phaseAgentIds.map((id) => messageRepo.findBySender('agent', id)),
|
||||
);
|
||||
const allMessages = messageLists
|
||||
.flat()
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
|
||||
if (allMessages.length > 0) {
|
||||
lastMessage = allMessages[0].content.slice(0, 160);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-critical: message retrieval failure does not crash the dashboard
|
||||
}
|
||||
|
||||
blockedPhases.push({
|
||||
initiativeId: initiative.id,
|
||||
initiativeName: initiative.name,
|
||||
phaseId: phase.id,
|
||||
phaseName: phase.name,
|
||||
lastMessage,
|
||||
since: phase.updatedAt.toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
blockedPhases.sort((a, b) => a.since.localeCompare(b.since));
|
||||
|
||||
return {
|
||||
waitingForInput,
|
||||
pendingReviewInitiatives,
|
||||
pendingReviewPhases,
|
||||
planningInitiatives,
|
||||
blockedPhases,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export interface ActiveArchitectAgent {
|
||||
initiativeId: string;
|
||||
mode: string;
|
||||
status: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
const MODE_TO_STATE: Record<string, InitiativeActivityState> = {
|
||||
@@ -30,6 +31,18 @@ export function deriveInitiativeActivity(
|
||||
if (initiative.status === 'archived') {
|
||||
return { ...base, state: 'archived' };
|
||||
}
|
||||
|
||||
// Check for active conflict resolution agent — takes priority over pending_review
|
||||
// because the agent is actively working to resolve merge conflicts
|
||||
const conflictAgent = activeArchitectAgents?.find(
|
||||
a => a.initiativeId === initiative.id
|
||||
&& a.name?.startsWith('conflict-')
|
||||
&& (a.status === 'running' || a.status === 'waiting_for_input'),
|
||||
);
|
||||
if (conflictAgent) {
|
||||
return { ...base, state: 'resolving_conflict' };
|
||||
}
|
||||
|
||||
if (initiative.status === 'pending_review') {
|
||||
return { ...base, state: 'pending_review' };
|
||||
}
|
||||
@@ -41,6 +54,7 @@ export function deriveInitiativeActivity(
|
||||
// so architect agents (discuss/plan/detail/refine) surface activity
|
||||
const activeAgent = activeArchitectAgents?.find(
|
||||
a => a.initiativeId === initiative.id
|
||||
&& !a.name?.startsWith('conflict-')
|
||||
&& (a.status === 'running' || a.status === 'waiting_for_input'),
|
||||
);
|
||||
if (activeAgent) {
|
||||
|
||||
@@ -129,27 +129,42 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
|
||||
: await repo.findAll();
|
||||
}
|
||||
|
||||
// Fetch active architect agents once for all initiatives
|
||||
// Fetch active agents once for all initiatives (architect + conflict)
|
||||
const ARCHITECT_MODES = ['discuss', 'plan', 'detail', 'refine'];
|
||||
const allAgents = ctx.agentManager ? await ctx.agentManager.list() : [];
|
||||
const activeArchitectAgents = allAgents
|
||||
.filter(a =>
|
||||
ARCHITECT_MODES.includes(a.mode ?? '')
|
||||
(ARCHITECT_MODES.includes(a.mode ?? '') || a.name?.startsWith('conflict-'))
|
||||
&& (a.status === 'running' || a.status === 'waiting_for_input')
|
||||
&& !a.userDismissedAt,
|
||||
)
|
||||
.map(a => ({ initiativeId: a.initiativeId ?? '', mode: a.mode ?? '', status: a.status }));
|
||||
.map(a => ({ initiativeId: a.initiativeId ?? '', mode: a.mode ?? '', status: a.status, name: a.name }));
|
||||
|
||||
// Batch-fetch projects for all initiatives
|
||||
const projectRepo = ctx.projectRepository;
|
||||
const projectsByInitiativeId = new Map<string, Array<{ id: string; name: string }>>();
|
||||
if (projectRepo) {
|
||||
await Promise.all(initiatives.map(async (init) => {
|
||||
const projects = await projectRepo.findProjectsByInitiativeId(init.id);
|
||||
projectsByInitiativeId.set(init.id, projects.map(p => ({ id: p.id, name: p.name })));
|
||||
}));
|
||||
}
|
||||
|
||||
const addProjects = (init: typeof initiatives[0]) => ({
|
||||
projects: projectsByInitiativeId.get(init.id) ?? [],
|
||||
});
|
||||
|
||||
if (ctx.phaseRepository) {
|
||||
const phaseRepo = ctx.phaseRepository;
|
||||
return Promise.all(initiatives.map(async (init) => {
|
||||
const phases = await phaseRepo.findByInitiativeId(init.id);
|
||||
return { ...init, activity: deriveInitiativeActivity(init, phases, activeArchitectAgents) };
|
||||
return { ...init, ...addProjects(init), activity: deriveInitiativeActivity(init, phases, activeArchitectAgents) };
|
||||
}));
|
||||
}
|
||||
|
||||
return initiatives.map(init => ({
|
||||
...init,
|
||||
...addProjects(init),
|
||||
activity: deriveInitiativeActivity(init, [], activeArchitectAgents),
|
||||
}));
|
||||
}),
|
||||
@@ -473,6 +488,7 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
|
||||
initiativeId: input.initiativeId,
|
||||
baseBranch: initiative.branch,
|
||||
branchName: tempBranch,
|
||||
skipPromptExtras: true,
|
||||
});
|
||||
}),
|
||||
};
|
||||
|
||||
92
apps/server/trpc/routers/project.test.ts
Normal file
92
apps/server/trpc/routers/project.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Tests for registerProject CONFLICT error disambiguation.
|
||||
* Verifies that UNIQUE constraint failures on specific columns produce
|
||||
* column-specific error messages.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { router, publicProcedure, createCallerFactory } from '../trpc.js';
|
||||
import { projectProcedures } from './project.js';
|
||||
import type { TRPCContext } from '../context.js';
|
||||
import type { ProjectRepository } from '../../db/repositories/project-repository.js';
|
||||
|
||||
const testRouter = router({
|
||||
...projectProcedures(publicProcedure),
|
||||
});
|
||||
|
||||
const createCaller = createCallerFactory(testRouter);
|
||||
|
||||
function makeCtx(mockCreate: () => Promise<never>): TRPCContext {
|
||||
const projectRepository: ProjectRepository = {
|
||||
create: mockCreate as unknown as ProjectRepository['create'],
|
||||
findById: vi.fn().mockResolvedValue(null),
|
||||
findByName: vi.fn().mockResolvedValue(null),
|
||||
findAll: vi.fn().mockResolvedValue([]),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
addProjectToInitiative: vi.fn(),
|
||||
removeProjectFromInitiative: vi.fn(),
|
||||
findProjectsByInitiativeId: vi.fn().mockResolvedValue([]),
|
||||
setInitiativeProjects: vi.fn(),
|
||||
};
|
||||
|
||||
return {
|
||||
eventBus: {} as TRPCContext['eventBus'],
|
||||
serverStartedAt: null,
|
||||
processCount: 0,
|
||||
projectRepository,
|
||||
// No workspaceRoot — prevents cloneProject from running
|
||||
};
|
||||
}
|
||||
|
||||
const INPUT = { name: 'my-project', url: 'https://github.com/example/repo' };
|
||||
|
||||
describe('registerProject — CONFLICT error disambiguation', () => {
|
||||
it('throws CONFLICT with name-specific message on projects.name UNIQUE violation', async () => {
|
||||
const caller = createCaller(makeCtx(() => {
|
||||
throw new Error('UNIQUE constraint failed: projects.name');
|
||||
}));
|
||||
|
||||
const err = await caller.registerProject(INPUT).catch(e => e);
|
||||
expect(err).toBeInstanceOf(TRPCError);
|
||||
expect(err.code).toBe('CONFLICT');
|
||||
expect(err.message).toBe('A project with this name already exists');
|
||||
});
|
||||
|
||||
it('throws CONFLICT with url-specific message on projects.url UNIQUE violation', async () => {
|
||||
const caller = createCaller(makeCtx(() => {
|
||||
throw new Error('UNIQUE constraint failed: projects.url');
|
||||
}));
|
||||
|
||||
const err = await caller.registerProject(INPUT).catch(e => e);
|
||||
expect(err).toBeInstanceOf(TRPCError);
|
||||
expect(err.code).toBe('CONFLICT');
|
||||
expect(err.message).toBe('A project with this URL already exists');
|
||||
});
|
||||
|
||||
it('throws CONFLICT with fallback message on unknown UNIQUE constraint violation', async () => {
|
||||
const caller = createCaller(makeCtx(() => {
|
||||
throw new Error('UNIQUE constraint failed: projects.unknown_col');
|
||||
}));
|
||||
|
||||
const err = await caller.registerProject(INPUT).catch(e => e);
|
||||
expect(err).toBeInstanceOf(TRPCError);
|
||||
expect(err.code).toBe('CONFLICT');
|
||||
expect(err.message).toBe('A project with this name or URL already exists');
|
||||
});
|
||||
|
||||
it('rethrows non-UNIQUE errors without wrapping in a CONFLICT', async () => {
|
||||
const originalError = new Error('SQLITE_BUSY');
|
||||
const caller = createCaller(makeCtx(() => {
|
||||
throw originalError;
|
||||
}));
|
||||
|
||||
const err = await caller.registerProject(INPUT).catch(e => e);
|
||||
expect(err).toBeDefined();
|
||||
// Must not be surfaced as a CONFLICT — the catch block should re-throw as-is
|
||||
expect(err).not.toMatchObject({ code: 'CONFLICT' });
|
||||
// The original error message must be preserved somewhere
|
||||
expect(err.message).toContain('SQLITE_BUSY');
|
||||
});
|
||||
});
|
||||
@@ -30,11 +30,24 @@ export function projectProcedures(publicProcedure: ProcedureBuilder) {
|
||||
...(input.defaultBranch && { defaultBranch: input.defaultBranch }),
|
||||
});
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
const msg = (error as Error).message ?? '';
|
||||
if (msg.includes('UNIQUE') || msg.includes('unique')) {
|
||||
if (msg.includes('projects.name') || (msg.includes('name') && !msg.includes('url'))) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'A project with this name already exists',
|
||||
});
|
||||
}
|
||||
if (msg.includes('projects.url') || msg.includes('url')) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'A project with this URL already exists',
|
||||
});
|
||||
}
|
||||
// fallback: neither column identifiable
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: `A project with that name or URL already exists`,
|
||||
message: 'A project with this name or URL already exists',
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
|
||||
@@ -70,6 +70,7 @@ export const ALL_EVENT_TYPES: DomainEventType[] = [
|
||||
'chat:session_closed',
|
||||
'initiative:pending_review',
|
||||
'initiative:review_approved',
|
||||
'initiative:changes_requested',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -102,6 +103,7 @@ export const TASK_EVENT_TYPES: DomainEventType[] = [
|
||||
'phase:merged',
|
||||
'initiative:pending_review',
|
||||
'initiative:review_approved',
|
||||
'initiative:changes_requested',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { CheckCircle2, XCircle, AlertTriangle, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { CheckCircle2, XCircle, AlertTriangle, Trash2, KeyRound } from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { UpdateCredentialsDialog } from "./UpdateCredentialsDialog";
|
||||
|
||||
function formatResetTime(isoDate: string): string {
|
||||
const now = Date.now();
|
||||
@@ -100,6 +102,7 @@ export function AccountCard({
|
||||
account: AccountData;
|
||||
onDelete?: (e: React.MouseEvent) => void;
|
||||
}) {
|
||||
const [updateCredOpen, setUpdateCredOpen] = useState(false);
|
||||
const hasWarning = account.credentialsValid && !account.isExhausted && account.error;
|
||||
|
||||
const statusIcon = !account.credentialsValid ? (
|
||||
@@ -123,6 +126,7 @@ export function AccountCard({
|
||||
const usage = account.usage;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardContent className="space-y-3 py-4">
|
||||
{/* Header row */}
|
||||
@@ -147,6 +151,17 @@ export function AccountCard({
|
||||
<span>{statusText}</span>
|
||||
</div>
|
||||
</div>
|
||||
{(!account.credentialsValid || !account.tokenValid) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setUpdateCredOpen(true)}
|
||||
title="Update credentials"
|
||||
>
|
||||
<KeyRound className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -220,5 +235,11 @@ export function AccountCard({
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<UpdateCredentialsDialog
|
||||
open={updateCredOpen}
|
||||
onOpenChange={setUpdateCredOpen}
|
||||
account={{ id: account.id, email: account.email, provider: account.provider }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
289
apps/web/src/components/AddAccountDialog.tsx
Normal file
289
apps/web/src/components/AddAccountDialog.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select'
|
||||
|
||||
interface AddAccountDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
export function AddAccountDialog({ open, onOpenChange }: AddAccountDialogProps) {
|
||||
const [tab, setTab] = useState<'token' | 'credentials'>('token')
|
||||
|
||||
// Tab A — token
|
||||
const [email, setEmail] = useState('')
|
||||
const [token, setToken] = useState('')
|
||||
const [provider, setProvider] = useState('claude')
|
||||
|
||||
// Tab B — credentials JSON
|
||||
const [credEmail, setCredEmail] = useState('')
|
||||
const [credProvider, setCredProvider] = useState('claude')
|
||||
const [configJsonText, setConfigJsonText] = useState('')
|
||||
const [credentialsText, setCredentialsText] = useState('')
|
||||
|
||||
// Validation errors
|
||||
const [emailError, setEmailError] = useState('')
|
||||
const [tokenError, setTokenError] = useState('')
|
||||
const [credEmailError, setCredEmailError] = useState('')
|
||||
const [configJsonError, setConfigJsonError] = useState('')
|
||||
const [credentialsError, setCredentialsError] = useState('')
|
||||
const [serverError, setServerError] = useState('')
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const providersQuery = trpc.listProviderNames.useQuery()
|
||||
|
||||
const addByToken = trpc.addAccountByToken.useMutation({
|
||||
onSuccess: (data) => {
|
||||
const msg = data.upserted
|
||||
? `Account updated — credentials refreshed for ${email}.`
|
||||
: `Account added: ${email}.`
|
||||
toast.success(msg)
|
||||
void utils.systemHealthCheck.invalidate()
|
||||
onOpenChange(false)
|
||||
},
|
||||
onError: (err) => {
|
||||
setServerError(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
const addAccount = trpc.addAccount.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success(`Account added: ${credEmail}.`)
|
||||
void utils.systemHealthCheck.invalidate()
|
||||
onOpenChange(false)
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err.message.toLowerCase().includes('already exists')) {
|
||||
setCredEmailError(
|
||||
"An account with this email already exists. Use 'Update credentials' on the existing account instead."
|
||||
)
|
||||
} else {
|
||||
setServerError(err.message)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTab('token')
|
||||
setEmail(''); setToken(''); setProvider('claude')
|
||||
setCredEmail(''); setCredProvider('claude')
|
||||
setConfigJsonText(''); setCredentialsText('')
|
||||
setEmailError(''); setTokenError('')
|
||||
setCredEmailError(''); setConfigJsonError(''); setCredentialsError('')
|
||||
setServerError('')
|
||||
}
|
||||
}, [open])
|
||||
|
||||
function handleSubmit() {
|
||||
setEmailError(''); setTokenError(''); setCredEmailError('')
|
||||
setConfigJsonError(''); setCredentialsError(''); setServerError('')
|
||||
|
||||
if (tab === 'token') {
|
||||
let hasError = false
|
||||
if (email.trim() === '') {
|
||||
setEmailError('Required')
|
||||
hasError = true
|
||||
} else if (!EMAIL_REGEX.test(email)) {
|
||||
setEmailError('Enter a valid email address')
|
||||
hasError = true
|
||||
}
|
||||
if (token.trim() === '') {
|
||||
setTokenError('Required')
|
||||
hasError = true
|
||||
}
|
||||
if (hasError) return
|
||||
addByToken.mutate({ email, token, provider })
|
||||
} else {
|
||||
let hasError = false
|
||||
if (credEmail.trim() === '') {
|
||||
setCredEmailError('Required')
|
||||
hasError = true
|
||||
} else if (!EMAIL_REGEX.test(credEmail)) {
|
||||
setCredEmailError('Enter a valid email address')
|
||||
hasError = true
|
||||
}
|
||||
if (configJsonText.trim() !== '') {
|
||||
try {
|
||||
JSON.parse(configJsonText)
|
||||
} catch {
|
||||
setConfigJsonError('Invalid JSON')
|
||||
hasError = true
|
||||
}
|
||||
}
|
||||
if (credentialsText.trim() !== '') {
|
||||
try {
|
||||
JSON.parse(credentialsText)
|
||||
} catch {
|
||||
setCredentialsError('Invalid JSON')
|
||||
hasError = true
|
||||
}
|
||||
}
|
||||
if (hasError) return
|
||||
addAccount.mutate({
|
||||
email: credEmail,
|
||||
provider: credProvider,
|
||||
configJson: configJsonText.trim() || undefined,
|
||||
credentials: credentialsText.trim() || undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const isPending = tab === 'token' ? addByToken.isPending : addAccount.isPending
|
||||
|
||||
function renderProviderSelect(value: string, onChange: (v: string) => void) {
|
||||
if (providersQuery.isError) {
|
||||
return <Input value={value} onChange={(e) => onChange(e.target.value)} />
|
||||
}
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(providersQuery.data ?? ['claude']).map((p) => (
|
||||
<SelectItem key={p} value={p}>{p}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Account</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex border-b">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTab('token')
|
||||
setCredEmail('')
|
||||
setCredProvider('claude')
|
||||
setConfigJsonText('')
|
||||
setCredentialsText('')
|
||||
setCredEmailError('')
|
||||
setConfigJsonError('')
|
||||
setCredentialsError('')
|
||||
setServerError('')
|
||||
}}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
tab === 'token'
|
||||
? 'border-primary text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
By token
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTab('credentials')
|
||||
setEmail('')
|
||||
setToken('')
|
||||
setEmailError('')
|
||||
setTokenError('')
|
||||
setServerError('')
|
||||
}}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
tab === 'credentials'
|
||||
? 'border-primary text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
By credentials JSON
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === 'token' ? (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Email</Label>
|
||||
<Input
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
{emailError && <p className="text-sm text-destructive">{emailError}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Setup token (from `claude setup-token`)</Label>
|
||||
<Input
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
/>
|
||||
{tokenError && <p className="text-sm text-destructive">{tokenError}</p>}
|
||||
{serverError && <p className="text-sm text-destructive">{serverError}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Provider</Label>
|
||||
{renderProviderSelect(provider, setProvider)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Email</Label>
|
||||
<Input
|
||||
value={credEmail}
|
||||
onChange={(e) => setCredEmail(e.target.value)}
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
{credEmailError && <p className="text-sm text-destructive">{credEmailError}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Provider</Label>
|
||||
{renderProviderSelect(credProvider, setCredProvider)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Config JSON (`.claude.json` content)</Label>
|
||||
<Textarea
|
||||
value={configJsonText}
|
||||
onChange={(e) => setConfigJsonText(e.target.value)}
|
||||
placeholder={`{ "oauthAccount": { ... } }`}
|
||||
/>
|
||||
{configJsonError && <p className="text-sm text-destructive">{configJsonError}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Credentials JSON (`.credentials.json` content)</Label>
|
||||
<Textarea
|
||||
value={credentialsText}
|
||||
onChange={(e) => setCredentialsText(e.target.value)}
|
||||
placeholder={`{ "claudeAiOauth": { ... } }`}
|
||||
/>
|
||||
{credentialsError && <p className="text-sm text-destructive">{credentialsError}</p>}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Run `cw account extract` in your terminal to obtain these values.
|
||||
</p>
|
||||
{serverError && <p className="text-sm text-destructive">{serverError}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Add Account
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
230
apps/web/src/components/AgentDetailsPanel.tsx
Normal file
230
apps/web/src/components/AgentDetailsPanel.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Skeleton } from "@/components/Skeleton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { StatusDot } from "@/components/StatusDot";
|
||||
import { formatRelativeTime } from "@/lib/utils";
|
||||
import { modeLabel } from "@/lib/labels";
|
||||
|
||||
export function AgentDetailsPanel({ agentId }: { agentId: string }) {
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-4 space-y-6">
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">Metadata</h3>
|
||||
<MetadataSection agentId={agentId} />
|
||||
</section>
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">Input Files</h3>
|
||||
<InputFilesSection agentId={agentId} />
|
||||
</section>
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">Effective Prompt</h3>
|
||||
<EffectivePromptSection agentId={agentId} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetadataSection({ agentId }: { agentId: string }) {
|
||||
const query = trpc.getAgent.useQuery({ id: agentId });
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} variant="line" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (query.isError) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-destructive">{query.error.message}</p>
|
||||
<Button variant="outline" size="sm" onClick={() => void query.refetch()}>Retry</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const agent = query.data;
|
||||
if (!agent) return null;
|
||||
|
||||
const showExitCode = !['idle', 'running', 'waiting_for_input'].includes(agent.status);
|
||||
|
||||
const rows: Array<{ label: string; value: React.ReactNode }> = [
|
||||
{
|
||||
label: 'Status',
|
||||
value: (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<StatusDot status={agent.status} size="sm" />
|
||||
{agent.status}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Mode',
|
||||
value: modeLabel(agent.mode),
|
||||
},
|
||||
{
|
||||
label: 'Provider',
|
||||
value: agent.provider,
|
||||
},
|
||||
{
|
||||
label: 'Initiative',
|
||||
value: agent.initiativeId ? (
|
||||
<Link
|
||||
to="/initiatives/$initiativeId"
|
||||
params={{ initiativeId: agent.initiativeId }}
|
||||
className="underline underline-offset-2"
|
||||
>
|
||||
{(agent as { initiativeName?: string | null }).initiativeName ?? agent.initiativeId}
|
||||
</Link>
|
||||
) : '—',
|
||||
},
|
||||
{
|
||||
label: 'Task',
|
||||
value: (agent as { taskName?: string | null }).taskName ?? (agent.taskId ? agent.taskId : '—'),
|
||||
},
|
||||
{
|
||||
label: 'Created',
|
||||
value: formatRelativeTime(String(agent.createdAt)),
|
||||
},
|
||||
];
|
||||
|
||||
if (showExitCode) {
|
||||
rows.push({
|
||||
label: 'Exit Code',
|
||||
value: (
|
||||
<span className={agent.exitCode === 1 ? 'text-destructive' : ''}>
|
||||
{agent.exitCode ?? '—'}
|
||||
</span>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{rows.map(({ label, value }) => (
|
||||
<div key={label} className="flex items-center gap-4 py-1.5 border-b border-border/30 last:border-0">
|
||||
<span className="w-28 shrink-0 text-xs text-muted-foreground">{label}</span>
|
||||
<span className="text-sm">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InputFilesSection({ agentId }: { agentId: string }) {
|
||||
const query = trpc.getAgentInputFiles.useQuery({ id: agentId });
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedFile(null);
|
||||
}, [agentId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!query.data?.files) return;
|
||||
if (selectedFile !== null) return;
|
||||
const manifest = query.data.files.find(f => f.name === 'manifest.json');
|
||||
setSelectedFile(manifest?.name ?? query.data.files[0]?.name ?? null);
|
||||
}, [query.data?.files]);
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Skeleton variant="line" />
|
||||
<Skeleton variant="line" />
|
||||
<Skeleton variant="line" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (query.isError) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-destructive">{query.error.message}</p>
|
||||
<Button variant="outline" size="sm" onClick={() => void query.refetch()}>Retry</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const data = query.data;
|
||||
if (!data) return null;
|
||||
|
||||
if (data.reason === 'worktree_missing') {
|
||||
return <p className="text-sm text-muted-foreground">Worktree no longer exists — input files unavailable</p>;
|
||||
}
|
||||
|
||||
if (data.reason === 'input_dir_missing') {
|
||||
return <p className="text-sm text-muted-foreground">Input directory not found — this agent may not have received input files</p>;
|
||||
}
|
||||
|
||||
const { files } = data;
|
||||
|
||||
if (files.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">No input files found</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row gap-2 min-h-0">
|
||||
{/* File list */}
|
||||
<div className="md:w-48 shrink-0 overflow-y-auto space-y-0.5">
|
||||
{files.map(file => (
|
||||
<button
|
||||
key={file.name}
|
||||
onClick={() => setSelectedFile(file.name)}
|
||||
className={cn(
|
||||
"w-full text-left px-2 py-1 text-xs rounded truncate",
|
||||
selectedFile === file.name
|
||||
? "bg-muted font-medium"
|
||||
: "hover:bg-muted/50 text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{file.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Content pane */}
|
||||
<pre className="flex-1 text-xs font-mono overflow-auto bg-terminal rounded p-3 min-h-0">
|
||||
{files.find(f => f.name === selectedFile)?.content ?? ''}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EffectivePromptSection({ agentId }: { agentId: string }) {
|
||||
const query = trpc.getAgentPrompt.useQuery({ id: agentId });
|
||||
|
||||
if (query.isLoading) {
|
||||
return <Skeleton variant="rect" className="h-32 w-full" />;
|
||||
}
|
||||
|
||||
if (query.isError) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-destructive">{query.error.message}</p>
|
||||
<Button variant="outline" size="sm" onClick={() => void query.refetch()}>Retry</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const data = query.data;
|
||||
if (!data) return null;
|
||||
|
||||
if (data.reason === 'prompt_not_written') {
|
||||
return <p className="text-sm text-muted-foreground">Prompt file not available — agent may have been spawned before this feature was added</p>;
|
||||
}
|
||||
|
||||
if (data.content) {
|
||||
return (
|
||||
<pre className="text-xs font-mono overflow-y-auto max-h-[400px] bg-terminal rounded p-3 whitespace-pre-wrap">
|
||||
{data.content}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
407
apps/web/src/components/AgentOutputViewer.test.tsx
Normal file
407
apps/web/src/components/AgentOutputViewer.test.tsx
Normal file
@@ -0,0 +1,407 @@
|
||||
// @vitest-environment happy-dom
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
||||
import * as parseModule from '@/lib/parse-agent-output'
|
||||
import { AgentOutputViewer } from './AgentOutputViewer'
|
||||
|
||||
vi.mock('@/lib/trpc', () => ({
|
||||
trpc: {
|
||||
getAgentOutput: {
|
||||
useQuery: vi.fn(() => ({ data: [], isLoading: false })),
|
||||
},
|
||||
onAgentOutput: {
|
||||
useSubscription: vi.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks', () => ({
|
||||
useSubscriptionWithErrorHandling: vi.fn(() => ({
|
||||
error: null,
|
||||
isConnecting: false,
|
||||
})),
|
||||
}))
|
||||
|
||||
function makeToolResultMessage(content: string) {
|
||||
return {
|
||||
type: 'tool_result' as const,
|
||||
content,
|
||||
timestamp: new Date('2024-01-01T00:00:00Z'),
|
||||
}
|
||||
}
|
||||
|
||||
function makeSystemMessage(content: string) {
|
||||
return {
|
||||
type: 'system' as const,
|
||||
content,
|
||||
timestamp: new Date('2024-01-01T00:00:00Z'),
|
||||
}
|
||||
}
|
||||
|
||||
function makeTextMessage(content: string) {
|
||||
return {
|
||||
type: 'text' as const,
|
||||
content,
|
||||
timestamp: new Date('2024-01-01T00:00:00Z'),
|
||||
}
|
||||
}
|
||||
|
||||
function makeToolCallMessage(content: string, toolName: string) {
|
||||
return {
|
||||
type: 'tool_call' as const,
|
||||
content,
|
||||
timestamp: new Date('2024-01-01T00:00:00Z'),
|
||||
meta: { toolName },
|
||||
}
|
||||
}
|
||||
|
||||
function makeTodoWriteMessage(todos: Array<{ content: string; status: string; activeForm: string }>) {
|
||||
return {
|
||||
type: 'tool_call' as const,
|
||||
content: 'TodoWrite(...)',
|
||||
timestamp: new Date('2024-01-01T00:00:00Z'),
|
||||
meta: {
|
||||
toolName: 'TodoWrite',
|
||||
toolInput: { todos },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function makeToolResultMessageWithMeta(
|
||||
content: string,
|
||||
meta: { toolName?: string; toolInput?: unknown }
|
||||
) {
|
||||
return {
|
||||
type: 'tool_result' as const,
|
||||
content,
|
||||
timestamp: new Date('2024-01-01T00:00:00Z'),
|
||||
meta,
|
||||
}
|
||||
}
|
||||
|
||||
function makeErrorMessage(content: string) {
|
||||
return {
|
||||
type: 'error' as const,
|
||||
content,
|
||||
timestamp: new Date('2024-01-01T00:00:00Z'),
|
||||
}
|
||||
}
|
||||
|
||||
function makeSessionEndMessage(content: string) {
|
||||
return {
|
||||
type: 'session_end' as const,
|
||||
content,
|
||||
timestamp: new Date('2024-01-01T00:00:00Z'),
|
||||
}
|
||||
}
|
||||
|
||||
describe('AgentOutputViewer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Default: no messages
|
||||
vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([])
|
||||
})
|
||||
|
||||
// Test 1: tool_result renders collapsed by default
|
||||
it('renders tool_result collapsed by default', () => {
|
||||
const content = 'file content here and more stuff'
|
||||
vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([makeToolResultMessage(content)])
|
||||
|
||||
render(<AgentOutputViewer agentId="agent-1" />)
|
||||
|
||||
// ChevronRight should be present (collapsed state)
|
||||
// We check that the SVG for ChevronRight is in the document
|
||||
// lucide-react renders SVGs — we look for the collapsed container containing the preview text
|
||||
expect(screen.getByText(content.substring(0, 80))).toBeInTheDocument()
|
||||
|
||||
// "Result" badge should NOT be visible (collapsed)
|
||||
expect(screen.queryByText('Result')).not.toBeInTheDocument()
|
||||
|
||||
// The container should NOT show a "Result" badge
|
||||
// ChevronRight is rendered — verify no ChevronDown
|
||||
const svgs = document.querySelectorAll('svg')
|
||||
// We look for the collapsed state by absence of "Result" text
|
||||
expect(screen.queryByText('Result')).toBeNull()
|
||||
})
|
||||
|
||||
// Test 2: Clicking collapsed result expands it
|
||||
it('expands tool_result on click', () => {
|
||||
const longContent = 'a'.repeat(100)
|
||||
vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([makeToolResultMessage(longContent)])
|
||||
|
||||
render(<AgentOutputViewer agentId="agent-1" />)
|
||||
|
||||
// Initially collapsed — click it
|
||||
const collapsedContainer = screen.getByText(longContent.substring(0, 80)).closest('div')!
|
||||
fireEvent.click(collapsedContainer)
|
||||
|
||||
// After click: "Result" badge should be visible
|
||||
expect(screen.getByText('Result')).toBeInTheDocument()
|
||||
|
||||
// Full content should be visible in whitespace-pre-wrap element
|
||||
const preWrap = document.querySelector('.whitespace-pre-wrap')
|
||||
expect(preWrap).toBeInTheDocument()
|
||||
expect(preWrap).toHaveTextContent(longContent)
|
||||
|
||||
// ChevronRight should no longer be visible; ChevronDown should be present
|
||||
expect(screen.queryByText('Result')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Test 3: Clicking expanded result collapses it again
|
||||
it('collapses tool_result on second click', () => {
|
||||
const longContent = 'b'.repeat(100)
|
||||
vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([makeToolResultMessage(longContent)])
|
||||
|
||||
render(<AgentOutputViewer agentId="agent-1" />)
|
||||
|
||||
// Click once to expand
|
||||
const container = screen.getByText(longContent.substring(0, 80)).closest('div')!
|
||||
fireEvent.click(container)
|
||||
expect(screen.getByText('Result')).toBeInTheDocument()
|
||||
|
||||
// Click again to collapse
|
||||
// After expansion, the container still exists — click the expanded container
|
||||
// The clickable container is the border-l-2 div
|
||||
const expandedContainer = screen.getByText('Result').closest('.border-l-2')!
|
||||
fireEvent.click(expandedContainer)
|
||||
|
||||
// Should be collapsed again
|
||||
expect(screen.queryByText('Result')).not.toBeInTheDocument()
|
||||
expect(screen.getByText(longContent.substring(0, 80))).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Test 4: system message renders as single dim line, no badge, no border-l
|
||||
it('renders system message as a single dim line without badge or border', () => {
|
||||
const content = 'Session started: abc-123'
|
||||
vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([makeSystemMessage(content)])
|
||||
|
||||
render(<AgentOutputViewer agentId="agent-1" />)
|
||||
|
||||
// Content is visible
|
||||
expect(screen.getByText(content)).toBeInTheDocument()
|
||||
|
||||
// No "System" badge text
|
||||
expect(screen.queryByText('System')).not.toBeInTheDocument()
|
||||
|
||||
// The rendered element should NOT have border-l class
|
||||
const el = screen.getByText(content)
|
||||
expect(el.className).not.toContain('border-l')
|
||||
expect(el.closest('[class*="border-l"]')).toBeNull()
|
||||
})
|
||||
|
||||
// Test 5: agentId prop change resets expanded results
|
||||
it('resets expanded results when agentId changes', () => {
|
||||
const content = 'c'.repeat(100)
|
||||
vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([makeToolResultMessage(content)])
|
||||
|
||||
const { rerender } = render(<AgentOutputViewer agentId="agent-1" />)
|
||||
|
||||
// Expand the result
|
||||
const collapsedContainer = screen.getByText(content.substring(0, 80)).closest('div')!
|
||||
fireEvent.click(collapsedContainer)
|
||||
expect(screen.getByText('Result')).toBeInTheDocument()
|
||||
|
||||
// Change agentId — should reset expandedResults
|
||||
rerender(<AgentOutputViewer agentId="agent-2" />)
|
||||
|
||||
// After agentId change, result should be collapsed again
|
||||
expect(screen.queryByText('Result')).not.toBeInTheDocument()
|
||||
// Preview text should be visible (collapsed state)
|
||||
expect(screen.getByText(content.substring(0, 80))).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Test 6: Other message types remain always-expanded (unaffected)
|
||||
it('always renders text messages fully', () => {
|
||||
const content = 'This is a text message'
|
||||
vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([makeTextMessage(content)])
|
||||
|
||||
render(<AgentOutputViewer agentId="agent-1" />)
|
||||
|
||||
expect(screen.getByText(content)).toBeInTheDocument()
|
||||
|
||||
// No chevron icons for text messages
|
||||
const svgCount = document.querySelectorAll('svg').length
|
||||
// Only the header bar icons (Pause) should be present, no expand/collapse chevrons
|
||||
expect(screen.queryByText('Result')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('System')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('always renders tool_call messages fully', () => {
|
||||
const content = 'Read(file.txt)'
|
||||
vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([makeToolCallMessage(content, 'Read')])
|
||||
|
||||
render(<AgentOutputViewer agentId="agent-1" />)
|
||||
|
||||
expect(screen.getByText(content)).toBeInTheDocument()
|
||||
// The tool name badge should be visible
|
||||
expect(screen.getByText('Read')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('always renders error messages with Error badge', () => {
|
||||
const content = 'Something went wrong'
|
||||
vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([makeErrorMessage(content)])
|
||||
|
||||
render(<AgentOutputViewer agentId="agent-1" />)
|
||||
|
||||
expect(screen.getByText('Error')).toBeInTheDocument()
|
||||
expect(screen.getByText(content)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('always renders session_end messages with session completed text', () => {
|
||||
vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([makeSessionEndMessage('Session completed')])
|
||||
|
||||
render(<AgentOutputViewer agentId="agent-1" />)
|
||||
|
||||
expect(screen.getByText('Session completed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('Todo strip', () => {
|
||||
it('is absent when no TodoWrite tool_call exists', () => {
|
||||
vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([
|
||||
makeTextMessage('some output'),
|
||||
makeToolCallMessage('Read(file.txt)', 'Read'),
|
||||
])
|
||||
|
||||
render(<AgentOutputViewer agentId="agent-1" />)
|
||||
|
||||
expect(screen.queryByText('TASKS')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('is present with TASKS label and all todos when a TodoWrite tool_call exists', () => {
|
||||
vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([
|
||||
makeTodoWriteMessage([
|
||||
{ content: 'Fix bug', status: 'completed', activeForm: 'Fixing bug' },
|
||||
{ content: 'Add tests', status: 'in_progress', activeForm: 'Adding tests' },
|
||||
{ content: 'Update docs', status: 'pending', activeForm: 'Updating docs' },
|
||||
]),
|
||||
])
|
||||
|
||||
render(<AgentOutputViewer agentId="agent-1" />)
|
||||
|
||||
expect(screen.getByText('TASKS')).toBeInTheDocument()
|
||||
expect(screen.getByText('Fix bug')).toBeInTheDocument()
|
||||
expect(screen.getByText('Add tests')).toBeInTheDocument()
|
||||
expect(screen.getByText('Update docs')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows only the most recent TodoWrite todos', () => {
|
||||
vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([
|
||||
makeTodoWriteMessage([
|
||||
{ content: 'Old task', status: 'pending', activeForm: 'Old task' },
|
||||
]),
|
||||
makeTodoWriteMessage([
|
||||
{ content: 'New task', status: 'in_progress', activeForm: 'New task' },
|
||||
]),
|
||||
])
|
||||
|
||||
render(<AgentOutputViewer agentId="agent-1" />)
|
||||
|
||||
expect(screen.getByText('New task')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Old task')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders Loader2 with animate-spin for in_progress todo', () => {
|
||||
vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([
|
||||
makeTodoWriteMessage([
|
||||
{ content: 'Work', status: 'in_progress', activeForm: 'Working' },
|
||||
]),
|
||||
])
|
||||
|
||||
render(<AgentOutputViewer agentId="agent-1" />)
|
||||
|
||||
// Find SVG with animate-spin class (Loader2)
|
||||
const spinningIcon = document.querySelector('svg.animate-spin')
|
||||
expect(spinningIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders CheckCircle2 and strikethrough text for completed todo', () => {
|
||||
vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([
|
||||
makeTodoWriteMessage([
|
||||
{ content: 'Done', status: 'completed', activeForm: 'Done' },
|
||||
]),
|
||||
])
|
||||
|
||||
render(<AgentOutputViewer agentId="agent-1" />)
|
||||
|
||||
const doneText = screen.getByText('Done')
|
||||
expect(doneText.className).toContain('line-through')
|
||||
})
|
||||
|
||||
it('renders Circle and muted text without strikethrough for pending todo', () => {
|
||||
vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([
|
||||
makeTodoWriteMessage([
|
||||
{ content: 'Later', status: 'pending', activeForm: 'Later' },
|
||||
]),
|
||||
])
|
||||
|
||||
render(<AgentOutputViewer agentId="agent-1" />)
|
||||
|
||||
const laterText = screen.getByText('Later')
|
||||
expect(laterText.className).not.toContain('line-through')
|
||||
})
|
||||
|
||||
it('renders at most 5 todo rows and shows overflow count when there are 7 todos', () => {
|
||||
const todos = Array.from({ length: 7 }, (_, i) => ({
|
||||
content: `Task ${i + 1}`,
|
||||
status: 'pending',
|
||||
activeForm: `Task ${i + 1}`,
|
||||
}))
|
||||
vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([
|
||||
makeTodoWriteMessage(todos),
|
||||
])
|
||||
|
||||
render(<AgentOutputViewer agentId="agent-1" />)
|
||||
|
||||
// Only first 5 are rendered
|
||||
expect(screen.getByText('Task 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Task 5')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Task 6')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Task 7')).not.toBeInTheDocument()
|
||||
|
||||
// Overflow indicator
|
||||
expect(screen.getByText('+ 2 more')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Task result collapsed preview', () => {
|
||||
it('shows subagent_type result label when meta.toolName is Task with subagent_type', () => {
|
||||
vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([
|
||||
makeToolResultMessageWithMeta('raw subagent output content '.repeat(5), {
|
||||
toolName: 'Task',
|
||||
toolInput: { subagent_type: 'Explore', description: 'desc', prompt: 'prompt' },
|
||||
}),
|
||||
])
|
||||
|
||||
render(<AgentOutputViewer agentId="agent-1" />)
|
||||
|
||||
expect(screen.getByText('Explore result')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows Subagent result when meta.toolName is Task but no subagent_type', () => {
|
||||
vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([
|
||||
makeToolResultMessageWithMeta('raw subagent output content '.repeat(5), {
|
||||
toolName: 'Task',
|
||||
toolInput: { description: 'desc', prompt: 'prompt' },
|
||||
}),
|
||||
])
|
||||
|
||||
render(<AgentOutputViewer agentId="agent-1" />)
|
||||
|
||||
expect(screen.getByText('Subagent result')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows first 80 chars of content for non-Task tool results', () => {
|
||||
const content = 'some file content that is longer than 80 characters '.repeat(3)
|
||||
vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([
|
||||
makeToolResultMessageWithMeta(content, {
|
||||
toolName: 'Read',
|
||||
}),
|
||||
])
|
||||
|
||||
render(<AgentOutputViewer agentId="agent-1" />)
|
||||
|
||||
expect(screen.getByText(content.substring(0, 80))).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ArrowDown, Pause, Play, AlertCircle, Square } from "lucide-react";
|
||||
import { ArrowDown, Pause, Play, AlertCircle, Square, ChevronRight, ChevronDown, CheckCircle2, Loader2, Circle } from "lucide-react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { useSubscriptionWithErrorHandling } from "@/hooks";
|
||||
import {
|
||||
type ParsedMessage,
|
||||
type TimestampedChunk,
|
||||
getMessageStyling,
|
||||
parseAgentOutput,
|
||||
} from "@/lib/parse-agent-output";
|
||||
@@ -20,9 +21,10 @@ interface AgentOutputViewerProps {
|
||||
export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentOutputViewerProps) {
|
||||
const [messages, setMessages] = useState<ParsedMessage[]>([]);
|
||||
const [follow, setFollow] = useState(true);
|
||||
const [expandedResults, setExpandedResults] = useState<Set<number>>(new Set());
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// Accumulate raw JSONL: initial query data + live subscription chunks
|
||||
const rawBufferRef = useRef<string>('');
|
||||
// Accumulate timestamped chunks: initial query data + live subscription chunks
|
||||
const chunksRef = useRef<TimestampedChunk[]>([]);
|
||||
|
||||
// Load initial/historical output
|
||||
const outputQuery = trpc.getAgentOutput.useQuery(
|
||||
@@ -40,8 +42,8 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
|
||||
// TrackedEnvelope shape: { id, data: { agentId, data: string } }
|
||||
const raw = event?.data?.data ?? event?.data;
|
||||
const chunk = typeof raw === 'string' ? raw : JSON.stringify(raw);
|
||||
rawBufferRef.current += chunk;
|
||||
setMessages(parseAgentOutput(rawBufferRef.current));
|
||||
chunksRef.current = [...chunksRef.current, { content: chunk, createdAt: new Date().toISOString() }];
|
||||
setMessages(parseAgentOutput(chunksRef.current));
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Agent output subscription error:', error);
|
||||
@@ -54,16 +56,17 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
|
||||
// Set initial output when query loads
|
||||
useEffect(() => {
|
||||
if (outputQuery.data) {
|
||||
rawBufferRef.current = outputQuery.data;
|
||||
chunksRef.current = outputQuery.data;
|
||||
setMessages(parseAgentOutput(outputQuery.data));
|
||||
}
|
||||
}, [outputQuery.data]);
|
||||
|
||||
// Reset output when agent changes
|
||||
useEffect(() => {
|
||||
rawBufferRef.current = '';
|
||||
chunksRef.current = [];
|
||||
setMessages([]);
|
||||
setFollow(true);
|
||||
setExpandedResults(new Set());
|
||||
}, [agentId]);
|
||||
|
||||
// Auto-scroll to bottom when following
|
||||
@@ -94,6 +97,14 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
|
||||
const isLoading = outputQuery.isLoading;
|
||||
const hasOutput = messages.length > 0;
|
||||
|
||||
const lastTodoWrite = [...messages]
|
||||
.reverse()
|
||||
.find(m => m.type === "tool_call" && m.meta?.toolName === "TodoWrite");
|
||||
|
||||
const currentTodos = lastTodoWrite
|
||||
? (lastTodoWrite.meta?.toolInput as { todos: Array<{ content: string; status: string; activeForm: string }> } | undefined)?.todos ?? []
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full rounded-lg border border-terminal-border overflow-hidden">
|
||||
{/* Header */}
|
||||
@@ -156,61 +167,111 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Todo strip */}
|
||||
{currentTodos.length > 0 && (
|
||||
<div className="bg-terminal border-b border-terminal-border px-4 py-2">
|
||||
<div className="text-[10px] text-terminal-muted/60 font-mono uppercase tracking-widest mb-1">TASKS</div>
|
||||
{currentTodos.slice(0, 5).map((todo, i) => (
|
||||
<div key={i} className="flex items-center gap-2 font-mono text-xs">
|
||||
{todo.status === "completed" && <CheckCircle2 className="h-3 w-3 text-terminal-muted/60" />}
|
||||
{todo.status === "in_progress" && <Loader2 className="h-3 w-3 text-terminal-fg animate-spin" />}
|
||||
{todo.status === "pending" && <Circle className="h-3 w-3 text-terminal-muted/40" />}
|
||||
<span className={
|
||||
todo.status === "completed"
|
||||
? "text-terminal-muted/60 line-through"
|
||||
: todo.status === "in_progress"
|
||||
? "text-terminal-fg"
|
||||
: "text-terminal-muted"
|
||||
}>{todo.content}</span>
|
||||
</div>
|
||||
))}
|
||||
{currentTodos.length > 5 && (
|
||||
<div className="font-mono text-xs text-terminal-muted/60">+ {currentTodos.length - 5} more</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Output content */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto bg-terminal p-4"
|
||||
className="flex-1 overflow-y-auto overflow-x-hidden bg-terminal p-4"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="text-terminal-muted text-sm">Loading output...</div>
|
||||
) : !hasOutput ? (
|
||||
<div className="text-terminal-muted text-sm">No output yet...</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-2 min-w-0">
|
||||
{messages.map((message, index) => (
|
||||
<div key={index} className={getMessageStyling(message.type)}>
|
||||
{message.type === 'system' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs bg-terminal-border text-terminal-system">System</Badge>
|
||||
<span className="text-xs text-terminal-muted">{message.content}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.type === 'text' && (
|
||||
<div className="font-mono text-sm whitespace-pre-wrap text-terminal-fg">
|
||||
<div className="text-terminal-muted/60 text-xs font-mono">
|
||||
{message.content}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.type === 'text' && (
|
||||
<>
|
||||
<Timestamp date={message.timestamp} />
|
||||
<div className="font-mono text-sm whitespace-pre-wrap break-words text-terminal-fg">
|
||||
{message.content}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{message.type === 'tool_call' && (
|
||||
<div className="border-l-2 border-terminal-tool pl-3 py-1">
|
||||
<Badge variant="default" className="mb-1 text-xs">
|
||||
{message.meta?.toolName}
|
||||
</Badge>
|
||||
<div className="font-mono text-xs text-terminal-muted whitespace-pre-wrap">
|
||||
<div className="border-l-2 border-terminal-tool pl-3 py-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Badge variant="default" className="text-xs">
|
||||
{message.meta?.toolName}
|
||||
</Badge>
|
||||
<Timestamp date={message.timestamp} />
|
||||
</div>
|
||||
<div className="font-mono text-xs text-terminal-muted whitespace-pre-wrap break-words">
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.type === 'tool_result' && (
|
||||
<div className="border-l-2 border-terminal-result pl-3 py-1 bg-white/[0.02]">
|
||||
<Badge variant="outline" className="mb-1 text-xs text-terminal-result border-terminal-result">
|
||||
Result
|
||||
</Badge>
|
||||
<div className="font-mono text-xs text-terminal-muted whitespace-pre-wrap">
|
||||
{message.content}
|
||||
</div>
|
||||
<div
|
||||
className="border-l-2 border-terminal-result pl-3 py-1 bg-white/[0.02] min-w-0 cursor-pointer"
|
||||
onClick={() => setExpandedResults(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(index)) next.delete(index); else next.add(index);
|
||||
return next;
|
||||
})}
|
||||
>
|
||||
{expandedResults.has(index) ? (
|
||||
<>
|
||||
<ChevronDown className="h-4 w-4 text-terminal-muted inline-block shrink-0 mr-1" />
|
||||
<Badge variant="outline" className="mb-1 text-xs text-terminal-result border-terminal-result">
|
||||
Result
|
||||
</Badge>
|
||||
<div className="font-mono text-xs text-terminal-muted whitespace-pre-wrap break-words">
|
||||
{message.content}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronRight className="h-4 w-4 text-terminal-muted inline-block shrink-0 mr-1" />
|
||||
<span className="text-terminal-muted/60 text-xs font-mono truncate">
|
||||
{message.meta?.toolName === "Task"
|
||||
? `${(message.meta.toolInput as any)?.subagent_type ?? "Subagent"} result`
|
||||
: message.content.substring(0, 80)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.type === 'error' && (
|
||||
<div className="border-l-2 border-terminal-error pl-3 py-1 bg-terminal-error/10">
|
||||
<div className="border-l-2 border-terminal-error pl-3 py-1 bg-terminal-error/10 min-w-0">
|
||||
<Badge variant="destructive" className="mb-1 text-xs">
|
||||
Error
|
||||
</Badge>
|
||||
<div className="font-mono text-xs text-terminal-error whitespace-pre-wrap">
|
||||
<div className="font-mono text-xs text-terminal-error whitespace-pre-wrap break-words">
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
@@ -228,6 +289,7 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
|
||||
{message.meta?.duration && (
|
||||
<span className="text-xs text-terminal-muted">{(message.meta.duration / 1000).toFixed(1)}s</span>
|
||||
)}
|
||||
<Timestamp date={message.timestamp} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -239,3 +301,16 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });
|
||||
}
|
||||
|
||||
function Timestamp({ date }: { date?: Date }) {
|
||||
if (!date) return null;
|
||||
return (
|
||||
<span className="shrink-0 text-[10px] text-terminal-muted/60 font-mono tabular-nums">
|
||||
{formatTime(date)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MoreHorizontal } from "lucide-react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -20,6 +21,7 @@ export interface SerializedInitiative {
|
||||
branch: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
projects?: Array<{ id: string; name: string }>;
|
||||
activity: {
|
||||
state: string;
|
||||
activePhase?: { id: string; name: string };
|
||||
@@ -30,11 +32,12 @@ export interface SerializedInitiative {
|
||||
|
||||
function activityVisual(state: string): { label: string; variant: StatusVariant; pulse: boolean } {
|
||||
switch (state) {
|
||||
case "executing": return { label: "Executing", variant: "active", pulse: true };
|
||||
case "pending_review": return { label: "Pending Review", variant: "warning", pulse: true };
|
||||
case "discussing": return { label: "Discussing", variant: "active", pulse: true };
|
||||
case "detailing": return { label: "Detailing", variant: "active", pulse: true };
|
||||
case "refining": return { label: "Refining", variant: "active", pulse: true };
|
||||
case "executing": return { label: "Executing", variant: "active", pulse: true };
|
||||
case "pending_review": return { label: "Pending Review", variant: "warning", pulse: true };
|
||||
case "discussing": return { label: "Discussing", variant: "active", pulse: true };
|
||||
case "detailing": return { label: "Detailing", variant: "active", pulse: true };
|
||||
case "refining": return { label: "Refining", variant: "active", pulse: true };
|
||||
case "resolving_conflict": return { label: "Resolving Conflict", variant: "urgent", pulse: true };
|
||||
case "ready": return { label: "Ready", variant: "active", pulse: false };
|
||||
case "blocked": return { label: "Blocked", variant: "error", pulse: false };
|
||||
case "complete": return { label: "Complete", variant: "success", pulse: false };
|
||||
@@ -87,11 +90,19 @@ export function InitiativeCard({ initiative, onClick }: InitiativeCardProps) {
|
||||
className="p-4"
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* Row 1: Name + overflow menu */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="min-w-0 truncate text-base font-bold">
|
||||
{initiative.name}
|
||||
</span>
|
||||
{/* Row 1: Name + project pills + overflow menu */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="shrink-0 text-base font-bold">
|
||||
{initiative.name}
|
||||
</span>
|
||||
{initiative.projects && initiative.projects.length > 0 &&
|
||||
initiative.projects.map((p) => (
|
||||
<Badge key={p.id} variant="outline" size="xs" className="shrink-0 font-normal">
|
||||
{p.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
|
||||
@@ -27,13 +28,20 @@ export function RegisterProjectDialog({
|
||||
const [defaultBranch, setDefaultBranch] = useState("main");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const registerMutation = trpc.registerProject.useMutation({
|
||||
onSuccess: () => {
|
||||
onOpenChange(false);
|
||||
toast.success("Project registered");
|
||||
void utils.listProjects.invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err.message);
|
||||
if (err.data?.code === "INTERNAL_SERVER_ERROR") {
|
||||
setError("Failed to clone repository. Check the URL and try again.");
|
||||
} else {
|
||||
setError(err.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -109,7 +117,14 @@ export function RegisterProjectDialog({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!canSubmit}>
|
||||
{registerMutation.isPending ? "Registering..." : "Register"}
|
||||
{registerMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin mr-2 h-4 w-4" />
|
||||
Cloning repository…
|
||||
</>
|
||||
) : (
|
||||
"Register"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
@@ -45,6 +45,10 @@ export function mapEntityStatus(rawStatus: string): StatusVariant {
|
||||
case "medium":
|
||||
return "warning";
|
||||
|
||||
// Urgent / conflict resolution
|
||||
case "resolving_conflict":
|
||||
return "urgent";
|
||||
|
||||
// Error / failed
|
||||
case "crashed":
|
||||
case "blocked":
|
||||
|
||||
175
apps/web/src/components/UpdateCredentialsDialog.test.tsx
Normal file
175
apps/web/src/components/UpdateCredentialsDialog.test.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
// @vitest-environment happy-dom
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
||||
import { UpdateCredentialsDialog } from './UpdateCredentialsDialog'
|
||||
|
||||
// Captured mutation options from the latest useMutation call
|
||||
let capturedOnSuccess: (() => void) | undefined
|
||||
let capturedOnError: ((err: { message: string }) => void) | undefined
|
||||
|
||||
const mockMutate = vi.fn()
|
||||
const mockInvalidate = vi.fn()
|
||||
|
||||
vi.mock('@/lib/trpc', () => ({
|
||||
trpc: {
|
||||
updateAccountAuth: {
|
||||
useMutation: vi.fn((opts?: {
|
||||
onSuccess?: () => void
|
||||
onError?: (err: { message: string }) => void
|
||||
}) => {
|
||||
capturedOnSuccess = opts?.onSuccess
|
||||
capturedOnError = opts?.onError
|
||||
return { mutate: mockMutate, isPending: false }
|
||||
}),
|
||||
},
|
||||
useUtils: vi.fn(() => ({
|
||||
systemHealthCheck: { invalidate: mockInvalidate },
|
||||
})),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { success: vi.fn() },
|
||||
}))
|
||||
|
||||
import { toast } from 'sonner'
|
||||
|
||||
const account = {
|
||||
id: 'acc-1',
|
||||
email: 'alice@example.com',
|
||||
provider: 'claude',
|
||||
}
|
||||
|
||||
describe('UpdateCredentialsDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
capturedOnSuccess = undefined
|
||||
capturedOnError = undefined
|
||||
})
|
||||
|
||||
// Test 1: Renders with read-only identity
|
||||
it('renders account email and provider as non-editable', () => {
|
||||
render(<UpdateCredentialsDialog open={true} onOpenChange={vi.fn()} account={account} />)
|
||||
|
||||
expect(screen.getByText('alice@example.com')).toBeInTheDocument()
|
||||
expect(screen.getByText('claude')).toBeInTheDocument()
|
||||
|
||||
// They must not be editable inputs
|
||||
const emailEl = screen.getByText('alice@example.com')
|
||||
expect(emailEl.tagName).not.toBe('INPUT')
|
||||
const providerEl = screen.getByText('claude')
|
||||
expect(providerEl.tagName).not.toBe('INPUT')
|
||||
})
|
||||
|
||||
// Test 2: Tab A submit — empty token — shows Required, no mutation
|
||||
it('shows Required error when token is empty and does not call mutation', async () => {
|
||||
render(<UpdateCredentialsDialog open={true} onOpenChange={vi.fn()} account={account} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /update credentials/i }))
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Required')).toBeInTheDocument())
|
||||
expect(mockMutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Test 3: Tab A submit — valid token — calls mutation with correct input
|
||||
it('calls mutation with correct token input', async () => {
|
||||
render(<UpdateCredentialsDialog open={true} onOpenChange={vi.fn()} account={account} />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/setup token/i), { target: { value: 'test-token' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /update credentials/i }))
|
||||
|
||||
await waitFor(() => expect(mockMutate).toHaveBeenCalledWith({
|
||||
id: 'acc-1',
|
||||
configJson: '{"hasCompletedOnboarding":true}',
|
||||
credentials: '{"claudeAiOauth":{"accessToken":"test-token"}}',
|
||||
}))
|
||||
})
|
||||
|
||||
// Test 4: Tab B submit — invalid config JSON — shows "Invalid JSON", no mutation
|
||||
it('shows Invalid JSON error for bad config JSON and does not call mutation', async () => {
|
||||
render(<UpdateCredentialsDialog open={true} onOpenChange={vi.fn()} account={account} />)
|
||||
|
||||
fireEvent.click(screen.getByText('By credentials JSON'))
|
||||
fireEvent.change(screen.getByLabelText(/config json/i), { target: { value: 'bad json' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /update credentials/i }))
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Invalid JSON')).toBeInTheDocument())
|
||||
expect(mockMutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Test 5: Tab B submit — empty textareas — calls mutation with '{}' defaults
|
||||
it("calls mutation with '{}' defaults when credentials textareas are empty", async () => {
|
||||
render(<UpdateCredentialsDialog open={true} onOpenChange={vi.fn()} account={account} />)
|
||||
|
||||
fireEvent.click(screen.getByText('By credentials JSON'))
|
||||
fireEvent.click(screen.getByRole('button', { name: /update credentials/i }))
|
||||
|
||||
await waitFor(() => expect(mockMutate).toHaveBeenCalledWith({
|
||||
id: 'acc-1',
|
||||
configJson: '{}',
|
||||
credentials: '{}',
|
||||
}))
|
||||
})
|
||||
|
||||
// Test 6: Server error — shows inline error, dialog stays open
|
||||
it('shows server error inline and does not close dialog on error', async () => {
|
||||
const onOpenChange = vi.fn()
|
||||
render(<UpdateCredentialsDialog open={true} onOpenChange={onOpenChange} account={account} />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/setup token/i), { target: { value: 'some-token' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /update credentials/i }))
|
||||
|
||||
act(() => {
|
||||
capturedOnError?.({ message: 'Token expired' })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Token expired')).toBeInTheDocument())
|
||||
expect(onOpenChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Test 7: Success — toasts and closes dialog
|
||||
it('shows success toast and closes dialog on success', async () => {
|
||||
const onOpenChange = vi.fn()
|
||||
render(<UpdateCredentialsDialog open={true} onOpenChange={onOpenChange} account={account} />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/setup token/i), { target: { value: 'some-token' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /update credentials/i }))
|
||||
|
||||
act(() => {
|
||||
capturedOnSuccess?.()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.success).toHaveBeenCalledWith('Credentials updated for alice@example.com.')
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
// Test 8: State reset on re-open
|
||||
it('resets state when dialog is re-opened', async () => {
|
||||
const { rerender } = render(
|
||||
<UpdateCredentialsDialog open={true} onOpenChange={vi.fn()} account={account} />
|
||||
)
|
||||
|
||||
// Type a token and trigger server error
|
||||
fireEvent.change(screen.getByLabelText(/setup token/i), { target: { value: 'old-token' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /update credentials/i }))
|
||||
|
||||
act(() => {
|
||||
capturedOnError?.({ message: 'Server error' })
|
||||
})
|
||||
await waitFor(() => expect(screen.getByText('Server error')).toBeInTheDocument())
|
||||
|
||||
// Close dialog
|
||||
rerender(<UpdateCredentialsDialog open={false} onOpenChange={vi.fn()} account={account} />)
|
||||
// Re-open dialog
|
||||
rerender(<UpdateCredentialsDialog open={true} onOpenChange={vi.fn()} account={account} />)
|
||||
|
||||
// Token should be cleared
|
||||
const tokenInput = screen.getByLabelText(/setup token/i) as HTMLInputElement
|
||||
expect(tokenInput.value).toBe('')
|
||||
// Server error should be gone
|
||||
expect(screen.queryByText('Server error')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
239
apps/web/src/components/UpdateCredentialsDialog.tsx
Normal file
239
apps/web/src/components/UpdateCredentialsDialog.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface UpdateCredentialsDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
account: { id: string; email: string; provider: string }
|
||||
}
|
||||
|
||||
export function UpdateCredentialsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
account,
|
||||
}: UpdateCredentialsDialogProps) {
|
||||
const [tab, setTab] = useState<'token' | 'credentials'>('token')
|
||||
|
||||
// Tab A — token
|
||||
const [token, setToken] = useState('')
|
||||
const [tokenError, setTokenError] = useState('')
|
||||
|
||||
// Tab B — credentials JSON
|
||||
const [configJsonText, setConfigJsonText] = useState('')
|
||||
const [credentialsText, setCredentialsText] = useState('')
|
||||
const [configJsonError, setConfigJsonError] = useState('')
|
||||
const [credentialsError, setCredentialsError] = useState('')
|
||||
|
||||
// Shared
|
||||
const [serverError, setServerError] = useState('')
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const mutation = trpc.updateAccountAuth.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success(`Credentials updated for ${account.email}.`)
|
||||
void utils.systemHealthCheck.invalidate()
|
||||
onOpenChange(false)
|
||||
},
|
||||
onError: (err) => {
|
||||
setServerError(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTab('token')
|
||||
setToken('')
|
||||
setTokenError('')
|
||||
setConfigJsonText('')
|
||||
setCredentialsText('')
|
||||
setConfigJsonError('')
|
||||
setCredentialsError('')
|
||||
setServerError('')
|
||||
}
|
||||
}, [open])
|
||||
|
||||
function handleSubmit() {
|
||||
if (tab === 'token') {
|
||||
if (!token.trim()) {
|
||||
setTokenError('Required')
|
||||
return
|
||||
}
|
||||
setTokenError('')
|
||||
mutation.mutate({
|
||||
id: account.id,
|
||||
configJson: JSON.stringify({ hasCompletedOnboarding: true }),
|
||||
credentials: JSON.stringify({ claudeAiOauth: { accessToken: token } }),
|
||||
})
|
||||
} else {
|
||||
let hasError = false
|
||||
|
||||
if (configJsonText.trim()) {
|
||||
try {
|
||||
JSON.parse(configJsonText)
|
||||
setConfigJsonError('')
|
||||
} catch {
|
||||
setConfigJsonError('Invalid JSON')
|
||||
hasError = true
|
||||
}
|
||||
} else {
|
||||
setConfigJsonError('')
|
||||
}
|
||||
|
||||
if (credentialsText.trim()) {
|
||||
try {
|
||||
JSON.parse(credentialsText)
|
||||
setCredentialsError('')
|
||||
} catch {
|
||||
setCredentialsError('Invalid JSON')
|
||||
hasError = true
|
||||
}
|
||||
} else {
|
||||
setCredentialsError('')
|
||||
}
|
||||
|
||||
if (hasError) return
|
||||
|
||||
mutation.mutate({
|
||||
id: account.id,
|
||||
configJson: configJsonText.trim() || '{}',
|
||||
credentials: credentialsText.trim() || '{}',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Update Credentials</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Read-only account identity */}
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium">{account.email}</p>
|
||||
<Badge variant="outline">{account.provider}</Badge>
|
||||
</div>
|
||||
|
||||
{/* Tab navigation */}
|
||||
<div className="flex border-b">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('token')}
|
||||
className={cn(
|
||||
'px-4 py-2 text-sm font-medium border-b-2 transition-colors',
|
||||
tab === 'token'
|
||||
? 'border-primary text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
By token
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('credentials')}
|
||||
className={cn(
|
||||
'px-4 py-2 text-sm font-medium border-b-2 transition-colors',
|
||||
tab === 'credentials'
|
||||
? 'border-primary text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
By credentials JSON
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab A — By token */}
|
||||
{tab === 'token' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="setup-token">
|
||||
Setup token (from `claude setup-token`)
|
||||
</Label>
|
||||
<Input
|
||||
id="setup-token"
|
||||
type="text"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
/>
|
||||
{tokenError && (
|
||||
<p className="text-sm text-destructive">{tokenError}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab B — By credentials JSON */}
|
||||
{tab === 'credentials' && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="config-json">
|
||||
Config JSON (`.claude.json` content)
|
||||
</Label>
|
||||
<Textarea
|
||||
id="config-json"
|
||||
value={configJsonText}
|
||||
onChange={(e) => setConfigJsonText(e.target.value)}
|
||||
placeholder='{ "oauthAccount": { ... } }'
|
||||
/>
|
||||
{configJsonError && (
|
||||
<p className="text-sm text-destructive">{configJsonError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="credentials-json">
|
||||
Credentials JSON (`.credentials.json` content)
|
||||
</Label>
|
||||
<Textarea
|
||||
id="credentials-json"
|
||||
value={credentialsText}
|
||||
onChange={(e) => setCredentialsText(e.target.value)}
|
||||
placeholder='{ "claudeAiOauth": { ... } }'
|
||||
/>
|
||||
{credentialsError && (
|
||||
<p className="text-sm text-destructive">{credentialsError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Run `cw account extract` in your terminal to obtain these values.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Server error */}
|
||||
{serverError && (
|
||||
<p className="text-sm text-destructive">{serverError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={mutation.isPending}>
|
||||
{mutation.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Update Credentials
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -253,13 +253,13 @@ export function ContentTab({ initiativeId, initiativeName }: ContentTabProps) {
|
||||
|
||||
{resolvedActivePageId && (
|
||||
<>
|
||||
{(isSaving || updateInitiativeMutation.isPending) && (
|
||||
<div className="flex justify-end mb-2">
|
||||
<div className="flex justify-end mb-2 h-4">
|
||||
{(isSaving || updateInitiativeMutation.isPending) && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Saving...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
{activePageQuery.isSuccess && (
|
||||
<input
|
||||
value={pageTitle}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import { useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
@@ -36,33 +36,33 @@ export function TiptapEditor({
|
||||
const onPageLinkDeletedRef = useRef(onPageLinkDeleted);
|
||||
onPageLinkDeletedRef.current = onPageLinkDeleted;
|
||||
|
||||
const pageLinkDeletionDetector = createPageLinkDeletionDetector(onPageLinkDeletedRef);
|
||||
|
||||
const baseExtensions = [
|
||||
StarterKit,
|
||||
Table.configure({ resizable: true, cellMinWidth: 50 }),
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
Placeholder.configure({
|
||||
includeChildren: true,
|
||||
placeholder: ({ node }) => {
|
||||
if (node.type.name === 'heading') {
|
||||
return `Heading ${node.attrs.level}`;
|
||||
}
|
||||
return "Type '/' for commands...";
|
||||
},
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
}),
|
||||
SlashCommands,
|
||||
BlockSelectionExtension,
|
||||
];
|
||||
|
||||
const extensions = enablePageLinks
|
||||
? [...baseExtensions, PageLinkExtension, pageLinkDeletionDetector]
|
||||
: baseExtensions;
|
||||
const extensions = useMemo(() => {
|
||||
const detector = createPageLinkDeletionDetector(onPageLinkDeletedRef);
|
||||
const base = [
|
||||
StarterKit,
|
||||
Table.configure({ resizable: true, cellMinWidth: 50 }),
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
Placeholder.configure({
|
||||
includeChildren: true,
|
||||
placeholder: ({ node }) => {
|
||||
if (node.type.name === 'heading') {
|
||||
return `Heading ${node.attrs.level}`;
|
||||
}
|
||||
return "Type '/' for commands...";
|
||||
},
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
}),
|
||||
SlashCommands,
|
||||
BlockSelectionExtension,
|
||||
];
|
||||
return enablePageLinks
|
||||
? [...base, PageLinkExtension, detector]
|
||||
: base;
|
||||
}, [enablePageLinks]);
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useMemo } from "react";
|
||||
import { useCallback, useEffect, useRef, useMemo, useState } from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { X, Trash2, MessageCircle, RotateCw } from "lucide-react";
|
||||
import type { ChatTarget } from "@/components/chat/ChatSlideOver";
|
||||
@@ -7,12 +7,15 @@ import { Button } from "@/components/ui/button";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { StatusDot } from "@/components/StatusDot";
|
||||
import { TiptapEditor } from "@/components/editor/TiptapEditor";
|
||||
import { AgentOutputViewer } from "@/components/AgentOutputViewer";
|
||||
import { getCategoryConfig } from "@/lib/category";
|
||||
import { markdownToTiptapJson } from "@/lib/markdown-to-tiptap";
|
||||
import { useExecutionContext } from "./ExecutionContext";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type SlideOverTab = "details" | "logs";
|
||||
|
||||
interface TaskSlideOverProps {
|
||||
onOpenChat?: (target: ChatTarget) => void;
|
||||
}
|
||||
@@ -24,8 +27,15 @@ export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) {
|
||||
const deleteTaskMutation = trpc.deleteTask.useMutation();
|
||||
const updateTaskMutation = trpc.updateTask.useMutation();
|
||||
|
||||
const [tab, setTab] = useState<SlideOverTab>("details");
|
||||
|
||||
const close = useCallback(() => setSelectedTaskId(null), [setSelectedTaskId]);
|
||||
|
||||
// Reset tab when task changes
|
||||
useEffect(() => {
|
||||
setTab("details");
|
||||
}, [selectedEntry?.task?.id]);
|
||||
|
||||
// Escape key closes
|
||||
useEffect(() => {
|
||||
if (!selectedEntry) return;
|
||||
@@ -152,80 +162,107 @@ export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="flex gap-4 border-b border-border px-5">
|
||||
{(["details", "logs"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
className={cn(
|
||||
"relative pb-2 pt-3 text-sm font-medium transition-colors",
|
||||
tab === t
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
onClick={() => setTab(t)}
|
||||
>
|
||||
{t === "details" ? "Details" : "Agent Logs"}
|
||||
{tab === t && (
|
||||
<span className="absolute inset-x-0 bottom-0 h-0.5 bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
|
||||
{/* Metadata grid */}
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<MetaField label="Status">
|
||||
<StatusBadge status={task.status} />
|
||||
</MetaField>
|
||||
<MetaField label="Category">
|
||||
<CategoryBadge category={task.category} />
|
||||
</MetaField>
|
||||
<MetaField label="Priority">
|
||||
<PriorityText priority={task.priority} />
|
||||
</MetaField>
|
||||
<MetaField label="Type">
|
||||
<span className="font-medium">{task.type}</span>
|
||||
</MetaField>
|
||||
<MetaField label="Agent" span={2}>
|
||||
<span className="font-medium">
|
||||
{selectedEntry.agentName ?? "Unassigned"}
|
||||
</span>
|
||||
</MetaField>
|
||||
</div>
|
||||
<div className={cn("flex-1 min-h-0", tab === "details" ? "overflow-y-auto" : "flex flex-col")}>
|
||||
{tab === "details" ? (
|
||||
<div className="px-5 py-4 space-y-5">
|
||||
{/* Metadata grid */}
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<MetaField label="Status">
|
||||
<StatusBadge status={task.status} />
|
||||
</MetaField>
|
||||
<MetaField label="Category">
|
||||
<CategoryBadge category={task.category} />
|
||||
</MetaField>
|
||||
<MetaField label="Priority">
|
||||
<PriorityText priority={task.priority} />
|
||||
</MetaField>
|
||||
<MetaField label="Type">
|
||||
<span className="font-medium">{task.type}</span>
|
||||
</MetaField>
|
||||
<MetaField label="Agent" span={2}>
|
||||
<span className="font-medium">
|
||||
{selectedEntry.agentName ?? "Unassigned"}
|
||||
</span>
|
||||
</MetaField>
|
||||
</div>
|
||||
|
||||
{/* Description — editable tiptap */}
|
||||
<Section title="Description">
|
||||
<TiptapEditor
|
||||
entityId={task.id}
|
||||
content={editorContent}
|
||||
onUpdate={handleDescriptionUpdate}
|
||||
enablePageLinks={false}
|
||||
/>
|
||||
</Section>
|
||||
{/* Description — editable tiptap */}
|
||||
<Section title="Description">
|
||||
<TiptapEditor
|
||||
entityId={task.id}
|
||||
content={editorContent}
|
||||
onUpdate={handleDescriptionUpdate}
|
||||
enablePageLinks={false}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* Dependencies */}
|
||||
<Section title="Blocked By">
|
||||
{dependencies.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">None</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{dependencies.map((dep) => (
|
||||
<li
|
||||
key={dep.name}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<StatusDot status={dep.status} size="sm" />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{dep.name}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
{/* Dependencies */}
|
||||
<Section title="Blocked By">
|
||||
{dependencies.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">None</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{dependencies.map((dep) => (
|
||||
<li
|
||||
key={dep.name}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<StatusDot status={dep.status} size="sm" />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{dep.name}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Blocks */}
|
||||
<Section title="Blocks">
|
||||
{dependents.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">None</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{dependents.map((dep) => (
|
||||
<li
|
||||
key={dep.name}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<StatusDot status={dep.status} size="sm" />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{dep.name}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
{/* Blocks */}
|
||||
<Section title="Blocks">
|
||||
{dependents.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">None</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{dependents.map((dep) => (
|
||||
<li
|
||||
key={dep.name}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<StatusDot status={dep.status} size="sm" />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{dep.name}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
) : (
|
||||
<AgentLogsTab taskId={task.id} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
@@ -235,13 +272,16 @@ export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
disabled={retryBlockedTaskMutation.isPending}
|
||||
onClick={() => {
|
||||
retryBlockedTaskMutation.mutate({ taskId: task.id });
|
||||
close();
|
||||
retryBlockedTaskMutation.mutate(
|
||||
{ taskId: task.id },
|
||||
{ onSuccess: () => close() },
|
||||
);
|
||||
}}
|
||||
>
|
||||
<RotateCw className="h-3.5 w-3.5" />
|
||||
Retry
|
||||
<RotateCw className={cn("h-3.5 w-3.5", retryBlockedTaskMutation.isPending && "animate-spin")} />
|
||||
{retryBlockedTaskMutation.isPending ? "Retrying…" : "Retry"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -293,6 +333,43 @@ export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent Logs Tab
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AgentLogsTab({ taskId }: { taskId: string }) {
|
||||
const { data: agent, isLoading } = trpc.getTaskAgent.useQuery(
|
||||
{ taskId },
|
||||
{ refetchOnWindowFocus: false },
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!agent) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
No agent has been assigned to this task yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0">
|
||||
<AgentOutputViewer
|
||||
agentId={agent.id}
|
||||
agentName={agent.name ?? undefined}
|
||||
status={agent.status}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Small helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
51
apps/web/src/components/hq/HQBlockedSection.tsx
Normal file
51
apps/web/src/components/hq/HQBlockedSection.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { formatRelativeTime } from '@/lib/utils'
|
||||
import type { BlockedPhaseItem } from './types'
|
||||
|
||||
interface Props {
|
||||
items: BlockedPhaseItem[]
|
||||
}
|
||||
|
||||
export function HQBlockedSection({ items }: Props) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Blocked
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<Card key={item.phaseId} className="p-4 flex items-center justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1 font-semibold text-sm">
|
||||
<span>{item.initiativeName} › {item.phaseName}</span>
|
||||
<Badge variant="destructive" className="ml-2">Blocked</Badge>
|
||||
</div>
|
||||
{item.lastMessage && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{item.lastMessage}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">{formatRelativeTime(item.since)}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: '/initiatives/$id',
|
||||
params: { id: item.initiativeId },
|
||||
search: { tab: 'execution' },
|
||||
})
|
||||
}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
15
apps/web/src/components/hq/HQEmptyState.tsx
Normal file
15
apps/web/src/components/hq/HQEmptyState.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
|
||||
export function HQEmptyState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center gap-3">
|
||||
<p className="text-lg font-semibold">All clear.</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No initiatives need your attention right now.
|
||||
</p>
|
||||
<Link to="/initiatives" className="text-sm text-primary hover:underline">
|
||||
Browse active work
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
50
apps/web/src/components/hq/HQNeedsApprovalSection.tsx
Normal file
50
apps/web/src/components/hq/HQNeedsApprovalSection.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { formatRelativeTime } from '@/lib/utils'
|
||||
import type { PlanningInitiativeItem } from './types'
|
||||
|
||||
interface Props {
|
||||
items: PlanningInitiativeItem[]
|
||||
}
|
||||
|
||||
export function HQNeedsApprovalSection({ items }: Props) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Needs Approval to Continue
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => {
|
||||
const s = item.pendingPhaseCount === 1 ? '' : 's'
|
||||
return (
|
||||
<Card key={item.initiativeId} className="p-4 flex items-center justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-semibold text-sm">{item.initiativeName}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Plan ready — {item.pendingPhaseCount} phase{s} awaiting approval
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{formatRelativeTime(item.since)}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: '/initiatives/$id',
|
||||
params: { id: item.initiativeId },
|
||||
search: { tab: 'plan' },
|
||||
})
|
||||
}
|
||||
>
|
||||
Review Plan
|
||||
</Button>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
68
apps/web/src/components/hq/HQNeedsReviewSection.tsx
Normal file
68
apps/web/src/components/hq/HQNeedsReviewSection.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { formatRelativeTime } from '@/lib/utils'
|
||||
import type { PendingReviewInitiativeItem, PendingReviewPhaseItem } from './types'
|
||||
|
||||
interface Props {
|
||||
initiatives: PendingReviewInitiativeItem[]
|
||||
phases: PendingReviewPhaseItem[]
|
||||
}
|
||||
|
||||
export function HQNeedsReviewSection({ initiatives, phases }: Props) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Needs Review
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{initiatives.map((item) => (
|
||||
<Card key={item.initiativeId} className="p-4 flex items-center justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-semibold text-sm">{item.initiativeName}</p>
|
||||
<p className="text-sm text-muted-foreground">Content ready for review</p>
|
||||
<p className="text-xs text-muted-foreground">{formatRelativeTime(item.since)}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: '/initiatives/$id',
|
||||
params: { id: item.initiativeId },
|
||||
search: { tab: 'review' },
|
||||
})
|
||||
}
|
||||
>
|
||||
Review
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
{phases.map((item) => (
|
||||
<Card key={item.phaseId} className="p-4 flex items-center justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-semibold text-sm">{item.initiativeName} › {item.phaseName}</p>
|
||||
<p className="text-sm text-muted-foreground">Phase execution complete — review diff</p>
|
||||
<p className="text-xs text-muted-foreground">{formatRelativeTime(item.since)}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: '/initiatives/$id',
|
||||
params: { id: item.initiativeId },
|
||||
search: { tab: 'review' },
|
||||
})
|
||||
}
|
||||
>
|
||||
Review Diff
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
376
apps/web/src/components/hq/HQSections.test.tsx
Normal file
376
apps/web/src/components/hq/HQSections.test.tsx
Normal file
@@ -0,0 +1,376 @@
|
||||
// @vitest-environment happy-dom
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
||||
|
||||
const mockNavigate = vi.fn()
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
useNavigate: () => mockNavigate,
|
||||
Link: ({ to, children, className }: { to: string; children: React.ReactNode; className?: string }) => (
|
||||
<a href={to} className={className}>{children}</a>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock formatRelativeTime to return a predictable string
|
||||
vi.mock('@/lib/utils', () => ({
|
||||
cn: (...classes: string[]) => classes.filter(Boolean).join(' '),
|
||||
formatRelativeTime: () => '5 minutes ago',
|
||||
}))
|
||||
|
||||
import { HQWaitingForInputSection } from './HQWaitingForInputSection'
|
||||
import { HQNeedsReviewSection } from './HQNeedsReviewSection'
|
||||
import { HQNeedsApprovalSection } from './HQNeedsApprovalSection'
|
||||
import { HQBlockedSection } from './HQBlockedSection'
|
||||
import { HQEmptyState } from './HQEmptyState'
|
||||
|
||||
const since = new Date(Date.now() - 5 * 60 * 1000).toISOString()
|
||||
|
||||
// ─── HQWaitingForInputSection ────────────────────────────────────────────────
|
||||
|
||||
describe('HQWaitingForInputSection', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('renders section heading "Waiting for Input"', () => {
|
||||
render(<HQWaitingForInputSection items={[]} />)
|
||||
expect(screen.getByText('Waiting for Input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders agent name and truncated question text', () => {
|
||||
const longQuestion = 'A'.repeat(150)
|
||||
render(
|
||||
<HQWaitingForInputSection
|
||||
items={[
|
||||
{
|
||||
agentId: 'a1',
|
||||
agentName: 'Agent Alpha',
|
||||
initiativeId: null,
|
||||
initiativeName: null,
|
||||
questionText: longQuestion,
|
||||
waitingSince: since,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Agent Alpha')).toBeInTheDocument()
|
||||
// Truncated to 120 chars + ellipsis
|
||||
const truncated = 'A'.repeat(120) + '…'
|
||||
expect(screen.getByText(truncated)).toBeInTheDocument()
|
||||
// Full text in tooltip content (forceMount renders it into DOM)
|
||||
expect(screen.getAllByText(longQuestion).length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('renders "waiting X" relative time', () => {
|
||||
render(
|
||||
<HQWaitingForInputSection
|
||||
items={[
|
||||
{
|
||||
agentId: 'a1',
|
||||
agentName: 'Agent Alpha',
|
||||
initiativeId: null,
|
||||
initiativeName: null,
|
||||
questionText: 'What should I do?',
|
||||
waitingSince: since,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('waiting 5 minutes ago')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('clicking "Answer" calls navigate to /inbox', () => {
|
||||
render(
|
||||
<HQWaitingForInputSection
|
||||
items={[
|
||||
{
|
||||
agentId: 'a1',
|
||||
agentName: 'Agent Alpha',
|
||||
initiativeId: null,
|
||||
initiativeName: null,
|
||||
questionText: 'Question?',
|
||||
waitingSince: since,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: /answer/i }))
|
||||
expect(mockNavigate).toHaveBeenCalledWith({ to: '/inbox' })
|
||||
})
|
||||
|
||||
it('shows initiative name when non-null', () => {
|
||||
render(
|
||||
<HQWaitingForInputSection
|
||||
items={[
|
||||
{
|
||||
agentId: 'a1',
|
||||
agentName: 'Agent Alpha',
|
||||
initiativeId: 'init-1',
|
||||
initiativeName: 'My Initiative',
|
||||
questionText: 'Question?',
|
||||
waitingSince: since,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(/My Initiative/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides initiative name when null', () => {
|
||||
render(
|
||||
<HQWaitingForInputSection
|
||||
items={[
|
||||
{
|
||||
agentId: 'a1',
|
||||
agentName: 'Agent Alpha',
|
||||
initiativeId: null,
|
||||
initiativeName: null,
|
||||
questionText: 'Question?',
|
||||
waitingSince: since,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
// No separator dot should appear since initiative is null
|
||||
expect(screen.queryByText(/·/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── HQNeedsReviewSection ────────────────────────────────────────────────────
|
||||
|
||||
describe('HQNeedsReviewSection', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('renders section heading "Needs Review"', () => {
|
||||
render(<HQNeedsReviewSection initiatives={[]} phases={[]} />)
|
||||
expect(screen.getByText('Needs Review')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('2a: shows initiative name, "Content ready for review", "Review" CTA navigates correctly', () => {
|
||||
render(
|
||||
<HQNeedsReviewSection
|
||||
initiatives={[
|
||||
{ initiativeId: 'init-1', initiativeName: 'Init One', since },
|
||||
]}
|
||||
phases={[]}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Init One')).toBeInTheDocument()
|
||||
expect(screen.getByText('Content ready for review')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: /^review$/i }))
|
||||
expect(mockNavigate).toHaveBeenCalledWith({
|
||||
to: '/initiatives/$id',
|
||||
params: { id: 'init-1' },
|
||||
search: { tab: 'review' },
|
||||
})
|
||||
})
|
||||
|
||||
it('2b: shows initiative › phase, "Phase execution complete — review diff", "Review Diff" navigates correctly', () => {
|
||||
render(
|
||||
<HQNeedsReviewSection
|
||||
initiatives={[]}
|
||||
phases={[
|
||||
{
|
||||
phaseId: 'ph-1',
|
||||
phaseName: 'Phase One',
|
||||
initiativeId: 'init-1',
|
||||
initiativeName: 'Init One',
|
||||
since,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Init One › Phase One')).toBeInTheDocument()
|
||||
expect(screen.getByText('Phase execution complete — review diff')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: /review diff/i }))
|
||||
expect(mockNavigate).toHaveBeenCalledWith({
|
||||
to: '/initiatives/$id',
|
||||
params: { id: 'init-1' },
|
||||
search: { tab: 'review' },
|
||||
})
|
||||
})
|
||||
|
||||
it('when only initiatives provided, only 2a cards render', () => {
|
||||
render(
|
||||
<HQNeedsReviewSection
|
||||
initiatives={[{ initiativeId: 'init-1', initiativeName: 'Init One', since }]}
|
||||
phases={[]}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Content ready for review')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Phase execution complete — review diff')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('when only phases provided, only 2b cards render', () => {
|
||||
render(
|
||||
<HQNeedsReviewSection
|
||||
initiatives={[]}
|
||||
phases={[
|
||||
{
|
||||
phaseId: 'ph-1',
|
||||
phaseName: 'Phase One',
|
||||
initiativeId: 'init-1',
|
||||
initiativeName: 'Init One',
|
||||
since,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Phase execution complete — review diff')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Content ready for review')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── HQNeedsApprovalSection ──────────────────────────────────────────────────
|
||||
|
||||
describe('HQNeedsApprovalSection', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('renders "Needs Approval to Continue" heading', () => {
|
||||
render(<HQNeedsApprovalSection items={[]} />)
|
||||
expect(screen.getByText('Needs Approval to Continue')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows singular phase count: "1 phase awaiting approval"', () => {
|
||||
render(
|
||||
<HQNeedsApprovalSection
|
||||
items={[
|
||||
{ initiativeId: 'init-1', initiativeName: 'Init One', pendingPhaseCount: 1, since },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Plan ready — 1 phase awaiting approval')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows plural phase count: "3 phases awaiting approval"', () => {
|
||||
render(
|
||||
<HQNeedsApprovalSection
|
||||
items={[
|
||||
{ initiativeId: 'init-1', initiativeName: 'Init One', pendingPhaseCount: 3, since },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Plan ready — 3 phases awaiting approval')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('"Review Plan" CTA navigates to /initiatives/$id?tab=plan', () => {
|
||||
render(
|
||||
<HQNeedsApprovalSection
|
||||
items={[
|
||||
{ initiativeId: 'init-1', initiativeName: 'Init One', pendingPhaseCount: 2, since },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: /review plan/i }))
|
||||
expect(mockNavigate).toHaveBeenCalledWith({
|
||||
to: '/initiatives/$id',
|
||||
params: { id: 'init-1' },
|
||||
search: { tab: 'plan' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ─── HQBlockedSection ────────────────────────────────────────────────────────
|
||||
|
||||
describe('HQBlockedSection', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('renders "Blocked" heading', () => {
|
||||
render(<HQBlockedSection items={[]} />)
|
||||
expect(screen.getByText('Blocked')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows initiative › phase with "Blocked" badge', () => {
|
||||
render(
|
||||
<HQBlockedSection
|
||||
items={[
|
||||
{
|
||||
phaseId: 'ph-1',
|
||||
phaseName: 'Phase One',
|
||||
initiativeId: 'init-1',
|
||||
initiativeName: 'Init One',
|
||||
lastMessage: null,
|
||||
since,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Init One › Phase One')).toBeInTheDocument()
|
||||
// The "Blocked" badge - there will be one in the heading and one in the card
|
||||
const badges = screen.getAllByText('Blocked')
|
||||
expect(badges.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('shows lastMessage when non-null', () => {
|
||||
render(
|
||||
<HQBlockedSection
|
||||
items={[
|
||||
{
|
||||
phaseId: 'ph-1',
|
||||
phaseName: 'Phase One',
|
||||
initiativeId: 'init-1',
|
||||
initiativeName: 'Init One',
|
||||
lastMessage: 'Something went wrong.',
|
||||
since,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Something went wrong.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('omits lastMessage when null', () => {
|
||||
render(
|
||||
<HQBlockedSection
|
||||
items={[
|
||||
{
|
||||
phaseId: 'ph-1',
|
||||
phaseName: 'Phase One',
|
||||
initiativeId: 'init-1',
|
||||
initiativeName: 'Init One',
|
||||
lastMessage: null,
|
||||
since,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByText('Something went wrong.')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('"View" CTA navigates to /initiatives/$id?tab=execution', () => {
|
||||
render(
|
||||
<HQBlockedSection
|
||||
items={[
|
||||
{
|
||||
phaseId: 'ph-1',
|
||||
phaseName: 'Phase One',
|
||||
initiativeId: 'init-1',
|
||||
initiativeName: 'Init One',
|
||||
lastMessage: null,
|
||||
since,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: /view/i }))
|
||||
expect(mockNavigate).toHaveBeenCalledWith({
|
||||
to: '/initiatives/$id',
|
||||
params: { id: 'init-1' },
|
||||
search: { tab: 'execution' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ─── HQEmptyState ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('HQEmptyState', () => {
|
||||
it('renders "All clear." text', () => {
|
||||
render(<HQEmptyState />)
|
||||
expect(screen.getByText('All clear.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders "Browse active work" link pointing to /initiatives', () => {
|
||||
render(<HQEmptyState />)
|
||||
const link = screen.getByRole('link', { name: /browse active work/i })
|
||||
expect(link).toBeInTheDocument()
|
||||
expect(link).toHaveAttribute('href', '/initiatives')
|
||||
})
|
||||
})
|
||||
65
apps/web/src/components/hq/HQWaitingForInputSection.tsx
Normal file
65
apps/web/src/components/hq/HQWaitingForInputSection.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { formatRelativeTime } from '@/lib/utils'
|
||||
import type { WaitingForInputItem } from './types'
|
||||
|
||||
interface Props {
|
||||
items: WaitingForInputItem[]
|
||||
}
|
||||
|
||||
export function HQWaitingForInputSection({ items }: Props) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Waiting for Input
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => {
|
||||
const truncated =
|
||||
item.questionText.slice(0, 120) +
|
||||
(item.questionText.length > 120 ? '…' : '')
|
||||
|
||||
return (
|
||||
<Card key={item.agentId} className="p-4 flex items-center justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-bold text-sm">
|
||||
{item.agentName}
|
||||
{item.initiativeName && (
|
||||
<span className="font-normal text-muted-foreground"> · {item.initiativeName}</span>
|
||||
)}
|
||||
</p>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<p className="text-sm text-muted-foreground truncate">{truncated}</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent forceMount>{item.questionText}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
waiting {formatRelativeTime(item.waitingSince)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate({ to: '/inbox' })}
|
||||
>
|
||||
Answer
|
||||
</Button>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
8
apps/web/src/components/hq/types.ts
Normal file
8
apps/web/src/components/hq/types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { RouterOutputs } from '@/lib/trpc'
|
||||
|
||||
type HQDashboard = RouterOutputs['getHeadquartersDashboard']
|
||||
export type WaitingForInputItem = HQDashboard['waitingForInput'][number]
|
||||
export type PendingReviewInitiativeItem = HQDashboard['pendingReviewInitiatives'][number]
|
||||
export type PendingReviewPhaseItem = HQDashboard['pendingReviewPhases'][number]
|
||||
export type PlanningInitiativeItem = HQDashboard['planningInitiatives'][number]
|
||||
export type BlockedPhaseItem = HQDashboard['blockedPhases'][number]
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Loader2, AlertCircle, GitMerge, CheckCircle2, ChevronDown, ChevronRight, Terminal } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { QuestionForm } from '@/components/QuestionForm';
|
||||
import { useConflictAgent } from '@/hooks/useConflictAgent';
|
||||
@@ -13,6 +13,17 @@ interface ConflictResolutionPanelProps {
|
||||
export function ConflictResolutionPanel({ initiativeId, conflicts, onResolved }: ConflictResolutionPanelProps) {
|
||||
const { state, agent, questions, spawn, resume, stop, dismiss } = useConflictAgent(initiativeId);
|
||||
const [showManual, setShowManual] = useState(false);
|
||||
const prevStateRef = useRef<string | null>(null);
|
||||
|
||||
// Auto-dismiss and re-check mergeability when conflict agent completes
|
||||
useEffect(() => {
|
||||
const prev = prevStateRef.current;
|
||||
prevStateRef.current = state;
|
||||
if (prev !== 'completed' && state === 'completed') {
|
||||
dismiss();
|
||||
onResolved();
|
||||
}
|
||||
}, [state, dismiss, onResolved]);
|
||||
|
||||
if (state === 'none') {
|
||||
return (
|
||||
@@ -117,26 +128,13 @@ git commit --no-edit`}
|
||||
}
|
||||
|
||||
if (state === 'completed') {
|
||||
// Auto-dismiss effect above handles this — show brief success message during transition
|
||||
return (
|
||||
<div className="mx-4 mt-3 rounded-lg border border-status-success-border bg-status-success-bg/50 px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-status-success-fg" />
|
||||
<span className="text-sm text-status-success-fg">Conflicts resolved</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
dismiss();
|
||||
onResolved();
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
Re-check Mergeability
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-status-success-fg" />
|
||||
<span className="text-sm text-status-success-fg">Conflicts resolved — re-checking mergeability...</span>
|
||||
<Loader2 className="h-3 w-3 animate-spin text-status-success-fg" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -97,7 +97,7 @@ export function ReviewHeader({
|
||||
const total = totalCount ?? 0;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="border-b border-border bg-card backdrop-blur-sm sticky top-0 z-20 rounded-t-lg shadow-[0_-50px_0_0_hsl(var(--background))]">
|
||||
<div ref={ref} className="border-b border-border bg-card backdrop-blur-sm sticky top-0 z-20 rounded-t-lg">
|
||||
{/* Phase selector row */}
|
||||
{phases.length > 1 && (
|
||||
<div className="flex items-center gap-1 px-4 pt-3 pb-2 border-b border-border/50">
|
||||
|
||||
12
apps/web/src/components/ui/skeleton.tsx
Normal file
12
apps/web/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -7,7 +7,8 @@
|
||||
|
||||
export { useAutoSave } from './useAutoSave.js';
|
||||
export { useDebounce, useDebounceWithImmediate } from './useDebounce.js';
|
||||
export { useLiveUpdates } from './useLiveUpdates.js';
|
||||
export { useLiveUpdates, INITIATIVE_LIST_RULES } from './useLiveUpdates.js';
|
||||
export type { LiveUpdateRule } from './useLiveUpdates.js';
|
||||
export { useRefineAgent } from './useRefineAgent.js';
|
||||
export { useConflictAgent } from './useConflictAgent.js';
|
||||
export { useSubscriptionWithErrorHandling } from './useSubscriptionWithErrorHandling.js';
|
||||
|
||||
@@ -15,6 +15,18 @@ export interface LiveUpdateRule {
|
||||
*
|
||||
* Encapsulates error toast + reconnect config so pages don't duplicate boilerplate.
|
||||
*/
|
||||
/**
|
||||
* Reusable rules for any page displaying initiative cards.
|
||||
* Covers all event prefixes that can change derived initiative activity state.
|
||||
*/
|
||||
export const INITIATIVE_LIST_RULES: LiveUpdateRule[] = [
|
||||
{ prefix: 'initiative:', invalidate: ['listInitiatives'] },
|
||||
{ prefix: 'task:', invalidate: ['listInitiatives'] },
|
||||
{ prefix: 'phase:', invalidate: ['listInitiatives'] },
|
||||
{ prefix: 'agent:', invalidate: ['listInitiatives'] },
|
||||
{ prefix: 'merge:', invalidate: ['listInitiatives'] },
|
||||
];
|
||||
|
||||
export function useLiveUpdates(rules: LiveUpdateRule[]) {
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { trpc } from '@/lib/trpc'
|
||||
import type { ConnectionState } from '@/hooks/useConnectionStatus'
|
||||
|
||||
const navItems = [
|
||||
{ label: 'HQ', to: '/hq', badgeKey: null },
|
||||
{ label: 'Initiatives', to: '/initiatives', badgeKey: null },
|
||||
{ label: 'Errands', to: '/errands', badgeKey: 'pendingErrands' as const },
|
||||
{ label: 'Agents', to: '/agents', badgeKey: 'running' as const },
|
||||
|
||||
@@ -44,17 +44,21 @@ const INVALIDATION_MAP: Partial<Record<MutationName, QueryName[]>> = {
|
||||
spawnArchitectDiscuss: ["listAgents"],
|
||||
spawnArchitectPlan: ["listAgents"],
|
||||
spawnArchitectDetail: ["listAgents", "listInitiativeTasks"],
|
||||
spawnConflictResolutionAgent: ["listAgents", "listInitiatives", "getInitiative"],
|
||||
|
||||
// --- Initiatives ---
|
||||
createInitiative: ["listInitiatives"],
|
||||
updateInitiative: ["listInitiatives", "getInitiative"],
|
||||
updateInitiativeProjects: ["getInitiative"],
|
||||
approveInitiativeReview: ["listInitiatives", "getInitiative"],
|
||||
requestInitiativeChanges: ["listInitiatives", "getInitiative"],
|
||||
|
||||
// --- Phases ---
|
||||
createPhase: ["listPhases", "listInitiativePhaseDependencies"],
|
||||
deletePhase: ["listPhases", "listInitiativeTasks", "listInitiativePhaseDependencies", "listChangeSets"],
|
||||
updatePhase: ["listPhases", "getPhase"],
|
||||
approvePhase: ["listPhases", "listInitiativeTasks"],
|
||||
approvePhase: ["listPhases", "listInitiativeTasks", "listInitiatives"],
|
||||
requestPhaseChanges: ["listPhases", "listInitiativeTasks", "listPhaseTasks", "getInitiative"],
|
||||
queuePhase: ["listPhases"],
|
||||
createPhaseDependency: ["getPhaseDependencies", "listInitiativePhaseDependencies", "listPhaseTaskDependencies"],
|
||||
removePhaseDependency: ["getPhaseDependencies", "listInitiativePhaseDependencies", "listPhaseTaskDependencies"],
|
||||
@@ -66,12 +70,17 @@ const INVALIDATION_MAP: Partial<Record<MutationName, QueryName[]>> = {
|
||||
queueTask: ["listTasks", "listInitiativeTasks", "listPhaseTasks"],
|
||||
|
||||
deleteTask: ["listTasks", "listInitiativeTasks", "listPhaseTasks", "listChangeSets"],
|
||||
retryBlockedTask: ["listTasks", "listInitiativeTasks", "listPhaseTasks", "listAgents"],
|
||||
|
||||
// --- Change Sets ---
|
||||
revertChangeSet: ["listPhases", "listPhaseTasks", "listInitiativeTasks", "listPages", "getPage", "listChangeSets", "getRootPage", "getChangeSet"],
|
||||
|
||||
// --- Pages ---
|
||||
updatePage: ["listPages", "getPage", "getRootPage"],
|
||||
// NOTE: getPage omitted — useAutoSave handles optimistic updates for the
|
||||
// active page, and SSE `page:updated` events cover external changes.
|
||||
// Including getPage here caused double-invalidation (mutation + SSE) and
|
||||
// refetch storms that flickered the editor.
|
||||
updatePage: ["listPages", "getRootPage"],
|
||||
createPage: ["listPages", "getRootPage"],
|
||||
deletePage: ["listPages", "getRootPage"],
|
||||
|
||||
|
||||
264
apps/web/src/lib/parse-agent-output.test.ts
Normal file
264
apps/web/src/lib/parse-agent-output.test.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { parseAgentOutput } from "./parse-agent-output";
|
||||
|
||||
function chunk(events: object[]): string {
|
||||
return events.map((e) => JSON.stringify(e)).join("\n");
|
||||
}
|
||||
|
||||
describe("parseAgentOutput", () => {
|
||||
// 1. toolInput is set on tool_call messages
|
||||
it("sets meta.toolInput on tool_call messages", () => {
|
||||
const input = chunk([
|
||||
{
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tu1",
|
||||
name: "Read",
|
||||
input: { file_path: "/foo.ts" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const messages = parseAgentOutput(input);
|
||||
const toolCall = messages.find((m) => m.type === "tool_call");
|
||||
expect(toolCall).toBeDefined();
|
||||
expect(toolCall!.meta?.toolInput).toEqual({ file_path: "/foo.ts" });
|
||||
});
|
||||
|
||||
// 2. tool_result with tool_use_id gets meta.toolName and meta.toolInput from registry
|
||||
it("correlates tool_result to its tool_use via registry", () => {
|
||||
const input = chunk([
|
||||
{
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tu1",
|
||||
name: "Read",
|
||||
input: { file_path: "/foo.ts" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "user",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tu1",
|
||||
content: "file contents",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const messages = parseAgentOutput(input);
|
||||
const toolResult = messages.find((m) => m.type === "tool_result");
|
||||
expect(toolResult).toBeDefined();
|
||||
expect(toolResult!.meta?.toolName).toBe("Read");
|
||||
expect(toolResult!.meta?.toolInput).toEqual({ file_path: "/foo.ts" });
|
||||
});
|
||||
|
||||
// 3. tool_result with no matching registry entry has no meta.toolName
|
||||
it("tool_result with unknown tool_use_id has no meta.toolName", () => {
|
||||
const input = chunk([
|
||||
{
|
||||
type: "user",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "unknown-id",
|
||||
content: "output",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const messages = parseAgentOutput(input);
|
||||
const toolResult = messages.find((m) => m.type === "tool_result");
|
||||
expect(toolResult).toBeDefined();
|
||||
expect(toolResult!.meta?.toolName).toBeUndefined();
|
||||
});
|
||||
|
||||
// 4. tool_result with is_error: true produces type: "error" and meta.isError: true
|
||||
it("tool_result with is_error: true produces error message", () => {
|
||||
const input = chunk([
|
||||
{
|
||||
type: "user",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tu1",
|
||||
is_error: true,
|
||||
content: "something went wrong",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const messages = parseAgentOutput(input);
|
||||
const errorMsg = messages.find((m) => m.content === "something went wrong");
|
||||
expect(errorMsg).toBeDefined();
|
||||
expect(errorMsg!.type).toBe("error");
|
||||
expect(errorMsg!.meta?.isError).toBe(true);
|
||||
});
|
||||
|
||||
// 5. tool_result from a Task tool_use gets correct meta.toolName and meta.toolInput
|
||||
it("tool_result from Task tool_use has correct meta", () => {
|
||||
const taskInput = {
|
||||
subagent_type: "Explore",
|
||||
description: "find files",
|
||||
prompt: "search for *.ts",
|
||||
};
|
||||
const input = chunk([
|
||||
{
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tu2",
|
||||
name: "Task",
|
||||
input: taskInput,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "user",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tu2",
|
||||
content: "found 10 files",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const messages = parseAgentOutput(input);
|
||||
const toolResult = messages.find((m) => m.type === "tool_result");
|
||||
expect(toolResult).toBeDefined();
|
||||
expect(toolResult!.meta?.toolName).toBe("Task");
|
||||
expect(toolResult!.meta?.toolInput).toEqual(taskInput);
|
||||
});
|
||||
|
||||
// 6. Unknown top-level event type produces a system message
|
||||
it("unknown top-level event type produces system message", () => {
|
||||
const input = chunk([{ type: "future_event_type", data: {} }]);
|
||||
const messages = parseAgentOutput(input);
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].type).toBe("system");
|
||||
expect(messages[0].content).toBe("[unknown event: future_event_type]");
|
||||
});
|
||||
|
||||
// 7. Unknown assistant content block type produces a system message
|
||||
it("unknown assistant content block type produces system message", () => {
|
||||
const input = chunk([
|
||||
{
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [{ type: "image", data: "base64..." }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const messages = parseAgentOutput(input);
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].type).toBe("system");
|
||||
expect(messages[0].content).toBe("[unsupported content block: image]");
|
||||
});
|
||||
|
||||
// 8. Previously passing behavior unchanged
|
||||
describe("previously passing behavior", () => {
|
||||
it("system event with session_id produces system message", () => {
|
||||
const input = chunk([{ type: "system", session_id: "sess-123" }]);
|
||||
const messages = parseAgentOutput(input);
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].type).toBe("system");
|
||||
expect(messages[0].content).toBe("Session started: sess-123");
|
||||
});
|
||||
|
||||
it("assistant text block produces text message", () => {
|
||||
const input = chunk([
|
||||
{
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [{ type: "text", text: "Hello, world!" }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const messages = parseAgentOutput(input);
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].type).toBe("text");
|
||||
expect(messages[0].content).toBe("Hello, world!");
|
||||
});
|
||||
|
||||
it("assistant tool_use block produces tool_call message with meta.toolName", () => {
|
||||
const input = chunk([
|
||||
{
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tu1",
|
||||
name: "Bash",
|
||||
input: { command: "ls -la", description: "list files" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const messages = parseAgentOutput(input);
|
||||
const toolCall = messages.find((m) => m.type === "tool_call");
|
||||
expect(toolCall).toBeDefined();
|
||||
expect(toolCall!.meta?.toolName).toBe("Bash");
|
||||
});
|
||||
|
||||
it("result event with is_error: false produces session_end", () => {
|
||||
const input = chunk([
|
||||
{
|
||||
type: "result",
|
||||
is_error: false,
|
||||
total_cost_usd: 0.01,
|
||||
duration_ms: 5000,
|
||||
},
|
||||
]);
|
||||
const messages = parseAgentOutput(input);
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].type).toBe("session_end");
|
||||
expect(messages[0].content).toBe("Session completed");
|
||||
});
|
||||
|
||||
it("result event with is_error: true produces session_end with meta.isError", () => {
|
||||
const input = chunk([
|
||||
{
|
||||
type: "result",
|
||||
is_error: true,
|
||||
total_cost_usd: 0.01,
|
||||
duration_ms: 5000,
|
||||
},
|
||||
]);
|
||||
const messages = parseAgentOutput(input);
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].type).toBe("session_end");
|
||||
expect(messages[0].meta?.isError).toBe(true);
|
||||
});
|
||||
|
||||
it("non-JSON line produces error message with raw line as content", () => {
|
||||
const rawLine = "This is not JSON at all";
|
||||
const messages = parseAgentOutput(rawLine);
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].type).toBe("error");
|
||||
expect(messages[0].content).toBe(rawLine);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,8 +7,10 @@ export interface ParsedMessage {
|
||||
| "session_end"
|
||||
| "error";
|
||||
content: string;
|
||||
timestamp?: Date;
|
||||
meta?: {
|
||||
toolName?: string;
|
||||
toolInput?: unknown;
|
||||
isError?: boolean;
|
||||
cost?: number;
|
||||
duration?: number;
|
||||
@@ -60,108 +62,159 @@ export function getMessageStyling(type: ParsedMessage["type"]): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function parseAgentOutput(raw: string): ParsedMessage[] {
|
||||
const lines = raw.split("\n").filter(Boolean);
|
||||
/**
|
||||
* A chunk of raw JSONL content with an optional timestamp from the DB.
|
||||
*/
|
||||
export interface TimestampedChunk {
|
||||
content: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse agent output. Accepts either a flat string (legacy) or timestamped chunks.
|
||||
* When chunks have timestamps, each parsed message inherits the chunk's timestamp.
|
||||
*/
|
||||
export function parseAgentOutput(raw: string | TimestampedChunk[]): ParsedMessage[] {
|
||||
const chunks: { content: string; timestamp?: Date }[] =
|
||||
typeof raw === "string"
|
||||
? [{ content: raw }]
|
||||
: raw.map((c) => ({ content: c.content, timestamp: new Date(c.createdAt) }));
|
||||
|
||||
const parsedMessages: ParsedMessage[] = [];
|
||||
const toolUseRegistry = new Map<string, { name: string; input: unknown }>();
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const event = JSON.parse(line);
|
||||
for (const chunk of chunks) {
|
||||
const lines = chunk.content.split("\n").filter(Boolean);
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const event = JSON.parse(line);
|
||||
|
||||
// System initialization
|
||||
if (event.type === "system" && event.session_id) {
|
||||
parsedMessages.push({
|
||||
type: "system",
|
||||
content: `Session started: ${event.session_id}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Assistant messages with text and tool calls
|
||||
else if (
|
||||
event.type === "assistant" &&
|
||||
Array.isArray(event.message?.content)
|
||||
) {
|
||||
for (const block of event.message.content) {
|
||||
if (block.type === "text" && block.text) {
|
||||
parsedMessages.push({
|
||||
type: "text",
|
||||
content: block.text,
|
||||
});
|
||||
} else if (block.type === "tool_use") {
|
||||
parsedMessages.push({
|
||||
type: "tool_call",
|
||||
content: formatToolCall(block),
|
||||
meta: { toolName: block.name },
|
||||
});
|
||||
}
|
||||
// System initialization
|
||||
if (event.type === "system" && event.session_id) {
|
||||
parsedMessages.push({
|
||||
type: "system",
|
||||
content: `Session started: ${event.session_id}`,
|
||||
timestamp: chunk.timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// User messages with tool results
|
||||
else if (
|
||||
event.type === "user" &&
|
||||
Array.isArray(event.message?.content)
|
||||
) {
|
||||
for (const block of event.message.content) {
|
||||
if (block.type === "tool_result") {
|
||||
const rawContent = block.content;
|
||||
const output =
|
||||
typeof rawContent === "string"
|
||||
? rawContent
|
||||
: Array.isArray(rawContent)
|
||||
? rawContent
|
||||
.map((c: any) => c.text ?? JSON.stringify(c))
|
||||
.join("\n")
|
||||
: (event.tool_use_result?.stdout || "");
|
||||
const stderr = event.tool_use_result?.stderr;
|
||||
|
||||
if (stderr) {
|
||||
// Assistant messages with text and tool calls
|
||||
else if (
|
||||
event.type === "assistant" &&
|
||||
Array.isArray(event.message?.content)
|
||||
) {
|
||||
for (const block of event.message.content) {
|
||||
if (block.type === "text" && block.text) {
|
||||
parsedMessages.push({
|
||||
type: "error",
|
||||
content: stderr,
|
||||
meta: { isError: true },
|
||||
type: "text",
|
||||
content: block.text,
|
||||
timestamp: chunk.timestamp,
|
||||
});
|
||||
} else if (output) {
|
||||
const displayOutput =
|
||||
output.length > 1000
|
||||
? output.substring(0, 1000) + "\n... (truncated)"
|
||||
: output;
|
||||
} else if (block.type === "tool_use") {
|
||||
parsedMessages.push({
|
||||
type: "tool_result",
|
||||
content: displayOutput,
|
||||
type: "tool_call",
|
||||
content: formatToolCall(block),
|
||||
timestamp: chunk.timestamp,
|
||||
meta: { toolName: block.name, toolInput: block.input },
|
||||
});
|
||||
toolUseRegistry.set(block.id, { name: block.name, input: block.input });
|
||||
} else {
|
||||
parsedMessages.push({
|
||||
type: "system",
|
||||
content: `[unsupported content block: ${block.type}]`,
|
||||
timestamp: chunk.timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy streaming format
|
||||
else if (event.type === "stream_event" && event.event?.delta?.text) {
|
||||
// User messages with tool results
|
||||
else if (
|
||||
event.type === "user" &&
|
||||
Array.isArray(event.message?.content)
|
||||
) {
|
||||
for (const block of event.message.content) {
|
||||
if (block.type === "tool_result") {
|
||||
const rawContent = block.content;
|
||||
const output =
|
||||
typeof rawContent === "string"
|
||||
? rawContent
|
||||
: Array.isArray(rawContent)
|
||||
? rawContent
|
||||
.map((c: any) => c.text ?? JSON.stringify(c))
|
||||
.join("\n")
|
||||
: (event.tool_use_result?.stdout || "");
|
||||
const stderr = event.tool_use_result?.stderr;
|
||||
|
||||
if (stderr) {
|
||||
parsedMessages.push({
|
||||
type: "error",
|
||||
content: stderr,
|
||||
timestamp: chunk.timestamp,
|
||||
meta: { isError: true },
|
||||
});
|
||||
} else if (output) {
|
||||
const displayOutput =
|
||||
output.length > 1000
|
||||
? output.substring(0, 1000) + "\n... (truncated)"
|
||||
: output;
|
||||
const isError = block.is_error === true;
|
||||
const originatingCall = block.tool_use_id
|
||||
? toolUseRegistry.get(block.tool_use_id)
|
||||
: undefined;
|
||||
parsedMessages.push({
|
||||
type: isError ? "error" : "tool_result",
|
||||
content: displayOutput,
|
||||
timestamp: chunk.timestamp,
|
||||
meta: {
|
||||
...(isError ? { isError: true } : {}),
|
||||
...(originatingCall
|
||||
? { toolName: originatingCall.name, toolInput: originatingCall.input }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy streaming format
|
||||
else if (event.type === "stream_event" && event.event?.delta?.text) {
|
||||
parsedMessages.push({
|
||||
type: "text",
|
||||
content: event.event.delta.text,
|
||||
timestamp: chunk.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
// Session completion
|
||||
else if (event.type === "result") {
|
||||
parsedMessages.push({
|
||||
type: "session_end",
|
||||
content: event.is_error ? "Session failed" : "Session completed",
|
||||
timestamp: chunk.timestamp,
|
||||
meta: {
|
||||
isError: event.is_error,
|
||||
cost: event.total_cost_usd,
|
||||
duration: event.duration_ms,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
parsedMessages.push({
|
||||
type: "system",
|
||||
content: `[unknown event: ${event.type ?? "(no type)"}]`,
|
||||
timestamp: chunk.timestamp,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, display as-is
|
||||
parsedMessages.push({
|
||||
type: "text",
|
||||
content: event.event.delta.text,
|
||||
type: "error",
|
||||
content: line,
|
||||
timestamp: chunk.timestamp,
|
||||
meta: { isError: true },
|
||||
});
|
||||
}
|
||||
|
||||
// Session completion
|
||||
else if (event.type === "result") {
|
||||
parsedMessages.push({
|
||||
type: "session_end",
|
||||
content: event.is_error ? "Session failed" : "Session completed",
|
||||
meta: {
|
||||
isError: event.is_error,
|
||||
cost: event.total_cost_usd,
|
||||
duration: event.duration_ms,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, display as-is
|
||||
parsedMessages.push({
|
||||
type: "error",
|
||||
content: line,
|
||||
meta: { isError: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
return parsedMessages;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { createTRPCReact } from '@trpc/react-query';
|
||||
import { httpBatchLink, splitLink, httpSubscriptionLink } from '@trpc/client';
|
||||
import type { AppRouter } from '@codewalk-district/shared';
|
||||
import type { inferRouterOutputs } from '@trpc/server';
|
||||
|
||||
export const trpc = createTRPCReact<AppRouter>();
|
||||
export type RouterOutputs = inferRouterOutputs<AppRouter>;
|
||||
|
||||
export function createTRPCClient() {
|
||||
return trpc.createClient({
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as SettingsRouteImport } from './routes/settings'
|
||||
import { Route as InboxRouteImport } from './routes/inbox'
|
||||
import { Route as HqRouteImport } from './routes/hq'
|
||||
import { Route as AgentsRouteImport } from './routes/agents'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as SettingsIndexRouteImport } from './routes/settings/index'
|
||||
@@ -29,6 +30,11 @@ const InboxRoute = InboxRouteImport.update({
|
||||
path: '/inbox',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const HqRoute = HqRouteImport.update({
|
||||
id: '/hq',
|
||||
path: '/hq',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AgentsRoute = AgentsRouteImport.update({
|
||||
id: '/agents',
|
||||
path: '/agents',
|
||||
@@ -68,6 +74,7 @@ const InitiativesIdRoute = InitiativesIdRouteImport.update({
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/agents': typeof AgentsRoute
|
||||
'/hq': typeof HqRoute
|
||||
'/inbox': typeof InboxRoute
|
||||
'/settings': typeof SettingsRouteWithChildren
|
||||
'/initiatives/$id': typeof InitiativesIdRoute
|
||||
@@ -79,6 +86,7 @@ export interface FileRoutesByFullPath {
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/agents': typeof AgentsRoute
|
||||
'/hq': typeof HqRoute
|
||||
'/inbox': typeof InboxRoute
|
||||
'/initiatives/$id': typeof InitiativesIdRoute
|
||||
'/settings/health': typeof SettingsHealthRoute
|
||||
@@ -90,6 +98,7 @@ export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/agents': typeof AgentsRoute
|
||||
'/hq': typeof HqRoute
|
||||
'/inbox': typeof InboxRoute
|
||||
'/settings': typeof SettingsRouteWithChildren
|
||||
'/initiatives/$id': typeof InitiativesIdRoute
|
||||
@@ -103,6 +112,7 @@ export interface FileRouteTypes {
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/agents'
|
||||
| '/hq'
|
||||
| '/inbox'
|
||||
| '/settings'
|
||||
| '/initiatives/$id'
|
||||
@@ -114,6 +124,7 @@ export interface FileRouteTypes {
|
||||
to:
|
||||
| '/'
|
||||
| '/agents'
|
||||
| '/hq'
|
||||
| '/inbox'
|
||||
| '/initiatives/$id'
|
||||
| '/settings/health'
|
||||
@@ -124,6 +135,7 @@ export interface FileRouteTypes {
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/agents'
|
||||
| '/hq'
|
||||
| '/inbox'
|
||||
| '/settings'
|
||||
| '/initiatives/$id'
|
||||
@@ -136,6 +148,7 @@ export interface FileRouteTypes {
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
AgentsRoute: typeof AgentsRoute
|
||||
HqRoute: typeof HqRoute
|
||||
InboxRoute: typeof InboxRoute
|
||||
SettingsRoute: typeof SettingsRouteWithChildren
|
||||
InitiativesIdRoute: typeof InitiativesIdRoute
|
||||
@@ -158,6 +171,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof InboxRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/hq': {
|
||||
id: '/hq'
|
||||
path: '/hq'
|
||||
fullPath: '/hq'
|
||||
preLoaderRoute: typeof HqRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/agents': {
|
||||
id: '/agents'
|
||||
path: '/agents'
|
||||
@@ -229,6 +249,7 @@ const SettingsRouteWithChildren = SettingsRoute._addFileChildren(
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
AgentsRoute: AgentsRoute,
|
||||
HqRoute: HqRoute,
|
||||
InboxRoute: InboxRoute,
|
||||
SettingsRoute: SettingsRouteWithChildren,
|
||||
InitiativesIdRoute: InitiativesIdRoute,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { createFileRoute, useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import { motion } from "motion/react";
|
||||
import { AlertCircle, RefreshCw, Terminal, Users } from "lucide-react";
|
||||
@@ -9,8 +9,9 @@ import { Skeleton } from "@/components/Skeleton";
|
||||
import { toast } from "sonner";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { AgentOutputViewer } from "@/components/AgentOutputViewer";
|
||||
import { AgentDetailsPanel } from "@/components/AgentDetailsPanel";
|
||||
import { AgentActions } from "@/components/AgentActions";
|
||||
import { formatRelativeTime } from "@/lib/utils";
|
||||
import { formatRelativeTime, cn } from "@/lib/utils";
|
||||
import { modeLabel } from "@/lib/labels";
|
||||
import { StatusDot } from "@/components/StatusDot";
|
||||
import { useLiveUpdates } from "@/hooks";
|
||||
@@ -29,7 +30,12 @@ export const Route = createFileRoute("/agents")({
|
||||
|
||||
function AgentsPage() {
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'output' | 'details'>('output');
|
||||
const { filter } = useSearch({ from: "/agents" });
|
||||
|
||||
useEffect(() => {
|
||||
setActiveTab('output');
|
||||
}, [selectedAgentId]);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Live updates
|
||||
@@ -308,15 +314,49 @@ function AgentsPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Output Viewer */}
|
||||
{/* Right: Output/Details Viewer */}
|
||||
<div className="min-h-0 overflow-hidden">
|
||||
{selectedAgent ? (
|
||||
<AgentOutputViewer
|
||||
agentId={selectedAgent.id}
|
||||
agentName={selectedAgent.name}
|
||||
status={selectedAgent.status}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
<div className="flex flex-col min-h-0 h-full">
|
||||
{/* Tab bar */}
|
||||
<div className="flex shrink-0 border-b border-terminal-border">
|
||||
<button
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium",
|
||||
activeTab === 'output'
|
||||
? "border-b-2 border-primary text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setActiveTab('output')}
|
||||
>
|
||||
Output
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium",
|
||||
activeTab === 'details'
|
||||
? "border-b-2 border-primary text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setActiveTab('details')}
|
||||
>
|
||||
Details
|
||||
</button>
|
||||
</div>
|
||||
{/* Panel content */}
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
{activeTab === 'output' ? (
|
||||
<AgentOutputViewer
|
||||
agentId={selectedAgent.id}
|
||||
agentName={selectedAgent.name}
|
||||
status={selectedAgent.status}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
) : (
|
||||
<AgentDetailsPanel agentId={selectedAgent.id} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 rounded-lg border border-dashed">
|
||||
<Terminal className="h-10 w-10 text-muted-foreground/30" />
|
||||
|
||||
156
apps/web/src/routes/hq.test.tsx
Normal file
156
apps/web/src/routes/hq.test.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
// @vitest-environment happy-dom
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
||||
|
||||
const mockUseQuery = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/lib/trpc', () => ({
|
||||
trpc: {
|
||||
getHeadquartersDashboard: { useQuery: mockUseQuery },
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks', () => ({
|
||||
useLiveUpdates: vi.fn(),
|
||||
LiveUpdateRule: undefined,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/hq/HQWaitingForInputSection', () => ({
|
||||
HQWaitingForInputSection: ({ items }: any) => <div data-testid="waiting">{items.length}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/hq/HQNeedsReviewSection', () => ({
|
||||
HQNeedsReviewSection: ({ initiatives, phases }: any) => (
|
||||
<div data-testid="needs-review">{initiatives.length},{phases.length}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/hq/HQNeedsApprovalSection', () => ({
|
||||
HQNeedsApprovalSection: ({ items }: any) => <div data-testid="needs-approval">{items.length}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/hq/HQBlockedSection', () => ({
|
||||
HQBlockedSection: ({ items }: any) => <div data-testid="blocked">{items.length}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/hq/HQEmptyState', () => ({
|
||||
HQEmptyState: () => <div data-testid="empty-state">All clear</div>,
|
||||
}))
|
||||
|
||||
// Import after mocks are set up
|
||||
import { HeadquartersPage } from './hq'
|
||||
|
||||
const emptyData = {
|
||||
waitingForInput: [],
|
||||
pendingReviewInitiatives: [],
|
||||
pendingReviewPhases: [],
|
||||
planningInitiatives: [],
|
||||
blockedPhases: [],
|
||||
}
|
||||
|
||||
describe('HeadquartersPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders skeleton loading state', () => {
|
||||
mockUseQuery.mockReturnValue({ isLoading: true, isError: false, data: undefined })
|
||||
render(<HeadquartersPage />)
|
||||
|
||||
// Should show heading
|
||||
expect(screen.getByText('Headquarters')).toBeInTheDocument()
|
||||
// Should show skeleton elements (there are 3)
|
||||
const skeletons = document.querySelectorAll('[class*="skeleton"], [class*="h-16"]')
|
||||
expect(skeletons.length).toBeGreaterThan(0)
|
||||
// No section components
|
||||
expect(screen.queryByTestId('waiting')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('needs-review')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('needs-approval')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('blocked')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders error state with retry button', () => {
|
||||
const mockRefetch = vi.fn()
|
||||
mockUseQuery.mockReturnValue({ isLoading: false, isError: true, data: undefined, refetch: mockRefetch })
|
||||
render(<HeadquartersPage />)
|
||||
|
||||
expect(screen.getByText('Failed to load headquarters data.')).toBeInTheDocument()
|
||||
const retryButton = screen.getByRole('button', { name: /retry/i })
|
||||
expect(retryButton).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(retryButton)
|
||||
expect(mockRefetch).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('renders empty state when all arrays are empty', () => {
|
||||
mockUseQuery.mockReturnValue({ isLoading: false, isError: false, data: emptyData })
|
||||
render(<HeadquartersPage />)
|
||||
|
||||
expect(screen.getByTestId('empty-state')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('waiting')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('needs-review')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('needs-approval')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('blocked')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders WaitingForInput section when items exist', () => {
|
||||
mockUseQuery.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: { ...emptyData, waitingForInput: [{ id: '1' }] },
|
||||
})
|
||||
render(<HeadquartersPage />)
|
||||
|
||||
expect(screen.getByTestId('waiting')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('needs-review')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('needs-approval')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('blocked')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders all four sections when all arrays have items', () => {
|
||||
mockUseQuery.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: {
|
||||
waitingForInput: [{ id: '1' }],
|
||||
pendingReviewInitiatives: [{ id: '2' }],
|
||||
pendingReviewPhases: [{ id: '3' }],
|
||||
planningInitiatives: [{ id: '4' }],
|
||||
blockedPhases: [{ id: '5' }],
|
||||
},
|
||||
})
|
||||
render(<HeadquartersPage />)
|
||||
|
||||
expect(screen.getByTestId('waiting')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('needs-review')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('needs-approval')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('blocked')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders NeedsReview section when only pendingReviewInitiatives has items', () => {
|
||||
mockUseQuery.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: { ...emptyData, pendingReviewInitiatives: [{ id: '1' }] },
|
||||
})
|
||||
render(<HeadquartersPage />)
|
||||
|
||||
expect(screen.getByTestId('needs-review')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders NeedsReview section when only pendingReviewPhases has items', () => {
|
||||
mockUseQuery.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: { ...emptyData, pendingReviewPhases: [{ id: '1' }] },
|
||||
})
|
||||
render(<HeadquartersPage />)
|
||||
|
||||
expect(screen.getByTestId('needs-review')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
117
apps/web/src/routes/hq.tsx
Normal file
117
apps/web/src/routes/hq.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { motion } from "motion/react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { useLiveUpdates, type LiveUpdateRule } from "@/hooks";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HQWaitingForInputSection } from "@/components/hq/HQWaitingForInputSection";
|
||||
import { HQNeedsReviewSection } from "@/components/hq/HQNeedsReviewSection";
|
||||
import { HQNeedsApprovalSection } from "@/components/hq/HQNeedsApprovalSection";
|
||||
import { HQBlockedSection } from "@/components/hq/HQBlockedSection";
|
||||
import { HQEmptyState } from "@/components/hq/HQEmptyState";
|
||||
|
||||
export const Route = createFileRoute("/hq")({
|
||||
component: HeadquartersPage,
|
||||
});
|
||||
|
||||
const HQ_LIVE_UPDATE_RULES: LiveUpdateRule[] = [
|
||||
{ prefix: "initiative:", invalidate: ["getHeadquartersDashboard"] },
|
||||
{ prefix: "phase:", invalidate: ["getHeadquartersDashboard"] },
|
||||
{ prefix: "agent:", invalidate: ["getHeadquartersDashboard"] },
|
||||
];
|
||||
|
||||
export function HeadquartersPage() {
|
||||
useLiveUpdates(HQ_LIVE_UPDATE_RULES);
|
||||
const query = trpc.getHeadquartersDashboard.useQuery();
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<motion.div
|
||||
className="mx-auto max-w-4xl space-y-6"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Headquarters</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Items waiting for your attention.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-16 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
if (query.isError) {
|
||||
return (
|
||||
<motion.div
|
||||
className="mx-auto max-w-4xl space-y-6"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center py-24 gap-3 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Failed to load headquarters data.
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={() => query.refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
const data = query.data!;
|
||||
|
||||
const hasAny =
|
||||
data.waitingForInput.length > 0 ||
|
||||
data.pendingReviewInitiatives.length > 0 ||
|
||||
data.pendingReviewPhases.length > 0 ||
|
||||
data.planningInitiatives.length > 0 ||
|
||||
data.blockedPhases.length > 0;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="mx-auto max-w-4xl space-y-6"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Headquarters</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Items waiting for your attention.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!hasAny ? (
|
||||
<HQEmptyState />
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{data.waitingForInput.length > 0 && (
|
||||
<HQWaitingForInputSection items={data.waitingForInput} />
|
||||
)}
|
||||
{(data.pendingReviewInitiatives.length > 0 ||
|
||||
data.pendingReviewPhases.length > 0) && (
|
||||
<HQNeedsReviewSection
|
||||
initiatives={data.pendingReviewInitiatives}
|
||||
phases={data.pendingReviewPhases}
|
||||
/>
|
||||
)}
|
||||
{data.planningInitiatives.length > 0 && (
|
||||
<HQNeedsApprovalSection items={data.planningInitiatives} />
|
||||
)}
|
||||
{data.blockedPhases.length > 0 && (
|
||||
<HQBlockedSection items={data.blockedPhases} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,6 @@ import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/')({
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: '/initiatives' })
|
||||
throw redirect({ to: '/hq' })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { motion } from "motion/react";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
@@ -11,6 +12,7 @@ import { ExecutionTab } from "@/components/ExecutionTab";
|
||||
import { ReviewTab } from "@/components/review";
|
||||
import { PipelineTab } from "@/components/pipeline";
|
||||
import { useLiveUpdates } from "@/hooks";
|
||||
import type { LiveUpdateRule } from "@/hooks";
|
||||
|
||||
type Tab = "content" | "plan" | "execution" | "review";
|
||||
const TABS: Tab[] = ["content", "plan", "execution", "review"];
|
||||
@@ -27,15 +29,17 @@ function InitiativeDetailPage() {
|
||||
const { tab: activeTab } = Route.useSearch();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Single SSE stream for all live updates
|
||||
useLiveUpdates([
|
||||
// Single SSE stream for all live updates — memoized to avoid re-subscribe on render
|
||||
const liveUpdateRules = useMemo<LiveUpdateRule[]>(() => [
|
||||
{ prefix: 'initiative:', invalidate: ['getInitiative'] },
|
||||
{ prefix: 'task:', invalidate: ['listPhases', 'listTasks', 'listInitiativeTasks', 'getPhaseDependencies', 'listPhaseTaskDependencies'] },
|
||||
{ prefix: 'phase:', invalidate: ['listPhases', 'listTasks', 'listInitiativePhaseDependencies', 'getPhaseDependencies'] },
|
||||
{ prefix: 'agent:', invalidate: ['listAgents', 'getActiveRefineAgent'] },
|
||||
{ prefix: 'agent:', invalidate: ['listAgents', 'getActiveRefineAgent', 'getTaskAgent', 'getActiveConflictAgent'] },
|
||||
{ prefix: 'page:', invalidate: ['listPages', 'getPage', 'getRootPage'] },
|
||||
{ prefix: 'changeset:', invalidate: ['getChangeSet', 'listChangeSets'] },
|
||||
{ prefix: 'preview:', invalidate: ['listPreviews', 'getPreviewStatus'] },
|
||||
]);
|
||||
], []);
|
||||
useLiveUpdates(liveUpdateRules);
|
||||
|
||||
// tRPC queries
|
||||
const initiativeQuery = trpc.getInitiative.useQuery({ id });
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Plus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InitiativeList } from "@/components/InitiativeList";
|
||||
import { CreateInitiativeDialog } from "@/components/CreateInitiativeDialog";
|
||||
import { useLiveUpdates } from "@/hooks";
|
||||
import { useLiveUpdates, INITIATIVE_LIST_RULES } from "@/hooks";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
|
||||
export const Route = createFileRoute("/initiatives/")({
|
||||
@@ -29,10 +29,7 @@ function DashboardPage() {
|
||||
const projectsQuery = trpc.listProjects.useQuery();
|
||||
|
||||
// Single SSE stream for live updates
|
||||
useLiveUpdates([
|
||||
{ prefix: 'task:', invalidate: ['listInitiatives'] },
|
||||
{ prefix: 'phase:', invalidate: ['listInitiatives'] },
|
||||
]);
|
||||
useLiveUpdates(INITIATIVE_LIST_RULES);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import {
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
Loader2,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
@@ -11,6 +15,8 @@ import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/Skeleton'
|
||||
import { AccountCard } from '@/components/AccountCard'
|
||||
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { AddAccountDialog } from '@/components/AddAccountDialog'
|
||||
|
||||
export const Route = createFileRoute('/settings/health')({
|
||||
component: HealthCheckPage,
|
||||
@@ -45,7 +51,23 @@ function HealthCheckPage() {
|
||||
},
|
||||
})
|
||||
|
||||
const { data, isLoading, isError, error, refetch } = healthQuery
|
||||
const [addAccountOpen, setAddAccountOpen] = useState(false)
|
||||
|
||||
const refreshAccounts = trpc.refreshAccounts.useMutation({
|
||||
onSuccess: (data) => {
|
||||
const msg =
|
||||
data.cleared === 0
|
||||
? 'No expired flags to clear.'
|
||||
: `Cleared ${data.cleared} expired exhaustion flag(s).`
|
||||
toast.success(msg)
|
||||
void utils.systemHealthCheck.invalidate()
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(`Failed to refresh: ${err.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const { data, isLoading, isError, refetch } = healthQuery
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
@@ -61,24 +83,9 @@ function HealthCheckPage() {
|
||||
)
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-12">
|
||||
<XCircle className="h-8 w-8 text-status-error-fg" />
|
||||
<p className="text-sm text-status-error-fg">
|
||||
Failed to load health check: {error?.message ?? 'Unknown error'}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={() => void refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) return null
|
||||
|
||||
const { server, accounts, projects } = data
|
||||
const server = data?.server
|
||||
const accounts = data?.accounts ?? []
|
||||
const projects = data?.projects ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -95,37 +102,76 @@ function HealthCheckPage() {
|
||||
</div>
|
||||
|
||||
{/* Server Status */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 font-display text-lg">
|
||||
<Server className="h-5 w-5" />
|
||||
Server Status
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-status-success-dot" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Running</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Uptime: {formatUptime(server.uptime)}
|
||||
{server.startedAt && (
|
||||
<>
|
||||
{' '}
|
||||
· Started{' '}
|
||||
{new Date(server.startedAt).toLocaleString()}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{server && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 font-display text-lg">
|
||||
<Server className="h-5 w-5" />
|
||||
Server Status
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-status-success-dot" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Running</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Uptime: {formatUptime(server.uptime)}
|
||||
{server.startedAt && (
|
||||
<>
|
||||
{' '}
|
||||
· Started{' '}
|
||||
{new Date(server.startedAt).toLocaleString()}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Accounts */}
|
||||
<div className="space-y-3">
|
||||
<h2 className="font-display text-lg font-semibold">Accounts</h2>
|
||||
{accounts.length === 0 ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-display text-lg font-semibold">Accounts</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refreshAccounts.mutate()}
|
||||
disabled={refreshAccounts.isPending}
|
||||
>
|
||||
{refreshAccounts.isPending
|
||||
? <Loader2 className="h-4 w-4 animate-spin" />
|
||||
: <RotateCcw className="h-4 w-4" />}
|
||||
<span className="sr-only">Refresh</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Clear expired exhaustion flags</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setAddAccountOpen(true)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Account
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{isError ? (
|
||||
<Card>
|
||||
<CardContent className="py-6">
|
||||
<p className="text-center text-sm text-status-error-fg">
|
||||
Could not load account status. Retrying…
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : accounts.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-6">
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
@@ -133,7 +179,7 @@ function HealthCheckPage() {
|
||||
<code className="rounded bg-muted px-1 py-0.5 text-xs">
|
||||
cw account add
|
||||
</code>{' '}
|
||||
to register one.
|
||||
to register one, or click 'Add Account' above.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -143,7 +189,14 @@ function HealthCheckPage() {
|
||||
key={account.id}
|
||||
account={account}
|
||||
onDelete={(e) => {
|
||||
if (e.shiftKey || window.confirm(`Remove account "${account.email}"?`)) {
|
||||
if (
|
||||
e.shiftKey ||
|
||||
(account.activeAgentCount > 0
|
||||
? window.confirm(
|
||||
`This account has ${account.activeAgentCount} active agent(s). Deleting it will not stop those agents but they will lose their account association. Continue?`
|
||||
)
|
||||
: window.confirm(`Remove account "${account.email}"?`))
|
||||
) {
|
||||
removeAccount.mutate({ id: account.id })
|
||||
}
|
||||
}}
|
||||
@@ -186,6 +239,8 @@ function HealthCheckPage() {
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AddAccountDialog open={addAccountOpen} onOpenChange={setAddAccountOpen} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,24 @@ function ProjectsSettingsPage() {
|
||||
const projectsQuery = trpc.listProjects.useQuery()
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const syncAllMutation = trpc.syncAllProjects.useMutation({
|
||||
onSuccess: (results) => {
|
||||
const failed = results.filter(r => !r.success)
|
||||
if (failed.length === 0) {
|
||||
toast.success('All projects synced.')
|
||||
} else {
|
||||
toast.error(`${failed.length} project(s) failed to sync — check project names in the list.`)
|
||||
}
|
||||
void utils.listProjects.invalidate()
|
||||
results.forEach(r => {
|
||||
void utils.getProjectSyncStatus.invalidate({ id: r.projectId })
|
||||
})
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(`Sync failed: ${err.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = trpc.deleteProject.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.listProjects.invalidate()
|
||||
@@ -43,7 +61,18 @@ function ProjectsSettingsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-end">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{(projects?.length ?? 0) > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => syncAllMutation.mutate()}
|
||||
disabled={syncAllMutation.isPending}
|
||||
>
|
||||
<RefreshCw className={syncAllMutation.isPending ? 'animate-spin mr-2 h-4 w-4' : 'mr-2 h-4 w-4'} />
|
||||
{syncAllMutation.isPending ? 'Syncing…' : 'Sync All'}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" onClick={() => setRegisterOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Register Project
|
||||
@@ -72,6 +101,7 @@ function ProjectsSettingsPage() {
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
project={project}
|
||||
syncAllPending={syncAllMutation.isPending}
|
||||
onDelete={() => {
|
||||
if (window.confirm(`Delete project "${project.name}"? This will also remove the cloned repository.`)) {
|
||||
deleteMutation.mutate({ id: project.id })
|
||||
@@ -108,9 +138,11 @@ function formatRelativeTime(date: Date | string | null): string {
|
||||
function ProjectCard({
|
||||
project,
|
||||
onDelete,
|
||||
syncAllPending,
|
||||
}: {
|
||||
project: { id: string; name: string; url: string; defaultBranch: string; lastFetchedAt: Date | null }
|
||||
onDelete: () => void
|
||||
syncAllPending?: boolean
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editValue, setEditValue] = useState(project.defaultBranch)
|
||||
@@ -215,7 +247,7 @@ function ProjectCard({
|
||||
size="icon"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
onClick={() => syncMutation.mutate({ id: project.id })}
|
||||
disabled={syncMutation.isPending}
|
||||
disabled={syncMutation.isPending || syncAllPending}
|
||||
title="Sync from remote"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${syncMutation.isPending ? 'animate-spin' : ''}`} />
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -24,15 +24,15 @@
|
||||
| `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, plan, detail, refine, chat, conflict-resolution, errand) + shared blocks (test integrity, deviation rules, git workflow, session startup, progress tracking) + inter-agent communication instructions |
|
||||
| `prompts/` | Mode-specific prompt builders (execute, discuss, plan, detail, refine, chat, conflict-resolution, errand) + shared blocks (test integrity, deviation rules, git workflow, session startup, progress tracking) + inter-agent communication instructions. Conflict-resolution uses a minimal inline startup (pwd, git status, CLAUDE.md) instead of the full `SESSION_STARTUP`/`CONTEXT_MANAGEMENT` blocks. |
|
||||
|
||||
## 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>/`
|
||||
2. Manager generates alias (adjective-animal), creates DB record. Appends inter-agent communication and preview instructions unless `skipPromptExtras: true` (used by conflict-resolution agents to keep prompts lean).
|
||||
3. `AgentProcessManager.createProjectWorktrees()` — creates git worktrees at `agent-workdirs/<alias>/<project>/`. After creation, each project subdirectory is verified to exist; missing worktrees throw immediately to prevent agents running in the wrong directory.
|
||||
4. `file-io.writeInputFiles()` — writes `.cw/input/` with assignment files (initiative, pages, phase, task) and read-only context dirs (`context/phases/`, `context/tasks/`)
|
||||
5. Provider config builds spawn command via `buildSpawnCommand()`
|
||||
6. `spawnDetached()` — launches detached child process with file output redirection
|
||||
@@ -121,7 +121,7 @@ Stored as `credentials: {"claudeAiOauth":{"accessToken":"<token>"}}` and `config
|
||||
|
||||
Delivers a user message directly to a running or idle errand agent without going through the conversations table. Used by the `errand.sendMessage` tRPC procedure.
|
||||
|
||||
**Steps**: look up agent → validate status (`running`|`idle`|`waiting_for_input`) → validate `sessionId` → clear signal.json → update status to `running` → build resume command → stop active tailer/poll → spawn detached → start polling.
|
||||
**Steps**: look up agent → validate status (`running`|`idle`) → validate `sessionId` → clear signal.json → update status to `running` → build resume command → stop active tailer/poll → spawn detached → start polling.
|
||||
|
||||
**Key difference from `resumeForConversation`**: no `conversationResumeLocks`, no conversations table entry, raw message passed as resume prompt.
|
||||
|
||||
@@ -177,9 +177,10 @@ Agent output is persisted to `agent_log_chunks` table and drives all live stream
|
||||
- DB insert → `agent:output` event emission (single source of truth for UI)
|
||||
- No FK to agents — survives agent deletion
|
||||
- Session tracking: spawn=1, resume=previousMax+1
|
||||
- Read path (`getAgentOutput` tRPC): concatenates all DB chunks (no file fallback)
|
||||
- Live path (`onAgentOutput` subscription): listens for `agent:output` events
|
||||
- Frontend: initial query loads from DB, subscription accumulates raw JSONL, both parsed via `parseAgentOutput()`
|
||||
- Read path (`getAgentOutput` tRPC): returns timestamped chunks `{ content, createdAt }[]` from DB
|
||||
- Live path (`onAgentOutput` subscription): listens for `agent:output` events (client stamps with `Date.now()`)
|
||||
- Frontend: initial query loads timestamped chunks, subscription accumulates live chunks, both parsed via `parseAgentOutput()` which accepts `TimestampedChunk[]`
|
||||
- Timestamps displayed inline (HH:MM:SS) on text, tool_call, system, and session_end messages
|
||||
|
||||
## Inter-Agent Communication
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
## Architecture
|
||||
|
||||
- **Schema**: `apps/server/db/schema.ts` — all tables, columns, relations
|
||||
- **Ports** (interfaces): `apps/server/db/repositories/*.ts` — 13 repository interfaces
|
||||
- **Adapters** (implementations): `apps/server/db/repositories/drizzle/*.ts` — 13 Drizzle adapters
|
||||
- **Ports** (interfaces): `apps/server/db/repositories/*.ts` — 14 repository interfaces
|
||||
- **Adapters** (implementations): `apps/server/db/repositories/drizzle/*.ts` — 14 Drizzle adapters
|
||||
- **Barrel exports**: `apps/server/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.
|
||||
@@ -51,6 +51,7 @@ All adapters use nanoid() for IDs, auto-manage timestamps, and use Drizzle's `.r
|
||||
| status | text enum | 'pending' \| 'in_progress' \| 'completed' \| 'blocked' |
|
||||
| order | integer | default 0 |
|
||||
| summary | text nullable | Agent result summary — propagated to dependent tasks as context |
|
||||
| retryCount | integer NOT NULL | default 0, incremented on agent crash auto-retry, reset on manual retry |
|
||||
| createdAt, updatedAt | integer/timestamp | |
|
||||
|
||||
### task_dependencies
|
||||
@@ -71,6 +72,7 @@ All adapters use nanoid() for IDs, auto-manage timestamps, and use Drizzle's `.r
|
||||
| mode | text enum | 'execute' \| 'discuss' \| 'plan' \| 'detail' \| 'refine' |
|
||||
| pid | integer nullable | OS process ID |
|
||||
| exitCode | integer nullable | |
|
||||
| prompt | text nullable | Full assembled prompt passed to agent at spawn; persisted for durability after log cleanup |
|
||||
| outputFilePath | text nullable | |
|
||||
| result | text nullable | JSON |
|
||||
| pendingQuestions | text nullable | JSON |
|
||||
@@ -195,6 +197,21 @@ Messages within a chat session.
|
||||
|
||||
Index: `(chatSessionId)`.
|
||||
|
||||
### errands
|
||||
|
||||
Tracks errand work items linked to a project branch, optionally assigned to an agent.
|
||||
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | text PK | caller-supplied |
|
||||
| description | text NOT NULL | human-readable description |
|
||||
| branch | text NOT NULL | working branch name |
|
||||
| baseBranch | text NOT NULL | default 'main' |
|
||||
| agentId | text FK → agents (set null) | assigned agent; null if unassigned |
|
||||
| projectId | text FK → projects (cascade) | owning project |
|
||||
| status | text enum | active, pending_review, conflict, merged, abandoned; default 'active' |
|
||||
| createdAt, updatedAt | integer/timestamp | |
|
||||
|
||||
### review_comments
|
||||
|
||||
Inline review comments on phase diffs, persisted across page reloads.
|
||||
@@ -215,7 +232,7 @@ Index: `(phaseId)`.
|
||||
|
||||
## Repository Interfaces
|
||||
|
||||
13 repositories, each with standard CRUD plus domain-specific methods:
|
||||
14 repositories, each with standard CRUD plus domain-specific methods:
|
||||
|
||||
| Repository | Key Methods |
|
||||
|-----------|-------------|
|
||||
@@ -232,6 +249,7 @@ Index: `(phaseId)`.
|
||||
| ConversationRepository | create, findById, findPendingForAgent, answer |
|
||||
| ChatSessionRepository | createSession, findActiveSession, findActiveSessionByAgentId, updateSession, createMessage, findMessagesBySessionId |
|
||||
| ReviewCommentRepository | create, findByPhaseId, resolve, unresolve, delete |
|
||||
| ErrandRepository | create, findById, findAll (filter by projectId/status), update, delete |
|
||||
|
||||
## Migrations
|
||||
|
||||
@@ -243,4 +261,4 @@ Key rules:
|
||||
- See [database-migrations.md](database-migrations.md) for full workflow
|
||||
- Snapshots stale after 0008; migrations 0008+ are hand-written
|
||||
|
||||
Current migrations: 0000 through 0030 (31 total).
|
||||
Current migrations: 0000 through 0035 (36 total).
|
||||
|
||||
@@ -69,6 +69,7 @@ InitiativeChangesRequestedEvent { initiativeId, phaseId, taskId }
|
||||
7. **Summary propagation** — `completeTask()` reads the completing agent's `result.message` and stores it on the task's `summary` column. Dependent tasks see this summary in `context/tasks/<id>.md` frontmatter.
|
||||
8. **Spawn failure** — If `agentManager.spawn()` throws, the task is blocked via `blockTask()` with the error message. The dispatch cycle continues instead of crashing.
|
||||
9. **Retry blocked** — `retryBlockedTask(taskId)` resets a blocked task to pending and re-queues it. Exposed via tRPC `retryBlockedTask` mutation. The UI shows a Retry button in the task slide-over when status is `blocked`.
|
||||
10. **Branch validation** — Branch computation and `ensureBranch` errors are fatal for execution tasks (execute, verify, merge, review) but non-fatal for planning tasks. This prevents agents from spawning without proper branch isolation.
|
||||
|
||||
### DispatchManager Methods
|
||||
|
||||
@@ -112,9 +113,22 @@ InitiativeChangesRequestedEvent { initiativeId, phaseId, taskId }
|
||||
| Event | Action |
|
||||
|-------|--------|
|
||||
| `phase:queued` | Dispatch ready phases → dispatch their tasks to idle agents |
|
||||
| `agent:stopped` | Re-dispatch queued tasks (freed agent slot) |
|
||||
| `agent:stopped` | Auto-complete task (unless user_requested), re-dispatch queued tasks (freed agent slot) |
|
||||
| `agent:crashed` | Auto-retry crashed task up to `MAX_TASK_RETRIES` (3). Increments `retryCount`, resets status to `pending`, re-queues. Exceeding retries leaves task `in_progress` for manual intervention. |
|
||||
| `task:completed` | Merge task branch (if branch exists), check phase completion, dispatch next queued task |
|
||||
|
||||
### Crash Recovery
|
||||
|
||||
When an agent crashes (`agent:crashed` event), the orchestrator automatically retries the task:
|
||||
1. Finds the task associated with the crashed agent
|
||||
2. Checks `task.retryCount` against `MAX_TASK_RETRIES` (3)
|
||||
3. If under limit: increments `retryCount`, resets task to `pending`, re-queues for dispatch
|
||||
4. If over limit: logs warning, task stays `in_progress` for manual intervention
|
||||
|
||||
On server restart, `recoverDispatchQueues()` also recovers stuck `in_progress` tasks whose agents are dead (status is not `running` or `waiting_for_input`). These are reset to `pending` and re-queued.
|
||||
|
||||
Manual retry via `retryBlockedTask()` resets `retryCount` to 0, giving the task a fresh set of automatic retries.
|
||||
|
||||
### Coalesced Scheduling
|
||||
|
||||
Multiple rapid events (e.g. several `phase:queued` from `queueAllPhases`) are coalesced into a single async dispatch cycle via `scheduleDispatch()`. The cycle loops `dispatchNextPhase()` + `dispatchNext()` until both queues are drained, then re-runs if new events arrived during execution.
|
||||
|
||||
@@ -44,6 +44,7 @@ Use `mapEntityStatus(rawStatus)` from `StatusDot.tsx` to convert raw entity stat
|
||||
|-------|-----------|---------|
|
||||
| `/` | `routes/index.tsx` | Dashboard / initiative list |
|
||||
| `/initiatives/$id` | `routes/initiatives/$initiativeId.tsx` | Initiative detail (tabbed) |
|
||||
| `/agents` | `routes/agents.tsx` | Agent list with Output / Details tab panel |
|
||||
| `/settings` | `routes/settings/index.tsx` | Settings page |
|
||||
|
||||
## Initiative Detail Tabs
|
||||
@@ -54,7 +55,7 @@ The initiative detail page has three tabs managed via local state (not URL param
|
||||
2. **Execution Tab** — Pipeline visualization, phase management, task dispatch
|
||||
3. **Review Tab** — Pending proposals from agents
|
||||
|
||||
## Component Inventory (73 components)
|
||||
## Component Inventory (74 components)
|
||||
|
||||
### Core Components (`src/components/`)
|
||||
| Component | Purpose |
|
||||
@@ -66,6 +67,7 @@ The initiative detail page has three tabs managed via local state (not URL param
|
||||
| `StatusBadge` | Colored badge using status tokens |
|
||||
| `TaskRow` | Task list item with status, priority, category |
|
||||
| `QuestionForm` | Agent question form with options |
|
||||
| `AgentDetailsPanel` | Details tab for agent right-panel: metadata, input files, effective prompt |
|
||||
| `InboxDetailPanel` | Agent message detail + response form |
|
||||
| `ProjectPicker` | Checkbox list for project selection |
|
||||
| `RegisterProjectDialog` | Dialog to register new git project |
|
||||
@@ -198,4 +200,4 @@ Components: `ChatSlideOver`, `ChatBubble`, `ChatInput`, `ChangeSetInline` in `sr
|
||||
|
||||
`listInitiatives` returns an `activity` field on each initiative, computed server-side from phase statuses via `deriveInitiativeActivity()` in `apps/server/trpc/routers/initiative-activity.ts`. This eliminates per-card N+1 `listPhases` queries.
|
||||
|
||||
Activity states (priority order): active architect agents > `pending_review` > `executing` > `blocked` > `complete` > `ready` > `planning` > `idle` > `archived`. Each state maps to a `StatusVariant` + pulse animation in `InitiativeCard`'s `activityVisual()` function. Active architect agents (modes: discuss, plan, detail, refine) are checked first — mapping to `discussing`, `detailing`, `detailing`, `refining` states respectively — so auto-spawned agents surface activity even when no phases exist yet. `PhaseSidebarItem` also shows a spinner when a detail agent is active for its phase.
|
||||
Activity states (priority order): conflict agent > `archived` > active architect agents > `pending_review` > `executing` > `blocked` > `complete` > `ready` > `planning` > `idle`. Each state maps to a `StatusVariant` + pulse animation in `InitiativeCard`'s `activityVisual()` function. Active conflict agents (name starts with `conflict-`) are checked first — returning `resolving_conflict` (urgent variant, pulsing). Active architect agents (modes: discuss, plan, detail, refine) are checked next — mapping to `discussing`, `detailing`, `detailing`, `refining` states respectively — so auto-spawned agents surface activity even when no phases exist yet. `PhaseSidebarItem` also shows a spinner when a detail agent is active for its phase.
|
||||
|
||||
@@ -59,10 +59,13 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
|
||||
| dismissAgent | mutation | Dismiss agent (set userDismissedAt) |
|
||||
| resumeAgent | mutation | Resume with answers |
|
||||
| listAgents | query | All agents |
|
||||
| getAgent | query | Single agent by name or ID |
|
||||
| getAgent | query | Single agent by name or ID; also returns `taskName`, `initiativeName`, `exitCode` |
|
||||
| getAgentResult | query | Execution result |
|
||||
| getAgentQuestions | query | Pending questions |
|
||||
| getAgentOutput | query | Full output from DB log chunks |
|
||||
| getAgentOutput | query | Timestamped log chunks from DB (`{ content, createdAt }[]`) |
|
||||
| getTaskAgent | query | Most recent agent assigned to a task (by taskId) |
|
||||
| getAgentInputFiles | query | Files written to agent's `.cw/input/` dir (text only, sorted, 500 KB cap) |
|
||||
| getAgentPrompt | query | Assembled prompt — reads from DB (`agents.prompt`) first; falls back to `.cw/agent-logs/<name>/PROMPT.md` for pre-persistence agents (1 MB cap) |
|
||||
| getActiveRefineAgent | query | Active refine agent for initiative |
|
||||
| getActiveConflictAgent | query | Active conflict resolution agent for initiative (name starts with `conflict-`) |
|
||||
| listWaitingAgents | query | Agents waiting for input |
|
||||
@@ -195,7 +198,7 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
|
||||
| updateAccountAuth | mutation | Update credentials |
|
||||
| markAccountExhausted | mutation | Set exhaustion timer |
|
||||
| listProviderNames | query | Available provider names |
|
||||
| addAccountByToken | mutation | Upsert account by email + raw OAuth token |
|
||||
| addAccountByToken | mutation | Upsert account from OAuth token; returns `{ upserted, account }` |
|
||||
|
||||
### Proposals
|
||||
| Procedure | Type | Description |
|
||||
@@ -278,7 +281,7 @@ Small isolated changes that spawn a dedicated agent in a git worktree. Errands a
|
||||
|-----------|------|-------------|
|
||||
| `errand.create` | mutation | Create errand: `{description, projectId, baseBranch?}` → `{id, branch, agentId}`. Creates branch, worktree, DB record, spawns agent. |
|
||||
| `errand.list` | query | List errands: `{projectId?, status?}` → ErrandWithAlias[] (ordered newest-first) |
|
||||
| `errand.get` | query | Get errand by ID: `{id}` → ErrandWithAlias with parsed `conflictFiles: string[]` (never null) and `projectPath: string \| null` (computed from workspaceRoot) |
|
||||
| `errand.get` | query | Get errand by ID: `{id}` → ErrandWithAlias with `projectPath: string \| null` (computed from workspaceRoot) |
|
||||
| `errand.diff` | query | Get branch diff: `{id}` → `{diff: string}` |
|
||||
| `errand.complete` | mutation | Mark active errand ready for review (stops agent): `{id}` → Errand |
|
||||
| `errand.merge` | mutation | Merge errand branch: `{id, target?}` → `{status: 'merged'}` or throws conflict |
|
||||
@@ -288,6 +291,28 @@ Small isolated changes that spawn a dedicated agent in a git worktree. Errands a
|
||||
|
||||
**Errand statuses**: `active` → `pending_review` (via complete) → `merged` (via merge) or `conflict` (merge failed) → retry merge. `abandoned` is terminal. Only `pending_review` and `conflict` errands can be merged.
|
||||
|
||||
**Merge conflict flow**: On conflict, `errand.merge` updates status to `conflict` and stores `conflictFiles` (JSON string[]). After manual resolution, call `errand.merge` again.
|
||||
|
||||
Context dependencies: `requireErrandRepository(ctx)`, `requireProjectRepository(ctx)`, `requireAgentManager(ctx)`, `requireBranchManager(ctx)`, `ctx.workspaceRoot` (for `ensureProjectClone`). `SimpleGitWorktreeManager` is created on-the-fly per project clone path.
|
||||
|
||||
## Headquarters Procedures
|
||||
|
||||
Composite dashboard query aggregating all action items that require user intervention.
|
||||
|
||||
| Procedure | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `getHeadquartersDashboard` | query | Returns 5 typed arrays of action items (no input required) |
|
||||
|
||||
### Return Shape
|
||||
|
||||
```typescript
|
||||
{
|
||||
waitingForInput: Array<{ agentId, agentName, initiativeId, initiativeName, questionText, waitingSince }>;
|
||||
pendingReviewInitiatives: Array<{ initiativeId, initiativeName, since }>;
|
||||
pendingReviewPhases: Array<{ initiativeId, initiativeName, phaseId, phaseName, since }>;
|
||||
planningInitiatives: Array<{ initiativeId, initiativeName, pendingPhaseCount, since }>;
|
||||
blockedPhases: Array<{ initiativeId, initiativeName, phaseId, phaseName, lastMessage, since }>;
|
||||
}
|
||||
```
|
||||
|
||||
Each array is sorted ascending by timestamp (oldest-first). All timestamps are ISO 8601 strings. `lastMessage` is truncated to 160 chars and is `null` when no messages exist or the message repository is not wired.
|
||||
|
||||
Context dependency: `requireInitiativeRepository(ctx)`, `requirePhaseRepository(ctx)`, `requireAgentManager(ctx)`. Task/message repos are accessed via optional `ctx` fields for `blockedPhases.lastMessage`.
|
||||
|
||||
250
package-lock.json
generated
250
package-lock.json
generated
@@ -37,6 +37,9 @@
|
||||
"cw": "apps/server/dist/bin/cw.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.10.7",
|
||||
@@ -673,6 +676,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@adobe/css-tools": {
|
||||
"version": "4.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz",
|
||||
"integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
||||
@@ -982,6 +992,16 @@
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
|
||||
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||
@@ -4377,6 +4397,96 @@
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/aria-query": "^5.0.1",
|
||||
"aria-query": "5.3.0",
|
||||
"dom-accessibility-api": "^0.5.9",
|
||||
"lz-string": "^1.5.0",
|
||||
"picocolors": "1.1.1",
|
||||
"pretty-format": "^27.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/jest-dom": {
|
||||
"version": "6.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
|
||||
"integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@adobe/css-tools": "^4.4.0",
|
||||
"aria-query": "^5.0.0",
|
||||
"css.escape": "^1.5.1",
|
||||
"dom-accessibility-api": "^0.6.3",
|
||||
"picocolors": "^1.1.1",
|
||||
"redent": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14",
|
||||
"npm": ">=6",
|
||||
"yarn": ">=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
|
||||
"integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@testing-library/react": {
|
||||
"version": "16.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
|
||||
"integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@testing-library/dom": "^10.0.0",
|
||||
"@types/react": "^18.0.0 || ^19.0.0",
|
||||
"@types/react-dom": "^18.0.0 || ^19.0.0",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/user-event": {
|
||||
"version": "14.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
|
||||
"integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12",
|
||||
"npm": ">=6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@testing-library/dom": ">=7.21.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/core": {
|
||||
"version": "3.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.19.0.tgz",
|
||||
@@ -4926,6 +5036,14 @@
|
||||
"typescript": ">=5.7.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/aria-query": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@@ -5291,6 +5409,31 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ansis": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz",
|
||||
@@ -5360,6 +5503,16 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/aria-query": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
||||
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
@@ -5816,6 +5969,13 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/css.escape": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
|
||||
"integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
@@ -5947,6 +6107,14 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-accessibility-api": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/drizzle-kit": {
|
||||
"version": "0.31.8",
|
||||
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.8.tgz",
|
||||
@@ -7212,6 +7380,16 @@
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/indent-string": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
|
||||
"integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
@@ -7513,6 +7691,17 @@
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -7745,6 +7934,16 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/min-indent": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
||||
"integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.1.1",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
|
||||
@@ -8511,6 +8710,22 @@
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "27.5.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
"react-is": "^17.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-ms": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz",
|
||||
@@ -8829,6 +9044,14 @@
|
||||
"react": "^19.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
@@ -8984,6 +9207,20 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/redent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||
"integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"indent-string": "^4.0.0",
|
||||
"strip-indent": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/regex": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz",
|
||||
@@ -9540,6 +9777,19 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-indent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
|
||||
"integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"min-indent": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
||||
|
||||
@@ -50,6 +50,9 @@
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.10.7",
|
||||
|
||||
@@ -4,17 +4,18 @@ export type { PendingQuestions, QuestionItem } from '../../../apps/server/agent/
|
||||
export type ExecutionMode = 'yolo' | 'review_per_phase';
|
||||
|
||||
export type InitiativeActivityState =
|
||||
| 'idle' // Active but no phases and no agents
|
||||
| 'discussing' // Discuss agent actively scoping the initiative
|
||||
| 'planning' // All phases pending (no work started)
|
||||
| 'detailing' // Detail/plan agent actively decomposing phases into tasks
|
||||
| 'refining' // Refine agent actively working on content
|
||||
| 'ready' // Phases approved, waiting to execute
|
||||
| 'executing' // At least one phase in_progress
|
||||
| 'pending_review' // At least one phase pending_review
|
||||
| 'blocked' // At least one phase blocked (none in_progress/pending_review)
|
||||
| 'complete' // All phases completed
|
||||
| 'archived'; // Initiative archived
|
||||
| 'idle' // Active but no phases and no agents
|
||||
| 'discussing' // Discuss agent actively scoping the initiative
|
||||
| 'planning' // All phases pending (no work started)
|
||||
| 'detailing' // Detail/plan agent actively decomposing phases into tasks
|
||||
| 'refining' // Refine agent actively working on content
|
||||
| 'resolving_conflict' // Conflict resolution agent actively fixing merge conflicts
|
||||
| 'ready' // Phases approved, waiting to execute
|
||||
| 'executing' // At least one phase in_progress
|
||||
| 'pending_review' // At least one phase pending_review
|
||||
| 'blocked' // At least one phase blocked (none in_progress/pending_review)
|
||||
| 'complete' // All phases completed
|
||||
| 'archived'; // Initiative archived
|
||||
|
||||
export interface InitiativeActivity {
|
||||
state: InitiativeActivityState;
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'node:path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './apps/web/src'),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
// Enable test globals (describe, it, expect without imports)
|
||||
globals: true,
|
||||
@@ -8,8 +16,11 @@ export default defineConfig({
|
||||
CW_LOG_LEVEL: 'silent',
|
||||
},
|
||||
// Test file pattern
|
||||
include: ['**/*.test.ts'],
|
||||
exclude: ['**/node_modules/**', '**/dist/**', 'apps/web/**', 'packages/**'],
|
||||
include: ['**/*.test.ts', '**/*.test.tsx'],
|
||||
exclude: ['**/node_modules/**', '**/dist/**', 'packages/**'],
|
||||
environmentMatchGlobs: [
|
||||
['apps/web/**', 'happy-dom'],
|
||||
],
|
||||
// TypeScript support uses tsconfig.json automatically
|
||||
// Coverage reporter (optional, for future use)
|
||||
coverage: {
|
||||
|
||||
Reference in New Issue
Block a user