From 28521e1c20680be220d0489b4c725e456a56f2d5 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 16:48:12 +0100 Subject: [PATCH] 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 --- README.md | 481 ++-- apps/server/agent/file-io.test.ts | 32 - .../agent/lifecycle/cleanup-strategy.ts | 1 + .../server/agent/lifecycle/controller.test.ts | 1 + apps/server/agent/lifecycle/controller.ts | 28 +- apps/server/agent/lifecycle/factory.ts | 8 +- apps/server/agent/manager.test.ts | 4 +- apps/server/agent/manager.ts | 47 +- apps/server/agent/mock-manager.ts | 2 + .../agent/prompts/conflict-resolution.ts | 10 +- apps/server/agent/prompts/errand.ts | 15 - apps/server/agent/prompts/plan.ts | 9 + apps/server/agent/prompts/refine.ts | 9 + apps/server/agent/prompts/workspace.ts | 2 + apps/server/agent/types.ts | 6 + apps/server/container.ts | 11 +- .../db/repositories/agent-repository.ts | 1 + .../db/repositories/drizzle/errand.test.ts | 371 +++- apps/server/db/repositories/drizzle/errand.ts | 60 +- .../db/repositories/errand-repository.ts | 44 +- apps/server/db/repositories/index.ts | 4 +- apps/server/db/schema.ts | 32 +- apps/server/dispatch/manager.test.ts | 4 + apps/server/dispatch/manager.ts | 18 +- .../drizzle/0034_add_task_retry_count.sql | 1 + apps/server/drizzle/0035_faulty_human_fly.sql | 13 + apps/server/drizzle/0036_icy_silvermane.sql | 1 + apps/server/drizzle/meta/0035_snapshot.json | 1974 +++++++++++++++++ apps/server/drizzle/meta/0036_snapshot.json | 1159 ++++++++++ apps/server/drizzle/meta/_journal.json | 20 +- apps/server/execution/orchestrator.test.ts | 62 +- apps/server/execution/orchestrator.ts | 61 +- apps/server/git/branch-manager.ts | 6 + apps/server/git/manager.test.ts | 52 + apps/server/git/manager.ts | 31 +- apps/server/git/remote-sync.test.ts | 172 ++ apps/server/git/simple-git-branch-manager.ts | 47 +- apps/server/git/types.ts | 2 + .../integration/crash-race-condition.test.ts | 4 +- apps/server/test/unit/headquarters.test.ts | 320 +++ apps/server/trpc/router.ts | 2 + apps/server/trpc/routers/agent.test.ts | 327 +++ apps/server/trpc/routers/agent.ts | 151 +- apps/server/trpc/routers/errand.test.ts | 30 +- apps/server/trpc/routers/errand.ts | 28 +- apps/server/trpc/routers/headquarters.ts | 214 ++ .../trpc/routers/initiative-activity.ts | 14 + apps/server/trpc/routers/initiative.ts | 24 +- apps/server/trpc/routers/project.test.ts | 92 + apps/server/trpc/routers/project.ts | 17 +- apps/server/trpc/subscriptions.ts | 2 + apps/web/src/components/AccountCard.tsx | 23 +- apps/web/src/components/AddAccountDialog.tsx | 289 +++ apps/web/src/components/AgentDetailsPanel.tsx | 230 ++ .../src/components/AgentOutputViewer.test.tsx | 407 ++++ apps/web/src/components/AgentOutputViewer.tsx | 137 +- apps/web/src/components/InitiativeCard.tsx | 31 +- .../src/components/RegisterProjectDialog.tsx | 19 +- apps/web/src/components/StatusDot.tsx | 4 + .../UpdateCredentialsDialog.test.tsx | 175 ++ .../components/UpdateCredentialsDialog.tsx | 239 ++ apps/web/src/components/editor/ContentTab.tsx | 8 +- .../src/components/editor/TiptapEditor.tsx | 56 +- .../components/execution/TaskSlideOver.tsx | 227 +- .../src/components/hq/HQBlockedSection.tsx | 51 + apps/web/src/components/hq/HQEmptyState.tsx | 15 + .../components/hq/HQNeedsApprovalSection.tsx | 50 + .../components/hq/HQNeedsReviewSection.tsx | 68 + .../web/src/components/hq/HQSections.test.tsx | 376 ++++ .../hq/HQWaitingForInputSection.tsx | 65 + apps/web/src/components/hq/types.ts | 8 + .../review/ConflictResolutionPanel.tsx | 36 +- .../src/components/review/ReviewHeader.tsx | 2 +- apps/web/src/components/ui/skeleton.tsx | 12 + apps/web/src/hooks/index.ts | 3 +- apps/web/src/hooks/useLiveUpdates.ts | 12 + apps/web/src/layouts/AppLayout.tsx | 1 + apps/web/src/lib/invalidation.ts | 13 +- apps/web/src/lib/parse-agent-output.test.ts | 264 +++ apps/web/src/lib/parse-agent-output.ts | 225 +- apps/web/src/lib/trpc.ts | 2 + apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/agents.tsx | 58 +- apps/web/src/routes/hq.test.tsx | 156 ++ apps/web/src/routes/hq.tsx | 117 + apps/web/src/routes/index.tsx | 2 +- apps/web/src/routes/initiatives/$id.tsx | 12 +- apps/web/src/routes/initiatives/index.tsx | 7 +- apps/web/src/routes/settings/health.tsx | 151 +- apps/web/src/routes/settings/projects.tsx | 36 +- apps/web/tsconfig.app.tsbuildinfo | 2 +- docs/agent.md | 15 +- docs/database.md | 26 +- docs/dispatch-events.md | 16 +- docs/frontend.md | 6 +- docs/server-api.md | 37 +- package-lock.json | 250 +++ package.json | 3 + packages/shared/src/types.ts | 23 +- vitest.config.ts | 15 +- 100 files changed, 9054 insertions(+), 973 deletions(-) create mode 100644 apps/server/drizzle/0034_add_task_retry_count.sql create mode 100644 apps/server/drizzle/0035_faulty_human_fly.sql create mode 100644 apps/server/drizzle/0036_icy_silvermane.sql create mode 100644 apps/server/drizzle/meta/0035_snapshot.json create mode 100644 apps/server/drizzle/meta/0036_snapshot.json create mode 100644 apps/server/git/remote-sync.test.ts create mode 100644 apps/server/test/unit/headquarters.test.ts create mode 100644 apps/server/trpc/routers/agent.test.ts create mode 100644 apps/server/trpc/routers/headquarters.ts create mode 100644 apps/server/trpc/routers/project.test.ts create mode 100644 apps/web/src/components/AddAccountDialog.tsx create mode 100644 apps/web/src/components/AgentDetailsPanel.tsx create mode 100644 apps/web/src/components/AgentOutputViewer.test.tsx create mode 100644 apps/web/src/components/UpdateCredentialsDialog.test.tsx create mode 100644 apps/web/src/components/UpdateCredentialsDialog.tsx create mode 100644 apps/web/src/components/hq/HQBlockedSection.tsx create mode 100644 apps/web/src/components/hq/HQEmptyState.tsx create mode 100644 apps/web/src/components/hq/HQNeedsApprovalSection.tsx create mode 100644 apps/web/src/components/hq/HQNeedsReviewSection.tsx create mode 100644 apps/web/src/components/hq/HQSections.test.tsx create mode 100644 apps/web/src/components/hq/HQWaitingForInputSection.tsx create mode 100644 apps/web/src/components/hq/types.ts create mode 100644 apps/web/src/components/ui/skeleton.tsx create mode 100644 apps/web/src/lib/parse-agent-output.test.ts create mode 100644 apps/web/src/routes/hq.test.tsx create mode 100644 apps/web/src/routes/hq.tsx diff --git a/README.md b/README.md index 8bbe58d..d9a7cf1 100644 --- a/README.md +++ b/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 && 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 --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 +``` -**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 --task [--provider ] +cw agent stop|delete|list|get|resume|result + +cw initiative create|list|get|phases +cw architect discuss|plan|detail|refine + +cw phase add-dependency --phase --depends-on +cw phase queue|dispatch|queue-status|dependencies + +cw task list|get|status +cw dispatch queue|next|status|complete + +cw project register --name --url +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 +cw ask --from --agent-id +cw answer --conversation-id +``` + +## Workflow Overview + +``` +1. Create initiative cw initiative create "Feature X" +2. Plan with architect cw architect discuss --> 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 -- # Run specific test file + +# Record cassettes (one-time API cost) +CW_CASSETTE_RECORD=1 npm test -- + +# 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 diff --git a/apps/server/agent/file-io.test.ts b/apps/server/agent/file-io.test.ts index 0e747fa..ae0fb9a 100644 --- a/apps/server/agent/file-io.test.ts +++ b/apps/server/agent/file-io.test.ts @@ -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); - }); }); diff --git a/apps/server/agent/lifecycle/cleanup-strategy.ts b/apps/server/agent/lifecycle/cleanup-strategy.ts index 4391124..a2ca671 100644 --- a/apps/server/agent/lifecycle/cleanup-strategy.ts +++ b/apps/server/agent/lifecycle/cleanup-strategy.ts @@ -18,6 +18,7 @@ export interface AgentInfo { status: string; initiativeId?: string | null; worktreeId: string; + exitCode?: number | null; } export interface CleanupStrategy { diff --git a/apps/server/agent/lifecycle/controller.test.ts b/apps/server/agent/lifecycle/controller.test.ts index 751305c..1ce41b9 100644 --- a/apps/server/agent/lifecycle/controller.test.ts +++ b/apps/server/agent/lifecycle/controller.test.ts @@ -50,6 +50,7 @@ function makeController(overrides: { cleanupStrategy, overrides.accountRepository as AccountRepository | undefined, false, + overrides.eventBus, ); } diff --git a/apps/server/agent/lifecycle/controller.ts b/apps/server/agent/lifecycle/controller.ts index 833634d..6b6810f 100644 --- a/apps/server/agent/lifecycle/controller.ts +++ b/apps/server/agent/lifecycle/controller.ts @@ -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 { 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, }; } } \ No newline at end of file diff --git a/apps/server/agent/lifecycle/factory.ts b/apps/server/agent/lifecycle/factory.ts index 4bff87b..51c502a 100644 --- a/apps/server/agent/lifecycle/factory.ts +++ b/apps/server/agent/lifecycle/factory.ts @@ -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; diff --git a/apps/server/agent/manager.test.ts b/apps/server/agent/manager.test.ts index ef0d60a..d9a751d 100644 --- a/apps/server/agent/manager.test.ts +++ b/apps/server/agent/manager.test.ts @@ -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(); diff --git a/apps/server/agent/manager.ts b/apps/server/agent/manager.ts index e9c1e16..5c4fc11 100644 --- a/apps/server/agent/manager.ts +++ b/apps/server/agent/manager.ts @@ -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, }; } } diff --git a/apps/server/agent/mock-manager.ts b/apps/server/agent/mock-manager.ts index 7ca2361..529b769 100644 --- a/apps/server/agent/mock-manager.ts +++ b/apps/server/agent/mock-manager.ts @@ -163,6 +163,8 @@ export class MockAgentManager implements AgentManager { accountId: null, createdAt: now, updatedAt: now, + exitCode: null, + prompt: null, }; const record: MockAgentRecord = { diff --git a/apps/server/agent/prompts/conflict-resolution.ts b/apps/server/agent/prompts/conflict-resolution.ts index bb33ab7..e295b29 100644 --- a/apps/server/agent/prompts/conflict-resolution.ts +++ b/apps/server/agent/prompts/conflict-resolution.ts @@ -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} ${SIGNAL_FORMAT} -${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. + Follow these steps in order: @@ -57,7 +60,6 @@ Follow these steps in order: 8. **Signal done**: Write signal.json with status "done". ${GIT_WORKFLOW} -${CONTEXT_MANAGEMENT} - 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. diff --git a/apps/server/agent/prompts/errand.ts b/apps/server/agent/prompts/errand.ts index 3c2ac91..e94b950 100644 --- a/apps/server/agent/prompts/errand.ts +++ b/apps/server/agent/prompts/errand.ts @@ -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 "" - -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": "" } } diff --git a/apps/server/agent/prompts/plan.ts b/apps/server/agent/prompts/plan.ts index f11d9b5..acb1604 100644 --- a/apps/server/agent/prompts/plan.ts +++ b/apps/server/agent/prompts/plan.ts @@ -81,6 +81,15 @@ Each phase must pass: **"Could a detail agent break this into tasks without clar + +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. + + - Account for existing phases/tasks — don't plan work already covered - Always generate new phase IDs — never reuse existing ones diff --git a/apps/server/agent/prompts/refine.ts b/apps/server/agent/prompts/refine.ts index 843a66c..8d831bb 100644 --- a/apps/server/agent/prompts/refine.ts +++ b/apps/server/agent/prompts/refine.ts @@ -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. + +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. + + - Ask 2-4 questions if you need clarification - Preserve [[page:\$id|title]] cross-references diff --git a/apps/server/agent/prompts/workspace.ts b/apps/server/agent/prompts/workspace.ts index 846850a..f01c6d3 100644 --- a/apps/server/agent/prompts/workspace.ts +++ b/apps/server/agent/prompts/workspace.ts @@ -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. `; } diff --git a/apps/server/agent/types.ts b/apps/server/agent/types.ts index e46bcea..0e26557 100644 --- a/apps/server/agent/types.ts +++ b/apps/server/agent/types.ts @@ -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; } /** diff --git a/apps/server/container.ts b/apps/server/container.ts index fbb2a99..daa6151 100644 --- a/apps/server/container.ts +++ b/apps/server/container.ts @@ -191,10 +191,6 @@ export async function createContainer(options?: ContainerOptions): Promise { let db: DrizzleDatabase; let repo: DrizzleErrandRepository; - let projectRepo: DrizzleProjectRepository; - - const createProject = async (): Promise => { - 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(); }); }); }); diff --git a/apps/server/db/repositories/drizzle/errand.ts b/apps/server/db/repositories/drizzle/errand.ts index a5999e2..0774e4b 100644 --- a/apps/server/db/repositories/drizzle/errand.ts +++ b/apps/server/db/repositories/drizzle/errand.ts @@ -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 { 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 { - const rows = await this.db + async findById(id: string): Promise { + 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 { + async findAll(opts?: { projectId?: string; status?: ErrandStatus }): Promise { 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 { - await this.db + async update(id: string, data: UpdateErrandData): Promise { + 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 { diff --git a/apps/server/db/repositories/errand-repository.ts b/apps/server/db/repositories/errand-repository.ts index ca831eb..9502e34 100644 --- a/apps/server/db/repositories/errand-repository.ts +++ b/apps/server/db/repositories/errand-repository.ts @@ -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; - -/** - * Data for updating an errand. - */ +export type CreateErrandData = Omit; export type UpdateErrandData = Partial>; -/** - * 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; - findById(id: string): Promise; - findAll(options?: FindAllErrandOptions): Promise; - update(id: string, data: UpdateErrandData): Promise; + findById(id: string): Promise; + findAll(opts?: { projectId?: string; status?: ErrandStatus }): Promise; + update(id: string, data: UpdateErrandData): Promise; delete(id: string): Promise; } diff --git a/apps/server/db/repositories/index.ts b/apps/server/db/repositories/index.ts index 0c42fd7..c1407df 100644 --- a/apps/server/db/repositories/index.ts +++ b/apps/server/db/repositories/index.ts @@ -85,8 +85,8 @@ export type { export type { ErrandRepository, + ErrandWithAlias, + ErrandStatus, CreateErrandData, UpdateErrandData, - ErrandWithAlias, - FindAllErrandOptions, } from './errand-repository.js'; diff --git a/apps/server/db/schema.ts b/apps/server/db/schema.ts index cba5967..ce35cec 100644 --- a/apps/server/db/schema.ts +++ b/apps/server/db/schema.ts @@ -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; // 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; export type NewErrand = InferInsertModel; diff --git a/apps/server/dispatch/manager.test.ts b/apps/server/dispatch/manager.test.ts index 477c2ce..e0032e9 100644 --- a/apps/server/dispatch/manager.test.ts +++ b/apps/server/dispatch/manager.test.ts @@ -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, }; } diff --git a/apps/server/dispatch/manager.ts b/apps/server/dispatch/manager.ts index 026be74..554dff3 100644 --- a/apps/server/dispatch/manager.ts +++ b/apps/server/dispatch/manager.ts @@ -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'); } } } diff --git a/apps/server/drizzle/0034_add_task_retry_count.sql b/apps/server/drizzle/0034_add_task_retry_count.sql new file mode 100644 index 0000000..2d483a6 --- /dev/null +++ b/apps/server/drizzle/0034_add_task_retry_count.sql @@ -0,0 +1 @@ +ALTER TABLE tasks ADD COLUMN retry_count integer NOT NULL DEFAULT 0; diff --git a/apps/server/drizzle/0035_faulty_human_fly.sql b/apps/server/drizzle/0035_faulty_human_fly.sql new file mode 100644 index 0000000..5afe9b5 --- /dev/null +++ b/apps/server/drizzle/0035_faulty_human_fly.sql @@ -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 +); \ No newline at end of file diff --git a/apps/server/drizzle/0036_icy_silvermane.sql b/apps/server/drizzle/0036_icy_silvermane.sql new file mode 100644 index 0000000..43dbeed --- /dev/null +++ b/apps/server/drizzle/0036_icy_silvermane.sql @@ -0,0 +1 @@ +ALTER TABLE `agents` ADD `prompt` text; diff --git a/apps/server/drizzle/meta/0035_snapshot.json b/apps/server/drizzle/meta/0035_snapshot.json new file mode 100644 index 0000000..d735a97 --- /dev/null +++ b/apps/server/drizzle/meta/0035_snapshot.json @@ -0,0 +1,1974 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "c84e499f-7df8-4091-b2a5-6b12847898bd", + "prevId": "5fbe1151-1dfb-4b0c-a7fa-2177369543fd", + "tables": { + "accounts": { + "name": "accounts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'claude'" + }, + "config_json": { + "name": "config_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_exhausted": { + "name": "is_exhausted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "exhausted_until": { + "name": "exhausted_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agent_log_chunks": { + "name": "agent_log_chunks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_number": { + "name": "session_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "agent_log_chunks_agent_id_idx": { + "name": "agent_log_chunks_agent_id_idx", + "columns": [ + "agent_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agents": { + "name": "agents", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_id": { + "name": "worktree_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'claude'" + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'idle'" + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'execute'" + }, + "pid": { + "name": "pid", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_file_path": { + "name": "output_file_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pending_questions": { + "name": "pending_questions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_dismissed_at": { + "name": "user_dismissed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "agents_name_unique": { + "name": "agents_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "agents_task_id_tasks_id_fk": { + "name": "agents_task_id_tasks_id_fk", + "tableFrom": "agents", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "agents_initiative_id_initiatives_id_fk": { + "name": "agents_initiative_id_initiatives_id_fk", + "tableFrom": "agents", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "agents_account_id_accounts_id_fk": { + "name": "agents_account_id_accounts_id_fk", + "tableFrom": "agents", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "change_set_entries": { + "name": "change_set_entries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "change_set_id": { + "name": "change_set_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "previous_state": { + "name": "previous_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "new_state": { + "name": "new_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "change_set_entries_change_set_id_idx": { + "name": "change_set_entries_change_set_id_idx", + "columns": [ + "change_set_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "change_set_entries_change_set_id_change_sets_id_fk": { + "name": "change_set_entries_change_set_id_change_sets_id_fk", + "tableFrom": "change_set_entries", + "tableTo": "change_sets", + "columnsFrom": [ + "change_set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "change_sets": { + "name": "change_sets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'applied'" + }, + "reverted_at": { + "name": "reverted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "change_sets_initiative_id_idx": { + "name": "change_sets_initiative_id_idx", + "columns": [ + "initiative_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "change_sets_agent_id_agents_id_fk": { + "name": "change_sets_agent_id_agents_id_fk", + "tableFrom": "change_sets", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "change_sets_initiative_id_initiatives_id_fk": { + "name": "change_sets_initiative_id_initiatives_id_fk", + "tableFrom": "change_sets", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat_messages": { + "name": "chat_messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "chat_session_id": { + "name": "chat_session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "change_set_id": { + "name": "change_set_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "chat_messages_session_id_idx": { + "name": "chat_messages_session_id_idx", + "columns": [ + "chat_session_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chat_messages_chat_session_id_chat_sessions_id_fk": { + "name": "chat_messages_chat_session_id_chat_sessions_id_fk", + "tableFrom": "chat_messages", + "tableTo": "chat_sessions", + "columnsFrom": [ + "chat_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_messages_change_set_id_change_sets_id_fk": { + "name": "chat_messages_change_set_id_change_sets_id_fk", + "tableFrom": "chat_messages", + "tableTo": "change_sets", + "columnsFrom": [ + "change_set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat_sessions": { + "name": "chat_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "chat_sessions_target_idx": { + "name": "chat_sessions_target_idx", + "columns": [ + "target_type", + "target_id" + ], + "isUnique": false + }, + "chat_sessions_initiative_id_idx": { + "name": "chat_sessions_initiative_id_idx", + "columns": [ + "initiative_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chat_sessions_initiative_id_initiatives_id_fk": { + "name": "chat_sessions_initiative_id_initiatives_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_sessions_agent_id_agents_id_fk": { + "name": "chat_sessions_agent_id_agents_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "conversations": { + "name": "conversations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "from_agent_id": { + "name": "from_agent_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_agent_id": { + "name": "to_agent_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phase_id": { + "name": "phase_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "question": { + "name": "question", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "answer": { + "name": "answer", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "conversations_to_agent_status_idx": { + "name": "conversations_to_agent_status_idx", + "columns": [ + "to_agent_id", + "status" + ], + "isUnique": false + }, + "conversations_from_agent_idx": { + "name": "conversations_from_agent_idx", + "columns": [ + "from_agent_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "conversations_from_agent_id_agents_id_fk": { + "name": "conversations_from_agent_id_agents_id_fk", + "tableFrom": "conversations", + "tableTo": "agents", + "columnsFrom": [ + "from_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversations_to_agent_id_agents_id_fk": { + "name": "conversations_to_agent_id_agents_id_fk", + "tableFrom": "conversations", + "tableTo": "agents", + "columnsFrom": [ + "to_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversations_initiative_id_initiatives_id_fk": { + "name": "conversations_initiative_id_initiatives_id_fk", + "tableFrom": "conversations", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "conversations_phase_id_phases_id_fk": { + "name": "conversations_phase_id_phases_id_fk", + "tableFrom": "conversations", + "tableTo": "phases", + "columnsFrom": [ + "phase_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "conversations_task_id_tasks_id_fk": { + "name": "conversations_task_id_tasks_id_fk", + "tableFrom": "conversations", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "errands": { + "name": "errands", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'main'" + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "errands_agent_id_agents_id_fk": { + "name": "errands_agent_id_agents_id_fk", + "tableFrom": "errands", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "errands_project_id_projects_id_fk": { + "name": "errands_project_id_projects_id_fk", + "tableFrom": "errands", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "initiative_projects": { + "name": "initiative_projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "initiative_project_unique": { + "name": "initiative_project_unique", + "columns": [ + "initiative_id", + "project_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "initiative_projects_initiative_id_initiatives_id_fk": { + "name": "initiative_projects_initiative_id_initiatives_id_fk", + "tableFrom": "initiative_projects", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "initiative_projects_project_id_projects_id_fk": { + "name": "initiative_projects_project_id_projects_id_fk", + "tableFrom": "initiative_projects", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "initiatives": { + "name": "initiatives", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "execution_mode": { + "name": "execution_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'review_per_phase'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "messages": { + "name": "messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "sender_type": { + "name": "sender_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender_id": { + "name": "sender_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipient_type": { + "name": "recipient_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_id": { + "name": "recipient_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'info'" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requires_response": { + "name": "requires_response", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "messages_sender_id_agents_id_fk": { + "name": "messages_sender_id_agents_id_fk", + "tableFrom": "messages", + "tableTo": "agents", + "columnsFrom": [ + "sender_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "messages_recipient_id_agents_id_fk": { + "name": "messages_recipient_id_agents_id_fk", + "tableFrom": "messages", + "tableTo": "agents", + "columnsFrom": [ + "recipient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "messages_parent_message_id_messages_id_fk": { + "name": "messages_parent_message_id_messages_id_fk", + "tableFrom": "messages", + "tableTo": "messages", + "columnsFrom": [ + "parent_message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pages": { + "name": "pages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_page_id": { + "name": "parent_page_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "pages_initiative_id_initiatives_id_fk": { + "name": "pages_initiative_id_initiatives_id_fk", + "tableFrom": "pages", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pages_parent_page_id_pages_id_fk": { + "name": "pages_parent_page_id_pages_id_fk", + "tableFrom": "pages", + "tableTo": "pages", + "columnsFrom": [ + "parent_page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "phase_dependencies": { + "name": "phase_dependencies", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "phase_id": { + "name": "phase_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "depends_on_phase_id": { + "name": "depends_on_phase_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "phase_dependencies_phase_id_phases_id_fk": { + "name": "phase_dependencies_phase_id_phases_id_fk", + "tableFrom": "phase_dependencies", + "tableTo": "phases", + "columnsFrom": [ + "phase_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "phase_dependencies_depends_on_phase_id_phases_id_fk": { + "name": "phase_dependencies_depends_on_phase_id_phases_id_fk", + "tableFrom": "phase_dependencies", + "tableTo": "phases", + "columnsFrom": [ + "depends_on_phase_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "phases": { + "name": "phases", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "merge_base": { + "name": "merge_base", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "phases_initiative_id_initiatives_id_fk": { + "name": "phases_initiative_id_initiatives_id_fk", + "tableFrom": "phases", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'main'" + }, + "last_fetched_at": { + "name": "last_fetched_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "projects_name_unique": { + "name": "projects_name_unique", + "columns": [ + "name" + ], + "isUnique": true + }, + "projects_url_unique": { + "name": "projects_url_unique", + "columns": [ + "url" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "review_comments": { + "name": "review_comments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "phase_id": { + "name": "phase_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "line_number": { + "name": "line_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "line_type": { + "name": "line_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'you'" + }, + "parent_comment_id": { + "name": "parent_comment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved": { + "name": "resolved", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "review_comments_phase_id_idx": { + "name": "review_comments_phase_id_idx", + "columns": [ + "phase_id" + ], + "isUnique": false + }, + "review_comments_parent_id_idx": { + "name": "review_comments_parent_id_idx", + "columns": [ + "parent_comment_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "review_comments_phase_id_phases_id_fk": { + "name": "review_comments_phase_id_phases_id_fk", + "tableFrom": "review_comments", + "tableTo": "phases", + "columnsFrom": [ + "phase_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_comments_parent_comment_id_review_comments_id_fk": { + "name": "review_comments_parent_comment_id_review_comments_id_fk", + "tableFrom": "review_comments", + "tableTo": "review_comments", + "columnsFrom": [ + "parent_comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "task_dependencies": { + "name": "task_dependencies", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "depends_on_task_id": { + "name": "depends_on_task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "task_dependencies_task_id_tasks_id_fk": { + "name": "task_dependencies_task_id_tasks_id_fk", + "tableFrom": "task_dependencies", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_dependencies_depends_on_task_id_tasks_id_fk": { + "name": "task_dependencies_depends_on_task_id_tasks_id_fk", + "tableFrom": "task_dependencies", + "tableTo": "tasks", + "columnsFrom": [ + "depends_on_task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "phase_id": { + "name": "phase_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_task_id": { + "name": "parent_task_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'auto'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'execute'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'medium'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "tasks_phase_id_phases_id_fk": { + "name": "tasks_phase_id_phases_id_fk", + "tableFrom": "tasks", + "tableTo": "phases", + "columnsFrom": [ + "phase_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_initiative_id_initiatives_id_fk": { + "name": "tasks_initiative_id_initiatives_id_fk", + "tableFrom": "tasks", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_parent_task_id_tasks_id_fk": { + "name": "tasks_parent_task_id_tasks_id_fk", + "tableFrom": "tasks", + "tableTo": "tasks", + "columnsFrom": [ + "parent_task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/meta/0036_snapshot.json b/apps/server/drizzle/meta/0036_snapshot.json new file mode 100644 index 0000000..f60484b --- /dev/null +++ b/apps/server/drizzle/meta/0036_snapshot.json @@ -0,0 +1,1159 @@ +{ + "id": "f85b9df3-dead-4c46-90ac-cf36bcaa6eb4", + "prevId": "c0b6d7d3-c9da-440a-9fb8-9dd88df5672a", + "version": "6", + "dialect": "sqlite", + "tables": { + "accounts": { + "name": "accounts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'claude'" + }, + "config_dir": { + "name": "config_dir", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_exhausted": { + "name": "is_exhausted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "exhausted_until": { + "name": "exhausted_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agents": { + "name": "agents", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_id": { + "name": "worktree_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'claude'" + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'idle'" + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'execute'" + }, + "pid": { + "name": "pid", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_file_path": { + "name": "output_file_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pending_questions": { + "name": "pending_questions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "agents_name_unique": { + "name": "agents_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "agents_task_id_tasks_id_fk": { + "name": "agents_task_id_tasks_id_fk", + "tableFrom": "agents", + "columnsFrom": [ + "task_id" + ], + "tableTo": "tasks", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "agents_initiative_id_initiatives_id_fk": { + "name": "agents_initiative_id_initiatives_id_fk", + "tableFrom": "agents", + "columnsFrom": [ + "initiative_id" + ], + "tableTo": "initiatives", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "agents_account_id_accounts_id_fk": { + "name": "agents_account_id_accounts_id_fk", + "tableFrom": "agents", + "columnsFrom": [ + "account_id" + ], + "tableTo": "accounts", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "initiative_projects": { + "name": "initiative_projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "initiative_project_unique": { + "name": "initiative_project_unique", + "columns": [ + "initiative_id", + "project_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "initiative_projects_initiative_id_initiatives_id_fk": { + "name": "initiative_projects_initiative_id_initiatives_id_fk", + "tableFrom": "initiative_projects", + "columnsFrom": [ + "initiative_id" + ], + "tableTo": "initiatives", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "initiative_projects_project_id_projects_id_fk": { + "name": "initiative_projects_project_id_projects_id_fk", + "tableFrom": "initiative_projects", + "columnsFrom": [ + "project_id" + ], + "tableTo": "projects", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "initiatives": { + "name": "initiatives", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "merge_requires_approval": { + "name": "merge_requires_approval", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "merge_target": { + "name": "merge_target", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "messages": { + "name": "messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "sender_type": { + "name": "sender_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender_id": { + "name": "sender_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipient_type": { + "name": "recipient_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_id": { + "name": "recipient_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'info'" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requires_response": { + "name": "requires_response", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "messages_sender_id_agents_id_fk": { + "name": "messages_sender_id_agents_id_fk", + "tableFrom": "messages", + "columnsFrom": [ + "sender_id" + ], + "tableTo": "agents", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "messages_recipient_id_agents_id_fk": { + "name": "messages_recipient_id_agents_id_fk", + "tableFrom": "messages", + "columnsFrom": [ + "recipient_id" + ], + "tableTo": "agents", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "messages_parent_message_id_messages_id_fk": { + "name": "messages_parent_message_id_messages_id_fk", + "tableFrom": "messages", + "columnsFrom": [ + "parent_message_id" + ], + "tableTo": "messages", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pages": { + "name": "pages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_page_id": { + "name": "parent_page_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "pages_initiative_id_initiatives_id_fk": { + "name": "pages_initiative_id_initiatives_id_fk", + "tableFrom": "pages", + "columnsFrom": [ + "initiative_id" + ], + "tableTo": "initiatives", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "pages_parent_page_id_pages_id_fk": { + "name": "pages_parent_page_id_pages_id_fk", + "tableFrom": "pages", + "columnsFrom": [ + "parent_page_id" + ], + "tableTo": "pages", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "phase_dependencies": { + "name": "phase_dependencies", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "phase_id": { + "name": "phase_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "depends_on_phase_id": { + "name": "depends_on_phase_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "phase_dependencies_phase_id_phases_id_fk": { + "name": "phase_dependencies_phase_id_phases_id_fk", + "tableFrom": "phase_dependencies", + "columnsFrom": [ + "phase_id" + ], + "tableTo": "phases", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "phase_dependencies_depends_on_phase_id_phases_id_fk": { + "name": "phase_dependencies_depends_on_phase_id_phases_id_fk", + "tableFrom": "phase_dependencies", + "columnsFrom": [ + "depends_on_phase_id" + ], + "tableTo": "phases", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "phases": { + "name": "phases", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "phases_initiative_id_initiatives_id_fk": { + "name": "phases_initiative_id_initiatives_id_fk", + "tableFrom": "phases", + "columnsFrom": [ + "initiative_id" + ], + "tableTo": "initiatives", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plans": { + "name": "plans", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "phase_id": { + "name": "phase_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "plans_phase_id_phases_id_fk": { + "name": "plans_phase_id_phases_id_fk", + "tableFrom": "plans", + "columnsFrom": [ + "phase_id" + ], + "tableTo": "phases", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "projects_name_unique": { + "name": "projects_name_unique", + "columns": [ + "name" + ], + "isUnique": true + }, + "projects_url_unique": { + "name": "projects_url_unique", + "columns": [ + "url" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "task_dependencies": { + "name": "task_dependencies", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "depends_on_task_id": { + "name": "depends_on_task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "task_dependencies_task_id_tasks_id_fk": { + "name": "task_dependencies_task_id_tasks_id_fk", + "tableFrom": "task_dependencies", + "columnsFrom": [ + "task_id" + ], + "tableTo": "tasks", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "task_dependencies_depends_on_task_id_tasks_id_fk": { + "name": "task_dependencies_depends_on_task_id_tasks_id_fk", + "tableFrom": "task_dependencies", + "columnsFrom": [ + "depends_on_task_id" + ], + "tableTo": "tasks", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "plan_id": { + "name": "plan_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phase_id": { + "name": "phase_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'auto'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'execute'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'medium'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "requires_approval": { + "name": "requires_approval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "tasks_plan_id_plans_id_fk": { + "name": "tasks_plan_id_plans_id_fk", + "tableFrom": "tasks", + "columnsFrom": [ + "plan_id" + ], + "tableTo": "plans", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "tasks_phase_id_phases_id_fk": { + "name": "tasks_phase_id_phases_id_fk", + "tableFrom": "tasks", + "columnsFrom": [ + "phase_id" + ], + "tableTo": "phases", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "tasks_initiative_id_initiatives_id_fk": { + "name": "tasks_initiative_id_initiatives_id_fk", + "tableFrom": "tasks", + "columnsFrom": [ + "initiative_id" + ], + "tableTo": "initiatives", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index 91e8afc..a58f2cf 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -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 } ] -} \ No newline at end of file +} diff --git a/apps/server/execution/orchestrator.test.ts b/apps/server/execution/orchestrator.test.ts index fb52e13..6cf293d 100644 --- a/apps/server/execution/orchestrator.test.ts +++ b/apps/server/execution/orchestrator.test.ts @@ -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; 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) { + 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(); + }); + }); }); diff --git a/apps/server/execution/orchestrator.ts b/apps/server/execution/orchestrator.ts index e29e5a4..5b8c521 100644 --- a/apps/server/execution/orchestrator.ts +++ b/apps/server/execution/orchestrator.ts @@ -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> = 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('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 { + 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 { 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); diff --git a/apps/server/git/branch-manager.ts b/apps/server/git/branch-manager.ts index ceb399c..9ba6d85 100644 --- a/apps/server/git/branch-manager.ts +++ b/apps/server/git/branch-manager.ts @@ -88,4 +88,10 @@ export interface BranchManager { * (i.e. the branches have diverged). */ fastForwardBranch(repoPath: string, branch: string, remote?: string): Promise; + + /** + * 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; } diff --git a/apps/server/git/manager.test.ts b/apps/server/git/manager.test.ts index 41006dc..017daeb 100644 --- a/apps/server/git/manager.test.ts +++ b/apps/server/git/manager.test.ts @@ -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 // ========================================================================== diff --git a/apps/server/git/manager.ts b/apps/server/git/manager.ts index f7d3c1b..1bd9b46 100644 --- a/apps/server/git/manager.ts +++ b/apps/server/git/manager.ts @@ -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 @@ -140,8 +159,14 @@ export class SimpleGitWorktreeManager implements WorktreeManager { * Finds worktree by matching path ending with id. */ async get(id: string): Promise { + 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; } /** diff --git a/apps/server/git/remote-sync.test.ts b/apps/server/git/remote-sync.test.ts new file mode 100644 index 0000000..8f08044 --- /dev/null +++ b/apps/server/git/remote-sync.test.ts @@ -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 { + 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[] = [ + { success: true }, + { success: false }, + { success: true }, + { success: false }, + ] + const failed = results.filter(r => !r.success) + expect(failed.length).toBe(2) + }) + }) +}) diff --git a/apps/server/git/simple-git-branch-manager.ts b/apps/server/git/simple-git-branch-manager.ts index e686a6f..47b690e 100644 --- a/apps/server/git/simple-git-branch-manager.ts +++ b/apps/server/git/simple-git-branch-manager.ts @@ -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 { 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 { 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 { + 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'); + } } diff --git a/apps/server/git/types.ts b/apps/server/git/types.ts index 8471b75..51a35b7 100644 --- a/apps/server/git/types.ts +++ b/apps/server/git/types.ts @@ -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; } // ============================================================================= diff --git a/apps/server/test/integration/crash-race-condition.test.ts b/apps/server/test/integration/crash-race-condition.test.ts index f7ce25f..4af02a1 100644 --- a/apps/server/test/integration/crash-race-condition.test.ts +++ b/apps/server/test/integration/crash-race-condition.test.ts @@ -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 diff --git a/apps/server/test/unit/headquarters.test.ts b/apps/server/test/unit/headquarters.test.ts new file mode 100644 index 0000000..8f079a2 --- /dev/null +++ b/apps/server/test/unit/headquarters.test.ts @@ -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 = new Map(); + + addAgent(info: Partial & Pick): 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 { + return [...this.agents]; + } + + async getPendingQuestions(agentId: string): Promise { + return this.questions.get(agentId) ?? null; + } + + async spawn(): Promise { throw new Error('Not implemented'); } + async stop(): Promise { throw new Error('Not implemented'); } + async get(): Promise { return null; } + async getByName(): Promise { return null; } + async resume(): Promise { throw new Error('Not implemented'); } + async getResult() { return null; } + async delete(): Promise { throw new Error('Not implemented'); } + async dismiss(): Promise { throw new Error('Not implemented'); } + async resumeForConversation(): Promise { return false; } +} + +// ============================================================================= +// Test router +// ============================================================================= + +const testRouter = router({ + ...headquartersProcedures(publicProcedure), +}); + +const createCaller = createCallerFactory(testRouter); + +// ============================================================================= +// Helpers +// ============================================================================= + +function makeCtx(agentManager: MockAgentManager, overrides?: Partial): 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'); + }); +}); diff --git a/apps/server/trpc/router.ts b/apps/server/trpc/router.ts index 085a808..0339bfd 100644 --- a/apps/server/trpc/router.ts +++ b/apps/server/trpc/router.ts @@ -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; diff --git a/apps/server/trpc/routers/agent.test.ts b/apps/server/trpc/routers/agent.test.ts new file mode 100644 index 0000000..21bcc6d --- /dev/null +++ b/apps/server/trpc/routers/agent.test.ts @@ -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 { + 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 = {}) { + 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 }); + }); +}); diff --git a/apps/server/trpc/routers/agent.ts b/apps/server/trpc/routers/agent.ts index a0c3660..bdf6395 100644 --- a/apps/server/trpc/routers/agent.ts +++ b/apps/server/trpc/routers/agent.ts @@ -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 => { + 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 => { @@ -207,12 +236,15 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) { getAgentOutput: publicProcedure .input(agentIdentifierSchema) - .query(async ({ ctx, input }): Promise => { + .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) }; + }), }; } diff --git a/apps/server/trpc/routers/errand.test.ts b/apps/server/trpc/routers/errand.test.ts index a389e92..127a041 100644 --- a/apps/server/trpc/routers/errand.test.ts +++ b/apps/server/trpc/routers/errand.test.ts @@ -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, }); diff --git a/apps/server/trpc/routers/errand.ts b/apps/server/trpc/routers/errand.ts index a2f5502..57fb738 100644 --- a/apps/server/trpc/routers/errand.ts +++ b/apps/server/trpc/routers/errand.ts @@ -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)`, diff --git a/apps/server/trpc/routers/headquarters.ts b/apps/server/trpc/routers/headquarters.ts new file mode 100644 index 0000000..eed001b --- /dev/null +++ b/apps/server/trpc/routers/headquarters.ts @@ -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(); + 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, + }; + }), + }; +} diff --git a/apps/server/trpc/routers/initiative-activity.ts b/apps/server/trpc/routers/initiative-activity.ts index fc16b35..8bdbea8 100644 --- a/apps/server/trpc/routers/initiative-activity.ts +++ b/apps/server/trpc/routers/initiative-activity.ts @@ -9,6 +9,7 @@ export interface ActiveArchitectAgent { initiativeId: string; mode: string; status: string; + name?: string; } const MODE_TO_STATE: Record = { @@ -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) { diff --git a/apps/server/trpc/routers/initiative.ts b/apps/server/trpc/routers/initiative.ts index 6b48b77..0077ad9 100644 --- a/apps/server/trpc/routers/initiative.ts +++ b/apps/server/trpc/routers/initiative.ts @@ -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>(); + 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, }); }), }; diff --git a/apps/server/trpc/routers/project.test.ts b/apps/server/trpc/routers/project.test.ts new file mode 100644 index 0000000..4e0beb7 --- /dev/null +++ b/apps/server/trpc/routers/project.test.ts @@ -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): 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'); + }); +}); diff --git a/apps/server/trpc/routers/project.ts b/apps/server/trpc/routers/project.ts index 5d79400..cb0188e 100644 --- a/apps/server/trpc/routers/project.ts +++ b/apps/server/trpc/routers/project.ts @@ -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; diff --git a/apps/server/trpc/subscriptions.ts b/apps/server/trpc/subscriptions.ts index 027e055..b4102bd 100644 --- a/apps/server/trpc/subscriptions.ts +++ b/apps/server/trpc/subscriptions.ts @@ -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', ]; /** diff --git a/apps/web/src/components/AccountCard.tsx b/apps/web/src/components/AccountCard.tsx index 5ec5bf0..2912dd3 100644 --- a/apps/web/src/components/AccountCard.tsx +++ b/apps/web/src/components/AccountCard.tsx @@ -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 ( + <> {/* Header row */} @@ -147,6 +151,17 @@ export function AccountCard({ {statusText} + {(!account.credentialsValid || !account.tokenValid) && ( + + )} {onDelete && ( + + + + {tab === 'token' ? ( +
+
+ + setEmail(e.target.value)} + placeholder="user@example.com" + /> + {emailError &&

{emailError}

} +
+
+ + setToken(e.target.value)} + /> + {tokenError &&

{tokenError}

} + {serverError &&

{serverError}

} +
+
+ + {renderProviderSelect(provider, setProvider)} +
+
+ ) : ( +
+
+ + setCredEmail(e.target.value)} + placeholder="user@example.com" + /> + {credEmailError &&

{credEmailError}

} +
+
+ + {renderProviderSelect(credProvider, setCredProvider)} +
+
+ +